Add account switcher

Fixes #146
This commit is contained in:
Henry Hiles 2026-11-02 17:00:54 -05:00
commit 641341ed90
Signed by: Henry-Hiles
SSH key fingerprint: SHA256:VKQUdS31Q90KvX7EkKMHMBpUspcmItAh86a+v7PGiIs
17 changed files with 667 additions and 144 deletions

View 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,
);
}

View 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"),
);
}

View file

@ -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<int> {
class ClientController(final String? accountId) extends AsyncNotifier<int> {
@override
Future<int> build() async {
debugPrint("Setting Gomuks env...");
if (Platform.isAndroid || Platform.isIOS) {
final env = {
"GOMUKS_ROOT": (await getApplicationSupportDirectory()).path,
"GOMUKS_CACHE_HOME": (await getApplicationCacheDirectory()).path,
};
for (final MapEntry(:key, :value) in env.entries) {
final keyPtr = key.toNativeUtf8().cast<Char>();
final valuePtr = value.toNativeUtf8().cast<Char>();
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<Char>();
final valuePtr = value.toNativeUtf8().cast<Char>();
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<int> {
ref.onDispose(() => GomuksDestroy(handle));
if (accountId != null) {
await _callGomuksMethod(handle, {}, GomuksHandlePushAsync);
}
return handle;
}
static Future<({Directory data, Directory cache})>
_accountsDirectories() async => (
data: Directory(
join((await getApplicationSupportDirectory()).path, "accounts"),
),
cache: Directory(
join((await getApplicationCacheDirectory()).path, "accounts"),
),
);
static Future<IMap<String, String>> _environment(String accountId) async {
if (accountId == Account.legacyId) {
// Keep the directories gomuks used before account switching existed
final isMobile = Platform.isAndroid || Platform.isIOS;
return .new({
"GOMUKS_ROOT": isMobile
? (await getApplicationSupportDirectory()).path
: Platform.environment["GOMUKS_ROOT"] ?? "",
"GOMUKS_CACHE_HOME": isMobile
? (await getApplicationCacheDirectory()).path
: Platform.environment["GOMUKS_CACHE_HOME"] ?? "",
});
}
final (:data, :cache) = await _accountsDirectories();
return .new({
"GOMUKS_ROOT": join(data.path, accountId),
"GOMUKS_CACHE_HOME": join(cache.path, accountId),
});
}
// Can't be done on removal, since the old container still has them open
Future<void> _deleteRemovedAccounts() async {
final ids = (await ref.read(AccountsController.provider.future))
.map((account) => account.id)
.toISet();
final (:data, :cache) = await _accountsDirectories();
final removed = await Stream.fromIterable([data, cache])
.where((directory) => directory.existsSync())
.asyncExpand((directory) => directory.list())
.where((entry) => !ids.contains(basename(entry.path)))
.toList();
await Future.wait(removed.map((entry) => entry.delete(recursive: true)));
}
/// Calls gomuks with [data] as JSON, returning its decoded response.
Future<dynamic> callGomuksMethod(
Map<String, dynamic> data,
@ -118,8 +172,18 @@ class ClientController extends AsyncNotifier<int> {
Pointer<NativeFunction<Void Function(GomuksResponse)>> callback,
)
call,
) async {
final handle = await future;
) async => _callGomuksMethod(await future, data, call);
static Future<dynamic> _callGomuksMethod(
int handle,
Map<String, dynamic> data,
void Function(
int handle,
GomuksOwnedBuffer data,
Pointer<NativeFunction<Void Function(GomuksResponse)>> callback,
)
call,
) {
final buffer = data.toGomuksOwnedBuffer();
final completer = Completer<dynamic>();
@ -421,6 +485,12 @@ class ClientController extends AsyncNotifier<int> {
}
static final provider = AsyncNotifierProvider<ClientController, int>(
ClientController.new,
() => ClientController(null),
);
/// The instance for an account other than the active one.
static final forAccount =
AsyncNotifierProvider.family<ClientController, int, String>(
ClientController.new,
);
}

View file

@ -3,9 +3,12 @@ import "dart:ffi";
import "dart:io";
import "dart:isolate";
import "package:collection/collection.dart";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter/foundation.dart";
import "package:nexus/controllers/account_data.dart";
import "package:nexus/controllers/accounts.dart";
import "package:nexus/controllers/active_account.dart";
import "package:nexus/controllers/app_paused.dart";
import "package:nexus/controllers/client.dart";
import "package:nexus/controllers/client_state.dart";
@ -152,6 +155,45 @@ class GomuksListenerController extends AsyncNotifier<void> {
if (!ref.mounted) return;
// Cached so the account switcher and push routing have them while the
// account isn't active
ref.listen(
ClientStateController.provider.select(
(state) => (state?.isInitialized, state?.userId, state?.deviceId),
),
(_, ids) => Future(() async {
final id = ref.read(ActiveAccountController.provider);
final accounts = ref.read(AccountsController.provider.notifier);
switch (ids) {
// Logged out, so it shouldn't show as an account anymore
case (true, null, _):
await accounts.reset(id);
case (_, final userId?, final deviceId):
final previous = (await ref.read(
AccountsController.provider.future,
)).firstWhereOrNull((account) => account.id == id);
final profile =
(await ref
.read(ClientController.provider.notifier)
.getProfile(userId))
.profile;
// Fetching the profile fails while offline, so keep what was cached
await accounts.save(
.new(
id: id,
userId: userId,
deviceId: deviceId,
displayName: profile.displayName ?? previous?.displayName,
avatar: profile.avatarUrl ?? previous?.avatar,
),
);
}
}).catchError(showError),
fireImmediately: true,
);
if (Platform.isAndroid || Platform.isIOS) {
// Mobile OSes block network access for backgrounded apps, so pause
// syncing instead of letting it fail.

View file

@ -1,23 +1,38 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/active_account.dart";
import "package:nexus/controllers/shared_prefs.dart";
import "package:nexus/models/account.dart";
class KeyController(final String key) extends AsyncNotifier<String?> {
static const String spaceKey = "space";
static const String roomKey = "room";
static const String pushKeyKey = "pushKey";
static const String accountsKey = "accounts";
static const String activeAccountKey = "activeAccount";
static const ISet<String> _perAccountKeys = ISetConst({spaceKey, roomKey});
String get _prefsKey {
final account = ref.read(ActiveAccountController.provider);
return _perAccountKeys.contains(key) && account != Account.legacyId
? "$account/$key"
: key;
}
@override
Future<String?> build() =>
ref.watch(SharedPrefsController.provider).getString(key);
ref.watch(SharedPrefsController.provider).getString(_prefsKey);
Future<void> set(String? value) async {
final prefs = ref.read(SharedPrefsController.provider);
state = .data(value);
if (value == null) {
await prefs.remove(key);
await prefs.remove(_prefsKey);
} else {
await prefs.setString(key, value);
await prefs.setString(_prefsKey, value);
}
}

View file

@ -4,12 +4,12 @@ import "dart:io";
import "package:flutter/services.dart";
import "package:material_ui/material_ui.dart";
import "package:nexus/controllers/active_account.dart";
import "package:nexus/controllers/portal.dart";
import "package:nexus/controllers/sidebar_page.dart";
import "package:nexus/main.dart";
import "package:flutter_local_notifications/flutter_local_notifications.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/notification_request.dart";
import "package:nexus/models/push_notification.dart";
import "package:xdg_desktop_portal/xdg_desktop_portal.dart";
@ -41,17 +41,7 @@ class NotificationController
macOS: darwin,
),
onDidReceiveNotificationResponse: (details) {
if (details.payload == null) return;
if (int.tryParse(details.payload!) case final rowId?) {
navigatorKey.currentState?.popUntil((route) => route.isFirst);
ref
.read(SidebarPageController.provider.notifier)
.set(.notifications);
ref
.read(NotificationRequestController.provider.notifier)
.request(rowId);
}
if (details.payload case final payload?) _open(payload);
},
);
}
@ -64,21 +54,36 @@ class NotificationController
return;
}
if (int.tryParse(call.arguments) case int rowId) {
navigatorKey.currentState?.popUntil((route) => route.isFirst);
ref.read(SidebarPageController.provider.notifier).set(.notifications);
ref
.read(NotificationRequestController.provider.notifier)
.request(rowId);
}
if (call.arguments case final String payload) _open(payload);
});
ref.onDispose(() => notificationChannel.setMethodCallHandler(null));
}
return notifications;
}
static String payloadFor(String accountId, int rowId) => "$accountId:$rowId";
void _open(String payload) {
final (accountId, rowId) = switch (payload.split(":")) {
[final accountId, final rowId] => (accountId, int.tryParse(rowId)),
// From before notifications were per account
[final rowId] => (null, int.tryParse(rowId)),
_ => (null, null),
};
if (rowId == null) return;
navigatorKey.currentState?.popUntil((route) => route.isFirst);
ref.read(SidebarPageController.provider.notifier).set(.notifications);
ref
.read(ActiveAccountController.provider.notifier)
.switchTo(
accountId ?? ref.read(ActiveAccountController.provider),
notificationRowId: rowId,
)
.onError(showError);
}
Future<bool> requestPermissions() async {
if (Platform.isLinux) {
return true;
@ -149,10 +154,12 @@ class NotificationController
}
/// Passing [message] shows it as a chat message in the conversation for
/// [threadId], naming the sender in group chats.
/// [threadId], naming the sender in group chats. [subtitle] is shown on
/// platforms that support it.
Future<void> send({
required int id,
required String title,
String? subtitle,
required String body,
String? payload,
String? threadId,
@ -208,6 +215,7 @@ class NotificationController
: null;
final darwin = DarwinNotificationDetails(
subtitle: subtitle,
presentSound: sound,
threadIdentifier: threadId,
);
@ -222,6 +230,7 @@ class NotificationController
? .new(
"general",
"General",
subText: subtitle,
silent: !sound,
when: timestamp?.millisecondsSinceEpoch,
largeIcon: icon == null
@ -232,6 +241,7 @@ class NotificationController
: .new(
"messages",
"Messages",
subText: subtitle,
silent: !sound,
when: timestamp?.millisecondsSinceEpoch,
category: .message,

View file

@ -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,
),
]),
),
]),

View file

@ -1,9 +1,14 @@
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/notification_request.dart";
import "package:nexus/models/sidebar_page.dart";
class SidebarPageController extends Notifier<SidebarPage> {
// Show the notification that switched accounts, if one did
@override
SidebarPage build() => .rooms;
SidebarPage build() =>
ref.read(NotificationRequestController.provider) == null
? .rooms
: .notifications;
void set(SidebarPage page) => state = page;

View file

@ -2,11 +2,15 @@ import "dart:async";
import "dart:convert";
import "dart:io";
import "package:collection/collection.dart";
import "package:flutter/foundation.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:intl/intl.dart";
import "package:nexus/controllers/accounts.dart";
import "package:nexus/controllers/active_account.dart";
import "package:nexus/controllers/selected_room.dart";
import "package:nexus/controllers/notification.dart";
import "package:nexus/controllers/client_state.dart";
import "package:nexus/controllers/push_key.dart";
import "package:nexus/main.dart";
import "package:nexus/controllers/client.dart";
@ -18,6 +22,19 @@ import "package:window_manager/window_manager.dart";
class UnifiedPushController extends AsyncNotifier<String?> {
Completer<void>? _endpoint;
/// The account [instance] (its device ID) belongs to, or the active one for
/// devices registered before account switching.
Future<String> _accountFor(String instance) async =>
(await ref.read(AccountsController.provider.future))
.firstWhereOrNull((account) => account.deviceId == instance)
?.id ??
ref.read(ActiveAccountController.provider);
ClientController _clientFor(String accountId) =>
accountId == ref.read(ActiveAccountController.provider)
? ref.read(ClientController.provider.notifier)
: ref.read(ClientController.forAccount(accountId).notifier);
@override
Future<String?> build() async {
if (!Platform.isLinux && !Platform.isAndroid) return null;
@ -36,23 +53,21 @@ class UnifiedPushController extends AsyncNotifier<String?> {
.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<String?> {
"Failed to decrypt notification. Try toggling off and on UnifiedPush in settings.",
);
}
final notification = await ref
.read(ClientController.provider.notifier)
.handlePush(json.decode(String.fromCharCodes(message.content)));
final accountId = await _accountFor(instance);
final client = _clientFor(accountId);
final isActive =
accountId == ref.read(ActiveAccountController.provider);
final notification = await client.handlePush(
json.decode(String.fromCharCodes(message.content)),
);
if (notification == null ||
(!isInBackground &&
isActive &&
(!Platform.isAndroid &&
(WindowManager.instance.getCurrent()?.isFocused ?? true)) &&
ref.read(SelectedRoomController.provider) ==
@ -96,7 +116,8 @@ class UnifiedPushController extends AsyncNotifier<String?> {
return;
}
final client = ref.read(ClientController.provider.notifier);
final hasOtherAccounts =
(await ref.read(AccountsController.provider.future)).length > 1;
final icon = notification.roomAvatar == null
? null
@ -116,17 +137,20 @@ class UnifiedPushController extends AsyncNotifier<String?> {
await ref
.read(NotificationController.provider.notifier)
.send(
// Android stacks messages into one conversation per room
// Android stacks messages into one conversation per room (and
// account, since two accounts can share a room)
id:
(Platform.isAndroid
? notification.roomId
: notification.eventId)
"$accountId/${Platform.isAndroid ? notification.roomId : notification.eventId}"
.hashCode &
0x7fffffff,
threadId: notification.roomId,
threadId: "$accountId/${notification.roomId}",
title: notification.roomName,
subtitle: hasOtherAccounts ? notification.self.id : null,
body: notification.text,
payload: notification.eventRowId.toString(),
payload: NotificationController.payloadFor(
accountId,
notification.eventRowId,
),
timestamp: notification.timestamp,
icon: icon,
sound: notification.sound,
@ -147,14 +171,32 @@ class UnifiedPushController extends AsyncNotifier<String?> {
if (!registered || isInBackground) return null;
try {
if (await deregisterIfDisabled()) return null;
ref.listen(
ClientStateController.provider.select((state) => state?.isLoggedIn),
(previous, isLoggedIn) => Future(() async {
if (isLoggedIn != true) return;
if (await deregisterIfDisabled()) return;
return await register();
} catch (error, stackTrace) {
showError(error, stackTrace);
return null;
}
// On startup, only accounts with push on (which have a push key).
// After logging in, push is on by default.
final deviceId = ref.read(ClientStateController.provider)?.deviceId;
if (previous != false &&
(deviceId == null ||
await ref.read(PushKeyController.provider(deviceId).future) ==
null)) {
return;
}
try {
await register();
} on UnsupportedError {
// The settings tile explains that the homeserver can't send Web Push
}
}).catchError(showError),
fireImmediately: true,
);
return null;
}
/// Registers with [distributor], or the current/default distributor if none
@ -166,12 +208,13 @@ class UnifiedPushController extends AsyncNotifier<String?> {
await deregister();
}
// 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<String?> {
final keyProvider = PushKeyController.provider(instance);
final key = await ref.read(keyProvider.future);
final accountId = await _accountFor(instance);
if (key != null) {
await ref
.read(ClientController.provider.notifier)
await _clientFor(accountId)
.deregisterPusher(.new(appId: "nexus.federated.nexus", pushKey: key));
await ref.read(keyProvider.notifier).set(null);
} else {
@ -251,7 +294,9 @@ class UnifiedPushController extends AsyncNotifier<String?> {
}
await UnifiedPush.unregister(instance);
state = .data(null);
if (accountId == ref.read(ActiveAccountController.provider)) {
state = .data(null);
}
}
static final provider = AsyncNotifierProvider<UnifiedPushController, String?>(

View 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);
}
}

View file

@ -5,6 +5,7 @@ import "package:flutter/foundation.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:material_emoji_picker/material_emoji_picker.dart";
import "package:media_kit/media_kit.dart";
import "package:nexus/controllers/active_account.dart";
import "package:nexus/controllers/client.dart";
import "package:nexus/controllers/client_state.dart";
import "package:nexus/controllers/gomuks_listener.dart";
@ -20,6 +21,7 @@ import "package:nexus/widgets/pages/chat.dart";
import "package:nexus/widgets/pages/select_server.dart";
import "package:nexus/widgets/pages/settings.dart";
import "package:nexus/widgets/pages/verify.dart";
import "package:nexus/widgets/account_scope.dart";
import "package:nexus/widgets/appbar.dart";
import "package:nexus/widgets/error_dialog.dart";
import "package:nexus/widgets/loading.dart";
@ -74,10 +76,17 @@ void main(List<String> args) async {
Platform.environment["FLUTTER_HEADLESS"] != null ||
args.contains("--unifiedpush-bg");
final accountId = await ActiveAccountController.stored();
if (isInBackground) {
await ProviderContainer()
.read(UnifiedPushController.provider.future)
.timeout(Duration(seconds: 5));
await ProviderContainer(
overrides: [
// Nothing to switch to in the background
ActiveAccountController.provider.overrideWith(
() => ActiveAccountController(accountId, (_) {}),
),
],
).read(UnifiedPushController.provider.future).timeout(Duration(seconds: 5));
await Future.delayed(Duration(seconds: 10));
exit(0);
@ -90,7 +99,8 @@ void main(List<String> args) async {
}
runApp(
ProviderScope(
AccountScope(
initialAccountId: accountId,
retry: (_, _) => null,
observers: [
// Change false to true if you want debug information on provider reloads

26
lib/models/account.dart Normal file
View 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);
}

View 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,
);
}
}

View 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"),
),
),
],
);
}
}

View file

@ -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()),

View file

@ -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()),

View file

@ -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,