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:async";
|
||||||
import "dart:ffi";
|
import "dart:ffi";
|
||||||
import "dart:io";
|
import "dart:io";
|
||||||
import "dart:isolate";
|
|
||||||
import "dart:math";
|
import "dart:math";
|
||||||
|
|
||||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||||
import "package:ffi/ffi.dart";
|
import "package:ffi/ffi.dart";
|
||||||
import "package:flutter/cupertino.dart";
|
import "package:flutter/cupertino.dart";
|
||||||
import "package:intl/intl.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/controllers/settings.dart";
|
||||||
import "package:nexus/helpers/extensions/gomuks_buffer.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/capabilities.dart";
|
||||||
import "package:nexus/models/content/message.dart";
|
import "package:nexus/models/content/message.dart";
|
||||||
import "package:nexus/models/event.dart";
|
import "package:nexus/models/event.dart";
|
||||||
|
|
@ -57,47 +60,47 @@ import "package:nexus/models/space_hierarchy.dart";
|
||||||
import "package:nexus/models/spec_versions_response.dart";
|
import "package:nexus/models/spec_versions_response.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";
|
||||||
|
import "package:path/path.dart";
|
||||||
import "package:path_provider/path_provider.dart";
|
import "package:path_provider/path_provider.dart";
|
||||||
|
|
||||||
class ClientController extends AsyncNotifier<int> {
|
class ClientController(final String? accountId) extends AsyncNotifier<int> {
|
||||||
@override
|
@override
|
||||||
Future<int> build() async {
|
Future<int> build() async {
|
||||||
debugPrint("Setting Gomuks env...");
|
final String account =
|
||||||
if (Platform.isAndroid || Platform.isIOS) {
|
accountId ?? ref.watch(ActiveAccountController.provider);
|
||||||
final env = {
|
if (accountId == null && !isInBackground) await _deleteRemovedAccounts();
|
||||||
"GOMUKS_ROOT": (await getApplicationSupportDirectory()).path,
|
|
||||||
"GOMUKS_CACHE_HOME": (await getApplicationCacheDirectory()).path,
|
final environment = await _environment(account);
|
||||||
};
|
|
||||||
for (final MapEntry(:key, :value) in env.entries) {
|
debugPrint("Setting Gomuks env for $account...");
|
||||||
final keyPtr = key.toNativeUtf8().cast<Char>();
|
for (final MapEntry(:key, :value) in environment.entries) {
|
||||||
final valuePtr = value.toNativeUtf8().cast<Char>();
|
final keyPtr = key.toNativeUtf8().cast<Char>();
|
||||||
try {
|
final valuePtr = value.toNativeUtf8().cast<Char>();
|
||||||
GomuksSetEnv(keyPtr, valuePtr);
|
try {
|
||||||
} finally {
|
GomuksSetEnv(keyPtr, valuePtr);
|
||||||
calloc
|
} finally {
|
||||||
..free(keyPtr)
|
calloc
|
||||||
..free(valuePtr);
|
..free(keyPtr)
|
||||||
}
|
..free(valuePtr);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
debugPrint("Initializing Gomuks...");
|
debugPrint("Initializing Gomuks...");
|
||||||
final handle = await Isolate.run(() {
|
final bufferPointer = GomuksConfig(
|
||||||
final bufferPointer = GomuksConfig(
|
matrix: .new(
|
||||||
matrix: .new(
|
initialDeviceDisplayName:
|
||||||
initialDeviceDisplayName:
|
"Nexus on ${toBeginningOfSentenceCase(Platform.operatingSystem)}",
|
||||||
"Nexus on ${toBeginningOfSentenceCase(Platform.operatingSystem)}",
|
),
|
||||||
),
|
).toJson().toGomuksBufferPtr();
|
||||||
).toJson().toGomuksBufferPtr();
|
|
||||||
|
|
||||||
try {
|
final int handle;
|
||||||
return GomuksInit(bufferPointer.ref);
|
try {
|
||||||
} finally {
|
handle = GomuksInit(bufferPointer.ref);
|
||||||
calloc
|
} finally {
|
||||||
..free(bufferPointer.ref.base)
|
calloc
|
||||||
..free(bufferPointer);
|
..free(bufferPointer.ref.base)
|
||||||
}
|
..free(bufferPointer);
|
||||||
});
|
}
|
||||||
|
|
||||||
if (!ref.mounted) {
|
if (!ref.mounted) {
|
||||||
GomuksDestroy(handle);
|
GomuksDestroy(handle);
|
||||||
|
|
@ -106,9 +109,60 @@ class ClientController extends AsyncNotifier<int> {
|
||||||
|
|
||||||
ref.onDispose(() => GomuksDestroy(handle));
|
ref.onDispose(() => GomuksDestroy(handle));
|
||||||
|
|
||||||
|
if (accountId != null) {
|
||||||
|
await _callGomuksMethod(handle, {}, GomuksHandlePushAsync);
|
||||||
|
}
|
||||||
|
|
||||||
return handle;
|
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.
|
/// Calls gomuks with [data] as JSON, returning its decoded response.
|
||||||
Future<dynamic> callGomuksMethod(
|
Future<dynamic> callGomuksMethod(
|
||||||
Map<String, dynamic> data,
|
Map<String, dynamic> data,
|
||||||
|
|
@ -118,8 +172,18 @@ class ClientController extends AsyncNotifier<int> {
|
||||||
Pointer<NativeFunction<Void Function(GomuksResponse)>> callback,
|
Pointer<NativeFunction<Void Function(GomuksResponse)>> callback,
|
||||||
)
|
)
|
||||||
call,
|
call,
|
||||||
) async {
|
) async => _callGomuksMethod(await future, data, call);
|
||||||
final handle = await future;
|
|
||||||
|
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 buffer = data.toGomuksOwnedBuffer();
|
||||||
|
|
||||||
final completer = Completer<dynamic>();
|
final completer = Completer<dynamic>();
|
||||||
|
|
@ -421,6 +485,12 @@ class ClientController extends AsyncNotifier<int> {
|
||||||
}
|
}
|
||||||
|
|
||||||
static final provider = AsyncNotifierProvider<ClientController, int>(
|
static final provider = AsyncNotifierProvider<ClientController, int>(
|
||||||
ClientController.new,
|
() => 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:io";
|
||||||
import "dart:isolate";
|
import "dart:isolate";
|
||||||
|
|
||||||
|
import "package:collection/collection.dart";
|
||||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||||
import "package:flutter/foundation.dart";
|
import "package:flutter/foundation.dart";
|
||||||
import "package:nexus/controllers/account_data.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/app_paused.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";
|
||||||
|
|
@ -152,6 +155,45 @@ class GomuksListenerController extends AsyncNotifier<void> {
|
||||||
|
|
||||||
if (!ref.mounted) return;
|
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) {
|
if (Platform.isAndroid || Platform.isIOS) {
|
||||||
// Mobile OSes block network access for backgrounded apps, so pause
|
// Mobile OSes block network access for backgrounded apps, so pause
|
||||||
// syncing instead of letting it fail.
|
// 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:flutter_riverpod/flutter_riverpod.dart";
|
||||||
|
import "package:nexus/controllers/active_account.dart";
|
||||||
import "package:nexus/controllers/shared_prefs.dart";
|
import "package:nexus/controllers/shared_prefs.dart";
|
||||||
|
import "package:nexus/models/account.dart";
|
||||||
|
|
||||||
class KeyController(final String key) extends AsyncNotifier<String?> {
|
class KeyController(final String key) extends AsyncNotifier<String?> {
|
||||||
static const String spaceKey = "space";
|
static const String spaceKey = "space";
|
||||||
static const String roomKey = "room";
|
static const String roomKey = "room";
|
||||||
static const String pushKeyKey = "pushKey";
|
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
|
@override
|
||||||
Future<String?> build() =>
|
Future<String?> build() =>
|
||||||
ref.watch(SharedPrefsController.provider).getString(key);
|
ref.watch(SharedPrefsController.provider).getString(_prefsKey);
|
||||||
|
|
||||||
Future<void> set(String? value) async {
|
Future<void> set(String? value) async {
|
||||||
final prefs = ref.read(SharedPrefsController.provider);
|
final prefs = ref.read(SharedPrefsController.provider);
|
||||||
state = .data(value);
|
state = .data(value);
|
||||||
|
|
||||||
if (value == null) {
|
if (value == null) {
|
||||||
await prefs.remove(key);
|
await prefs.remove(_prefsKey);
|
||||||
} else {
|
} 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:flutter/services.dart";
|
||||||
import "package:material_ui/material_ui.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/portal.dart";
|
||||||
import "package:nexus/controllers/sidebar_page.dart";
|
import "package:nexus/controllers/sidebar_page.dart";
|
||||||
import "package:nexus/main.dart";
|
import "package:nexus/main.dart";
|
||||||
import "package:flutter_local_notifications/flutter_local_notifications.dart";
|
import "package:flutter_local_notifications/flutter_local_notifications.dart";
|
||||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||||
import "package:nexus/controllers/notification_request.dart";
|
|
||||||
import "package:nexus/models/push_notification.dart";
|
import "package:nexus/models/push_notification.dart";
|
||||||
import "package:xdg_desktop_portal/xdg_desktop_portal.dart";
|
import "package:xdg_desktop_portal/xdg_desktop_portal.dart";
|
||||||
|
|
||||||
|
|
@ -41,17 +41,7 @@ class NotificationController
|
||||||
macOS: darwin,
|
macOS: darwin,
|
||||||
),
|
),
|
||||||
onDidReceiveNotificationResponse: (details) {
|
onDidReceiveNotificationResponse: (details) {
|
||||||
if (details.payload == null) return;
|
if (details.payload case final payload?) _open(payload);
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -64,21 +54,36 @@ class NotificationController
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (int.tryParse(call.arguments) case int rowId) {
|
if (call.arguments case final String payload) _open(payload);
|
||||||
navigatorKey.currentState?.popUntil((route) => route.isFirst);
|
|
||||||
ref.read(SidebarPageController.provider.notifier).set(.notifications);
|
|
||||||
ref
|
|
||||||
.read(NotificationRequestController.provider.notifier)
|
|
||||||
.request(rowId);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
ref.onDispose(() => notificationChannel.setMethodCallHandler(null));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return notifications;
|
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 {
|
Future<bool> requestPermissions() async {
|
||||||
if (Platform.isLinux) {
|
if (Platform.isLinux) {
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -149,10 +154,12 @@ class NotificationController
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Passing [message] shows it as a chat message in the conversation for
|
/// 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({
|
Future<void> send({
|
||||||
required int id,
|
required int id,
|
||||||
required String title,
|
required String title,
|
||||||
|
String? subtitle,
|
||||||
required String body,
|
required String body,
|
||||||
String? payload,
|
String? payload,
|
||||||
String? threadId,
|
String? threadId,
|
||||||
|
|
@ -208,6 +215,7 @@ class NotificationController
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
final darwin = DarwinNotificationDetails(
|
final darwin = DarwinNotificationDetails(
|
||||||
|
subtitle: subtitle,
|
||||||
presentSound: sound,
|
presentSound: sound,
|
||||||
threadIdentifier: threadId,
|
threadIdentifier: threadId,
|
||||||
);
|
);
|
||||||
|
|
@ -222,6 +230,7 @@ class NotificationController
|
||||||
? .new(
|
? .new(
|
||||||
"general",
|
"general",
|
||||||
"General",
|
"General",
|
||||||
|
subText: subtitle,
|
||||||
silent: !sound,
|
silent: !sound,
|
||||||
when: timestamp?.millisecondsSinceEpoch,
|
when: timestamp?.millisecondsSinceEpoch,
|
||||||
largeIcon: icon == null
|
largeIcon: icon == null
|
||||||
|
|
@ -232,6 +241,7 @@ class NotificationController
|
||||||
: .new(
|
: .new(
|
||||||
"messages",
|
"messages",
|
||||||
"Messages",
|
"Messages",
|
||||||
|
subText: subtitle,
|
||||||
silent: !sound,
|
silent: !sound,
|
||||||
when: timestamp?.millisecondsSinceEpoch,
|
when: timestamp?.millisecondsSinceEpoch,
|
||||||
category: .message,
|
category: .message,
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ import "package:nexus/controllers/account_data.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/enabled_image_packs.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/notification.dart";
|
||||||
import "package:nexus/controllers/profile.dart";
|
import "package:nexus/controllers/profile.dart";
|
||||||
import "package:nexus/controllers/rooms.dart";
|
import "package:nexus/controllers/rooms.dart";
|
||||||
|
|
@ -809,43 +808,6 @@ class SettingsSectionsController
|
||||||
),
|
),
|
||||||
icon: Icons.key_off,
|
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:flutter_riverpod/flutter_riverpod.dart";
|
||||||
|
import "package:nexus/controllers/notification_request.dart";
|
||||||
import "package:nexus/models/sidebar_page.dart";
|
import "package:nexus/models/sidebar_page.dart";
|
||||||
|
|
||||||
class SidebarPageController extends Notifier<SidebarPage> {
|
class SidebarPageController extends Notifier<SidebarPage> {
|
||||||
|
// Show the notification that switched accounts, if one did
|
||||||
@override
|
@override
|
||||||
SidebarPage build() => .rooms;
|
SidebarPage build() =>
|
||||||
|
ref.read(NotificationRequestController.provider) == null
|
||||||
|
? .rooms
|
||||||
|
: .notifications;
|
||||||
|
|
||||||
void set(SidebarPage page) => state = page;
|
void set(SidebarPage page) => state = page;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,15 @@ import "dart:async";
|
||||||
import "dart:convert";
|
import "dart:convert";
|
||||||
import "dart:io";
|
import "dart:io";
|
||||||
|
|
||||||
|
import "package:collection/collection.dart";
|
||||||
import "package:flutter/foundation.dart";
|
import "package:flutter/foundation.dart";
|
||||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||||
import "package:intl/intl.dart";
|
import "package:intl/intl.dart";
|
||||||
|
import "package:nexus/controllers/accounts.dart";
|
||||||
|
import "package:nexus/controllers/active_account.dart";
|
||||||
import "package:nexus/controllers/selected_room.dart";
|
import "package:nexus/controllers/selected_room.dart";
|
||||||
import "package:nexus/controllers/notification.dart";
|
import "package:nexus/controllers/notification.dart";
|
||||||
|
import "package:nexus/controllers/client_state.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";
|
||||||
|
|
@ -18,6 +22,19 @@ import "package:window_manager/window_manager.dart";
|
||||||
class UnifiedPushController extends AsyncNotifier<String?> {
|
class UnifiedPushController extends AsyncNotifier<String?> {
|
||||||
Completer<void>? _endpoint;
|
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
|
@override
|
||||||
Future<String?> build() async {
|
Future<String?> build() async {
|
||||||
if (!Platform.isLinux && !Platform.isAndroid) return null;
|
if (!Platform.isLinux && !Platform.isAndroid) return null;
|
||||||
|
|
@ -36,23 +53,21 @@ class UnifiedPushController extends AsyncNotifier<String?> {
|
||||||
.read(PushKeyController.provider(instance).notifier)
|
.read(PushKeyController.provider(instance).notifier)
|
||||||
.set(pushKey);
|
.set(pushKey);
|
||||||
|
|
||||||
await ref
|
await _clientFor(await _accountFor(instance)).registerPusher(
|
||||||
.read(ClientController.provider.notifier)
|
.new(
|
||||||
.registerPusher(
|
appDisplayName: "Nexus",
|
||||||
.new(
|
appId: "nexus.federated.nexus",
|
||||||
appDisplayName: "Nexus",
|
data: .webPush(
|
||||||
appId: "nexus.federated.nexus",
|
url: .parse(endpoint.url),
|
||||||
data: .webPush(
|
auth: endpoint.pubKeySet!.auth,
|
||||||
url: .parse(endpoint.url),
|
),
|
||||||
auth: endpoint.pubKeySet!.auth,
|
deviceDisplayName:
|
||||||
),
|
"Nexus on ${toBeginningOfSentenceCase(Platform.operatingSystem)}",
|
||||||
deviceDisplayName:
|
kind: .webPush,
|
||||||
"Nexus on ${toBeginningOfSentenceCase(Platform.operatingSystem)}",
|
lang: "en",
|
||||||
kind: .webPush,
|
pushKey: pushKey,
|
||||||
lang: "en",
|
),
|
||||||
pushKey: pushKey,
|
);
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
state = .data(await UnifiedPush.getDistributor());
|
state = .data(await UnifiedPush.getDistributor());
|
||||||
});
|
});
|
||||||
|
|
@ -82,12 +97,17 @@ class UnifiedPushController extends AsyncNotifier<String?> {
|
||||||
"Failed to decrypt notification. Try toggling off and on UnifiedPush in settings.",
|
"Failed to decrypt notification. Try toggling off and on UnifiedPush in settings.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
final notification = await ref
|
final accountId = await _accountFor(instance);
|
||||||
.read(ClientController.provider.notifier)
|
final client = _clientFor(accountId);
|
||||||
.handlePush(json.decode(String.fromCharCodes(message.content)));
|
final isActive =
|
||||||
|
accountId == ref.read(ActiveAccountController.provider);
|
||||||
|
final notification = await client.handlePush(
|
||||||
|
json.decode(String.fromCharCodes(message.content)),
|
||||||
|
);
|
||||||
|
|
||||||
if (notification == null ||
|
if (notification == null ||
|
||||||
(!isInBackground &&
|
(!isInBackground &&
|
||||||
|
isActive &&
|
||||||
(!Platform.isAndroid &&
|
(!Platform.isAndroid &&
|
||||||
(WindowManager.instance.getCurrent()?.isFocused ?? true)) &&
|
(WindowManager.instance.getCurrent()?.isFocused ?? true)) &&
|
||||||
ref.read(SelectedRoomController.provider) ==
|
ref.read(SelectedRoomController.provider) ==
|
||||||
|
|
@ -96,7 +116,8 @@ class UnifiedPushController extends AsyncNotifier<String?> {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final client = ref.read(ClientController.provider.notifier);
|
final hasOtherAccounts =
|
||||||
|
(await ref.read(AccountsController.provider.future)).length > 1;
|
||||||
|
|
||||||
final icon = notification.roomAvatar == null
|
final icon = notification.roomAvatar == null
|
||||||
? null
|
? null
|
||||||
|
|
@ -116,17 +137,20 @@ class UnifiedPushController extends AsyncNotifier<String?> {
|
||||||
await ref
|
await ref
|
||||||
.read(NotificationController.provider.notifier)
|
.read(NotificationController.provider.notifier)
|
||||||
.send(
|
.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:
|
id:
|
||||||
(Platform.isAndroid
|
"$accountId/${Platform.isAndroid ? notification.roomId : notification.eventId}"
|
||||||
? notification.roomId
|
|
||||||
: notification.eventId)
|
|
||||||
.hashCode &
|
.hashCode &
|
||||||
0x7fffffff,
|
0x7fffffff,
|
||||||
threadId: notification.roomId,
|
threadId: "$accountId/${notification.roomId}",
|
||||||
title: notification.roomName,
|
title: notification.roomName,
|
||||||
|
subtitle: hasOtherAccounts ? notification.self.id : null,
|
||||||
body: notification.text,
|
body: notification.text,
|
||||||
payload: notification.eventRowId.toString(),
|
payload: NotificationController.payloadFor(
|
||||||
|
accountId,
|
||||||
|
notification.eventRowId,
|
||||||
|
),
|
||||||
timestamp: notification.timestamp,
|
timestamp: notification.timestamp,
|
||||||
icon: icon,
|
icon: icon,
|
||||||
sound: notification.sound,
|
sound: notification.sound,
|
||||||
|
|
@ -147,14 +171,32 @@ class UnifiedPushController extends AsyncNotifier<String?> {
|
||||||
|
|
||||||
if (!registered || isInBackground) return null;
|
if (!registered || isInBackground) return null;
|
||||||
|
|
||||||
try {
|
ref.listen(
|
||||||
if (await deregisterIfDisabled()) return null;
|
ClientStateController.provider.select((state) => state?.isLoggedIn),
|
||||||
|
(previous, isLoggedIn) => Future(() async {
|
||||||
|
if (isLoggedIn != true) return;
|
||||||
|
if (await deregisterIfDisabled()) return;
|
||||||
|
|
||||||
return await register();
|
// On startup, only accounts with push on (which have a push key).
|
||||||
} catch (error, stackTrace) {
|
// After logging in, push is on by default.
|
||||||
showError(error, stackTrace);
|
final deviceId = ref.read(ClientStateController.provider)?.deviceId;
|
||||||
return null;
|
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
|
/// Registers with [distributor], or the current/default distributor if none
|
||||||
|
|
@ -166,12 +208,13 @@ class UnifiedPushController extends AsyncNotifier<String?> {
|
||||||
await deregister();
|
await deregister();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Not loading until logged in, since the app waits for this
|
||||||
|
final deviceId = await ref
|
||||||
|
.read(GomuksListenerController.provider.notifier)
|
||||||
|
.deviceId();
|
||||||
|
|
||||||
state = .loading();
|
state = .loading();
|
||||||
try {
|
try {
|
||||||
final deviceId = await ref
|
|
||||||
.read(GomuksListenerController.provider.notifier)
|
|
||||||
.deviceId();
|
|
||||||
|
|
||||||
await Future(() async {
|
await Future(() async {
|
||||||
final capabilities = await ref
|
final capabilities = await ref
|
||||||
.read(ClientController.provider.notifier)
|
.read(ClientController.provider.notifier)
|
||||||
|
|
@ -238,10 +281,10 @@ class UnifiedPushController extends AsyncNotifier<String?> {
|
||||||
|
|
||||||
final keyProvider = PushKeyController.provider(instance);
|
final keyProvider = PushKeyController.provider(instance);
|
||||||
final key = await ref.read(keyProvider.future);
|
final key = await ref.read(keyProvider.future);
|
||||||
|
final accountId = await _accountFor(instance);
|
||||||
|
|
||||||
if (key != null) {
|
if (key != null) {
|
||||||
await ref
|
await _clientFor(accountId)
|
||||||
.read(ClientController.provider.notifier)
|
|
||||||
.deregisterPusher(.new(appId: "nexus.federated.nexus", pushKey: key));
|
.deregisterPusher(.new(appId: "nexus.federated.nexus", pushKey: key));
|
||||||
await ref.read(keyProvider.notifier).set(null);
|
await ref.read(keyProvider.notifier).set(null);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -251,7 +294,9 @@ class UnifiedPushController extends AsyncNotifier<String?> {
|
||||||
}
|
}
|
||||||
|
|
||||||
await UnifiedPush.unregister(instance);
|
await UnifiedPush.unregister(instance);
|
||||||
state = .data(null);
|
if (accountId == ref.read(ActiveAccountController.provider)) {
|
||||||
|
state = .data(null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static final provider = AsyncNotifierProvider<UnifiedPushController, String?>(
|
static final provider = AsyncNotifierProvider<UnifiedPushController, String?>(
|
||||||
|
|
|
||||||
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:flutter_riverpod/flutter_riverpod.dart";
|
||||||
import "package:material_emoji_picker/material_emoji_picker.dart";
|
import "package:material_emoji_picker/material_emoji_picker.dart";
|
||||||
import "package:media_kit/media_kit.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.dart";
|
||||||
import "package:nexus/controllers/client_state.dart";
|
import "package:nexus/controllers/client_state.dart";
|
||||||
import "package:nexus/controllers/gomuks_listener.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/select_server.dart";
|
||||||
import "package:nexus/widgets/pages/settings.dart";
|
import "package:nexus/widgets/pages/settings.dart";
|
||||||
import "package:nexus/widgets/pages/verify.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/appbar.dart";
|
||||||
import "package:nexus/widgets/error_dialog.dart";
|
import "package:nexus/widgets/error_dialog.dart";
|
||||||
import "package:nexus/widgets/loading.dart";
|
import "package:nexus/widgets/loading.dart";
|
||||||
|
|
@ -74,10 +76,17 @@ void main(List<String> args) async {
|
||||||
Platform.environment["FLUTTER_HEADLESS"] != null ||
|
Platform.environment["FLUTTER_HEADLESS"] != null ||
|
||||||
args.contains("--unifiedpush-bg");
|
args.contains("--unifiedpush-bg");
|
||||||
|
|
||||||
|
final accountId = await ActiveAccountController.stored();
|
||||||
|
|
||||||
if (isInBackground) {
|
if (isInBackground) {
|
||||||
await ProviderContainer()
|
await ProviderContainer(
|
||||||
.read(UnifiedPushController.provider.future)
|
overrides: [
|
||||||
.timeout(Duration(seconds: 5));
|
// 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));
|
await Future.delayed(Duration(seconds: 10));
|
||||||
exit(0);
|
exit(0);
|
||||||
|
|
@ -90,7 +99,8 @@ void main(List<String> args) async {
|
||||||
}
|
}
|
||||||
|
|
||||||
runApp(
|
runApp(
|
||||||
ProviderScope(
|
AccountScope(
|
||||||
|
initialAccountId: accountId,
|
||||||
retry: (_, _) => null,
|
retry: (_, _) => null,
|
||||||
observers: [
|
observers: [
|
||||||
// Change false to true if you want debug information on provider reloads
|
// 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/homeserver.dart";
|
||||||
import "package:nexus/models/oauth_auth_code_response.dart";
|
import "package:nexus/models/oauth_auth_code_response.dart";
|
||||||
import "package:nexus/widgets/pages/settings.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/appbar.dart";
|
||||||
import "package:nexus/widgets/divider_text.dart";
|
import "package:nexus/widgets/divider_text.dart";
|
||||||
|
|
||||||
|
|
@ -122,6 +123,7 @@ class const SelectServerPage({super.key}) extends HookConsumerWidget {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: Appbar(
|
appBar: Appbar(
|
||||||
actions: .new([
|
actions: .new([
|
||||||
|
const AccountSwitcher(onlyWithOthers: true),
|
||||||
IconButton(
|
IconButton(
|
||||||
onPressed: () =>
|
onPressed: () =>
|
||||||
showDialog(context: context, builder: (_) => SettingsPage()),
|
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/helpers/extensions/show_adaptive_sheet.dart";
|
||||||
import "package:nexus/widgets/loading.dart";
|
import "package:nexus/widgets/loading.dart";
|
||||||
import "package:nexus/widgets/pages/settings.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/appbar.dart";
|
||||||
import "package:nexus/helpers/required_validator_helper.dart";
|
import "package:nexus/helpers/required_validator_helper.dart";
|
||||||
import "package:nexus/widgets/reset_encryption_sheet.dart";
|
import "package:nexus/widgets/reset_encryption_sheet.dart";
|
||||||
|
|
@ -50,6 +51,7 @@ class const VerifyPage({super.key}) extends HookConsumerWidget {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: Appbar(
|
appBar: Appbar(
|
||||||
actions: .new([
|
actions: .new([
|
||||||
|
const AccountSwitcher(onlyWithOthers: true),
|
||||||
IconButton(
|
IconButton(
|
||||||
onPressed: () =>
|
onPressed: () =>
|
||||||
showDialog(context: context, builder: (_) => SettingsPage()),
|
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/get_all_child_rooms.dart";
|
||||||
import "package:nexus/helpers/extensions/room_destinations.dart";
|
import "package:nexus/helpers/extensions/room_destinations.dart";
|
||||||
import "package:nexus/helpers/extensions/show_adaptive_sheet.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/widgets/adaptive_menu_button.dart";
|
||||||
import "package:nexus/models/nav_page.dart";
|
import "package:nexus/models/nav_page.dart";
|
||||||
import "package:nexus/widgets/appbar.dart";
|
import "package:nexus/widgets/appbar.dart";
|
||||||
|
|
@ -192,6 +193,7 @@ class const SpacesRail({super.key}) extends ConsumerWidget {
|
||||||
],
|
],
|
||||||
icon: const Icon(Icons.add),
|
icon: const Icon(Icons.add),
|
||||||
),
|
),
|
||||||
|
const AccountSwitcher(),
|
||||||
const IconButton(
|
const IconButton(
|
||||||
tooltip: "Explore other rooms (Not yet implemented)",
|
tooltip: "Explore other rooms (Not yet implemented)",
|
||||||
onPressed: null,
|
onPressed: null,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue