parent
3a8d977f11
commit
641341ed90
17 changed files with 667 additions and 144 deletions
60
lib/controllers/accounts.dart
Normal file
60
lib/controllers/accounts.dart
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import "dart:convert";
|
||||
|
||||
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/models/account.dart";
|
||||
|
||||
class AccountsController extends AsyncNotifier<IList<Account>> {
|
||||
@override
|
||||
Future<IList<Account>> build() async {
|
||||
final stored = await ref.watch(
|
||||
KeyController.provider(KeyController.accountsKey).future,
|
||||
);
|
||||
final accounts = IList<Account>(
|
||||
(json.decode(stored ?? "[]") as List).map(
|
||||
(account) => .fromJson(account),
|
||||
),
|
||||
);
|
||||
|
||||
// Installs from before account switching are logged into the legacy slot
|
||||
return accounts.isEmpty
|
||||
? .new([const .new(id: Account.legacyId)])
|
||||
: accounts;
|
||||
}
|
||||
|
||||
Future<void> _set(IList<Account> accounts) async {
|
||||
state = .data(accounts);
|
||||
await ref
|
||||
.read(KeyController.provider(KeyController.accountsKey).notifier)
|
||||
.set(json.encode(accounts.map((account) => account.toJson()).toList()));
|
||||
}
|
||||
|
||||
Future<IList<Account>> get _current async {
|
||||
await future;
|
||||
return state.requireValue;
|
||||
}
|
||||
|
||||
Future<void> save(Account account) async {
|
||||
final accounts = await _current;
|
||||
final index = accounts.indexWhere((existing) => existing.id == account.id);
|
||||
|
||||
await _set(
|
||||
index == -1 ? accounts.add(account) : accounts.replace(index, account),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> reset(String id) async {
|
||||
final accounts = await _current;
|
||||
final index = accounts.indexWhere((account) => account.id == id);
|
||||
if (index != -1) await _set(accounts.replace(index, .new(id: id)));
|
||||
}
|
||||
|
||||
Future<void> remove(String id) async =>
|
||||
_set((await _current).removeWhere((account) => account.id == id));
|
||||
|
||||
static final provider =
|
||||
AsyncNotifierProvider<AccountsController, IList<Account>>(
|
||||
AccountsController.new,
|
||||
);
|
||||
}
|
||||
53
lib/controllers/active_account.dart
Normal file
53
lib/controllers/active_account.dart
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import "package:collection/collection.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:nexus/controllers/accounts.dart";
|
||||
import "package:nexus/controllers/key.dart";
|
||||
import "package:nexus/controllers/notification_request.dart";
|
||||
import "package:nexus/models/account.dart";
|
||||
import "package:shared_preferences/shared_preferences.dart";
|
||||
|
||||
typedef AccountSwitch = ({String accountId, int? notificationRowId});
|
||||
|
||||
class ActiveAccountController(
|
||||
final String accountId,
|
||||
final void Function(AccountSwitch next) restart,
|
||||
) extends Notifier<String> {
|
||||
@override
|
||||
String build() => accountId;
|
||||
|
||||
static Future<String> stored() async =>
|
||||
await SharedPreferencesAsync().getString(
|
||||
KeyController.activeAccountKey,
|
||||
) ??
|
||||
Account.legacyId;
|
||||
|
||||
/// Restarts the app as [accountId], then opens [notificationRowId] if given.
|
||||
Future<void> switchTo(String accountId, {int? notificationRowId}) async {
|
||||
if (accountId == state) {
|
||||
if (notificationRowId != null) {
|
||||
ref
|
||||
.read(NotificationRequestController.provider.notifier)
|
||||
.request(notificationRowId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final accounts = await ref.read(AccountsController.provider.future);
|
||||
if (!accounts.any((account) => account.id == accountId)) return;
|
||||
|
||||
if (accounts.firstWhereOrNull((account) => account.id == state)
|
||||
case Account(userId: null)) {
|
||||
await ref.read(AccountsController.provider.notifier).remove(state);
|
||||
}
|
||||
|
||||
await ref
|
||||
.read(KeyController.provider(KeyController.activeAccountKey).notifier)
|
||||
.set(accountId);
|
||||
|
||||
restart((accountId: accountId, notificationRowId: notificationRowId));
|
||||
}
|
||||
|
||||
static final provider = NotifierProvider<ActiveAccountController, String>(
|
||||
() => throw UnimplementedError("Provided by AccountScope"),
|
||||
);
|
||||
}
|
||||
|
|
@ -1,15 +1,18 @@
|
|||
import "dart:async";
|
||||
import "dart:ffi";
|
||||
import "dart:io";
|
||||
import "dart:isolate";
|
||||
import "dart:math";
|
||||
|
||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:ffi/ffi.dart";
|
||||
import "package:flutter/cupertino.dart";
|
||||
import "package:intl/intl.dart";
|
||||
import "package:nexus/controllers/accounts.dart";
|
||||
import "package:nexus/controllers/active_account.dart";
|
||||
import "package:nexus/controllers/settings.dart";
|
||||
import "package:nexus/helpers/extensions/gomuks_buffer.dart";
|
||||
import "package:nexus/main.dart";
|
||||
import "package:nexus/models/account.dart";
|
||||
import "package:nexus/models/capabilities.dart";
|
||||
import "package:nexus/models/content/message.dart";
|
||||
import "package:nexus/models/event.dart";
|
||||
|
|
@ -57,18 +60,20 @@ import "package:nexus/models/space_hierarchy.dart";
|
|||
import "package:nexus/models/spec_versions_response.dart";
|
||||
import "package:nexus/src/third_party/gomuks.g.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:path/path.dart";
|
||||
import "package:path_provider/path_provider.dart";
|
||||
|
||||
class ClientController extends AsyncNotifier<int> {
|
||||
class ClientController(final String? accountId) extends AsyncNotifier<int> {
|
||||
@override
|
||||
Future<int> build() async {
|
||||
debugPrint("Setting Gomuks env...");
|
||||
if (Platform.isAndroid || Platform.isIOS) {
|
||||
final env = {
|
||||
"GOMUKS_ROOT": (await getApplicationSupportDirectory()).path,
|
||||
"GOMUKS_CACHE_HOME": (await getApplicationCacheDirectory()).path,
|
||||
};
|
||||
for (final MapEntry(:key, :value) in env.entries) {
|
||||
final String account =
|
||||
accountId ?? ref.watch(ActiveAccountController.provider);
|
||||
if (accountId == null && !isInBackground) await _deleteRemovedAccounts();
|
||||
|
||||
final environment = await _environment(account);
|
||||
|
||||
debugPrint("Setting Gomuks env for $account...");
|
||||
for (final MapEntry(:key, :value) in environment.entries) {
|
||||
final keyPtr = key.toNativeUtf8().cast<Char>();
|
||||
final valuePtr = value.toNativeUtf8().cast<Char>();
|
||||
try {
|
||||
|
|
@ -79,10 +84,8 @@ class ClientController extends AsyncNotifier<int> {
|
|||
..free(valuePtr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
debugPrint("Initializing Gomuks...");
|
||||
final handle = await Isolate.run(() {
|
||||
final bufferPointer = GomuksConfig(
|
||||
matrix: .new(
|
||||
initialDeviceDisplayName:
|
||||
|
|
@ -90,14 +93,14 @@ class ClientController extends AsyncNotifier<int> {
|
|||
),
|
||||
).toJson().toGomuksBufferPtr();
|
||||
|
||||
final int handle;
|
||||
try {
|
||||
return GomuksInit(bufferPointer.ref);
|
||||
handle = GomuksInit(bufferPointer.ref);
|
||||
} finally {
|
||||
calloc
|
||||
..free(bufferPointer.ref.base)
|
||||
..free(bufferPointer);
|
||||
}
|
||||
});
|
||||
|
||||
if (!ref.mounted) {
|
||||
GomuksDestroy(handle);
|
||||
|
|
@ -106,9 +109,60 @@ class ClientController extends AsyncNotifier<int> {
|
|||
|
||||
ref.onDispose(() => GomuksDestroy(handle));
|
||||
|
||||
if (accountId != null) {
|
||||
await _callGomuksMethod(handle, {}, GomuksHandlePushAsync);
|
||||
}
|
||||
|
||||
return handle;
|
||||
}
|
||||
|
||||
static Future<({Directory data, Directory cache})>
|
||||
_accountsDirectories() async => (
|
||||
data: Directory(
|
||||
join((await getApplicationSupportDirectory()).path, "accounts"),
|
||||
),
|
||||
cache: Directory(
|
||||
join((await getApplicationCacheDirectory()).path, "accounts"),
|
||||
),
|
||||
);
|
||||
|
||||
static Future<IMap<String, String>> _environment(String accountId) async {
|
||||
if (accountId == Account.legacyId) {
|
||||
// Keep the directories gomuks used before account switching existed
|
||||
final isMobile = Platform.isAndroid || Platform.isIOS;
|
||||
return .new({
|
||||
"GOMUKS_ROOT": isMobile
|
||||
? (await getApplicationSupportDirectory()).path
|
||||
: Platform.environment["GOMUKS_ROOT"] ?? "",
|
||||
"GOMUKS_CACHE_HOME": isMobile
|
||||
? (await getApplicationCacheDirectory()).path
|
||||
: Platform.environment["GOMUKS_CACHE_HOME"] ?? "",
|
||||
});
|
||||
}
|
||||
|
||||
final (:data, :cache) = await _accountsDirectories();
|
||||
return .new({
|
||||
"GOMUKS_ROOT": join(data.path, accountId),
|
||||
"GOMUKS_CACHE_HOME": join(cache.path, accountId),
|
||||
});
|
||||
}
|
||||
|
||||
// Can't be done on removal, since the old container still has them open
|
||||
Future<void> _deleteRemovedAccounts() async {
|
||||
final ids = (await ref.read(AccountsController.provider.future))
|
||||
.map((account) => account.id)
|
||||
.toISet();
|
||||
|
||||
final (:data, :cache) = await _accountsDirectories();
|
||||
final removed = await Stream.fromIterable([data, cache])
|
||||
.where((directory) => directory.existsSync())
|
||||
.asyncExpand((directory) => directory.list())
|
||||
.where((entry) => !ids.contains(basename(entry.path)))
|
||||
.toList();
|
||||
|
||||
await Future.wait(removed.map((entry) => entry.delete(recursive: true)));
|
||||
}
|
||||
|
||||
/// Calls gomuks with [data] as JSON, returning its decoded response.
|
||||
Future<dynamic> callGomuksMethod(
|
||||
Map<String, dynamic> data,
|
||||
|
|
@ -118,8 +172,18 @@ class ClientController extends AsyncNotifier<int> {
|
|||
Pointer<NativeFunction<Void Function(GomuksResponse)>> callback,
|
||||
)
|
||||
call,
|
||||
) async {
|
||||
final handle = await future;
|
||||
) async => _callGomuksMethod(await future, data, call);
|
||||
|
||||
static Future<dynamic> _callGomuksMethod(
|
||||
int handle,
|
||||
Map<String, dynamic> data,
|
||||
void Function(
|
||||
int handle,
|
||||
GomuksOwnedBuffer data,
|
||||
Pointer<NativeFunction<Void Function(GomuksResponse)>> callback,
|
||||
)
|
||||
call,
|
||||
) {
|
||||
final buffer = data.toGomuksOwnedBuffer();
|
||||
|
||||
final completer = Completer<dynamic>();
|
||||
|
|
@ -421,6 +485,12 @@ class ClientController extends AsyncNotifier<int> {
|
|||
}
|
||||
|
||||
static final provider = AsyncNotifierProvider<ClientController, int>(
|
||||
() => ClientController(null),
|
||||
);
|
||||
|
||||
/// The instance for an account other than the active one.
|
||||
static final forAccount =
|
||||
AsyncNotifierProvider.family<ClientController, int, String>(
|
||||
ClientController.new,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,9 +3,12 @@ import "dart:ffi";
|
|||
import "dart:io";
|
||||
import "dart:isolate";
|
||||
|
||||
import "package:collection/collection.dart";
|
||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:flutter/foundation.dart";
|
||||
import "package:nexus/controllers/account_data.dart";
|
||||
import "package:nexus/controllers/accounts.dart";
|
||||
import "package:nexus/controllers/active_account.dart";
|
||||
import "package:nexus/controllers/app_paused.dart";
|
||||
import "package:nexus/controllers/client.dart";
|
||||
import "package:nexus/controllers/client_state.dart";
|
||||
|
|
@ -152,6 +155,45 @@ class GomuksListenerController extends AsyncNotifier<void> {
|
|||
|
||||
if (!ref.mounted) return;
|
||||
|
||||
// Cached so the account switcher and push routing have them while the
|
||||
// account isn't active
|
||||
ref.listen(
|
||||
ClientStateController.provider.select(
|
||||
(state) => (state?.isInitialized, state?.userId, state?.deviceId),
|
||||
),
|
||||
(_, ids) => Future(() async {
|
||||
final id = ref.read(ActiveAccountController.provider);
|
||||
final accounts = ref.read(AccountsController.provider.notifier);
|
||||
|
||||
switch (ids) {
|
||||
// Logged out, so it shouldn't show as an account anymore
|
||||
case (true, null, _):
|
||||
await accounts.reset(id);
|
||||
case (_, final userId?, final deviceId):
|
||||
final previous = (await ref.read(
|
||||
AccountsController.provider.future,
|
||||
)).firstWhereOrNull((account) => account.id == id);
|
||||
final profile =
|
||||
(await ref
|
||||
.read(ClientController.provider.notifier)
|
||||
.getProfile(userId))
|
||||
.profile;
|
||||
|
||||
// Fetching the profile fails while offline, so keep what was cached
|
||||
await accounts.save(
|
||||
.new(
|
||||
id: id,
|
||||
userId: userId,
|
||||
deviceId: deviceId,
|
||||
displayName: profile.displayName ?? previous?.displayName,
|
||||
avatar: profile.avatarUrl ?? previous?.avatar,
|
||||
),
|
||||
);
|
||||
}
|
||||
}).catchError(showError),
|
||||
fireImmediately: true,
|
||||
);
|
||||
|
||||
if (Platform.isAndroid || Platform.isIOS) {
|
||||
// Mobile OSes block network access for backgrounded apps, so pause
|
||||
// syncing instead of letting it fail.
|
||||
|
|
|
|||
|
|
@ -1,23 +1,38 @@
|
|||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:nexus/controllers/active_account.dart";
|
||||
import "package:nexus/controllers/shared_prefs.dart";
|
||||
import "package:nexus/models/account.dart";
|
||||
|
||||
class KeyController(final String key) extends AsyncNotifier<String?> {
|
||||
static const String spaceKey = "space";
|
||||
static const String roomKey = "room";
|
||||
static const String pushKeyKey = "pushKey";
|
||||
static const String accountsKey = "accounts";
|
||||
static const String activeAccountKey = "activeAccount";
|
||||
|
||||
static const ISet<String> _perAccountKeys = ISetConst({spaceKey, roomKey});
|
||||
|
||||
String get _prefsKey {
|
||||
final account = ref.read(ActiveAccountController.provider);
|
||||
|
||||
return _perAccountKeys.contains(key) && account != Account.legacyId
|
||||
? "$account/$key"
|
||||
: key;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String?> build() =>
|
||||
ref.watch(SharedPrefsController.provider).getString(key);
|
||||
ref.watch(SharedPrefsController.provider).getString(_prefsKey);
|
||||
|
||||
Future<void> set(String? value) async {
|
||||
final prefs = ref.read(SharedPrefsController.provider);
|
||||
state = .data(value);
|
||||
|
||||
if (value == null) {
|
||||
await prefs.remove(key);
|
||||
await prefs.remove(_prefsKey);
|
||||
} else {
|
||||
await prefs.setString(key, value);
|
||||
await prefs.setString(_prefsKey, value);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,12 +4,12 @@ import "dart:io";
|
|||
|
||||
import "package:flutter/services.dart";
|
||||
import "package:material_ui/material_ui.dart";
|
||||
import "package:nexus/controllers/active_account.dart";
|
||||
import "package:nexus/controllers/portal.dart";
|
||||
import "package:nexus/controllers/sidebar_page.dart";
|
||||
import "package:nexus/main.dart";
|
||||
import "package:flutter_local_notifications/flutter_local_notifications.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:nexus/controllers/notification_request.dart";
|
||||
import "package:nexus/models/push_notification.dart";
|
||||
import "package:xdg_desktop_portal/xdg_desktop_portal.dart";
|
||||
|
||||
|
|
@ -41,17 +41,7 @@ class NotificationController
|
|||
macOS: darwin,
|
||||
),
|
||||
onDidReceiveNotificationResponse: (details) {
|
||||
if (details.payload == null) return;
|
||||
|
||||
if (int.tryParse(details.payload!) case final rowId?) {
|
||||
navigatorKey.currentState?.popUntil((route) => route.isFirst);
|
||||
ref
|
||||
.read(SidebarPageController.provider.notifier)
|
||||
.set(.notifications);
|
||||
ref
|
||||
.read(NotificationRequestController.provider.notifier)
|
||||
.request(rowId);
|
||||
}
|
||||
if (details.payload case final payload?) _open(payload);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -64,21 +54,36 @@ class NotificationController
|
|||
return;
|
||||
}
|
||||
|
||||
if (int.tryParse(call.arguments) case int rowId) {
|
||||
navigatorKey.currentState?.popUntil((route) => route.isFirst);
|
||||
ref.read(SidebarPageController.provider.notifier).set(.notifications);
|
||||
ref
|
||||
.read(NotificationRequestController.provider.notifier)
|
||||
.request(rowId);
|
||||
}
|
||||
if (call.arguments case final String payload) _open(payload);
|
||||
});
|
||||
|
||||
ref.onDispose(() => notificationChannel.setMethodCallHandler(null));
|
||||
}
|
||||
|
||||
return notifications;
|
||||
}
|
||||
|
||||
static String payloadFor(String accountId, int rowId) => "$accountId:$rowId";
|
||||
|
||||
void _open(String payload) {
|
||||
final (accountId, rowId) = switch (payload.split(":")) {
|
||||
[final accountId, final rowId] => (accountId, int.tryParse(rowId)),
|
||||
// From before notifications were per account
|
||||
[final rowId] => (null, int.tryParse(rowId)),
|
||||
_ => (null, null),
|
||||
};
|
||||
if (rowId == null) return;
|
||||
|
||||
navigatorKey.currentState?.popUntil((route) => route.isFirst);
|
||||
ref.read(SidebarPageController.provider.notifier).set(.notifications);
|
||||
|
||||
ref
|
||||
.read(ActiveAccountController.provider.notifier)
|
||||
.switchTo(
|
||||
accountId ?? ref.read(ActiveAccountController.provider),
|
||||
notificationRowId: rowId,
|
||||
)
|
||||
.onError(showError);
|
||||
}
|
||||
|
||||
Future<bool> requestPermissions() async {
|
||||
if (Platform.isLinux) {
|
||||
return true;
|
||||
|
|
@ -149,10 +154,12 @@ class NotificationController
|
|||
}
|
||||
|
||||
/// Passing [message] shows it as a chat message in the conversation for
|
||||
/// [threadId], naming the sender in group chats.
|
||||
/// [threadId], naming the sender in group chats. [subtitle] is shown on
|
||||
/// platforms that support it.
|
||||
Future<void> send({
|
||||
required int id,
|
||||
required String title,
|
||||
String? subtitle,
|
||||
required String body,
|
||||
String? payload,
|
||||
String? threadId,
|
||||
|
|
@ -208,6 +215,7 @@ class NotificationController
|
|||
: null;
|
||||
|
||||
final darwin = DarwinNotificationDetails(
|
||||
subtitle: subtitle,
|
||||
presentSound: sound,
|
||||
threadIdentifier: threadId,
|
||||
);
|
||||
|
|
@ -222,6 +230,7 @@ class NotificationController
|
|||
? .new(
|
||||
"general",
|
||||
"General",
|
||||
subText: subtitle,
|
||||
silent: !sound,
|
||||
when: timestamp?.millisecondsSinceEpoch,
|
||||
largeIcon: icon == null
|
||||
|
|
@ -232,6 +241,7 @@ class NotificationController
|
|||
: .new(
|
||||
"messages",
|
||||
"Messages",
|
||||
subText: subtitle,
|
||||
silent: !sound,
|
||||
when: timestamp?.millisecondsSinceEpoch,
|
||||
category: .message,
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ import "package:nexus/controllers/account_data.dart";
|
|||
import "package:nexus/controllers/client.dart";
|
||||
import "package:nexus/controllers/client_state.dart";
|
||||
import "package:nexus/controllers/enabled_image_packs.dart";
|
||||
import "package:nexus/controllers/init_complete.dart";
|
||||
import "package:nexus/controllers/notification.dart";
|
||||
import "package:nexus/controllers/profile.dart";
|
||||
import "package:nexus/controllers/rooms.dart";
|
||||
|
|
@ -809,43 +808,6 @@ class SettingsSectionsController
|
|||
),
|
||||
icon: Icons.key_off,
|
||||
),
|
||||
.new(
|
||||
title: "Log Out",
|
||||
description:
|
||||
"Log out of your account, returning you to the login page.",
|
||||
builder: (title, description, icon) => Builder(
|
||||
builder: (context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return M3EButton.icon(
|
||||
onPressed: () async {
|
||||
final container = ProviderScope.containerOf(context);
|
||||
|
||||
final route = ModalRoute.of(context)!;
|
||||
Navigator.of(context).popUntil((r) => r.isFirst);
|
||||
await route.completed;
|
||||
|
||||
await container
|
||||
.read(UnifiedPushController.provider.notifier)
|
||||
.deregister()
|
||||
.onError(showError);
|
||||
container.invalidate(InitCompleteController.provider);
|
||||
await container
|
||||
.read(ClientController.provider.notifier)
|
||||
.logout()
|
||||
.onError(showError);
|
||||
},
|
||||
label: Text(title),
|
||||
icon: Icon(icon),
|
||||
tooltip: description,
|
||||
decoration: .styleFrom(
|
||||
backgroundColor: colorScheme.errorContainer,
|
||||
foregroundColor: colorScheme.onErrorContainer,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
icon: Icons.logout,
|
||||
),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
|
|
|
|||
|
|
@ -1,9 +1,14 @@
|
|||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:nexus/controllers/notification_request.dart";
|
||||
import "package:nexus/models/sidebar_page.dart";
|
||||
|
||||
class SidebarPageController extends Notifier<SidebarPage> {
|
||||
// Show the notification that switched accounts, if one did
|
||||
@override
|
||||
SidebarPage build() => .rooms;
|
||||
SidebarPage build() =>
|
||||
ref.read(NotificationRequestController.provider) == null
|
||||
? .rooms
|
||||
: .notifications;
|
||||
|
||||
void set(SidebarPage page) => state = page;
|
||||
|
||||
|
|
|
|||
|
|
@ -2,11 +2,15 @@ import "dart:async";
|
|||
import "dart:convert";
|
||||
import "dart:io";
|
||||
|
||||
import "package:collection/collection.dart";
|
||||
import "package:flutter/foundation.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:intl/intl.dart";
|
||||
import "package:nexus/controllers/accounts.dart";
|
||||
import "package:nexus/controllers/active_account.dart";
|
||||
import "package:nexus/controllers/selected_room.dart";
|
||||
import "package:nexus/controllers/notification.dart";
|
||||
import "package:nexus/controllers/client_state.dart";
|
||||
import "package:nexus/controllers/push_key.dart";
|
||||
import "package:nexus/main.dart";
|
||||
import "package:nexus/controllers/client.dart";
|
||||
|
|
@ -18,6 +22,19 @@ import "package:window_manager/window_manager.dart";
|
|||
class UnifiedPushController extends AsyncNotifier<String?> {
|
||||
Completer<void>? _endpoint;
|
||||
|
||||
/// The account [instance] (its device ID) belongs to, or the active one for
|
||||
/// devices registered before account switching.
|
||||
Future<String> _accountFor(String instance) async =>
|
||||
(await ref.read(AccountsController.provider.future))
|
||||
.firstWhereOrNull((account) => account.deviceId == instance)
|
||||
?.id ??
|
||||
ref.read(ActiveAccountController.provider);
|
||||
|
||||
ClientController _clientFor(String accountId) =>
|
||||
accountId == ref.read(ActiveAccountController.provider)
|
||||
? ref.read(ClientController.provider.notifier)
|
||||
: ref.read(ClientController.forAccount(accountId).notifier);
|
||||
|
||||
@override
|
||||
Future<String?> build() async {
|
||||
if (!Platform.isLinux && !Platform.isAndroid) return null;
|
||||
|
|
@ -36,9 +53,7 @@ class UnifiedPushController extends AsyncNotifier<String?> {
|
|||
.read(PushKeyController.provider(instance).notifier)
|
||||
.set(pushKey);
|
||||
|
||||
await ref
|
||||
.read(ClientController.provider.notifier)
|
||||
.registerPusher(
|
||||
await _clientFor(await _accountFor(instance)).registerPusher(
|
||||
.new(
|
||||
appDisplayName: "Nexus",
|
||||
appId: "nexus.federated.nexus",
|
||||
|
|
@ -82,12 +97,17 @@ class UnifiedPushController extends AsyncNotifier<String?> {
|
|||
"Failed to decrypt notification. Try toggling off and on UnifiedPush in settings.",
|
||||
);
|
||||
}
|
||||
final notification = await ref
|
||||
.read(ClientController.provider.notifier)
|
||||
.handlePush(json.decode(String.fromCharCodes(message.content)));
|
||||
final accountId = await _accountFor(instance);
|
||||
final client = _clientFor(accountId);
|
||||
final isActive =
|
||||
accountId == ref.read(ActiveAccountController.provider);
|
||||
final notification = await client.handlePush(
|
||||
json.decode(String.fromCharCodes(message.content)),
|
||||
);
|
||||
|
||||
if (notification == null ||
|
||||
(!isInBackground &&
|
||||
isActive &&
|
||||
(!Platform.isAndroid &&
|
||||
(WindowManager.instance.getCurrent()?.isFocused ?? true)) &&
|
||||
ref.read(SelectedRoomController.provider) ==
|
||||
|
|
@ -96,7 +116,8 @@ class UnifiedPushController extends AsyncNotifier<String?> {
|
|||
return;
|
||||
}
|
||||
|
||||
final client = ref.read(ClientController.provider.notifier);
|
||||
final hasOtherAccounts =
|
||||
(await ref.read(AccountsController.provider.future)).length > 1;
|
||||
|
||||
final icon = notification.roomAvatar == null
|
||||
? null
|
||||
|
|
@ -116,17 +137,20 @@ class UnifiedPushController extends AsyncNotifier<String?> {
|
|||
await ref
|
||||
.read(NotificationController.provider.notifier)
|
||||
.send(
|
||||
// Android stacks messages into one conversation per room
|
||||
// Android stacks messages into one conversation per room (and
|
||||
// account, since two accounts can share a room)
|
||||
id:
|
||||
(Platform.isAndroid
|
||||
? notification.roomId
|
||||
: notification.eventId)
|
||||
"$accountId/${Platform.isAndroid ? notification.roomId : notification.eventId}"
|
||||
.hashCode &
|
||||
0x7fffffff,
|
||||
threadId: notification.roomId,
|
||||
threadId: "$accountId/${notification.roomId}",
|
||||
title: notification.roomName,
|
||||
subtitle: hasOtherAccounts ? notification.self.id : null,
|
||||
body: notification.text,
|
||||
payload: notification.eventRowId.toString(),
|
||||
payload: NotificationController.payloadFor(
|
||||
accountId,
|
||||
notification.eventRowId,
|
||||
),
|
||||
timestamp: notification.timestamp,
|
||||
icon: icon,
|
||||
sound: notification.sound,
|
||||
|
|
@ -147,14 +171,32 @@ class UnifiedPushController extends AsyncNotifier<String?> {
|
|||
|
||||
if (!registered || isInBackground) return null;
|
||||
|
||||
try {
|
||||
if (await deregisterIfDisabled()) return null;
|
||||
ref.listen(
|
||||
ClientStateController.provider.select((state) => state?.isLoggedIn),
|
||||
(previous, isLoggedIn) => Future(() async {
|
||||
if (isLoggedIn != true) return;
|
||||
if (await deregisterIfDisabled()) return;
|
||||
|
||||
return await register();
|
||||
} catch (error, stackTrace) {
|
||||
showError(error, stackTrace);
|
||||
return null;
|
||||
// On startup, only accounts with push on (which have a push key).
|
||||
// After logging in, push is on by default.
|
||||
final deviceId = ref.read(ClientStateController.provider)?.deviceId;
|
||||
if (previous != false &&
|
||||
(deviceId == null ||
|
||||
await ref.read(PushKeyController.provider(deviceId).future) ==
|
||||
null)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await register();
|
||||
} on UnsupportedError {
|
||||
// The settings tile explains that the homeserver can't send Web Push
|
||||
}
|
||||
}).catchError(showError),
|
||||
fireImmediately: true,
|
||||
);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Registers with [distributor], or the current/default distributor if none
|
||||
|
|
@ -166,12 +208,13 @@ class UnifiedPushController extends AsyncNotifier<String?> {
|
|||
await deregister();
|
||||
}
|
||||
|
||||
state = .loading();
|
||||
try {
|
||||
// Not loading until logged in, since the app waits for this
|
||||
final deviceId = await ref
|
||||
.read(GomuksListenerController.provider.notifier)
|
||||
.deviceId();
|
||||
|
||||
state = .loading();
|
||||
try {
|
||||
await Future(() async {
|
||||
final capabilities = await ref
|
||||
.read(ClientController.provider.notifier)
|
||||
|
|
@ -238,10 +281,10 @@ class UnifiedPushController extends AsyncNotifier<String?> {
|
|||
|
||||
final keyProvider = PushKeyController.provider(instance);
|
||||
final key = await ref.read(keyProvider.future);
|
||||
final accountId = await _accountFor(instance);
|
||||
|
||||
if (key != null) {
|
||||
await ref
|
||||
.read(ClientController.provider.notifier)
|
||||
await _clientFor(accountId)
|
||||
.deregisterPusher(.new(appId: "nexus.federated.nexus", pushKey: key));
|
||||
await ref.read(keyProvider.notifier).set(null);
|
||||
} else {
|
||||
|
|
@ -251,8 +294,10 @@ class UnifiedPushController extends AsyncNotifier<String?> {
|
|||
}
|
||||
|
||||
await UnifiedPush.unregister(instance);
|
||||
if (accountId == ref.read(ActiveAccountController.provider)) {
|
||||
state = .data(null);
|
||||
}
|
||||
}
|
||||
|
||||
static final provider = AsyncNotifierProvider<UnifiedPushController, String?>(
|
||||
UnifiedPushController.new,
|
||||
|
|
|
|||
53
lib/helpers/extensions/log_out.dart
Normal file
53
lib/helpers/extensions/log_out.dart
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import "package:collection/collection.dart";
|
||||
import "package:flutter_riverpod/experimental/mutation.dart";
|
||||
import "package:nexus/controllers/accounts.dart";
|
||||
import "package:nexus/controllers/active_account.dart";
|
||||
import "package:nexus/controllers/client.dart";
|
||||
import "package:nexus/controllers/init_complete.dart";
|
||||
import "package:nexus/controllers/unified_push.dart";
|
||||
import "package:nexus/main.dart";
|
||||
|
||||
extension LogOut on MutationTarget {
|
||||
Future<void> logOut(String accountId) async {
|
||||
final accounts = await container.read(AccountsController.provider.future);
|
||||
final account = accounts.firstWhereOrNull(
|
||||
(account) => account.id == accountId,
|
||||
);
|
||||
if (account == null) return;
|
||||
|
||||
final isActive =
|
||||
accountId == container.read(ActiveAccountController.provider);
|
||||
final client = isActive
|
||||
? container.read(ClientController.provider.notifier)
|
||||
: container.read(ClientController.forAccount(accountId).notifier);
|
||||
|
||||
if (account.deviceId case final deviceId?) {
|
||||
await container
|
||||
.read(UnifiedPushController.provider.notifier)
|
||||
.deregister(deviceId)
|
||||
.onError(showError);
|
||||
}
|
||||
if (isActive) container.invalidate(InitCompleteController.provider);
|
||||
await client.logout().onError(showError);
|
||||
|
||||
if (!isActive) {
|
||||
container.invalidate(ClientController.forAccount(accountId));
|
||||
await container
|
||||
.read(AccountsController.provider.notifier)
|
||||
.remove(accountId);
|
||||
return;
|
||||
}
|
||||
|
||||
final other = accounts.firstWhereOrNull(
|
||||
(account) => account.id != accountId,
|
||||
);
|
||||
if (other == null) return;
|
||||
|
||||
await container
|
||||
.read(AccountsController.provider.notifier)
|
||||
.remove(accountId);
|
||||
await container
|
||||
.read(ActiveAccountController.provider.notifier)
|
||||
.switchTo(other.id);
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import "package:flutter/foundation.dart";
|
|||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:material_emoji_picker/material_emoji_picker.dart";
|
||||
import "package:media_kit/media_kit.dart";
|
||||
import "package:nexus/controllers/active_account.dart";
|
||||
import "package:nexus/controllers/client.dart";
|
||||
import "package:nexus/controllers/client_state.dart";
|
||||
import "package:nexus/controllers/gomuks_listener.dart";
|
||||
|
|
@ -20,6 +21,7 @@ import "package:nexus/widgets/pages/chat.dart";
|
|||
import "package:nexus/widgets/pages/select_server.dart";
|
||||
import "package:nexus/widgets/pages/settings.dart";
|
||||
import "package:nexus/widgets/pages/verify.dart";
|
||||
import "package:nexus/widgets/account_scope.dart";
|
||||
import "package:nexus/widgets/appbar.dart";
|
||||
import "package:nexus/widgets/error_dialog.dart";
|
||||
import "package:nexus/widgets/loading.dart";
|
||||
|
|
@ -74,10 +76,17 @@ void main(List<String> args) async {
|
|||
Platform.environment["FLUTTER_HEADLESS"] != null ||
|
||||
args.contains("--unifiedpush-bg");
|
||||
|
||||
final accountId = await ActiveAccountController.stored();
|
||||
|
||||
if (isInBackground) {
|
||||
await ProviderContainer()
|
||||
.read(UnifiedPushController.provider.future)
|
||||
.timeout(Duration(seconds: 5));
|
||||
await ProviderContainer(
|
||||
overrides: [
|
||||
// Nothing to switch to in the background
|
||||
ActiveAccountController.provider.overrideWith(
|
||||
() => ActiveAccountController(accountId, (_) {}),
|
||||
),
|
||||
],
|
||||
).read(UnifiedPushController.provider.future).timeout(Duration(seconds: 5));
|
||||
|
||||
await Future.delayed(Duration(seconds: 10));
|
||||
exit(0);
|
||||
|
|
@ -90,7 +99,8 @@ void main(List<String> args) async {
|
|||
}
|
||||
|
||||
runApp(
|
||||
ProviderScope(
|
||||
AccountScope(
|
||||
initialAccountId: accountId,
|
||||
retry: (_, _) => null,
|
||||
observers: [
|
||||
// Change false to true if you want debug information on provider reloads
|
||||
|
|
|
|||
26
lib/models/account.dart
Normal file
26
lib/models/account.dart
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import "package:freezed_annotation/freezed_annotation.dart";
|
||||
|
||||
part "account.freezed.dart";
|
||||
part "account.g.dart";
|
||||
|
||||
/// An account slot, each with its own gomuks directories.
|
||||
///
|
||||
/// Everything but [id] is cached from the last time the account was active, so
|
||||
/// the account switcher and push routing work while it isn't.
|
||||
@Freezed(toJson: false, fromJson: false)
|
||||
@JsonSerializable()
|
||||
class const Account({
|
||||
required final String id,
|
||||
final String? userId,
|
||||
final String? deviceId,
|
||||
final String? displayName,
|
||||
final Uri? avatar,
|
||||
}) with _$Account {
|
||||
/// The slot accounts from before account switching existed
|
||||
static const String legacyId = "default";
|
||||
|
||||
Map<String, Object?> toJson() => _$AccountToJson(this);
|
||||
|
||||
factory Account.fromJson(Map<String, Object?> json) =>
|
||||
_$AccountFromJson(json);
|
||||
}
|
||||
41
lib/widgets/account_scope.dart
Normal file
41
lib/widgets/account_scope.dart
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import "package:flutter_hooks/flutter_hooks.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:material_ui/material_ui.dart";
|
||||
import "package:nexus/controllers/active_account.dart";
|
||||
import "package:nexus/controllers/notification_request.dart";
|
||||
|
||||
/// Runs [child] as one account, in a [ProviderScope] of its own that's
|
||||
/// replaced when switching accounts, so no state leaks between them.
|
||||
class const AccountScope({
|
||||
required final String initialAccountId,
|
||||
required final Widget child,
|
||||
final List<ProviderObserver>? observers,
|
||||
final Duration? Function(int retryCount, Object error)? retry,
|
||||
super.key,
|
||||
}) extends HookWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final active = useState<AccountSwitch>((
|
||||
accountId: initialAccountId,
|
||||
notificationRowId: null,
|
||||
));
|
||||
|
||||
return ProviderScope(
|
||||
key: ValueKey(active.value.accountId),
|
||||
retry: retry,
|
||||
observers: observers,
|
||||
overrides: [
|
||||
ActiveAccountController.provider.overrideWith(
|
||||
() => ActiveAccountController(
|
||||
active.value.accountId,
|
||||
(next) => active.value = next,
|
||||
),
|
||||
),
|
||||
NotificationRequestController.provider.overrideWithBuild(
|
||||
(_, _) => active.value.notificationRowId,
|
||||
),
|
||||
],
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
125
lib/widgets/account_switcher.dart
Normal file
125
lib/widgets/account_switcher.dart
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import "package:collection/collection.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:material_ui/material_ui.dart";
|
||||
import "package:nexus/controllers/accounts.dart";
|
||||
import "package:nexus/controllers/active_account.dart";
|
||||
import "package:nexus/helpers/extensions/log_out.dart";
|
||||
import "package:nexus/main.dart";
|
||||
import "package:nexus/models/account.dart";
|
||||
import "package:nexus/widgets/adaptive_menu_button.dart";
|
||||
import "package:nexus/widgets/avatar_or_hash.dart";
|
||||
|
||||
/// Switches between, adds and logs out of accounts. With [onlyWithOthers], it's
|
||||
/// hidden when there's only one account.
|
||||
class const AccountSwitcher({final bool onlyWithOthers = false, super.key})
|
||||
extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final danger = Theme.of(context).colorScheme.error;
|
||||
final accounts = ref.watch(AccountsController.provider).value;
|
||||
final activeId = ref.watch(ActiveAccountController.provider);
|
||||
if (accounts == null || (onlyWithOthers && accounts.length < 2)) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
String nameOf(Account account) =>
|
||||
account.displayName ?? account.userId ?? "New account";
|
||||
|
||||
Widget avatarOf(Account account, double dimension) => AvatarOrHash(
|
||||
account.avatar,
|
||||
nameOf(account),
|
||||
dimension: dimension,
|
||||
fallback: account.userId == null
|
||||
? Icon(Icons.account_circle, size: dimension)
|
||||
: null,
|
||||
);
|
||||
|
||||
final active = accounts.firstWhereOrNull(
|
||||
(account) => account.id == activeId,
|
||||
);
|
||||
|
||||
void switchTo(String accountId) => ref
|
||||
.read(ActiveAccountController.provider.notifier)
|
||||
.switchTo(accountId)
|
||||
.onError(showError);
|
||||
|
||||
return AdaptiveMenuButton(
|
||||
tooltip: "Switch account",
|
||||
icon: active == null
|
||||
? const Icon(Icons.account_circle)
|
||||
: avatarOf(active, 28),
|
||||
itemBuilder: (_) => [
|
||||
for (final account in accounts)
|
||||
PopupMenuItem(
|
||||
onTap: () => switchTo(account.id),
|
||||
child: ListTile(
|
||||
selected: account.id == activeId,
|
||||
leading: avatarOf(account, 32),
|
||||
title: Text(nameOf(account)),
|
||||
subtitle: switch (account) {
|
||||
Account(displayName: _?, :final userId?) => Text(userId),
|
||||
_ => null,
|
||||
},
|
||||
trailing: account.userId == null
|
||||
? null
|
||||
// Inside the menu, so it can close it
|
||||
: Builder(
|
||||
builder: (menuContext) => IconButton(
|
||||
tooltip: "Log out",
|
||||
icon: Icon(Icons.logout, color: danger),
|
||||
onPressed: () async {
|
||||
Navigator.of(menuContext).pop();
|
||||
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: Text("Log out of ${nameOf(account)}?"),
|
||||
content: Text(
|
||||
account.id == activeId
|
||||
? "You'll be switched to another account, or returned to the login page if this is your only one."
|
||||
: "You'll stop receiving notifications for this account.",
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () =>
|
||||
Navigator.of(dialogContext).pop(false),
|
||||
child: const Text("Cancel"),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () =>
|
||||
Navigator.of(dialogContext).pop(true),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: danger,
|
||||
),
|
||||
child: const Text("Log out"),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
|
||||
await ref.logOut(account.id).onError(showError);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const PopupMenuDivider(),
|
||||
PopupMenuItem(
|
||||
onTap: () {
|
||||
final id = DateTime.now().millisecondsSinceEpoch.toString();
|
||||
ref
|
||||
.read(AccountsController.provider.notifier)
|
||||
.save(.new(id: id))
|
||||
.then((_) => switchTo(id))
|
||||
.onError(showError);
|
||||
},
|
||||
child: const ListTile(
|
||||
leading: Icon(Icons.person_add),
|
||||
title: Text("Add account"),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ import "package:nexus/models/gomuks_exception.dart";
|
|||
import "package:nexus/models/homeserver.dart";
|
||||
import "package:nexus/models/oauth_auth_code_response.dart";
|
||||
import "package:nexus/widgets/pages/settings.dart";
|
||||
import "package:nexus/widgets/account_switcher.dart";
|
||||
import "package:nexus/widgets/appbar.dart";
|
||||
import "package:nexus/widgets/divider_text.dart";
|
||||
|
||||
|
|
@ -122,6 +123,7 @@ class const SelectServerPage({super.key}) extends HookConsumerWidget {
|
|||
return Scaffold(
|
||||
appBar: Appbar(
|
||||
actions: .new([
|
||||
const AccountSwitcher(onlyWithOthers: true),
|
||||
IconButton(
|
||||
onPressed: () =>
|
||||
showDialog(context: context, builder: (_) => SettingsPage()),
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import "package:nexus/models/client_state.dart";
|
|||
import "package:nexus/helpers/extensions/show_adaptive_sheet.dart";
|
||||
import "package:nexus/widgets/loading.dart";
|
||||
import "package:nexus/widgets/pages/settings.dart";
|
||||
import "package:nexus/widgets/account_switcher.dart";
|
||||
import "package:nexus/widgets/appbar.dart";
|
||||
import "package:nexus/helpers/required_validator_helper.dart";
|
||||
import "package:nexus/widgets/reset_encryption_sheet.dart";
|
||||
|
|
@ -50,6 +51,7 @@ class const VerifyPage({super.key}) extends HookConsumerWidget {
|
|||
return Scaffold(
|
||||
appBar: Appbar(
|
||||
actions: .new([
|
||||
const AccountSwitcher(onlyWithOthers: true),
|
||||
IconButton(
|
||||
onPressed: () =>
|
||||
showDialog(context: context, builder: (_) => SettingsPage()),
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import "package:navigation_rail_m3e/navigation_rail_m3e.dart";
|
|||
import "package:nexus/helpers/extensions/get_all_child_rooms.dart";
|
||||
import "package:nexus/helpers/extensions/room_destinations.dart";
|
||||
import "package:nexus/helpers/extensions/show_adaptive_sheet.dart";
|
||||
import "package:nexus/widgets/account_switcher.dart";
|
||||
import "package:nexus/widgets/adaptive_menu_button.dart";
|
||||
import "package:nexus/models/nav_page.dart";
|
||||
import "package:nexus/widgets/appbar.dart";
|
||||
|
|
@ -192,6 +193,7 @@ class const SpacesRail({super.key}) extends ConsumerWidget {
|
|||
],
|
||||
icon: const Icon(Icons.add),
|
||||
),
|
||||
const AccountSwitcher(),
|
||||
const IconButton(
|
||||
tooltip: "Explore other rooms (Not yet implemented)",
|
||||
onPressed: null,
|
||||
|
|
|
|||
Loading…
Reference in a new issue