From 641341ed904a0828bc31c963b19714f55ef2c752 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Mon, 2 Nov 2026 17:00:54 -0500 Subject: [PATCH] Add account switcher Fixes #146 --- lib/controllers/accounts.dart | 60 +++++++++++ lib/controllers/active_account.dart | 53 +++++++++ lib/controllers/client.dart | 142 ++++++++++++++++++------- lib/controllers/gomuks_listener.dart | 42 ++++++++ lib/controllers/key.dart | 21 +++- lib/controllers/notification.dart | 54 ++++++---- lib/controllers/settings_sections.dart | 38 ------- lib/controllers/sidebar_page.dart | 7 +- lib/controllers/unified_push.dart | 127 +++++++++++++++------- lib/helpers/extensions/log_out.dart | 53 +++++++++ lib/main.dart | 18 +++- lib/models/account.dart | 26 +++++ lib/widgets/account_scope.dart | 41 +++++++ lib/widgets/account_switcher.dart | 125 ++++++++++++++++++++++ lib/widgets/pages/select_server.dart | 2 + lib/widgets/pages/verify.dart | 2 + lib/widgets/room_list.dart | 2 + 17 files changed, 668 insertions(+), 145 deletions(-) create mode 100644 lib/controllers/accounts.dart create mode 100644 lib/controllers/active_account.dart create mode 100644 lib/helpers/extensions/log_out.dart create mode 100644 lib/models/account.dart create mode 100644 lib/widgets/account_scope.dart create mode 100644 lib/widgets/account_switcher.dart diff --git a/lib/controllers/accounts.dart b/lib/controllers/accounts.dart new file mode 100644 index 0000000..0e93f9a --- /dev/null +++ b/lib/controllers/accounts.dart @@ -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> { + @override + Future> build() async { + final stored = await ref.watch( + KeyController.provider(KeyController.accountsKey).future, + ); + final accounts = IList( + (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 _set(IList accounts) async { + state = .data(accounts); + await ref + .read(KeyController.provider(KeyController.accountsKey).notifier) + .set(json.encode(accounts.map((account) => account.toJson()).toList())); + } + + Future> get _current async { + await future; + return state.requireValue; + } + + Future 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 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 remove(String id) async => + _set((await _current).removeWhere((account) => account.id == id)); + + static final provider = + AsyncNotifierProvider>( + AccountsController.new, + ); +} diff --git a/lib/controllers/active_account.dart b/lib/controllers/active_account.dart new file mode 100644 index 0000000..d61e6b2 --- /dev/null +++ b/lib/controllers/active_account.dart @@ -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 { + @override + String build() => accountId; + + static Future stored() async => + await SharedPreferencesAsync().getString( + KeyController.activeAccountKey, + ) ?? + Account.legacyId; + + /// Restarts the app as [accountId], then opens [notificationRowId] if given. + Future 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( + () => throw UnimplementedError("Provided by AccountScope"), + ); +} diff --git a/lib/controllers/client.dart b/lib/controllers/client.dart index be78dec..5f6d8e8 100644 --- a/lib/controllers/client.dart +++ b/lib/controllers/client.dart @@ -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,47 +60,47 @@ 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 { +class ClientController(final String? accountId) extends AsyncNotifier { @override Future 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 keyPtr = key.toNativeUtf8().cast(); - final valuePtr = value.toNativeUtf8().cast(); - try { - GomuksSetEnv(keyPtr, valuePtr); - } finally { - calloc - ..free(keyPtr) - ..free(valuePtr); - } + 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(); + final valuePtr = value.toNativeUtf8().cast(); + try { + GomuksSetEnv(keyPtr, valuePtr); + } finally { + calloc + ..free(keyPtr) + ..free(valuePtr); } } debugPrint("Initializing Gomuks..."); - final handle = await Isolate.run(() { - final bufferPointer = GomuksConfig( - matrix: .new( - initialDeviceDisplayName: - "Nexus on ${toBeginningOfSentenceCase(Platform.operatingSystem)}", - ), - ).toJson().toGomuksBufferPtr(); + final bufferPointer = GomuksConfig( + matrix: .new( + initialDeviceDisplayName: + "Nexus on ${toBeginningOfSentenceCase(Platform.operatingSystem)}", + ), + ).toJson().toGomuksBufferPtr(); - try { - return GomuksInit(bufferPointer.ref); - } finally { - calloc - ..free(bufferPointer.ref.base) - ..free(bufferPointer); - } - }); + final int handle; + try { + 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 { 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> _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 _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 callGomuksMethod( Map data, @@ -118,8 +172,18 @@ class ClientController extends AsyncNotifier { Pointer> callback, ) call, - ) async { - final handle = await future; + ) async => _callGomuksMethod(await future, data, call); + + static Future _callGomuksMethod( + int handle, + Map data, + void Function( + int handle, + GomuksOwnedBuffer data, + Pointer> callback, + ) + call, + ) { final buffer = data.toGomuksOwnedBuffer(); final completer = Completer(); @@ -421,6 +485,12 @@ class ClientController extends AsyncNotifier { } static final provider = AsyncNotifierProvider( - ClientController.new, + () => ClientController(null), ); + + /// The instance for an account other than the active one. + static final forAccount = + AsyncNotifierProvider.family( + ClientController.new, + ); } diff --git a/lib/controllers/gomuks_listener.dart b/lib/controllers/gomuks_listener.dart index 320ac2d..0298727 100644 --- a/lib/controllers/gomuks_listener.dart +++ b/lib/controllers/gomuks_listener.dart @@ -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 { 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. diff --git a/lib/controllers/key.dart b/lib/controllers/key.dart index 33c76da..d0ea054 100644 --- a/lib/controllers/key.dart +++ b/lib/controllers/key.dart @@ -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 { 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 _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 build() => - ref.watch(SharedPrefsController.provider).getString(key); + ref.watch(SharedPrefsController.provider).getString(_prefsKey); Future 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); } } diff --git a/lib/controllers/notification.dart b/lib/controllers/notification.dart index b251e26..e8a5dff 100644 --- a/lib/controllers/notification.dart +++ b/lib/controllers/notification.dart @@ -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 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 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, diff --git a/lib/controllers/settings_sections.dart b/lib/controllers/settings_sections.dart index 277382e..ea00982 100644 --- a/lib/controllers/settings_sections.dart +++ b/lib/controllers/settings_sections.dart @@ -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, - ), ]), ), ]), diff --git a/lib/controllers/sidebar_page.dart b/lib/controllers/sidebar_page.dart index 5598691..d80cb44 100644 --- a/lib/controllers/sidebar_page.dart +++ b/lib/controllers/sidebar_page.dart @@ -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 { + // 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; diff --git a/lib/controllers/unified_push.dart b/lib/controllers/unified_push.dart index 40c4666..260d843 100644 --- a/lib/controllers/unified_push.dart +++ b/lib/controllers/unified_push.dart @@ -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 { Completer? _endpoint; + /// The account [instance] (its device ID) belongs to, or the active one for + /// devices registered before account switching. + Future _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 build() async { if (!Platform.isLinux && !Platform.isAndroid) return null; @@ -36,23 +53,21 @@ class UnifiedPushController extends AsyncNotifier { .read(PushKeyController.provider(instance).notifier) .set(pushKey); - await ref - .read(ClientController.provider.notifier) - .registerPusher( - .new( - appDisplayName: "Nexus", - appId: "nexus.federated.nexus", - data: .webPush( - url: .parse(endpoint.url), - auth: endpoint.pubKeySet!.auth, - ), - deviceDisplayName: - "Nexus on ${toBeginningOfSentenceCase(Platform.operatingSystem)}", - kind: .webPush, - lang: "en", - pushKey: pushKey, - ), - ); + await _clientFor(await _accountFor(instance)).registerPusher( + .new( + appDisplayName: "Nexus", + appId: "nexus.federated.nexus", + data: .webPush( + url: .parse(endpoint.url), + auth: endpoint.pubKeySet!.auth, + ), + deviceDisplayName: + "Nexus on ${toBeginningOfSentenceCase(Platform.operatingSystem)}", + kind: .webPush, + lang: "en", + pushKey: pushKey, + ), + ); state = .data(await UnifiedPush.getDistributor()); }); @@ -82,12 +97,17 @@ class UnifiedPushController extends AsyncNotifier { "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 { 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 { 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 { 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 { await deregister(); } + // Not loading until logged in, since the app waits for this + final deviceId = await ref + .read(GomuksListenerController.provider.notifier) + .deviceId(); + state = .loading(); try { - final deviceId = await ref - .read(GomuksListenerController.provider.notifier) - .deviceId(); - await Future(() async { final capabilities = await ref .read(ClientController.provider.notifier) @@ -238,10 +281,10 @@ class UnifiedPushController extends AsyncNotifier { 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,7 +294,9 @@ class UnifiedPushController extends AsyncNotifier { } await UnifiedPush.unregister(instance); - state = .data(null); + if (accountId == ref.read(ActiveAccountController.provider)) { + state = .data(null); + } } static final provider = AsyncNotifierProvider( diff --git a/lib/helpers/extensions/log_out.dart b/lib/helpers/extensions/log_out.dart new file mode 100644 index 0000000..166a2ef --- /dev/null +++ b/lib/helpers/extensions/log_out.dart @@ -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 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); + } +} diff --git a/lib/main.dart b/lib/main.dart index 66b253a..55bd90a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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 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 args) async { } runApp( - ProviderScope( + AccountScope( + initialAccountId: accountId, retry: (_, _) => null, observers: [ // Change false to true if you want debug information on provider reloads diff --git a/lib/models/account.dart b/lib/models/account.dart new file mode 100644 index 0000000..a171bda --- /dev/null +++ b/lib/models/account.dart @@ -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 toJson() => _$AccountToJson(this); + + factory Account.fromJson(Map json) => + _$AccountFromJson(json); +} diff --git a/lib/widgets/account_scope.dart b/lib/widgets/account_scope.dart new file mode 100644 index 0000000..3f89b4b --- /dev/null +++ b/lib/widgets/account_scope.dart @@ -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? observers, + final Duration? Function(int retryCount, Object error)? retry, + super.key, +}) extends HookWidget { + @override + Widget build(BuildContext context) { + final active = useState(( + 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, + ); + } +} diff --git a/lib/widgets/account_switcher.dart b/lib/widgets/account_switcher.dart new file mode 100644 index 0000000..055b3cf --- /dev/null +++ b/lib/widgets/account_switcher.dart @@ -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( + 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"), + ), + ), + ], + ); + } +} diff --git a/lib/widgets/pages/select_server.dart b/lib/widgets/pages/select_server.dart index 4a35a6c..1e20f32 100644 --- a/lib/widgets/pages/select_server.dart +++ b/lib/widgets/pages/select_server.dart @@ -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()), diff --git a/lib/widgets/pages/verify.dart b/lib/widgets/pages/verify.dart index 4858f8c..4cff235 100644 --- a/lib/widgets/pages/verify.dart +++ b/lib/widgets/pages/verify.dart @@ -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()), diff --git a/lib/widgets/room_list.dart b/lib/widgets/room_list.dart index c43d103..b2d3b46 100644 --- a/lib/widgets/room_list.dart +++ b/lib/widgets/room_list.dart @@ -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,