From 32336961898642897e63ab151fd6f725163e08f0 Mon Sep 17 00:00:00 2001 From: Micah Morrison Date: Wed, 17 Jul 2024 12:08:27 -0400 Subject: [PATCH] Migrate anonymous instances to database (#1500) * Migrate anonymous instances to database * Fix tests --- lib/account/models/account.dart | 123 ++++++++++++++++--- lib/account/pages/login_page.dart | 19 +-- lib/account/widgets/account_placeholder.dart | 2 +- lib/account/widgets/profile_modal_body.dart | 92 ++++++++------ lib/community/widgets/community_drawer.dart | 4 +- lib/core/auth/bloc/auth_bloc.dart | 1 + lib/core/database/database.dart | 19 ++- lib/core/database/database.g.dart | 46 +++++-- lib/core/database/migrations.dart | 1 + lib/core/database/tables.dart | 1 + lib/core/enums/local_settings.dart | 5 - lib/instance/pages/instance_page.dart | 2 +- lib/search/pages/search_page.dart | 4 +- lib/thunder/bloc/thunder_bloc.dart | 41 ++----- lib/thunder/bloc/thunder_event.dart | 12 +- lib/thunder/bloc/thunder_state.dart | 9 +- lib/utils/preferences.dart | 15 +++ 17 files changed, 260 insertions(+), 136 deletions(-) diff --git a/lib/account/models/account.dart b/lib/account/models/account.dart index 1eb038e21..cdf42b4a1 100644 --- a/lib/account/models/account.dart +++ b/lib/account/models/account.dart @@ -10,48 +10,121 @@ class Account { final String? username; final String? displayName; final String? jwt; - final String? instance; + final String instance; + final bool anonymous; final int? userId; + final int index; const Account({ required this.id, this.username, this.displayName, this.jwt, - this.instance, + this.anonymous = false, + required this.instance, this.userId, + required this.index, }); - Account copyWith({String? id}) => Account( + Account copyWith({String? id, int? index}) => Account( id: id ?? this.id, username: username, jwt: jwt, instance: instance, userId: userId, + index: index ?? this.index, ); String get actorId => 'https://$instance/u/$username'; static Future insertAccount(Account account) async { // If we are given a brand new account to insert with an existing id, something is wrong. - assert(account.id.isEmpty); + assert(account.id.isEmpty && account.index == -1 && !account.anonymous); try { - int id = await database - .into(database.accounts) - .insert(AccountsCompanion.insert(username: Value(account.username), jwt: Value(account.jwt), instance: Value(account.instance), userId: Value(account.userId))); - return account.copyWith(id: id.toString()); + // Find the highest index in the current accounts + final int maxIndex = await (database.selectOnly(database.accounts)..addColumns([database.accounts.listIndex.max()])).getSingle().then((row) => row.read(database.accounts.listIndex.max()) ?? 0); + + // Assign the next index + final int newIndex = maxIndex + 1; + + int id = await database.into(database.accounts).insert( + AccountsCompanion.insert( + username: Value(account.username), + jwt: Value(account.jwt), + instance: Value(account.instance), + userId: Value(account.userId), + listIndex: newIndex, + ), + ); + + return account.copyWith(id: id.toString(), index: newIndex); + } catch (e) { + debugPrint(e.toString()); + return null; + } + } + + static Future insertAnonymousInstance(Account anonymousInstance) async { + // If we are given a brand new account to insert with an existing id, something is wrong. + assert(anonymousInstance.id.isEmpty && anonymousInstance.index == -1 && anonymousInstance.anonymous); + + try { + // Find the highest index in the current accounts + final int maxIndex = await (database.selectOnly(database.accounts)..addColumns([database.accounts.listIndex.max()])).getSingle().then((row) => row.read(database.accounts.listIndex.max()) ?? 0); + + // Assign the next index + final int newIndex = maxIndex + 1; + + int id = await database.into(database.accounts).insert( + AccountsCompanion.insert( + username: Value(anonymousInstance.username), + jwt: Value(anonymousInstance.jwt), + instance: Value(anonymousInstance.instance), + userId: Value(anonymousInstance.userId), + anonymous: Value(anonymousInstance.anonymous), + listIndex: newIndex, + ), + ); + + return anonymousInstance.copyWith(id: id.toString(), index: newIndex); } catch (e) { debugPrint(e.toString()); return null; } } - // A method that retrieves all accounts from the database + // A method that retrieves all accounts from the database. Does not include anonymous instances static Future> accounts() async { try { - return (await database.accounts.all().get()) - .map((account) => Account(id: account.id.toString(), username: account.username, jwt: account.jwt, instance: account.instance, userId: account.userId)) + return (await (database.select(database.accounts)..where((t) => t.anonymous.equals(false))).get()) + .map((account) => Account( + id: account.id.toString(), + username: account.username, + jwt: account.jwt, + instance: account.instance ?? '', + userId: account.userId, + index: account.listIndex, + )) + .toList(); + } catch (e) { + debugPrint(e.toString()); + return []; + } + } + + // A method that retrieves all anonymous instances from the database. Does not include logged in accounts. + static Future> anonymousInstances() async { + try { + return (await (database.select(database.accounts)..where((t) => t.anonymous.equals(true))).get()) + .map((account) => Account( + id: account.id.toString(), + username: account.username, + jwt: account.jwt, + instance: account.instance ?? '', + userId: account.userId, + index: account.listIndex, + )) .toList(); } catch (e) { debugPrint(e.toString()); @@ -65,7 +138,14 @@ class Account { try { return await (database.select(database.accounts)..where((t) => t.id.equals(int.parse(accountId)))).getSingleOrNull().then((account) { if (account == null) return null; - return Account(id: account.id.toString(), username: account.username, jwt: account.jwt, instance: account.instance, userId: account.userId); + return Account( + id: account.id.toString(), + username: account.username, + jwt: account.jwt, + instance: account.instance ?? '', + userId: account.userId, + index: account.listIndex, + ); }); } catch (e) { debugPrint(e.toString()); @@ -75,9 +155,14 @@ class Account { static Future updateAccount(Account account) async { try { - await database - .update(database.accounts) - .replace(AccountsCompanion(id: Value(int.parse(account.id)), username: Value(account.username), jwt: Value(account.jwt), instance: Value(account.instance), userId: Value(account.userId))); + await database.update(database.accounts).replace(AccountsCompanion( + id: Value(int.parse(account.id)), + username: Value(account.username), + jwt: Value(account.jwt), + instance: Value(account.instance), + userId: Value(account.userId), + listIndex: Value(account.index), + )); } catch (e) { debugPrint(e.toString()); } @@ -90,4 +175,12 @@ class Account { debugPrint(e.toString()); } } + + static Future deleteAnonymousInstance(String instance) async { + try { + await (database.delete(database.accounts)..where((t) => t.instance.equals(instance) & t.anonymous.equals(true))).go(); + } catch (e) { + debugPrint(e.toString()); + } + } } diff --git a/lib/account/pages/login_page.dart b/lib/account/pages/login_page.dart index 1fc4dd446..dc6e1a103 100644 --- a/lib/account/pages/login_page.dart +++ b/lib/account/pages/login_page.dart @@ -8,12 +8,10 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_typeahead/flutter_typeahead.dart'; import 'package:go_router/go_router.dart'; import 'package:lemmy_api_client/v3.dart'; -import 'package:shared_preferences/shared_preferences.dart'; +import 'package:thunder/account/models/account.dart'; import 'package:thunder/core/auth/bloc/auth_bloc.dart'; -import 'package:thunder/core/enums/local_settings.dart'; import 'package:thunder/core/singletons/lemmy_client.dart'; -import 'package:thunder/core/singletons/preferences.dart'; import 'package:thunder/instances.dart'; import 'package:thunder/shared/dialogs.dart'; import 'package:thunder/shared/snackbar.dart'; @@ -38,6 +36,7 @@ class _LoginPageState extends State with SingleTickerProviderStateMix late TextEditingController _passwordTextEditingController; late TextEditingController _totpTextEditingController; late TextEditingController _instanceTextEditingController; + final FocusNode _usernameFieldFocusNode = FocusNode(); bool showPassword = false; bool fieldsFilledIn = false; @@ -327,7 +326,11 @@ class _LoginPageState extends State with SingleTickerProviderStateMix errorMaxLines: 2, ), enableSuggestions: false, - onSubmitted: (controller.text.isNotEmpty && widget.anonymous) ? (_) => _addAnonymousInstance(context) : null, + onSubmitted: !widget.anonymous + ? (_) => _usernameFieldFocusNode.requestFocus() + : controller.text.isNotEmpty + ? (_) => _addAnonymousInstance(context) + : null, ), suggestionsCallback: (String pattern) { if (pattern.isNotEmpty != true) { @@ -358,6 +361,7 @@ class _LoginPageState extends State with SingleTickerProviderStateMix keyboardType: TextInputType.url, autocorrect: false, controller: _usernameTextEditingController, + focusNode: _usernameFieldFocusNode, autofillHints: const [AutofillHints.username], decoration: InputDecoration( isDense: true, @@ -470,9 +474,8 @@ class _LoginPageState extends State with SingleTickerProviderStateMix final AppLocalizations l10n = AppLocalizations.of(context)!; if (await isLemmyInstance(_instanceTextEditingController.text)) { - final SharedPreferences prefs = (await UserPreferences.instance).sharedPreferences; - List anonymousInstances = prefs.getStringList(LocalSettings.anonymousInstances.name) ?? ['lemmy.ml']; - if (anonymousInstances.contains(_instanceTextEditingController.text)) { + final List anonymousInstances = await Account.anonymousInstances(); + if (anonymousInstances.any((anonymousInstance) => anonymousInstance.instance == _instanceTextEditingController.text)) { setState(() { instanceValidated = false; instanceError = AppLocalizations.of(context)!.instanceHasAlreadyBenAdded(currentInstance ?? ''); @@ -502,7 +505,7 @@ class _LoginPageState extends State with SingleTickerProviderStateMix if (acceptedContentWarning) { context.read().add(const LogOutOfAllAccounts()); - context.read().add(OnAddAnonymousInstance(_instanceTextEditingController.text)); + await Account.insertAnonymousInstance(Account(id: '', instance: _instanceTextEditingController.text, index: -1, anonymous: true)); context.read().add(OnSetCurrentAnonymousInstance(_instanceTextEditingController.text)); widget.popRegister(); } diff --git a/lib/account/widgets/account_placeholder.dart b/lib/account/widgets/account_placeholder.dart index 37078d5dd..0de8fbd5b 100644 --- a/lib/account/widgets/account_placeholder.dart +++ b/lib/account/widgets/account_placeholder.dart @@ -10,7 +10,7 @@ class AccountPlaceholder extends StatelessWidget { @override Widget build(BuildContext context) { final ThemeData theme = Theme.of(context); - String anonymousInstance = context.watch().state.currentAnonymousInstance; + String anonymousInstance = context.watch().state.currentAnonymousInstance ?? ''; return Center( child: Padding( diff --git a/lib/account/widgets/profile_modal_body.dart b/lib/account/widgets/profile_modal_body.dart index 2cb2690d6..b6dc440b2 100644 --- a/lib/account/widgets/profile_modal_body.dart +++ b/lib/account/widgets/profile_modal_body.dart @@ -158,14 +158,25 @@ class _ProfileSelectState extends State { fetchAnonymousInstances(); } - return BlocListener( - listener: (context, state) {}, - listenWhen: (previous, current) { - if ((previous.anonymousInstances.length != current.anonymousInstances.length) || (previous.currentAnonymousInstance != current.currentAnonymousInstance)) { - anonymousInstances = null; - } - return true; - }, + return MultiBlocListener( + listeners: [ + BlocListener( + listener: (context, state) {}, + listenWhen: (previous, current) { + if (previous.currentAnonymousInstance != current.currentAnonymousInstance) { + anonymousInstances = null; + } + return true; + }, + ), + BlocListener( + listener: (context, state) { + if (state.status == AuthStatus.success && state.isLoggedIn == true) { + context.read().add(const OnSetCurrentAnonymousInstance(null)); + } + }, + ), + ], child: Scaffold( backgroundColor: theme.cardColor, body: CustomScrollView( @@ -264,7 +275,7 @@ class _ProfileSelectState extends State { ), subtitle: Wrap( children: [ - Text(accounts![index].account.instance?.replaceAll('https://', '') ?? 'N/A'), + Text(accounts![index].account.instance.replaceAll('https://', '') ?? 'N/A'), AnimatedSize( duration: const Duration(milliseconds: 250), child: accounts![index].version == null @@ -365,15 +376,15 @@ class _ProfileSelectState extends State { return Padding( padding: const EdgeInsets.fromLTRB(10, 0, 10, 0), child: Material( - color: currentAccountId == null && currentAnonymousInstance == anonymousInstances![realIndex].instance ? selectedColor : null, + color: currentAccountId == null && currentAnonymousInstance == anonymousInstances![realIndex].anonymousInstance.instance ? selectedColor : null, borderRadius: BorderRadius.circular(50), child: InkWell( - onTap: (currentAccountId == null && currentAnonymousInstance == anonymousInstances![realIndex].instance) + onTap: (currentAccountId == null && currentAnonymousInstance == anonymousInstances![realIndex].anonymousInstance.instance) ? null : () async { context.read().add(const LogOutOfAllAccounts()); - context.read().add(OnSetCurrentAnonymousInstance(anonymousInstances![realIndex].instance)); - context.read().add(InstanceChanged(instance: anonymousInstances![realIndex].instance)); + context.read().add(OnSetCurrentAnonymousInstance(anonymousInstances![realIndex].anonymousInstance.instance)); + context.read().add(InstanceChanged(instance: anonymousInstances![realIndex].anonymousInstance.instance)); context.pop(); }, borderRadius: BorderRadius.circular(50), @@ -407,7 +418,7 @@ class _ProfileSelectState extends State { height: 12, child: Material( borderRadius: BorderRadius.circular(10), - color: currentAccountId == null && currentAnonymousInstance == anonymousInstances![realIndex].instance ? selectedColor : null, + color: currentAccountId == null && currentAnonymousInstance == anonymousInstances![realIndex].anonymousInstance.instance ? selectedColor : null, ), ), ), @@ -442,7 +453,7 @@ class _ProfileSelectState extends State { ), subtitle: Wrap( children: [ - Text(anonymousInstances![realIndex].instance), + Text(anonymousInstances![realIndex].anonymousInstance.instance), AnimatedSize( duration: const Duration(milliseconds: 250), child: anonymousInstances![realIndex].version == null @@ -494,17 +505,19 @@ class _ProfileSelectState extends State { ], ), trailing: !widget.quickSelectMode && ((accounts?.length ?? 0) > 0 || anonymousInstances!.length > 1) - ? (currentAccountId == null && currentAnonymousInstance == anonymousInstances![realIndex].instance) + ? (currentAccountId == null && currentAnonymousInstance == anonymousInstances![realIndex].anonymousInstance.instance) ? IconButton( icon: Icon(Icons.logout, semanticLabel: AppLocalizations.of(context)!.removeInstance), onPressed: () async { - context.read().add(OnRemoveAnonymousInstance(anonymousInstances![realIndex].instance)); + await Account.deleteAnonymousInstance(anonymousInstances![realIndex].anonymousInstance.instance); if (anonymousInstances!.length > 1) { context .read() - .add(OnSetCurrentAnonymousInstance(anonymousInstances!.lastWhere((instance) => instance != anonymousInstances![realIndex]).instance)); - context.read().add(InstanceChanged(instance: anonymousInstances!.lastWhere((instance) => instance != anonymousInstances![realIndex]).instance)); + .add(OnSetCurrentAnonymousInstance(anonymousInstances!.lastWhere((instance) => instance != anonymousInstances![realIndex]).anonymousInstance.instance)); + context + .read() + .add(InstanceChanged(instance: anonymousInstances!.lastWhere((instance) => instance != anonymousInstances![realIndex]).anonymousInstance.instance)); } else { context.read().add(SwitchAccount(accountId: accounts!.last.account.id)); } @@ -518,7 +531,7 @@ class _ProfileSelectState extends State { semanticLabel: AppLocalizations.of(context)!.removeInstance, ), onPressed: () async { - context.read().add(OnRemoveAnonymousInstance(anonymousInstances![realIndex].instance)); + await Account.deleteAnonymousInstance(anonymousInstances![realIndex].anonymousInstance.instance); setState(() { anonymousInstances = null; }); @@ -552,16 +565,17 @@ class _ProfileSelectState extends State { if (context.mounted && activeAccountId != null && await showLogOutDialog(context)) { setState(() => loggingOutId = activeAccountId); - await Future.delayed(const Duration(milliseconds: 1000), () { + await Future.delayed(const Duration(milliseconds: 1000), () async { if ((anonymousInstances?.length ?? 0) > 0) { - thunderBloc.add(OnSetCurrentAnonymousInstance(anonymousInstances!.last.instance)); - authBloc.add(InstanceChanged(instance: anonymousInstances!.last.instance)); + thunderBloc.add(OnSetCurrentAnonymousInstance(anonymousInstances!.last.anonymousInstance.instance)); + authBloc.add(InstanceChanged(instance: anonymousInstances!.last.anonymousInstance.instance)); } else if (accountsNotCurrent.isNotEmpty) { authBloc.add(SwitchAccount(accountId: accountsNotCurrent.last.id)); } else { // No accounts and no anonymous instances left. Create a new one. authBloc.add(const LogOutOfAllAccounts()); - thunderBloc.add(const OnAddAnonymousInstance('lemmy.ml')); + await Account.insertAnonymousInstance(const Account(id: '', instance: 'lemmy.ml', index: -1, anonymous: true)); + thunderBloc.add(const OnSetCurrentAnonymousInstance(null)); thunderBloc.add(const OnSetCurrentAnonymousInstance('lemmy.ml')); } @@ -576,9 +590,10 @@ class _ProfileSelectState extends State { Future fetchAccounts() async { List accounts = await Account.accounts(); - List accountsExtended = await Future.wait(accounts.map((Account account) async { + List accountsExtended = (await Future.wait(accounts.map((Account account) async { return AccountExtended(account: account, instance: account.instance, instanceIcon: null); - })).timeout(const Duration(seconds: 5)); + })).timeout(const Duration(seconds: 5))) + ..sort((a, b) => a.account.index.compareTo(b.account.index)); // Intentionally don't await these here fetchInstanceInfo(accountsExtended); @@ -614,8 +629,9 @@ class _ProfileSelectState extends State { }); } - void fetchAnonymousInstances() { - final List anonymousInstances = context.read().state.anonymousInstances.map((instance) => AnonymousInstanceExtended(instance: instance)).toList(); + Future fetchAnonymousInstances() async { + final List anonymousInstances = (await Account.anonymousInstances()).map((anonymousInstance) => AnonymousInstanceExtended(anonymousInstance: anonymousInstance)).toList() + ..sort((a, b) => a.anonymousInstance.index.compareTo(b.anonymousInstance.index)); fetchAnonymousInstanceInfo(anonymousInstances); pingAnonymousInstances(anonymousInstances); @@ -624,27 +640,27 @@ class _ProfileSelectState extends State { } Future fetchAnonymousInstanceInfo(List anonymousInstancesExtended) async { - anonymousInstancesExtended.forEach((anonymousInstance) async { - final GetInstanceInfoResponse instanceInfoResponse = await getInstanceInfo(anonymousInstance.instance).timeout( + anonymousInstancesExtended.forEach((anonymousInstanceExtended) async { + final GetInstanceInfoResponse instanceInfoResponse = await getInstanceInfo(anonymousInstanceExtended.anonymousInstance.instance).timeout( const Duration(seconds: 5), onTimeout: () => const GetInstanceInfoResponse(success: false), ); setState(() { - anonymousInstance.instanceIcon = instanceInfoResponse.icon; - anonymousInstance.version = instanceInfoResponse.version; - anonymousInstance.alive = instanceInfoResponse.success; + anonymousInstanceExtended.instanceIcon = instanceInfoResponse.icon; + anonymousInstanceExtended.version = instanceInfoResponse.version; + anonymousInstanceExtended.alive = instanceInfoResponse.success; }); }); } Future pingAnonymousInstances(List anonymousInstancesExtended) async { - anonymousInstancesExtended.forEach((anonymousInstance) async { + anonymousInstancesExtended.forEach((anonymousInstanceExtended) async { PingData pingData = await Ping( - anonymousInstance.instance, + anonymousInstanceExtended.anonymousInstance.instance, count: 1, timeout: 5, ).stream.first; - setState(() => anonymousInstance.latency = pingData.response?.time); + setState(() => anonymousInstanceExtended.latency = pingData.response?.time); }); } } @@ -663,11 +679,11 @@ class AccountExtended { /// Wrapper class around Account with support for instance icon class AnonymousInstanceExtended { - String instance; + Account anonymousInstance; String? instanceIcon; String? version; Duration? latency; bool? alive; - AnonymousInstanceExtended({required this.instance, this.instanceIcon}); + AnonymousInstanceExtended({required this.anonymousInstance, this.instanceIcon}); } diff --git a/lib/community/widgets/community_drawer.dart b/lib/community/widgets/community_drawer.dart index f4c7295c6..005294f0d 100644 --- a/lib/community/widgets/community_drawer.dart +++ b/lib/community/widgets/community_drawer.dart @@ -151,7 +151,7 @@ class UserDrawerItem extends StatelessWidget { AccountState accountState = context.watch().state; bool isLoggedIn = context.watch().state.isLoggedIn; - String anonymousInstance = context.watch().state.currentAnonymousInstance; + String? anonymousInstance = context.watch().state.currentAnonymousInstance; return Material( color: theme.colorScheme.surface, @@ -195,7 +195,7 @@ class UserDrawerItem extends StatelessWidget { ], ), Text( - isLoggedIn ? authState.account?.instance ?? '' : anonymousInstance, + isLoggedIn ? authState.account?.instance ?? '' : anonymousInstance ?? '', style: theme.textTheme.bodyMedium, maxLines: 1, overflow: TextOverflow.ellipsis, diff --git a/lib/core/auth/bloc/auth_bloc.dart b/lib/core/auth/bloc/auth_bloc.dart index 91c821d15..4394430c7 100644 --- a/lib/core/auth/bloc/auth_bloc.dart +++ b/lib/core/auth/bloc/auth_bloc.dart @@ -155,6 +155,7 @@ class AuthBloc extends Bloc { jwt: loginResponse.jwt, instance: instance, userId: getSiteResponse.myUser?.localUserView.person.id, + index: -1, ); account = await Account.insertAccount(account); diff --git a/lib/core/database/database.dart b/lib/core/database/database.dart index f477d5aed..bdd2ada23 100644 --- a/lib/core/database/database.dart +++ b/lib/core/database/database.dart @@ -20,7 +20,7 @@ class AppDatabase extends _$AppDatabase { AppDatabase() : super(_openConnection()); @override - int get schemaVersion => 4; + int get schemaVersion => 5; @override MigrationStrategy get migration => MigrationStrategy( @@ -41,15 +41,28 @@ class AppDatabase extends _$AppDatabase { // If we are migrating from 3 or lower to anything higher if (from <= 3 && to > 3) { - // Create the custom_thumbnail on the drafts table + // Create the custom_thumbnail column on the drafts table await customStatement('ALTER TABLE drafts ADD COLUMN custom_thumbnail TEXT'); } + // If we are migrating from 4 or lower to anything higher + if (from <= 4 && to > 4) { + // Add the list_index column to the Accounts table and use id as the default value + await customStatement('ALTER TABLE accounts ADD COLUMN list_index INTEGER DEFAULT -1;'); + await customStatement('UPDATE accounts SET list_index = id'); + } + // --- DOWNGRADES --- + // If we are downgrading from 5 or higher to 4 or lower + if (from >= 5 && to <= 4) { + // Drop the list_index column from Accounts + await customStatement('ALTER TABLE accounts DROP COLUMN list_index'); + } + // If we are downgrading from 4 or higher to 3 or lower if (from >= 4 && to <= 3) { - // Drop the custom_thumbnail column from Accounts + // Drop the custom_thumbnail column from drafts await customStatement('ALTER TABLE drafts DROP COLUMN custom_thumbnail'); } diff --git a/lib/core/database/database.g.dart b/lib/core/database/database.g.dart index 3d8ff4cd1..0592ca077 100644 --- a/lib/core/database/database.g.dart +++ b/lib/core/database/database.g.dart @@ -28,8 +28,11 @@ class $AccountsTable extends Accounts with TableInfo<$AccountsTable, Account> { static const VerificationMeta _userIdMeta = const VerificationMeta('userId'); @override late final GeneratedColumn userId = GeneratedColumn('user_id', aliasedName, true, type: DriftSqlType.int, requiredDuringInsert: false); + static const VerificationMeta _listIndexMeta = const VerificationMeta('listIndex'); @override - List get $columns => [id, username, jwt, instance, anonymous, userId]; + late final GeneratedColumn listIndex = GeneratedColumn('list_index', aliasedName, false, type: DriftSqlType.int, requiredDuringInsert: true); + @override + List get $columns => [id, username, jwt, instance, anonymous, userId, listIndex]; @override String get aliasedName => _alias ?? actualTableName; @override @@ -57,6 +60,11 @@ class $AccountsTable extends Accounts with TableInfo<$AccountsTable, Account> { if (data.containsKey('user_id')) { context.handle(_userIdMeta, userId.isAcceptableOrUnknown(data['user_id']!, _userIdMeta)); } + if (data.containsKey('list_index')) { + context.handle(_listIndexMeta, listIndex.isAcceptableOrUnknown(data['list_index']!, _listIndexMeta)); + } else if (isInserting) { + context.missing(_listIndexMeta); + } return context; } @@ -72,6 +80,7 @@ class $AccountsTable extends Accounts with TableInfo<$AccountsTable, Account> { instance: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}instance']), anonymous: attachedDatabase.typeMapping.read(DriftSqlType.bool, data['${effectivePrefix}anonymous'])!, userId: attachedDatabase.typeMapping.read(DriftSqlType.int, data['${effectivePrefix}user_id']), + listIndex: attachedDatabase.typeMapping.read(DriftSqlType.int, data['${effectivePrefix}list_index'])!, ); } @@ -88,7 +97,8 @@ class Account extends DataClass implements Insertable { final String? instance; final bool anonymous; final int? userId; - const Account({required this.id, this.username, this.jwt, this.instance, required this.anonymous, this.userId}); + final int listIndex; + const Account({required this.id, this.username, this.jwt, this.instance, required this.anonymous, this.userId, required this.listIndex}); @override Map toColumns(bool nullToAbsent) { final map = {}; @@ -106,6 +116,7 @@ class Account extends DataClass implements Insertable { if (!nullToAbsent || userId != null) { map['user_id'] = Variable(userId); } + map['list_index'] = Variable(listIndex); return map; } @@ -117,6 +128,7 @@ class Account extends DataClass implements Insertable { instance: instance == null && nullToAbsent ? const Value.absent() : Value(instance), anonymous: Value(anonymous), userId: userId == null && nullToAbsent ? const Value.absent() : Value(userId), + listIndex: Value(listIndex), ); } @@ -129,6 +141,7 @@ class Account extends DataClass implements Insertable { instance: serializer.fromJson(json['instance']), anonymous: serializer.fromJson(json['anonymous']), userId: serializer.fromJson(json['userId']), + listIndex: serializer.fromJson(json['listIndex']), ); } @override @@ -141,6 +154,7 @@ class Account extends DataClass implements Insertable { 'instance': serializer.toJson(instance), 'anonymous': serializer.toJson(anonymous), 'userId': serializer.toJson(userId), + 'listIndex': serializer.toJson(listIndex), }; } @@ -150,7 +164,8 @@ class Account extends DataClass implements Insertable { Value jwt = const Value.absent(), Value instance = const Value.absent(), bool? anonymous, - Value userId = const Value.absent()}) => + Value userId = const Value.absent(), + int? listIndex}) => Account( id: id ?? this.id, username: username.present ? username.value : this.username, @@ -158,6 +173,7 @@ class Account extends DataClass implements Insertable { instance: instance.present ? instance.value : this.instance, anonymous: anonymous ?? this.anonymous, userId: userId.present ? userId.value : this.userId, + listIndex: listIndex ?? this.listIndex, ); @override String toString() { @@ -167,13 +183,14 @@ class Account extends DataClass implements Insertable { ..write('jwt: $jwt, ') ..write('instance: $instance, ') ..write('anonymous: $anonymous, ') - ..write('userId: $userId') + ..write('userId: $userId, ') + ..write('listIndex: $listIndex') ..write(')')) .toString(); } @override - int get hashCode => Object.hash(id, username, jwt, instance, anonymous, userId); + int get hashCode => Object.hash(id, username, jwt, instance, anonymous, userId, listIndex); @override bool operator ==(Object other) => identical(this, other) || @@ -183,7 +200,8 @@ class Account extends DataClass implements Insertable { other.jwt == this.jwt && other.instance == this.instance && other.anonymous == this.anonymous && - other.userId == this.userId); + other.userId == this.userId && + other.listIndex == this.listIndex); } class AccountsCompanion extends UpdateCompanion { @@ -193,6 +211,7 @@ class AccountsCompanion extends UpdateCompanion { final Value instance; final Value anonymous; final Value userId; + final Value listIndex; const AccountsCompanion({ this.id = const Value.absent(), this.username = const Value.absent(), @@ -200,6 +219,7 @@ class AccountsCompanion extends UpdateCompanion { this.instance = const Value.absent(), this.anonymous = const Value.absent(), this.userId = const Value.absent(), + this.listIndex = const Value.absent(), }); AccountsCompanion.insert({ this.id = const Value.absent(), @@ -208,7 +228,8 @@ class AccountsCompanion extends UpdateCompanion { this.instance = const Value.absent(), this.anonymous = const Value.absent(), this.userId = const Value.absent(), - }); + required int listIndex, + }) : listIndex = Value(listIndex); static Insertable custom({ Expression? id, Expression? username, @@ -216,6 +237,7 @@ class AccountsCompanion extends UpdateCompanion { Expression? instance, Expression? anonymous, Expression? userId, + Expression? listIndex, }) { return RawValuesInsertable({ if (id != null) 'id': id, @@ -224,10 +246,11 @@ class AccountsCompanion extends UpdateCompanion { if (instance != null) 'instance': instance, if (anonymous != null) 'anonymous': anonymous, if (userId != null) 'user_id': userId, + if (listIndex != null) 'list_index': listIndex, }); } - AccountsCompanion copyWith({Value? id, Value? username, Value? jwt, Value? instance, Value? anonymous, Value? userId}) { + AccountsCompanion copyWith({Value? id, Value? username, Value? jwt, Value? instance, Value? anonymous, Value? userId, Value? listIndex}) { return AccountsCompanion( id: id ?? this.id, username: username ?? this.username, @@ -235,6 +258,7 @@ class AccountsCompanion extends UpdateCompanion { instance: instance ?? this.instance, anonymous: anonymous ?? this.anonymous, userId: userId ?? this.userId, + listIndex: listIndex ?? this.listIndex, ); } @@ -259,6 +283,9 @@ class AccountsCompanion extends UpdateCompanion { if (userId.present) { map['user_id'] = Variable(userId.value); } + if (listIndex.present) { + map['list_index'] = Variable(listIndex.value); + } return map; } @@ -270,7 +297,8 @@ class AccountsCompanion extends UpdateCompanion { ..write('jwt: $jwt, ') ..write('instance: $instance, ') ..write('anonymous: $anonymous, ') - ..write('userId: $userId') + ..write('userId: $userId, ') + ..write('listIndex: $listIndex') ..write(')')) .toString(); } diff --git a/lib/core/database/migrations.dart b/lib/core/database/migrations.dart index 7c578f894..59bad7604 100644 --- a/lib/core/database/migrations.dart +++ b/lib/core/database/migrations.dart @@ -49,6 +49,7 @@ Future migrateToSQLite(AppDatabase database, {Database? originalDB, bool d jwt: Value(record['jwt']), instance: Value(record['instance']), userId: Value(record['userId']), + listIndex: -1, )); String? activeProfileId = prefs?.getString('active_profile_id'); diff --git a/lib/core/database/tables.dart b/lib/core/database/tables.dart index f1d8f200d..55f12759b 100644 --- a/lib/core/database/tables.dart +++ b/lib/core/database/tables.dart @@ -8,6 +8,7 @@ class Accounts extends Table { TextColumn get instance => text().nullable()(); BoolColumn get anonymous => boolean().withDefault(const Constant(false))(); IntColumn get userId => integer().nullable()(); + IntColumn get listIndex => integer()(); } class Favorites extends Table { diff --git a/lib/core/enums/local_settings.dart b/lib/core/enums/local_settings.dart index 650b36632..1e0ade60b 100644 --- a/lib/core/enums/local_settings.dart +++ b/lib/core/enums/local_settings.dart @@ -309,9 +309,6 @@ enum LocalSettings { enableExperimentalFeatures(name: 'setting_enable_experimental_features', key: 'enableExperimentalFeatures', category: LocalSettingsCategories.debug), imageDimensionTimeout(name: 'setting_image_dimension_timeout', key: 'imageDimensionTimeout', category: LocalSettingsCategories.debug), - draftsCache(name: 'drafts_cache', key: ''), - - anonymousInstances(name: 'setting_anonymous_instances', key: ''), currentAnonymousInstance(name: 'setting_current_anonymous_instance', key: ''), // This setting exists purely to save/load the user's selected advanced share options @@ -353,8 +350,6 @@ enum LocalSettings { /// Defines the settings that are excluded from import/export static List importExportExcludedSettings = [ - LocalSettings.draftsCache, - LocalSettings.anonymousInstances, LocalSettings.currentAnonymousInstance, LocalSettings.advancedShareOptions, ]; diff --git a/lib/instance/pages/instance_page.dart b/lib/instance/pages/instance_page.dart index a21281079..cb7184629 100644 --- a/lib/instance/pages/instance_page.dart +++ b/lib/instance/pages/instance_page.dart @@ -77,7 +77,7 @@ class _InstancePageState extends State { final bool isUserLoggedIn = context.read().state.isLoggedIn; final String? accountInstance = context.read().state.account?.instance; - final String currentAnonymousInstance = context.read().state.currentAnonymousInstance; + final String? currentAnonymousInstance = context.read().state.currentAnonymousInstance; return BlocListener( listener: (context, state) { diff --git a/lib/search/pages/search_page.dart b/lib/search/pages/search_page.dart index 5a05152e6..7aed8819c 100644 --- a/lib/search/pages/search_page.dart +++ b/lib/search/pages/search_page.dart @@ -165,7 +165,7 @@ class _SearchPageState extends State with AutomaticKeepAliveClientMi final bool isUserLoggedIn = context.read().state.isLoggedIn; final String? accountInstance = context.read().state.account?.instance; - final String currentAnonymousInstance = context.read().state.currentAnonymousInstance; + final String? currentAnonymousInstance = context.read().state.currentAnonymousInstance; return BlocProvider( create: (context) => FeedBloc(lemmyClient: LemmyClient.instance), @@ -481,7 +481,7 @@ class _SearchPageState extends State with AutomaticKeepAliveClientMi ); } - Widget _getSearchBody(BuildContext context, SearchState state, bool isUserLoggedIn, String? accountInstance, String currentAnonymousInstance) { + Widget _getSearchBody(BuildContext context, SearchState state, bool isUserLoggedIn, String? accountInstance, String? currentAnonymousInstance) { final ThemeData theme = Theme.of(context); final AppLocalizations l10n = AppLocalizations.of(context)!; final ThunderBloc thunderBloc = context.watch(); diff --git a/lib/thunder/bloc/thunder_bloc.dart b/lib/thunder/bloc/thunder_bloc.dart index fcc0cf63b..1915507a2 100644 --- a/lib/thunder/bloc/thunder_bloc.dart +++ b/lib/thunder/bloc/thunder_bloc.dart @@ -60,17 +60,8 @@ class ThunderBloc extends Bloc { _onFabSummonToggle, transformer: throttleDroppable(throttleDuration), ); - on( - _onAddAnonymousInstance, - transformer: throttleDroppable(throttleDuration), - ); - on( - _onRemoveAnonymousInstance, - transformer: throttleDroppable(throttleDuration), - ); on( _onSetCurrentAnonymousInstance, - transformer: throttleDroppable(throttleDuration), ); } @@ -271,9 +262,6 @@ class ThunderBloc extends Bloc { VideoAutoPlay videoAutoPlay = VideoAutoPlay.values.byName(prefs.getString(LocalSettings.videoAutoPlay.name) ?? VideoAutoPlay.never.name); VideoPlayBackSpeed videoDefaultPlaybackSpeed = VideoPlayBackSpeed.values.byName(prefs.getString(LocalSettings.videoDefaultPlaybackSpeed.name) ?? VideoPlayBackSpeed.normal.name); - List anonymousInstances = prefs.getStringList(LocalSettings.anonymousInstances.name) ?? - // If the user already has some accouts (i.e., an upgrade), we don't want to just throw an anonymous instance at them - ((await Account.accounts()).isNotEmpty ? [] : ['lemmy.ml']); String currentAnonymousInstance = prefs.getString(LocalSettings.currentAnonymousInstance.name) ?? 'lemmy.ml'; return emit(state.copyWith( @@ -441,7 +429,6 @@ class ThunderBloc extends Bloc { videoAutoPlay: videoAutoPlay, videoDefaultPlaybackSpeed: videoDefaultPlaybackSpeed, - anonymousInstances: anonymousInstances, currentAnonymousInstance: currentAnonymousInstance, )); } catch (e) { @@ -457,29 +444,15 @@ class ThunderBloc extends Bloc { emit(state.copyWith(isFabSummoned: !state.isFabSummoned)); } - void _onAddAnonymousInstance(OnAddAnonymousInstance event, Emitter emit) async { - final SharedPreferences prefs = (await UserPreferences.instance).sharedPreferences; - - prefs.setStringList(LocalSettings.anonymousInstances.name, [...state.anonymousInstances, event.instance]); - - emit(state.copyWith(anonymousInstances: [...state.anonymousInstances, event.instance])); - } - - void _onRemoveAnonymousInstance(OnRemoveAnonymousInstance event, Emitter emit) async { - final List instances = state.anonymousInstances; - instances.remove(event.instance); - - final SharedPreferences prefs = (await UserPreferences.instance).sharedPreferences; - prefs.setStringList(LocalSettings.anonymousInstances.name, instances); - - emit(state.copyWith(anonymousInstances: instances)); - } - void _onSetCurrentAnonymousInstance(OnSetCurrentAnonymousInstance event, Emitter emit) async { - LemmyClient.instance.changeBaseUrl(event.instance); - final SharedPreferences prefs = (await UserPreferences.instance).sharedPreferences; - prefs.setString(LocalSettings.currentAnonymousInstance.name, event.instance); + + if (event.instance != null) { + LemmyClient.instance.changeBaseUrl(event.instance!); + prefs.setString(LocalSettings.currentAnonymousInstance.name, event.instance!); + } else { + prefs.remove(LocalSettings.currentAnonymousInstance.name); + } emit(state.copyWith(currentAnonymousInstance: event.instance)); } diff --git a/lib/thunder/bloc/thunder_event.dart b/lib/thunder/bloc/thunder_event.dart index 894f052ad..f8cbcc2eb 100644 --- a/lib/thunder/bloc/thunder_event.dart +++ b/lib/thunder/bloc/thunder_event.dart @@ -28,17 +28,7 @@ class OnFabSummonToggle extends ThunderEvent { const OnFabSummonToggle(this.isFabSummoned); } -class OnAddAnonymousInstance extends ThunderEvent { - final String instance; - const OnAddAnonymousInstance(this.instance); -} - -class OnRemoveAnonymousInstance extends ThunderEvent { - final String instance; - const OnRemoveAnonymousInstance(this.instance); -} - class OnSetCurrentAnonymousInstance extends ThunderEvent { - final String instance; + final String? instance; const OnSetCurrentAnonymousInstance(this.instance); } diff --git a/lib/thunder/bloc/thunder_state.dart b/lib/thunder/bloc/thunder_state.dart index e4a59cffa..4efc9c82f 100644 --- a/lib/thunder/bloc/thunder_state.dart +++ b/lib/thunder/bloc/thunder_state.dart @@ -167,7 +167,6 @@ class ThunderState extends Equatable { /// -------------------------- Accessibility Related Settings -------------------------- this.reduceAnimations = false, - this.anonymousInstances = const ['lemmy.ml'], this.currentAnonymousInstance = 'lemmy.ml', /// --------------------------------- UI Events --------------------------------- @@ -340,8 +339,7 @@ class ThunderState extends Equatable { /// -------------------------- Accessibility Related Settings -------------------------- final bool reduceAnimations; - final List anonymousInstances; - final String currentAnonymousInstance; + final String? currentAnonymousInstance; /// ------------------ Video Player ------------------------ final bool videoAutoFullscreen; @@ -513,7 +511,6 @@ class ThunderState extends Equatable { /// -------------------------- Accessibility Related Settings -------------------------- bool? reduceAnimations, - List? anonymousInstances, String? currentAnonymousInstance, /// ------------------ Video Player ------------------------ @@ -697,8 +694,7 @@ class ThunderState extends Equatable { videoAutoMute: videoAutoMute ?? this.videoAutoMute, videoAutoPlay: videoAutoPlay ?? this.videoAutoPlay, videoDefaultPlaybackSpeed: videoDefaultPlaybackSpeed ?? this.videoDefaultPlaybackSpeed, - anonymousInstances: anonymousInstances ?? this.anonymousInstances, - currentAnonymousInstance: currentAnonymousInstance ?? this.currentAnonymousInstance, + currentAnonymousInstance: currentAnonymousInstance, /// ------------------ Video Player ------------------------ @@ -875,7 +871,6 @@ class ThunderState extends Equatable { /// -------------------------- Accessibility Related Settings -------------------------- reduceAnimations, - anonymousInstances, currentAnonymousInstance, /// --------------------------------- UI Events --------------------------------- diff --git a/lib/utils/preferences.dart b/lib/utils/preferences.dart index 9ec47e8fa..5a63b5694 100644 --- a/lib/utils/preferences.dart +++ b/lib/utils/preferences.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:lemmy_api_client/v3.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:thunder/account/models/account.dart'; import 'package:thunder/account/models/draft.dart'; import 'package:thunder/comment/view/create_comment_page.dart'; import 'package:thunder/community/pages/create_post_page.dart'; @@ -114,4 +115,18 @@ Future performSharedPreferencesMigration() async { if (defaultListingType == ListingType.subscribed) { await prefs.setString(LocalSettings.defaultFeedListingType.name, DEFAULT_LISTING_TYPE.name); } + + // Migrate anonymous instances to database + final List? anonymousInstances = prefs.getStringList('setting_anonymous_instances'); + try { + for (String instance in anonymousInstances ?? []) { + Account anonymousInstance = Account(id: '', instance: instance, index: -1, anonymous: true); + Account.insertAnonymousInstance(anonymousInstance); + } + + // If we've gotten this far without exception, it's safe to delete the shared pref eky + prefs.remove('setting_anonymous_instances'); + } catch (e) { + debugPrint('Cannot migrate anonymous instances from SharedPreferences: $e'); + } }