diff --git a/assets/i18n/en.json b/assets/i18n/en.json index d2dcd8c..035682e 100644 --- a/assets/i18n/en.json +++ b/assets/i18n/en.json @@ -2,8 +2,9 @@ "app": { "title": "VeilidChat" }, - "app_bar": { - "settings_tooltip": "Settings" + "menu": { + "settings_tooltip": "Settings", + "add_account_tooltip": "Add Account" }, "pager": { "chats": "Chats", @@ -18,7 +19,7 @@ "lock_type_password": "password" }, "new_account_page": { - "titlebar": "Create a new account", + "titlebar": "Create A New Account", "header": "Account Profile", "create": "Create", "instructions": "This information will be shared with the people you invite to connect with you on VeilidChat.", @@ -26,12 +27,37 @@ "name": "Name", "pronouns": "Pronouns" }, + "edit_account_page": { + "titlebar": "Edit Account", + "header": "Account Profile", + "update": "Update", + "instructions": "This information will be shared with the people you invite to connect with you on VeilidChat.", + "error": "Account modification error", + "name": "Name", + "pronouns": "Pronouns", + "remove_account": "Remove Account", + "delete_identity": "Delete Identity", + "remove_account_confirm": "Confirm Account Removal?", + "remove_account_description": "Remove account from this device only", + "delete_identity_description": "Delete identity and all messages completely", + "delete_identity_confirm_message": "This action is PERMANENT, and your identity will no longer be recoverable with the recovery key. This will not remove your messages you have sent from other people's devices.", + "confirm_are_you_sure": "Are you sure you want to do this?" + }, + "show_recovery_key_page": { + "titlebar": "Save Recovery Key", + "instructions": "You must save this recovery key somewhere safe. This key is the ONLY way to recover your VeilidChat account in the event of a forgotton password or a lost, stolen, or compromised device.", + "instructions_options": "Here are some options for your recovery key:", + "instructions_print": "Print the recovery key and keep it somewhere safe", + "instructions_write": "View the recovery key and write it down on paper", + "instructions_send": "Send the recovery key to another app to save it" + }, "button": { "ok": "Ok", "cancel": "Cancel", "delete": "Delete", "accept": "Accept", "reject": "Reject", + "finish": "Finish", "waiting_for_network": "Waiting For Network" }, "toast": { diff --git a/lib/account_manager/cubits/account_info_cubit.dart b/lib/account_manager/cubits/account_info_cubit.dart new file mode 100644 index 0000000..d9d93fc --- /dev/null +++ b/lib/account_manager/cubits/account_info_cubit.dart @@ -0,0 +1,40 @@ +import 'dart:async'; + +import 'package:bloc/bloc.dart'; +import 'package:veilid_support/veilid_support.dart'; + +import '../models/models.dart'; +import '../repository/account_repository.dart'; + +class AccountInfoCubit extends Cubit { + AccountInfoCubit( + {required AccountRepository accountRepository, + required TypedKey superIdentityRecordKey}) + : _accountRepository = accountRepository, + super(accountRepository.getAccountInfo(superIdentityRecordKey)!) { + // Subscribe to streams + _accountRepositorySubscription = _accountRepository.stream.listen((change) { + switch (change) { + case AccountRepositoryChange.activeLocalAccount: + case AccountRepositoryChange.localAccounts: + case AccountRepositoryChange.userLogins: + final acctInfo = + accountRepository.getAccountInfo(superIdentityRecordKey); + if (acctInfo != null) { + emit(acctInfo); + } + break; + } + }); + } + + @override + Future close() async { + await super.close(); + await _accountRepositorySubscription.cancel(); + } + + final AccountRepository _accountRepository; + late final StreamSubscription + _accountRepositorySubscription; +} diff --git a/lib/account_manager/cubits/account_record_cubit.dart b/lib/account_manager/cubits/account_record_cubit.dart index 60ddd88..4028d65 100644 --- a/lib/account_manager/cubits/account_record_cubit.dart +++ b/lib/account_manager/cubits/account_record_cubit.dart @@ -1,16 +1,51 @@ import 'dart:async'; +import 'package:protobuf/protobuf.dart'; import 'package:veilid_support/veilid_support.dart'; import '../../proto/proto.dart' as proto; +import '../account_manager.dart'; -class AccountRecordCubit extends DefaultDHTRecordCubit { - AccountRecordCubit({ - required super.open, - }) : super(decodeState: proto.Account.fromBuffer); +typedef AccountRecordState = proto.Account; + +/// The saved state of a VeilidChat Account on the DHT +/// Used to synchronize status, profile, and options for a specific account +/// across multiple clients. This DHT record is the 'source of truth' for an +/// account and is privately encrypted with an owned record from the 'userLogin' +/// tabledb-local storage, encrypted by the unlock code for the account. +class AccountRecordCubit extends DefaultDHTRecordCubit { + AccountRecordCubit( + {required LocalAccount localAccount, required UserLogin userLogin}) + : super( + decodeState: proto.Account.fromBuffer, + open: () => _open(localAccount, userLogin)); + + static Future _open( + LocalAccount localAccount, UserLogin userLogin) async { + // Record not yet open, do it + final pool = DHTRecordPool.instance; + final record = await pool.openRecordOwned( + userLogin.accountRecordInfo.accountRecord, + debugName: 'AccountRecordCubit::_open::AccountRecord', + parent: localAccount.superIdentity.currentInstance.recordKey); + + return record; + } @override Future close() async { await super.close(); } + + //////////////////////////////////////////////////////////////////////////// + // Public Interface + + Future updateProfile(proto.Profile profile) async { + await record.eventualUpdateProtobuf(proto.Account.fromBuffer, (old) async { + if (old == null || old.profile == profile) { + return null; + } + return old.deepCopy()..profile = profile; + }); + } } diff --git a/lib/account_manager/cubits/active_local_account_cubit.dart b/lib/account_manager/cubits/active_local_account_cubit.dart index 843cc59..58a9cb8 100644 --- a/lib/account_manager/cubits/active_local_account_cubit.dart +++ b/lib/account_manager/cubits/active_local_account_cubit.dart @@ -3,7 +3,7 @@ import 'dart:async'; import 'package:bloc/bloc.dart'; import 'package:veilid_support/veilid_support.dart'; -import '../repository/account_repository/account_repository.dart'; +import '../repository/account_repository.dart'; class ActiveLocalAccountCubit extends Cubit { ActiveLocalAccountCubit(AccountRepository accountRepository) diff --git a/lib/account_manager/cubits/cubits.dart b/lib/account_manager/cubits/cubits.dart index 6d2875d..da268ae 100644 --- a/lib/account_manager/cubits/cubits.dart +++ b/lib/account_manager/cubits/cubits.dart @@ -1,4 +1,7 @@ +export 'account_info_cubit.dart'; export 'account_record_cubit.dart'; export 'active_local_account_cubit.dart'; export 'local_accounts_cubit.dart'; +export 'per_account_collection_bloc_map_cubit.dart'; +export 'per_account_collection_cubit.dart'; export 'user_logins_cubit.dart'; diff --git a/lib/account_manager/cubits/local_accounts_cubit.dart b/lib/account_manager/cubits/local_accounts_cubit.dart index 484bdbc..704d8c5 100644 --- a/lib/account_manager/cubits/local_accounts_cubit.dart +++ b/lib/account_manager/cubits/local_accounts_cubit.dart @@ -1,12 +1,17 @@ import 'dart:async'; import 'package:bloc/bloc.dart'; +import 'package:bloc_advanced_tools/bloc_advanced_tools.dart'; import 'package:fast_immutable_collections/fast_immutable_collections.dart'; +import 'package:veilid_support/veilid_support.dart'; import '../models/models.dart'; -import '../repository/account_repository/account_repository.dart'; +import '../repository/account_repository.dart'; -class LocalAccountsCubit extends Cubit> { +typedef LocalAccountsState = IList; + +class LocalAccountsCubit extends Cubit + with StateMapFollowable { LocalAccountsCubit(AccountRepository accountRepository) : _accountRepository = accountRepository, super(accountRepository.getLocalAccounts()) { @@ -30,6 +35,14 @@ class LocalAccountsCubit extends Cubit> { await _accountRepositorySubscription.cancel(); } + /// StateMapFollowable ///////////////////////// + @override + IMap getStateMap(LocalAccountsState state) { + final stateValue = state; + return IMap.fromIterable(stateValue, + keyMapper: (e) => e.superIdentity.recordKey, valueMapper: (e) => e); + } + final AccountRepository _accountRepository; late final StreamSubscription _accountRepositorySubscription; diff --git a/lib/account_manager/cubits/per_account_collection_bloc_map_cubit.dart b/lib/account_manager/cubits/per_account_collection_bloc_map_cubit.dart new file mode 100644 index 0000000..f5334c1 --- /dev/null +++ b/lib/account_manager/cubits/per_account_collection_bloc_map_cubit.dart @@ -0,0 +1,63 @@ +import 'package:bloc_advanced_tools/bloc_advanced_tools.dart'; +import 'package:provider/provider.dart'; +import 'package:veilid_support/veilid_support.dart'; + +import '../../account_manager/account_manager.dart'; + +typedef PerAccountCollectionBlocMapState + = BlocMapState; + +/// Map of the logged in user accounts to their PerAccountCollectionCubit +/// Ensures there is an single account record cubit for each logged in account +class PerAccountCollectionBlocMapCubit extends BlocMapCubit + with StateMapFollower { + PerAccountCollectionBlocMapCubit({ + required Locator locator, + required AccountRepository accountRepository, + }) : _locator = locator, + _accountRepository = accountRepository { + // Follow the local accounts cubit + follow(locator()); + } + + // Add account record cubit + Future _addPerAccountCollectionCubit( + {required TypedKey superIdentityRecordKey}) async => + add(() => MapEntry( + superIdentityRecordKey, + PerAccountCollectionCubit( + locator: _locator, + accountInfoCubit: AccountInfoCubit( + accountRepository: _accountRepository, + superIdentityRecordKey: superIdentityRecordKey)))); + + /// StateFollower ///////////////////////// + + @override + Future removeFromState(TypedKey key) => remove(key); + + @override + Future updateState( + TypedKey key, LocalAccount? oldValue, LocalAccount newValue) async { + // Don't replace unless this is a totally different account + // The sub-cubit's subscription will update our state later + if (oldValue != null) { + if (oldValue.superIdentity.recordKey != + newValue.superIdentity.recordKey) { + throw StateError( + 'should remove LocalAccount and make a new one, not change it, if ' + 'the superidentity record key has changed'); + } + // This never changes anything that should result in rebuildin the + // sub-cubit + return; + } + await _addPerAccountCollectionCubit( + superIdentityRecordKey: newValue.superIdentity.recordKey); + } + + //////////////////////////////////////////////////////////////////////////// + final AccountRepository _accountRepository; + final Locator _locator; +} diff --git a/lib/account_manager/cubits/per_account_collection_cubit.dart b/lib/account_manager/cubits/per_account_collection_cubit.dart new file mode 100644 index 0000000..6cb8d5d --- /dev/null +++ b/lib/account_manager/cubits/per_account_collection_cubit.dart @@ -0,0 +1,299 @@ +import 'dart:async'; + +import 'package:async_tools/async_tools.dart'; +import 'package:bloc_advanced_tools/bloc_advanced_tools.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:provider/provider.dart'; +import 'package:veilid_support/veilid_support.dart'; + +import '../../chat/chat.dart'; +import '../../chat_list/chat_list.dart'; +import '../../contact_invitation/contact_invitation.dart'; +import '../../contacts/contacts.dart'; +import '../../conversation/conversation.dart'; +import '../../proto/proto.dart' as proto; +import '../account_manager.dart'; + +class PerAccountCollectionCubit extends Cubit { + PerAccountCollectionCubit({ + required Locator locator, + required this.accountInfoCubit, + }) : _locator = locator, + super(_initialState(accountInfoCubit)) { + // Async Init + _initWait.add(_init); + } + + @override + Future close() async { + await _initWait(); + + await _processor.close(); + await accountInfoCubit.close(); + await _accountRecordSubscription?.cancel(); + await accountRecordCubit?.close(); + + await activeSingleContactChatBlocMapCubitUpdater.close(); + await activeConversationsBlocMapCubitUpdater.close(); + await activeChatCubitUpdater.close(); + await waitingInvitationsBlocMapCubitUpdater.close(); + await chatListCubitUpdater.close(); + await contactListCubitUpdater.close(); + await contactInvitationListCubitUpdater.close(); + + await super.close(); + } + + Future _init() async { + // subscribe to accountInfo changes + _processor.follow(accountInfoCubit.stream, accountInfoCubit.state, + _followAccountInfoState); + } + + static PerAccountCollectionState _initialState( + AccountInfoCubit accountInfoCubit) => + PerAccountCollectionState( + accountInfo: accountInfoCubit.state, + avAccountRecordState: const AsyncValue.loading(), + contactInvitationListCubit: null, + accountInfoCubit: null, + accountRecordCubit: null, + contactListCubit: null, + waitingInvitationsBlocMapCubit: null, + activeChatCubit: null, + chatListCubit: null, + activeConversationsBlocMapCubit: null, + activeSingleContactChatBlocMapCubit: null); + + Future _followAccountInfoState(AccountInfo accountInfo) async { + // Get the next state + var nextState = state.copyWith(accountInfo: accountInfo); + + // Update AccountRecordCubit + if (accountInfo.userLogin == null) { + /////////////// Not logged in ///////////////// + + // Unsubscribe AccountRecordCubit + await _accountRecordSubscription?.cancel(); + _accountRecordSubscription = null; + + // Update state to 'loading' + nextState = _updateAccountRecordState(nextState, null); + emit(nextState); + + // Close AccountRecordCubit + await accountRecordCubit?.close(); + accountRecordCubit = null; + } else { + ///////////////// Logged in /////////////////// + + // Create AccountRecordCubit + accountRecordCubit ??= AccountRecordCubit( + localAccount: accountInfo.localAccount, + userLogin: accountInfo.userLogin!); + + // Update state to value + nextState = + _updateAccountRecordState(nextState, accountRecordCubit!.state); + emit(nextState); + + // Subscribe AccountRecordCubit + _accountRecordSubscription ??= + accountRecordCubit!.stream.listen((avAccountRecordState) { + emit(_updateAccountRecordState(state, avAccountRecordState)); + }); + } + } + + PerAccountCollectionState _updateAccountRecordState( + PerAccountCollectionState prevState, + AsyncValue? avAccountRecordState) { + // Get next state + final nextState = + prevState.copyWith(avAccountRecordState: avAccountRecordState); + + // Get bloc parameters + final accountInfo = nextState.accountInfo; + + // ContactInvitationListCubit + final contactInvitationListRecordPointer = nextState + .avAccountRecordState?.asData?.value.contactInvitationRecords + .toVeilid(); + + final contactInvitationListCubit = contactInvitationListCubitUpdater.update( + accountInfo.userLogin == null || + contactInvitationListRecordPointer == null + ? null + : (accountInfo, contactInvitationListRecordPointer)); + + // ContactListCubit + final contactListRecordPointer = + nextState.avAccountRecordState?.asData?.value.contactList.toVeilid(); + + final contactListCubit = contactListCubitUpdater.update( + accountInfo.userLogin == null || contactListRecordPointer == null + ? null + : (accountInfo, contactListRecordPointer)); + + // WaitingInvitationsBlocMapCubit + final waitingInvitationsBlocMapCubit = waitingInvitationsBlocMapCubitUpdater + .update(accountInfo.userLogin == null || + contactInvitationListCubit == null || + contactListCubit == null + ? null + : ( + accountInfo, + accountRecordCubit!, + contactInvitationListCubit, + contactListCubit, + )); + + // ActiveChatCubit + final activeChatCubit = activeChatCubitUpdater + .update((accountInfo.userLogin == null) ? null : true); + + // ChatListCubit + final chatListRecordPointer = + nextState.avAccountRecordState?.asData?.value.chatList.toVeilid(); + + final chatListCubit = chatListCubitUpdater.update( + accountInfo.userLogin == null || + chatListRecordPointer == null || + activeChatCubit == null + ? null + : (accountInfo, chatListRecordPointer, activeChatCubit)); + + // ActiveConversationsBlocMapCubit + final activeConversationsBlocMapCubit = + activeConversationsBlocMapCubitUpdater.update( + accountRecordCubit == null || + chatListCubit == null || + contactListCubit == null + ? null + : ( + accountInfo, + accountRecordCubit!, + chatListCubit, + contactListCubit + )); + + // ActiveSingleContactChatBlocMapCubit + final activeSingleContactChatBlocMapCubit = + activeSingleContactChatBlocMapCubitUpdater.update( + accountInfo.userLogin == null || + activeConversationsBlocMapCubit == null + ? null + : ( + accountInfo, + activeConversationsBlocMapCubit, + )); + + // Update available blocs in our state + return nextState.copyWith( + contactInvitationListCubit: contactInvitationListCubit, + accountInfoCubit: accountInfoCubit, + accountRecordCubit: accountRecordCubit, + contactListCubit: contactListCubit, + waitingInvitationsBlocMapCubit: waitingInvitationsBlocMapCubit, + activeChatCubit: activeChatCubit, + chatListCubit: chatListCubit, + activeConversationsBlocMapCubit: activeConversationsBlocMapCubit, + activeSingleContactChatBlocMapCubit: + activeSingleContactChatBlocMapCubit); + } + + T collectionLocator() { + if (T is AccountInfoCubit) { + return accountInfoCubit as T; + } + if (T is AccountRecordCubit) { + return accountRecordCubit! as T; + } + if (T is ContactInvitationListCubit) { + return contactInvitationListCubitUpdater.bloc! as T; + } + if (T is ContactListCubit) { + return contactListCubitUpdater.bloc! as T; + } + if (T is WaitingInvitationsBlocMapCubit) { + return waitingInvitationsBlocMapCubitUpdater.bloc! as T; + } + if (T is ActiveChatCubit) { + return activeChatCubitUpdater.bloc! as T; + } + if (T is ChatListCubit) { + return chatListCubitUpdater.bloc! as T; + } + if (T is ActiveConversationsBlocMapCubit) { + return activeConversationsBlocMapCubitUpdater.bloc! as T; + } + if (T is ActiveSingleContactChatBlocMapCubit) { + return activeSingleContactChatBlocMapCubitUpdater.bloc! as T; + } + return _locator(); + } + + final Locator _locator; + final _processor = SingleStateProcessor(); + final _initWait = WaitSet(); + + // Per-account cubits regardless of login state + final AccountInfoCubit accountInfoCubit; + + // Per logged-in account cubits + AccountRecordCubit? accountRecordCubit; + StreamSubscription>? + _accountRecordSubscription; + final contactInvitationListCubitUpdater = BlocUpdater< + ContactInvitationListCubit, (AccountInfo, OwnedDHTRecordPointer)>( + create: (params) => ContactInvitationListCubit( + accountInfo: params.$1, + contactInvitationListRecordPointer: params.$2, + )); + final contactListCubitUpdater = + BlocUpdater( + create: (params) => ContactListCubit( + accountInfo: params.$1, + contactListRecordPointer: params.$2, + )); + final waitingInvitationsBlocMapCubitUpdater = BlocUpdater< + WaitingInvitationsBlocMapCubit, + ( + AccountInfo, + AccountRecordCubit, + ContactInvitationListCubit, + ContactListCubit + )>( + create: (params) => WaitingInvitationsBlocMapCubit( + accountInfo: params.$1, + accountRecordCubit: params.$2, + contactInvitationListCubit: params.$3, + contactListCubit: params.$4, + )); + final activeChatCubitUpdater = + BlocUpdater(create: (_) => ActiveChatCubit(null)); + final chatListCubitUpdater = BlocUpdater( + create: (params) => ChatListCubit( + accountInfo: params.$1, + chatListRecordPointer: params.$2, + activeChatCubit: params.$3)); + final activeConversationsBlocMapCubitUpdater = BlocUpdater< + ActiveConversationsBlocMapCubit, + (AccountInfo, AccountRecordCubit, ChatListCubit, ContactListCubit)>( + create: (params) => ActiveConversationsBlocMapCubit( + accountInfo: params.$1, + accountRecordCubit: params.$2, + chatListCubit: params.$3, + contactListCubit: params.$4)); + final activeSingleContactChatBlocMapCubitUpdater = BlocUpdater< + ActiveSingleContactChatBlocMapCubit, + ( + AccountInfo, + ActiveConversationsBlocMapCubit, + )>( + create: (params) => ActiveSingleContactChatBlocMapCubit( + accountInfo: params.$1, + activeConversationsBlocMapCubit: params.$2, + )); +} diff --git a/lib/account_manager/cubits/user_logins_cubit.dart b/lib/account_manager/cubits/user_logins_cubit.dart index 9fa6974..734ced3 100644 --- a/lib/account_manager/cubits/user_logins_cubit.dart +++ b/lib/account_manager/cubits/user_logins_cubit.dart @@ -4,9 +4,11 @@ import 'package:bloc/bloc.dart'; import 'package:fast_immutable_collections/fast_immutable_collections.dart'; import '../models/models.dart'; -import '../repository/account_repository/account_repository.dart'; +import '../repository/account_repository.dart'; -class UserLoginsCubit extends Cubit> { +typedef UserLoginsState = IList; + +class UserLoginsCubit extends Cubit { UserLoginsCubit(AccountRepository accountRepository) : _accountRepository = accountRepository, super(accountRepository.getUserLogins()) { @@ -29,6 +31,7 @@ class UserLoginsCubit extends Cubit> { await super.close(); await _accountRepositorySubscription.cancel(); } + //////////////////////////////////////////////////////////////////////////// final AccountRepository _accountRepository; late final StreamSubscription diff --git a/lib/account_manager/models/account_info.dart b/lib/account_manager/models/account_info.dart index 7f2e058..12ed5e1 100644 --- a/lib/account_manager/models/account_info.dart +++ b/lib/account_manager/models/account_info.dart @@ -1,23 +1,62 @@ -import 'package:meta/meta.dart'; +import 'dart:convert'; -import 'active_account_info.dart'; +import 'package:equatable/equatable.dart'; +import 'package:meta/meta.dart'; +import 'package:veilid_support/veilid_support.dart'; + +import '../account_manager.dart'; enum AccountInfoStatus { - noAccount, accountInvalid, accountLocked, - accountReady, + accountUnlocked, } @immutable -class AccountInfo { +class AccountInfo extends Equatable { const AccountInfo({ required this.status, - required this.active, - required this.activeAccountInfo, + required this.localAccount, + required this.userLogin, }); final AccountInfoStatus status; - final bool active; - final ActiveAccountInfo? activeAccountInfo; + final LocalAccount localAccount; + final UserLogin? userLogin; + + @override + List get props => [ + status, + localAccount, + userLogin, + ]; +} + +extension AccountInfoExt on AccountInfo { + TypedKey get superIdentityRecordKey => localAccount.superIdentity.recordKey; + TypedKey get accountRecordKey => + userLogin!.accountRecordInfo.accountRecord.recordKey; + TypedKey get identityTypedPublicKey => + localAccount.superIdentity.currentInstance.typedPublicKey; + PublicKey get identityPublicKey => + localAccount.superIdentity.currentInstance.publicKey; + SecretKey get identitySecretKey => userLogin!.identitySecret.value; + KeyPair get identityWriter => + KeyPair(key: identityPublicKey, secret: identitySecretKey); + Future get identityCryptoSystem => + localAccount.superIdentity.currentInstance.cryptoSystem; + + Future makeConversationCrypto( + TypedKey remoteIdentityPublicKey) async { + final identitySecret = userLogin!.identitySecret; + final cs = await Veilid.instance.getCryptoSystem(identitySecret.kind); + final sharedSecret = await cs.generateSharedSecret( + remoteIdentityPublicKey.value, + identitySecret.value, + utf8.encode('VeilidChat Conversation')); + + final messagesCrypto = await VeilidCryptoPrivate.fromSharedSecret( + identitySecret.kind, sharedSecret); + return messagesCrypto; + } } diff --git a/lib/account_manager/models/active_account_info.dart b/lib/account_manager/models/active_account_info.dart deleted file mode 100644 index 5f69e32..0000000 --- a/lib/account_manager/models/active_account_info.dart +++ /dev/null @@ -1,47 +0,0 @@ -import 'dart:convert'; - -import 'package:meta/meta.dart'; -import 'package:veilid_support/veilid_support.dart'; - -import 'local_account/local_account.dart'; -import 'user_login/user_login.dart'; - -@immutable -class ActiveAccountInfo { - const ActiveAccountInfo({ - required this.localAccount, - required this.userLogin, - }); - // - - TypedKey get superIdentityRecordKey => localAccount.superIdentity.recordKey; - TypedKey get accountRecordKey => - userLogin.accountRecordInfo.accountRecord.recordKey; - TypedKey get identityTypedPublicKey => - localAccount.superIdentity.currentInstance.typedPublicKey; - PublicKey get identityPublicKey => - localAccount.superIdentity.currentInstance.publicKey; - SecretKey get identitySecretKey => userLogin.identitySecret.value; - KeyPair get identityWriter => - KeyPair(key: identityPublicKey, secret: identitySecretKey); - Future get identityCryptoSystem => - localAccount.superIdentity.currentInstance.cryptoSystem; - - Future makeConversationCrypto( - TypedKey remoteIdentityPublicKey) async { - final identitySecret = userLogin.identitySecret; - final cs = await Veilid.instance.getCryptoSystem(identitySecret.kind); - final sharedSecret = await cs.generateSharedSecret( - remoteIdentityPublicKey.value, - identitySecret.value, - utf8.encode('VeilidChat Conversation')); - - final messagesCrypto = await VeilidCryptoPrivate.fromSharedSecret( - identitySecret.kind, sharedSecret); - return messagesCrypto; - } - - // - final LocalAccount localAccount; - final UserLogin userLogin; -} diff --git a/lib/account_manager/models/models.dart b/lib/account_manager/models/models.dart index d4b0ab5..2860eec 100644 --- a/lib/account_manager/models/models.dart +++ b/lib/account_manager/models/models.dart @@ -1,6 +1,6 @@ export 'account_info.dart'; -export 'active_account_info.dart'; export 'encryption_key_type.dart'; export 'local_account/local_account.dart'; export 'new_profile_spec.dart'; +export 'per_account_collection_state/per_account_collection_state.dart'; export 'user_login/user_login.dart'; diff --git a/lib/account_manager/models/per_account_collection_state/per_account_collection_state.dart b/lib/account_manager/models/per_account_collection_state/per_account_collection_state.dart new file mode 100644 index 0000000..7fc8f0d --- /dev/null +++ b/lib/account_manager/models/per_account_collection_state/per_account_collection_state.dart @@ -0,0 +1,59 @@ +import 'package:async_tools/async_tools.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../../../chat/chat.dart'; +import '../../../chat_list/chat_list.dart'; +import '../../../contact_invitation/contact_invitation.dart'; +import '../../../contacts/contacts.dart'; +import '../../../conversation/conversation.dart'; +import '../../../proto/proto.dart' show Account; +import '../../account_manager.dart'; + +part 'per_account_collection_state.freezed.dart'; + +@freezed +class PerAccountCollectionState with _$PerAccountCollectionState { + const factory PerAccountCollectionState({ + required AccountInfo accountInfo, + required AsyncValue? avAccountRecordState, + required AccountInfoCubit? accountInfoCubit, + required AccountRecordCubit? accountRecordCubit, + required ContactInvitationListCubit? contactInvitationListCubit, + required ContactListCubit? contactListCubit, + required WaitingInvitationsBlocMapCubit? waitingInvitationsBlocMapCubit, + required ActiveChatCubit? activeChatCubit, + required ChatListCubit? chatListCubit, + required ActiveConversationsBlocMapCubit? activeConversationsBlocMapCubit, + required ActiveSingleContactChatBlocMapCubit? + activeSingleContactChatBlocMapCubit, + }) = _PerAccountCollectionState; +} + +extension PerAccountCollectionStateExt on PerAccountCollectionState { + bool get isReady => + avAccountRecordState != null && + avAccountRecordState!.isData && + accountInfoCubit != null && + accountRecordCubit != null && + contactInvitationListCubit != null && + contactListCubit != null && + waitingInvitationsBlocMapCubit != null && + activeChatCubit != null && + chatListCubit != null && + activeConversationsBlocMapCubit != null && + activeSingleContactChatBlocMapCubit != null; + + Widget provide({required Widget child}) => MultiBlocProvider(providers: [ + BlocProvider.value(value: accountInfoCubit!), + BlocProvider.value(value: accountRecordCubit!), + BlocProvider.value(value: contactInvitationListCubit!), + BlocProvider.value(value: contactListCubit!), + BlocProvider.value(value: waitingInvitationsBlocMapCubit!), + BlocProvider.value(value: activeChatCubit!), + BlocProvider.value(value: chatListCubit!), + BlocProvider.value(value: activeConversationsBlocMapCubit!), + BlocProvider.value(value: activeSingleContactChatBlocMapCubit!), + ], child: child); +} diff --git a/lib/account_manager/models/per_account_collection_state/per_account_collection_state.freezed.dart b/lib/account_manager/models/per_account_collection_state/per_account_collection_state.freezed.dart new file mode 100644 index 0000000..8dcc549 --- /dev/null +++ b/lib/account_manager/models/per_account_collection_state/per_account_collection_state.freezed.dart @@ -0,0 +1,408 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'per_account_collection_state.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); + +/// @nodoc +mixin _$PerAccountCollectionState { + AccountInfo get accountInfo => throw _privateConstructorUsedError; + AsyncValue? get avAccountRecordState => + throw _privateConstructorUsedError; + AccountInfoCubit? get accountInfoCubit => throw _privateConstructorUsedError; + AccountRecordCubit? get accountRecordCubit => + throw _privateConstructorUsedError; + ContactInvitationListCubit? get contactInvitationListCubit => + throw _privateConstructorUsedError; + ContactListCubit? get contactListCubit => throw _privateConstructorUsedError; + WaitingInvitationsBlocMapCubit? get waitingInvitationsBlocMapCubit => + throw _privateConstructorUsedError; + ActiveChatCubit? get activeChatCubit => throw _privateConstructorUsedError; + ChatListCubit? get chatListCubit => throw _privateConstructorUsedError; + ActiveConversationsBlocMapCubit? get activeConversationsBlocMapCubit => + throw _privateConstructorUsedError; + ActiveSingleContactChatBlocMapCubit? + get activeSingleContactChatBlocMapCubit => + throw _privateConstructorUsedError; + + @JsonKey(ignore: true) + $PerAccountCollectionStateCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $PerAccountCollectionStateCopyWith<$Res> { + factory $PerAccountCollectionStateCopyWith(PerAccountCollectionState value, + $Res Function(PerAccountCollectionState) then) = + _$PerAccountCollectionStateCopyWithImpl<$Res, PerAccountCollectionState>; + @useResult + $Res call( + {AccountInfo accountInfo, + AsyncValue? avAccountRecordState, + AccountInfoCubit? accountInfoCubit, + AccountRecordCubit? accountRecordCubit, + ContactInvitationListCubit? contactInvitationListCubit, + ContactListCubit? contactListCubit, + WaitingInvitationsBlocMapCubit? waitingInvitationsBlocMapCubit, + ActiveChatCubit? activeChatCubit, + ChatListCubit? chatListCubit, + ActiveConversationsBlocMapCubit? activeConversationsBlocMapCubit, + ActiveSingleContactChatBlocMapCubit? + activeSingleContactChatBlocMapCubit}); + + $AsyncValueCopyWith? get avAccountRecordState; +} + +/// @nodoc +class _$PerAccountCollectionStateCopyWithImpl<$Res, + $Val extends PerAccountCollectionState> + implements $PerAccountCollectionStateCopyWith<$Res> { + _$PerAccountCollectionStateCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? accountInfo = null, + Object? avAccountRecordState = freezed, + Object? accountInfoCubit = freezed, + Object? accountRecordCubit = freezed, + Object? contactInvitationListCubit = freezed, + Object? contactListCubit = freezed, + Object? waitingInvitationsBlocMapCubit = freezed, + Object? activeChatCubit = freezed, + Object? chatListCubit = freezed, + Object? activeConversationsBlocMapCubit = freezed, + Object? activeSingleContactChatBlocMapCubit = freezed, + }) { + return _then(_value.copyWith( + accountInfo: null == accountInfo + ? _value.accountInfo + : accountInfo // ignore: cast_nullable_to_non_nullable + as AccountInfo, + avAccountRecordState: freezed == avAccountRecordState + ? _value.avAccountRecordState + : avAccountRecordState // ignore: cast_nullable_to_non_nullable + as AsyncValue?, + accountInfoCubit: freezed == accountInfoCubit + ? _value.accountInfoCubit + : accountInfoCubit // ignore: cast_nullable_to_non_nullable + as AccountInfoCubit?, + accountRecordCubit: freezed == accountRecordCubit + ? _value.accountRecordCubit + : accountRecordCubit // ignore: cast_nullable_to_non_nullable + as AccountRecordCubit?, + contactInvitationListCubit: freezed == contactInvitationListCubit + ? _value.contactInvitationListCubit + : contactInvitationListCubit // ignore: cast_nullable_to_non_nullable + as ContactInvitationListCubit?, + contactListCubit: freezed == contactListCubit + ? _value.contactListCubit + : contactListCubit // ignore: cast_nullable_to_non_nullable + as ContactListCubit?, + waitingInvitationsBlocMapCubit: freezed == waitingInvitationsBlocMapCubit + ? _value.waitingInvitationsBlocMapCubit + : waitingInvitationsBlocMapCubit // ignore: cast_nullable_to_non_nullable + as WaitingInvitationsBlocMapCubit?, + activeChatCubit: freezed == activeChatCubit + ? _value.activeChatCubit + : activeChatCubit // ignore: cast_nullable_to_non_nullable + as ActiveChatCubit?, + chatListCubit: freezed == chatListCubit + ? _value.chatListCubit + : chatListCubit // ignore: cast_nullable_to_non_nullable + as ChatListCubit?, + activeConversationsBlocMapCubit: freezed == + activeConversationsBlocMapCubit + ? _value.activeConversationsBlocMapCubit + : activeConversationsBlocMapCubit // ignore: cast_nullable_to_non_nullable + as ActiveConversationsBlocMapCubit?, + activeSingleContactChatBlocMapCubit: freezed == + activeSingleContactChatBlocMapCubit + ? _value.activeSingleContactChatBlocMapCubit + : activeSingleContactChatBlocMapCubit // ignore: cast_nullable_to_non_nullable + as ActiveSingleContactChatBlocMapCubit?, + ) as $Val); + } + + @override + @pragma('vm:prefer-inline') + $AsyncValueCopyWith? get avAccountRecordState { + if (_value.avAccountRecordState == null) { + return null; + } + + return $AsyncValueCopyWith(_value.avAccountRecordState!, + (value) { + return _then(_value.copyWith(avAccountRecordState: value) as $Val); + }); + } +} + +/// @nodoc +abstract class _$$PerAccountCollectionStateImplCopyWith<$Res> + implements $PerAccountCollectionStateCopyWith<$Res> { + factory _$$PerAccountCollectionStateImplCopyWith( + _$PerAccountCollectionStateImpl value, + $Res Function(_$PerAccountCollectionStateImpl) then) = + __$$PerAccountCollectionStateImplCopyWithImpl<$Res>; + @override + @useResult + $Res call( + {AccountInfo accountInfo, + AsyncValue? avAccountRecordState, + AccountInfoCubit? accountInfoCubit, + AccountRecordCubit? accountRecordCubit, + ContactInvitationListCubit? contactInvitationListCubit, + ContactListCubit? contactListCubit, + WaitingInvitationsBlocMapCubit? waitingInvitationsBlocMapCubit, + ActiveChatCubit? activeChatCubit, + ChatListCubit? chatListCubit, + ActiveConversationsBlocMapCubit? activeConversationsBlocMapCubit, + ActiveSingleContactChatBlocMapCubit? + activeSingleContactChatBlocMapCubit}); + + @override + $AsyncValueCopyWith? get avAccountRecordState; +} + +/// @nodoc +class __$$PerAccountCollectionStateImplCopyWithImpl<$Res> + extends _$PerAccountCollectionStateCopyWithImpl<$Res, + _$PerAccountCollectionStateImpl> + implements _$$PerAccountCollectionStateImplCopyWith<$Res> { + __$$PerAccountCollectionStateImplCopyWithImpl( + _$PerAccountCollectionStateImpl _value, + $Res Function(_$PerAccountCollectionStateImpl) _then) + : super(_value, _then); + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? accountInfo = null, + Object? avAccountRecordState = freezed, + Object? accountInfoCubit = freezed, + Object? accountRecordCubit = freezed, + Object? contactInvitationListCubit = freezed, + Object? contactListCubit = freezed, + Object? waitingInvitationsBlocMapCubit = freezed, + Object? activeChatCubit = freezed, + Object? chatListCubit = freezed, + Object? activeConversationsBlocMapCubit = freezed, + Object? activeSingleContactChatBlocMapCubit = freezed, + }) { + return _then(_$PerAccountCollectionStateImpl( + accountInfo: null == accountInfo + ? _value.accountInfo + : accountInfo // ignore: cast_nullable_to_non_nullable + as AccountInfo, + avAccountRecordState: freezed == avAccountRecordState + ? _value.avAccountRecordState + : avAccountRecordState // ignore: cast_nullable_to_non_nullable + as AsyncValue?, + accountInfoCubit: freezed == accountInfoCubit + ? _value.accountInfoCubit + : accountInfoCubit // ignore: cast_nullable_to_non_nullable + as AccountInfoCubit?, + accountRecordCubit: freezed == accountRecordCubit + ? _value.accountRecordCubit + : accountRecordCubit // ignore: cast_nullable_to_non_nullable + as AccountRecordCubit?, + contactInvitationListCubit: freezed == contactInvitationListCubit + ? _value.contactInvitationListCubit + : contactInvitationListCubit // ignore: cast_nullable_to_non_nullable + as ContactInvitationListCubit?, + contactListCubit: freezed == contactListCubit + ? _value.contactListCubit + : contactListCubit // ignore: cast_nullable_to_non_nullable + as ContactListCubit?, + waitingInvitationsBlocMapCubit: freezed == waitingInvitationsBlocMapCubit + ? _value.waitingInvitationsBlocMapCubit + : waitingInvitationsBlocMapCubit // ignore: cast_nullable_to_non_nullable + as WaitingInvitationsBlocMapCubit?, + activeChatCubit: freezed == activeChatCubit + ? _value.activeChatCubit + : activeChatCubit // ignore: cast_nullable_to_non_nullable + as ActiveChatCubit?, + chatListCubit: freezed == chatListCubit + ? _value.chatListCubit + : chatListCubit // ignore: cast_nullable_to_non_nullable + as ChatListCubit?, + activeConversationsBlocMapCubit: freezed == + activeConversationsBlocMapCubit + ? _value.activeConversationsBlocMapCubit + : activeConversationsBlocMapCubit // ignore: cast_nullable_to_non_nullable + as ActiveConversationsBlocMapCubit?, + activeSingleContactChatBlocMapCubit: freezed == + activeSingleContactChatBlocMapCubit + ? _value.activeSingleContactChatBlocMapCubit + : activeSingleContactChatBlocMapCubit // ignore: cast_nullable_to_non_nullable + as ActiveSingleContactChatBlocMapCubit?, + )); + } +} + +/// @nodoc + +class _$PerAccountCollectionStateImpl implements _PerAccountCollectionState { + const _$PerAccountCollectionStateImpl( + {required this.accountInfo, + required this.avAccountRecordState, + required this.accountInfoCubit, + required this.accountRecordCubit, + required this.contactInvitationListCubit, + required this.contactListCubit, + required this.waitingInvitationsBlocMapCubit, + required this.activeChatCubit, + required this.chatListCubit, + required this.activeConversationsBlocMapCubit, + required this.activeSingleContactChatBlocMapCubit}); + + @override + final AccountInfo accountInfo; + @override + final AsyncValue? avAccountRecordState; + @override + final AccountInfoCubit? accountInfoCubit; + @override + final AccountRecordCubit? accountRecordCubit; + @override + final ContactInvitationListCubit? contactInvitationListCubit; + @override + final ContactListCubit? contactListCubit; + @override + final WaitingInvitationsBlocMapCubit? waitingInvitationsBlocMapCubit; + @override + final ActiveChatCubit? activeChatCubit; + @override + final ChatListCubit? chatListCubit; + @override + final ActiveConversationsBlocMapCubit? activeConversationsBlocMapCubit; + @override + final ActiveSingleContactChatBlocMapCubit? + activeSingleContactChatBlocMapCubit; + + @override + String toString() { + return 'PerAccountCollectionState(accountInfo: $accountInfo, avAccountRecordState: $avAccountRecordState, accountInfoCubit: $accountInfoCubit, accountRecordCubit: $accountRecordCubit, contactInvitationListCubit: $contactInvitationListCubit, contactListCubit: $contactListCubit, waitingInvitationsBlocMapCubit: $waitingInvitationsBlocMapCubit, activeChatCubit: $activeChatCubit, chatListCubit: $chatListCubit, activeConversationsBlocMapCubit: $activeConversationsBlocMapCubit, activeSingleContactChatBlocMapCubit: $activeSingleContactChatBlocMapCubit)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$PerAccountCollectionStateImpl && + (identical(other.accountInfo, accountInfo) || + other.accountInfo == accountInfo) && + (identical(other.avAccountRecordState, avAccountRecordState) || + other.avAccountRecordState == avAccountRecordState) && + (identical(other.accountInfoCubit, accountInfoCubit) || + other.accountInfoCubit == accountInfoCubit) && + (identical(other.accountRecordCubit, accountRecordCubit) || + other.accountRecordCubit == accountRecordCubit) && + (identical(other.contactInvitationListCubit, + contactInvitationListCubit) || + other.contactInvitationListCubit == + contactInvitationListCubit) && + (identical(other.contactListCubit, contactListCubit) || + other.contactListCubit == contactListCubit) && + (identical(other.waitingInvitationsBlocMapCubit, + waitingInvitationsBlocMapCubit) || + other.waitingInvitationsBlocMapCubit == + waitingInvitationsBlocMapCubit) && + (identical(other.activeChatCubit, activeChatCubit) || + other.activeChatCubit == activeChatCubit) && + (identical(other.chatListCubit, chatListCubit) || + other.chatListCubit == chatListCubit) && + (identical(other.activeConversationsBlocMapCubit, + activeConversationsBlocMapCubit) || + other.activeConversationsBlocMapCubit == + activeConversationsBlocMapCubit) && + (identical(other.activeSingleContactChatBlocMapCubit, + activeSingleContactChatBlocMapCubit) || + other.activeSingleContactChatBlocMapCubit == + activeSingleContactChatBlocMapCubit)); + } + + @override + int get hashCode => Object.hash( + runtimeType, + accountInfo, + avAccountRecordState, + accountInfoCubit, + accountRecordCubit, + contactInvitationListCubit, + contactListCubit, + waitingInvitationsBlocMapCubit, + activeChatCubit, + chatListCubit, + activeConversationsBlocMapCubit, + activeSingleContactChatBlocMapCubit); + + @JsonKey(ignore: true) + @override + @pragma('vm:prefer-inline') + _$$PerAccountCollectionStateImplCopyWith<_$PerAccountCollectionStateImpl> + get copyWith => __$$PerAccountCollectionStateImplCopyWithImpl< + _$PerAccountCollectionStateImpl>(this, _$identity); +} + +abstract class _PerAccountCollectionState implements PerAccountCollectionState { + const factory _PerAccountCollectionState( + {required final AccountInfo accountInfo, + required final AsyncValue? avAccountRecordState, + required final AccountInfoCubit? accountInfoCubit, + required final AccountRecordCubit? accountRecordCubit, + required final ContactInvitationListCubit? contactInvitationListCubit, + required final ContactListCubit? contactListCubit, + required final WaitingInvitationsBlocMapCubit? + waitingInvitationsBlocMapCubit, + required final ActiveChatCubit? activeChatCubit, + required final ChatListCubit? chatListCubit, + required final ActiveConversationsBlocMapCubit? + activeConversationsBlocMapCubit, + required final ActiveSingleContactChatBlocMapCubit? + activeSingleContactChatBlocMapCubit}) = + _$PerAccountCollectionStateImpl; + + @override + AccountInfo get accountInfo; + @override + AsyncValue? get avAccountRecordState; + @override + AccountInfoCubit? get accountInfoCubit; + @override + AccountRecordCubit? get accountRecordCubit; + @override + ContactInvitationListCubit? get contactInvitationListCubit; + @override + ContactListCubit? get contactListCubit; + @override + WaitingInvitationsBlocMapCubit? get waitingInvitationsBlocMapCubit; + @override + ActiveChatCubit? get activeChatCubit; + @override + ChatListCubit? get chatListCubit; + @override + ActiveConversationsBlocMapCubit? get activeConversationsBlocMapCubit; + @override + ActiveSingleContactChatBlocMapCubit? get activeSingleContactChatBlocMapCubit; + @override + @JsonKey(ignore: true) + _$$PerAccountCollectionStateImplCopyWith<_$PerAccountCollectionStateImpl> + get copyWith => throw _privateConstructorUsedError; +} diff --git a/lib/account_manager/repository/account_repository/account_repository.dart b/lib/account_manager/repository/account_repository.dart similarity index 85% rename from lib/account_manager/repository/account_repository/account_repository.dart rename to lib/account_manager/repository/account_repository.dart index ac29913..fd15b43 100644 --- a/lib/account_manager/repository/account_repository/account_repository.dart +++ b/lib/account_manager/repository/account_repository.dart @@ -3,9 +3,9 @@ import 'dart:async'; import 'package:fast_immutable_collections/fast_immutable_collections.dart'; import 'package:veilid_support/veilid_support.dart'; -import '../../../../proto/proto.dart' as proto; -import '../../../tools/tools.dart'; -import '../../models/models.dart'; +import '../../../proto/proto.dart' as proto; +import '../../tools/tools.dart'; +import '../models/models.dart'; const String veilidChatAccountKey = 'com.veilid.veilidchat'; @@ -45,19 +45,6 @@ class AccountRepository { valueToJson: (val) => val?.toJson(), makeInitialValue: () => null); - ////////////////////////////////////////////////////////////// - /// Fields - - final TableDBValue> _localAccounts; - final TableDBValue> _userLogins; - final TableDBValue _activeLocalAccount; - final StreamController _streamController; - - ////////////////////////////////////////////////////////////// - /// Singleton initialization - - static AccountRepository instance = AccountRepository._(); - Future init() async { await _localAccounts.get(); await _userLogins.get(); @@ -71,12 +58,10 @@ class AccountRepository { } ////////////////////////////////////////////////////////////// - /// Streams - + /// Public Interface + /// Stream get stream => _streamController.stream; - ////////////////////////////////////////////////////////////// - /// Selectors IList getLocalAccounts() => _localAccounts.value; TypedKey? getActiveLocalAccount() => _activeLocalAccount.value; IList getUserLogins() => _userLogins.value; @@ -107,29 +92,11 @@ class AccountRepository { return userLogins[idx]; } - AccountInfo getAccountInfo(TypedKey? superIdentityRecordKey) { - // Get active account if we have one - final activeLocalAccount = getActiveLocalAccount(); - if (superIdentityRecordKey == null) { - if (activeLocalAccount == null) { - // No user logged in - return const AccountInfo( - status: AccountInfoStatus.noAccount, - active: false, - activeAccountInfo: null); - } - superIdentityRecordKey = activeLocalAccount; - } - final active = superIdentityRecordKey == activeLocalAccount; - + AccountInfo? getAccountInfo(TypedKey superIdentityRecordKey) { // Get which local account we want to fetch the profile for final localAccount = fetchLocalAccount(superIdentityRecordKey); if (localAccount == null) { - // account does not exist - return AccountInfo( - status: AccountInfoStatus.noAccount, - active: active, - activeAccountInfo: null); + return null; } // See if we've logged into this account or if it is locked @@ -137,23 +104,20 @@ class AccountRepository { if (userLogin == null) { // Account was locked return AccountInfo( - status: AccountInfoStatus.accountLocked, - active: active, - activeAccountInfo: null); + status: AccountInfoStatus.accountLocked, + localAccount: localAccount, + userLogin: null, + ); } // Got account, decrypted and decoded return AccountInfo( - status: AccountInfoStatus.accountReady, - active: active, - activeAccountInfo: - ActiveAccountInfo(localAccount: localAccount, userLogin: userLogin), + status: AccountInfoStatus.accountUnlocked, + localAccount: localAccount, + userLogin: userLogin, ); } - ////////////////////////////////////////////////////////////// - /// Mutators - /// Reorder accounts Future reorderAccount(int oldIndex, int newIndex) async { final localAccounts = await _localAccounts.get(); @@ -168,104 +132,39 @@ class AccountRepository { /// Creates a new super identity, an identity instance, an account associated /// with the identity instance, stores the account in the identity key and /// then logs into that account with no password set at this time - Future createWithNewSuperIdentity(NewProfileSpec newProfileSpec) async { + Future createWithNewSuperIdentity(proto.Profile newProfile) async { log.debug('Creating super identity'); final wsi = await WritableSuperIdentity.create(); try { final localAccount = await _newLocalAccount( superIdentity: wsi.superIdentity, identitySecret: wsi.identitySecret, - newProfileSpec: newProfileSpec); + newProfile: newProfile); // Log in the new account by default with no pin final ok = await login( localAccount.superIdentity.recordKey, EncryptionKeyType.none, ''); assert(ok, 'login with none should never fail'); + + return wsi.superSecret; } on Exception catch (_) { await wsi.delete(); rethrow; } } - /// Creates a new Account associated with the current instance of the identity - /// Adds a logged-out LocalAccount to track its existence on this device - Future _newLocalAccount( - {required SuperIdentity superIdentity, - required SecretKey identitySecret, - required NewProfileSpec newProfileSpec, - EncryptionKeyType encryptionKeyType = EncryptionKeyType.none, - String encryptionKey = ''}) async { - log.debug('Creating new local account'); + Future editAccountProfile( + TypedKey superIdentityRecordKey, proto.Profile newProfile) async { + log.debug('Editing profile for $superIdentityRecordKey'); final localAccounts = await _localAccounts.get(); - // Add account with profile to DHT - await superIdentity.currentInstance.addAccount( - superRecordKey: superIdentity.recordKey, - secretKey: identitySecret, - accountKey: veilidChatAccountKey, - createAccountCallback: (parent) async { - // Make empty contact list - log.debug('Creating contacts list'); - final contactList = await (await DHTShortArray.create( - debugName: 'AccountRepository::_newLocalAccount::Contacts', - parent: parent)) - .scope((r) async => r.recordPointer); - - // Make empty contact invitation record list - log.debug('Creating contact invitation records list'); - final contactInvitationRecords = await (await DHTShortArray.create( - debugName: - 'AccountRepository::_newLocalAccount::ContactInvitations', - parent: parent)) - .scope((r) async => r.recordPointer); - - // Make empty chat record list - log.debug('Creating chat records list'); - final chatRecords = await (await DHTShortArray.create( - debugName: 'AccountRepository::_newLocalAccount::Chats', - parent: parent)) - .scope((r) async => r.recordPointer); - - // Make account object - final account = proto.Account() - ..profile = (proto.Profile() - ..name = newProfileSpec.name - ..pronouns = newProfileSpec.pronouns) - ..contactList = contactList.toProto() - ..contactInvitationRecords = contactInvitationRecords.toProto() - ..chatList = chatRecords.toProto(); - return account.writeToBuffer(); - }); - - // Encrypt identitySecret with key - final identitySecretBytes = await encryptionKeyType.encryptSecretToBytes( - secret: identitySecret, - cryptoKind: superIdentity.currentInstance.recordKey.kind, - encryptionKey: encryptionKey, - ); - - // Create local account object - // Does not contain the account key or its secret - // as that is not to be persisted, and only pulled from the identity key - // and optionally decrypted with the unlock password - final localAccount = LocalAccount( - superIdentity: superIdentity, - identitySecretBytes: identitySecretBytes, - encryptionKeyType: encryptionKeyType, - biometricsEnabled: false, - hiddenAccount: false, - name: newProfileSpec.name, - ); - - // Add local account object to internal store - final newLocalAccounts = localAccounts.add(localAccount); + final newLocalAccounts = localAccounts.replaceFirstWhere( + (x) => x.superIdentity.recordKey == superIdentityRecordKey, + (localAccount) => localAccount!.copyWith(name: newProfile.name)); await _localAccounts.set(newLocalAccounts); _streamController.add(AccountRepositoryChange.localAccounts); - - // Return local account object - return localAccount; } /// Remove an account and wipe the messages for this account from this device @@ -307,6 +206,88 @@ class AccountRepository { _streamController.add(AccountRepositoryChange.activeLocalAccount); } + ////////////////////////////////////////////////////////////// + /// Internal Implementation + + /// Creates a new Account associated with the current instance of the identity + /// Adds a logged-out LocalAccount to track its existence on this device + Future _newLocalAccount( + {required SuperIdentity superIdentity, + required SecretKey identitySecret, + required proto.Profile newProfile, + EncryptionKeyType encryptionKeyType = EncryptionKeyType.none, + String encryptionKey = ''}) async { + log.debug('Creating new local account'); + + final localAccounts = await _localAccounts.get(); + + // Add account with profile to DHT + await superIdentity.currentInstance.addAccount( + superRecordKey: superIdentity.recordKey, + secretKey: identitySecret, + accountKey: veilidChatAccountKey, + createAccountCallback: (parent) async { + // Make empty contact list + log.debug('Creating contacts list'); + final contactList = await (await DHTShortArray.create( + debugName: 'AccountRepository::_newLocalAccount::Contacts', + parent: parent)) + .scope((r) async => r.recordPointer); + + // Make empty contact invitation record list + log.debug('Creating contact invitation records list'); + final contactInvitationRecords = await (await DHTShortArray.create( + debugName: + 'AccountRepository::_newLocalAccount::ContactInvitations', + parent: parent)) + .scope((r) async => r.recordPointer); + + // Make empty chat record list + log.debug('Creating chat records list'); + final chatRecords = await (await DHTShortArray.create( + debugName: 'AccountRepository::_newLocalAccount::Chats', + parent: parent)) + .scope((r) async => r.recordPointer); + + // Make account object + final account = proto.Account() + ..profile = newProfile + ..contactList = contactList.toProto() + ..contactInvitationRecords = contactInvitationRecords.toProto() + ..chatList = chatRecords.toProto(); + return account.writeToBuffer(); + }); + + // Encrypt identitySecret with key + final identitySecretBytes = await encryptionKeyType.encryptSecretToBytes( + secret: identitySecret, + cryptoKind: superIdentity.currentInstance.recordKey.kind, + encryptionKey: encryptionKey, + ); + + // Create local account object + // Does not contain the account key or its secret + // as that is not to be persisted, and only pulled from the identity key + // and optionally decrypted with the unlock password + final localAccount = LocalAccount( + superIdentity: superIdentity, + identitySecretBytes: identitySecretBytes, + encryptionKeyType: encryptionKeyType, + biometricsEnabled: false, + hiddenAccount: false, + name: newProfile.name, + ); + + // Add local account object to internal store + final newLocalAccounts = localAccounts.add(localAccount); + + await _localAccounts.set(newLocalAccounts); + _streamController.add(AccountRepositoryChange.localAccounts); + + // Return local account object + return localAccount; + } + Future _decryptedLogin( SuperIdentity superIdentity, SecretKey identitySecret) async { // Verify identity secret works and return the valid cryptosystem @@ -399,16 +380,13 @@ class AccountRepository { _streamController.add(AccountRepositoryChange.userLogins); } - Future openAccountRecord(UserLogin userLogin) async { - final localAccount = fetchLocalAccount(userLogin.superIdentityRecordKey)!; + ////////////////////////////////////////////////////////////// + /// Fields - // Record not yet open, do it - final pool = DHTRecordPool.instance; - final record = await pool.openRecordOwned( - userLogin.accountRecordInfo.accountRecord, - debugName: 'AccountRepository::openAccountRecord::AccountRecord', - parent: localAccount.superIdentity.currentInstance.recordKey); + static AccountRepository instance = AccountRepository._(); - return record; - } + final TableDBValue> _localAccounts; + final TableDBValue> _userLogins; + final TableDBValue _activeLocalAccount; + final StreamController _streamController; } diff --git a/lib/account_manager/repository/repository.dart b/lib/account_manager/repository/repository.dart index 9d1b9fe..74bf9f8 100644 --- a/lib/account_manager/repository/repository.dart +++ b/lib/account_manager/repository/repository.dart @@ -1 +1 @@ -export 'account_repository/account_repository.dart'; +export 'account_repository.dart'; diff --git a/lib/account_manager/views/edit_account_page.dart b/lib/account_manager/views/edit_account_page.dart new file mode 100644 index 0000000..0e57d77 --- /dev/null +++ b/lib/account_manager/views/edit_account_page.dart @@ -0,0 +1,158 @@ +import 'package:awesome_extensions/awesome_extensions.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_form_builder/flutter_form_builder.dart'; +import 'package:flutter_translate/flutter_translate.dart'; +import 'package:go_router/go_router.dart'; +import 'package:protobuf/protobuf.dart'; +import 'package:veilid_support/veilid_support.dart'; + +import '../../layout/default_app_bar.dart'; +import '../../proto/proto.dart' as proto; +import '../../theme/theme.dart'; +import '../../tools/tools.dart'; +import '../../veilid_processor/veilid_processor.dart'; +import '../account_manager.dart'; +import 'profile_edit_form.dart'; + +class EditAccountPage extends StatefulWidget { + const EditAccountPage( + {required this.superIdentityRecordKey, + required this.existingProfile, + super.key}); + + @override + State createState() => _EditAccountPageState(); + + final TypedKey superIdentityRecordKey; + final proto.Profile existingProfile; + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty( + 'superIdentityRecordKey', superIdentityRecordKey)) + ..add(DiagnosticsProperty( + 'existingProfile', existingProfile)); + } +} + +class _EditAccountPageState extends State { + bool _isInAsyncCall = false; + + @override + void initState() { + super.initState(); + + WidgetsBinding.instance.addPostFrameCallback((_) async { + await changeWindowSetup( + TitleBarStyle.normal, OrientationCapability.portraitOnly); + }); + } + + Widget _editAccountForm(BuildContext context, + {required Future Function(GlobalKey) + onSubmit}) => + EditProfileForm( + header: translate('edit_account_page.header'), + instructions: translate('edit_account_page.instructions'), + submitText: translate('edit_account_page.update'), + submitDisabledText: translate('button.waiting_for_network'), + onSubmit: onSubmit, + initialValueCallback: (key) => switch (key) { + EditProfileForm.formFieldName => widget.existingProfile.name, + EditProfileForm.formFieldPronouns => widget.existingProfile.pronouns, + String() => throw UnimplementedError(), + }, + ); + + @override + Widget build(BuildContext context) { + final displayModalHUD = _isInAsyncCall; + + return Scaffold( + // resizeToAvoidBottomInset: false, + appBar: DefaultAppBar( + title: Text(translate('edit_account_page.titlebar')), + leading: Navigator.canPop(context) + ? IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () { + Navigator.pop(context); + }, + ) + : null, + actions: [ + const SignalStrengthMeterWidget(), + IconButton( + icon: const Icon(Icons.settings), + tooltip: translate('menu.settings_tooltip'), + onPressed: () async { + await GoRouterHelper(context).push('/settings'); + }) + ]), + body: _editAccountForm( + context, + onSubmit: (formKey) async { + // dismiss the keyboard by unfocusing the textfield + FocusScope.of(context).unfocus(); + + try { + final name = formKey.currentState! + .fields[EditProfileForm.formFieldName]!.value as String; + final pronouns = formKey + .currentState! + .fields[EditProfileForm.formFieldPronouns]! + .value as String? ?? + ''; + final newProfile = widget.existingProfile.deepCopy() + ..name = name + ..pronouns = pronouns + ..timestamp = Veilid.instance.now().toInt64(); + + setState(() { + _isInAsyncCall = true; + }); + try { + // Look up account cubit for this specific account + final perAccountCollectionBlocMapCubit = + context.read(); + final accountRecordCubit = await perAccountCollectionBlocMapCubit + .operate(widget.superIdentityRecordKey, + closure: (c) async => c.accountRecordCubit); + if (accountRecordCubit == null) { + return; + } + + // Update account profile DHT record + // This triggers ConversationCubits to update + await accountRecordCubit.updateProfile(newProfile); + + // Update local account profile + await AccountRepository.instance.editAccountProfile( + widget.superIdentityRecordKey, newProfile); + + if (context.mounted) { + Navigator.canPop(context) + ? GoRouterHelper(context).pop() + : GoRouterHelper(context).go('/'); + } + } finally { + if (mounted) { + setState(() { + _isInAsyncCall = false; + }); + } + } + } on Exception catch (e) { + if (context.mounted) { + await showErrorModal(context, + translate('edit_account_page.error'), 'Exception: $e'); + } + } + }, + ).paddingSymmetric(horizontal: 24, vertical: 8), + ).withModalHUD(context, displayModalHUD); + } +} diff --git a/lib/account_manager/views/new_account_page.dart b/lib/account_manager/views/new_account_page.dart index 7e15a32..65d57ea 100644 --- a/lib/account_manager/views/new_account_page.dart +++ b/lib/account_manager/views/new_account_page.dart @@ -1,30 +1,27 @@ import 'package:awesome_extensions/awesome_extensions.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_form_builder/flutter_form_builder.dart'; import 'package:flutter_translate/flutter_translate.dart'; -import 'package:form_builder_validators/form_builder_validators.dart'; import 'package:go_router/go_router.dart'; import '../../layout/default_app_bar.dart'; +import '../../proto/proto.dart' as proto; import '../../theme/theme.dart'; import '../../tools/tools.dart'; import '../../veilid_processor/veilid_processor.dart'; import '../account_manager.dart'; +import 'profile_edit_form.dart'; class NewAccountPage extends StatefulWidget { const NewAccountPage({super.key}); @override - NewAccountPageState createState() => NewAccountPageState(); + State createState() => _NewAccountPageState(); } -class NewAccountPageState extends State { - final _formKey = GlobalKey(); - late bool isInAsyncCall = false; - static const String formFieldName = 'name'; - static const String formFieldPronouns = 'pronouns'; +class _NewAccountPageState extends State { + bool _isInAsyncCall = false; @override void initState() { @@ -47,80 +44,35 @@ class NewAccountPageState extends State { false; final canSubmit = networkReady; - return FormBuilder( - key: _formKey, - child: ListView( - children: [ - Text(translate('new_account_page.header')) - .textStyle(context.headlineSmall) - .paddingSymmetric(vertical: 16), - FormBuilderTextField( - autofocus: true, - name: formFieldName, - decoration: - InputDecoration(labelText: translate('account.form_name')), - maxLength: 64, - // The validator receives the text that the user has entered. - validator: FormBuilderValidators.compose([ - FormBuilderValidators.required(), - ]), - textInputAction: TextInputAction.next, - ), - FormBuilderTextField( - name: formFieldPronouns, - maxLength: 64, - decoration: - InputDecoration(labelText: translate('account.form_pronouns')), - textInputAction: TextInputAction.next, - ), - Row(children: [ - const Spacer(), - Text(translate('new_account_page.instructions')) - .toCenter() - .flexible(flex: 6), - const Spacer(), - ]).paddingSymmetric(vertical: 4), - ElevatedButton( - onPressed: !canSubmit - ? null - : () async { - if (_formKey.currentState?.saveAndValidate() ?? false) { - setState(() { - isInAsyncCall = true; - }); - try { - await onSubmit(_formKey); - } finally { - if (mounted) { - setState(() { - isInAsyncCall = false; - }); - } - } - } - }, - child: Text(translate(!networkReady - ? 'button.waiting_for_network' - : 'new_account_page.create')), - ).paddingSymmetric(vertical: 4).alignAtCenterRight(), - ], - ), - ); + return EditProfileForm( + header: translate('new_account_page.header'), + instructions: translate('new_account_page.instructions'), + submitText: translate('new_account_page.create'), + submitDisabledText: translate('button.waiting_for_network'), + onSubmit: !canSubmit ? null : onSubmit); } @override Widget build(BuildContext context) { - final displayModalHUD = isInAsyncCall; + final displayModalHUD = _isInAsyncCall; return Scaffold( // resizeToAvoidBottomInset: false, appBar: DefaultAppBar( title: Text(translate('new_account_page.titlebar')), + leading: Navigator.canPop(context) + ? IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () { + Navigator.pop(context); + }, + ) + : null, actions: [ const SignalStrengthMeterWidget(), IconButton( icon: const Icon(Icons.settings), - tooltip: translate('app_bar.settings_tooltip'), + tooltip: translate('menu.settings_tooltip'), onPressed: () async { await GoRouterHelper(context).push('/settings'); }) @@ -132,16 +84,33 @@ class NewAccountPageState extends State { FocusScope.of(context).unfocus(); try { - final name = - _formKey.currentState!.fields[formFieldName]!.value as String; - final pronouns = _formKey.currentState!.fields[formFieldPronouns]! + final name = formKey.currentState! + .fields[EditProfileForm.formFieldName]!.value as String; + final pronouns = formKey + .currentState! + .fields[EditProfileForm.formFieldPronouns]! .value as String? ?? ''; - final newProfileSpec = - NewProfileSpec(name: name, pronouns: pronouns); + final newProfile = proto.Profile() + ..name = name + ..pronouns = pronouns; - await AccountRepository.instance - .createWithNewSuperIdentity(newProfileSpec); + setState(() { + _isInAsyncCall = true; + }); + try { + final superSecret = await AccountRepository.instance + .createWithNewSuperIdentity(newProfile); + GoRouterHelper(context).pushReplacement( + '/new_account/recovery_key', + extra: superSecret); + } finally { + if (mounted) { + setState(() { + _isInAsyncCall = false; + }); + } + } } on Exception catch (e) { if (context.mounted) { await showErrorModal(context, translate('new_account_page.error'), @@ -152,10 +121,4 @@ class NewAccountPageState extends State { ).paddingSymmetric(horizontal: 24, vertical: 8), ).withModalHUD(context, displayModalHUD); } - - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties.add(DiagnosticsProperty('isInAsyncCall', isInAsyncCall)); - } } diff --git a/lib/account_manager/views/profile_edit_form.dart b/lib/account_manager/views/profile_edit_form.dart new file mode 100644 index 0000000..2e14249 --- /dev/null +++ b/lib/account_manager/views/profile_edit_form.dart @@ -0,0 +1,114 @@ +import 'package:awesome_extensions/awesome_extensions.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_form_builder/flutter_form_builder.dart'; +import 'package:flutter_translate/flutter_translate.dart'; +import 'package:form_builder_validators/form_builder_validators.dart'; + +class EditProfileForm extends StatefulWidget { + const EditProfileForm({ + required this.header, + required this.instructions, + required this.submitText, + required this.submitDisabledText, + super.key, + this.onSubmit, + this.initialValueCallback, + }); + + @override + State createState() => _EditProfileFormState(); + + final String header; + final String instructions; + final Future Function(GlobalKey)? onSubmit; + final String submitText; + final String submitDisabledText; + final Object? Function(String key)? initialValueCallback; + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(StringProperty('header', header)) + ..add(StringProperty('instructions', instructions)) + ..add(ObjectFlagProperty< + Future Function( + GlobalKey p1)?>.has('onSubmit', onSubmit)) + ..add(StringProperty('submitText', submitText)) + ..add(StringProperty('submitDisabledText', submitDisabledText)) + ..add(ObjectFlagProperty.has( + 'initialValueCallback', initialValueCallback)); + } + + static const String formFieldName = 'name'; + static const String formFieldPronouns = 'pronouns'; +} + +class _EditProfileFormState extends State { + final _formKey = GlobalKey(); + + @override + void initState() { + super.initState(); + } + + Widget _editProfileForm( + BuildContext context, + ) => + FormBuilder( + key: _formKey, + child: ListView( + children: [ + Text(widget.header) + .textStyle(context.headlineSmall) + .paddingSymmetric(vertical: 16), + FormBuilderTextField( + autofocus: true, + name: EditProfileForm.formFieldName, + initialValue: widget.initialValueCallback + ?.call(EditProfileForm.formFieldName) as String?, + decoration: + InputDecoration(labelText: translate('account.form_name')), + maxLength: 64, + // The validator receives the text that the user has entered. + validator: FormBuilderValidators.compose([ + FormBuilderValidators.required(), + ]), + textInputAction: TextInputAction.next, + ), + FormBuilderTextField( + name: EditProfileForm.formFieldPronouns, + initialValue: widget.initialValueCallback + ?.call(EditProfileForm.formFieldPronouns) as String?, + maxLength: 64, + decoration: InputDecoration( + labelText: translate('account.form_pronouns')), + textInputAction: TextInputAction.next, + ), + Row(children: [ + const Spacer(), + Text(widget.instructions).toCenter().flexible(flex: 6), + const Spacer(), + ]).paddingSymmetric(vertical: 4), + ElevatedButton( + onPressed: widget.onSubmit == null + ? null + : () async { + if (_formKey.currentState?.saveAndValidate() ?? false) { + await widget.onSubmit!(_formKey); + } + }, + child: Text((widget.onSubmit == null) + ? widget.submitDisabledText + : widget.submitText), + ).paddingSymmetric(vertical: 4).alignAtCenterRight(), + ], + ), + ); + + @override + Widget build(BuildContext context) => _editProfileForm( + context, + ); +} diff --git a/lib/account_manager/views/show_recovery_key_page.dart b/lib/account_manager/views/show_recovery_key_page.dart new file mode 100644 index 0000000..e22e0e1 --- /dev/null +++ b/lib/account_manager/views/show_recovery_key_page.dart @@ -0,0 +1,63 @@ +import 'package:awesome_extensions/awesome_extensions.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_translate/flutter_translate.dart'; +import 'package:go_router/go_router.dart'; +import 'package:veilid_support/veilid_support.dart'; + +import '../../layout/default_app_bar.dart'; +import '../../tools/tools.dart'; +import '../../veilid_processor/veilid_processor.dart'; + +class ShowRecoveryKeyPage extends StatefulWidget { + const ShowRecoveryKeyPage({required SecretKey secretKey, super.key}) + : _secretKey = secretKey; + + @override + ShowRecoveryKeyPageState createState() => ShowRecoveryKeyPageState(); + + final SecretKey _secretKey; +} + +class ShowRecoveryKeyPageState extends State { + @override + void initState() { + super.initState(); + + WidgetsBinding.instance.addPostFrameCallback((_) async { + await changeWindowSetup( + TitleBarStyle.normal, OrientationCapability.portraitOnly); + }); + } + + @override + // ignore: prefer_expression_function_bodies + Widget build(BuildContext context) { + final secretKey = widget._secretKey; + + return Scaffold( + // resizeToAvoidBottomInset: false, + appBar: DefaultAppBar( + title: Text(translate('show_recovery_key_page.titlebar')), + actions: [ + const SignalStrengthMeterWidget(), + IconButton( + icon: const Icon(Icons.settings), + tooltip: translate('menu.settings_tooltip'), + onPressed: () async { + await GoRouterHelper(context).push('/settings'); + }) + ]), + body: Column(children: [ + Text('ASS: $secretKey'), + ElevatedButton( + onPressed: () { + if (context.mounted) { + Navigator.canPop(context) + ? GoRouterHelper(context).pop() + : GoRouterHelper(context).go('/'); + } + }, + child: Text(translate('button.finish'))) + ]).paddingSymmetric(horizontal: 24, vertical: 8)); + } +} diff --git a/lib/account_manager/views/views.dart b/lib/account_manager/views/views.dart index 2acc537..f554e88 100644 --- a/lib/account_manager/views/views.dart +++ b/lib/account_manager/views/views.dart @@ -1,2 +1,4 @@ +export 'edit_account_page.dart'; export 'new_account_page.dart'; export 'profile_widget.dart'; +export 'show_recovery_key_page.dart'; diff --git a/lib/app.dart b/lib/app.dart index aaa0190..01d2a6d 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -11,6 +11,7 @@ import 'package:provider/provider.dart'; import 'package:veilid_support/veilid_support.dart'; import 'account_manager/account_manager.dart'; +import 'account_manager/cubits/active_local_account_cubit.dart'; import 'init.dart'; import 'layout/splash.dart'; import 'router/router.dart'; @@ -129,7 +130,11 @@ class VeilidChatApp extends StatelessWidget { BlocProvider( create: (context) => PreferencesCubit(PreferencesRepository.instance), - ) + ), + BlocProvider( + create: (context) => PerAccountCollectionBlocMapCubit( + accountRepository: AccountRepository.instance, + locator: context.read)), ], child: BackgroundTicker( child: _buildShortcuts( @@ -137,7 +142,7 @@ class VeilidChatApp extends StatelessWidget { builder: (context) => MaterialApp.router( debugShowCheckedModeBanner: false, routerConfig: - context.watch().router(), + context.read().router(), title: translate('app.title'), theme: theme, localizationsDelegates: [ diff --git a/lib/chat/cubits/chat_component_cubit.dart b/lib/chat/cubits/chat_component_cubit.dart index bc7431c..83e3d21 100644 --- a/lib/chat/cubits/chat_component_cubit.dart +++ b/lib/chat/cubits/chat_component_cubit.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:typed_data'; import 'package:async_tools/async_tools.dart'; +import 'package:bloc_advanced_tools/bloc_advanced_tools.dart'; import 'package:fast_immutable_collections/fast_immutable_collections.dart'; import 'package:fixnum/fixnum.dart'; import 'package:flutter/widgets.dart'; @@ -12,7 +13,8 @@ import 'package:scroll_to_index/scroll_to_index.dart'; import 'package:veilid_support/veilid_support.dart'; import '../../account_manager/account_manager.dart'; -import '../../chat_list/chat_list.dart'; +import '../../contacts/contacts.dart'; +import '../../conversation/conversation.dart'; import '../../proto/proto.dart' as proto; import '../models/chat_component_state.dart'; import '../models/message_state.dart'; @@ -23,18 +25,27 @@ const metadataKeyIdentityPublicKey = 'identityPublicKey'; const metadataKeyExpirationDuration = 'expiration'; const metadataKeyViewLimit = 'view_limit'; const metadataKeyAttachments = 'attachments'; +const _sfChangedContacts = 'changedContacts'; class ChatComponentCubit extends Cubit { ChatComponentCubit._({ + required AccountInfo accountInfo, + required AccountRecordCubit accountRecordCubit, + required ContactListCubit contactListCubit, + required List conversationCubits, required SingleContactMessagesCubit messagesCubit, - required types.User localUser, - required IMap remoteUsers, - }) : _messagesCubit = messagesCubit, + }) : _accountInfo = accountInfo, + _accountRecordCubit = accountRecordCubit, + _contactListCubit = contactListCubit, + _conversationCubits = conversationCubits, + _messagesCubit = messagesCubit, super(ChatComponentState( chatKey: GlobalKey(), scrollController: AutoScrollController(), - localUser: localUser, - remoteUsers: remoteUsers, + localUser: null, + remoteUsers: const IMap.empty(), + historicalRemoteUsers: const IMap.empty(), + unknownUsers: const IMap.empty(), messageWindow: const AsyncLoading(), title: '', )) { @@ -42,54 +53,49 @@ class ChatComponentCubit extends Cubit { _initWait.add(_init); } - // ignore: prefer_constructors_over_static_methods - static ChatComponentCubit singleContact( - {required ActiveAccountInfo activeAccountInfo, - required proto.Account accountRecordInfo, - required ActiveConversationState activeConversationState, - required SingleContactMessagesCubit messagesCubit}) { - // Make local 'User' - final localUserIdentityKey = activeAccountInfo.identityTypedPublicKey; - final localUser = types.User( - id: localUserIdentityKey.toString(), - firstName: accountRecordInfo.profile.name, - metadata: {metadataKeyIdentityPublicKey: localUserIdentityKey}); - // Make remote 'User's - final remoteUsers = { - activeConversationState.contact.identityPublicKey.toVeilid(): types.User( - id: activeConversationState.contact.identityPublicKey - .toVeilid() - .toString(), - firstName: activeConversationState.contact.editedProfile.name, - metadata: { - metadataKeyIdentityPublicKey: - activeConversationState.contact.identityPublicKey.toVeilid() - }) - }.toIMap(); - - return ChatComponentCubit._( - messagesCubit: messagesCubit, - localUser: localUser, - remoteUsers: remoteUsers, - ); - } + factory ChatComponentCubit.singleContact( + {required AccountInfo accountInfo, + required AccountRecordCubit accountRecordCubit, + required ContactListCubit contactListCubit, + required ActiveConversationCubit activeConversationCubit, + required SingleContactMessagesCubit messagesCubit}) => + ChatComponentCubit._( + accountInfo: accountInfo, + accountRecordCubit: accountRecordCubit, + contactListCubit: contactListCubit, + conversationCubits: [activeConversationCubit], + messagesCubit: messagesCubit, + ); Future _init() async { - _messagesSubscription = _messagesCubit.stream.listen((messagesState) { - emit(state.copyWith( - messageWindow: _convertMessages(messagesState), - )); - }); - emit(state.copyWith( - messageWindow: _convertMessages(_messagesCubit.state), - title: _getTitle(), - )); + // Get local user info and account record cubit + _localUserIdentityKey = _accountInfo.identityTypedPublicKey; + + // Subscribe to local user info + _accountRecordSubscription = + _accountRecordCubit.stream.listen(_onChangedAccountRecord); + _onChangedAccountRecord(_accountRecordCubit.state); + + // Subscribe to remote user info + await _updateConversationSubscriptions(); + + // Subscribe to messages + _messagesSubscription = _messagesCubit.stream.listen(_onChangedMessages); + _onChangedMessages(_messagesCubit.state); + + // Subscribe to contact list changes + _contactListSubscription = + _contactListCubit.stream.listen(_onChangedContacts); + _onChangedContacts(_contactListCubit.state); } @override Future close() async { await _initWait(); + await _contactListSubscription.cancel(); + await _accountRecordSubscription.cancel(); await _messagesSubscription.cancel(); + await _conversationSubscriptions.values.map((v) => v.cancel()).wait; await super.close(); } @@ -153,23 +159,162 @@ class ChatComponentCubit extends Cubit { //////////////////////////////////////////////////////////////////////////// // Private Implementation - String _getTitle() { - if (state.remoteUsers.length == 1) { - final remoteUser = state.remoteUsers.values.first; - return remoteUser.firstName ?? ''; - } else { - return ''; + void _onChangedAccountRecord(AsyncValue avAccount) { + // Update local 'User' + final account = avAccount.asData?.value; + if (account == null) { + emit(state.copyWith(localUser: null)); + return; } + final localUser = types.User( + id: _localUserIdentityKey.toString(), + firstName: account.profile.name, + metadata: {metadataKeyIdentityPublicKey: _localUserIdentityKey}); + emit(state.copyWith(localUser: localUser)); } - types.Message? _messageStateToChatMessage(MessageState message) { + void _onChangedMessages( + AsyncValue> avMessagesState) { + emit(_convertMessages(state, avMessagesState)); + } + + void _onChangedContacts( + BlocBusyState>>> + bavContacts) { + // Rewrite users when contacts change + singleFuture((this, _sfChangedContacts), _updateConversationSubscriptions); + } + + void _onChangedConversation( + TypedKey remoteIdentityPublicKey, + AsyncValue avConversationState, + ) { + // Update remote 'User' + final activeConversationState = avConversationState.asData?.value; + if (activeConversationState == null) { + // Don't change user information on loading state + return; + } + emit(_updateTitle(state.copyWith( + remoteUsers: state.remoteUsers.add( + remoteIdentityPublicKey, + _convertRemoteUser( + remoteIdentityPublicKey, activeConversationState))))); + } + + static ChatComponentState _updateTitle(ChatComponentState currentState) { + if (currentState.remoteUsers.length == 0) { + return currentState.copyWith(title: 'Empty Chat'); + } + if (currentState.remoteUsers.length == 1) { + final remoteUser = currentState.remoteUsers.values.first; + return currentState.copyWith(title: remoteUser.firstName ?? ''); + } + return currentState.copyWith( + title: ''); + } + + types.User _convertRemoteUser(TypedKey remoteIdentityPublicKey, + ActiveConversationState activeConversationState) { + // See if we have a contact for this remote user + final contacts = _contactListCubit.state.state.asData?.value; + if (contacts != null) { + final contactIdx = contacts.indexWhere((x) => + x.value.identityPublicKey.toVeilid() == remoteIdentityPublicKey); + if (contactIdx != -1) { + final contact = contacts[contactIdx].value; + return types.User( + id: remoteIdentityPublicKey.toString(), + firstName: contact.displayName, + metadata: {metadataKeyIdentityPublicKey: remoteIdentityPublicKey}); + } + } + + return types.User( + id: remoteIdentityPublicKey.toString(), + firstName: activeConversationState.remoteConversation.profile.name, + metadata: {metadataKeyIdentityPublicKey: remoteIdentityPublicKey}); + } + + types.User _convertUnknownUser(TypedKey remoteIdentityPublicKey) => + types.User( + id: remoteIdentityPublicKey.toString(), + firstName: '<$remoteIdentityPublicKey>', + metadata: {metadataKeyIdentityPublicKey: remoteIdentityPublicKey}); + + Future _updateConversationSubscriptions() async { + // Get existing subscription keys and state + final existing = _conversationSubscriptions.keys.toList(); + var currentRemoteUsersState = state.remoteUsers; + + // Process cubit list + for (final cc in _conversationCubits) { + // Get the remote identity key + final remoteIdentityPublicKey = cc.input.remoteIdentityPublicKey; + + // If the cubit is already being listened to we have nothing to do + if (existing.remove(remoteIdentityPublicKey)) { + // If the cubit is not already being listened to we should do that + _conversationSubscriptions[remoteIdentityPublicKey] = cc.stream.listen( + (avConv) => + _onChangedConversation(remoteIdentityPublicKey, avConv)); + } + + final activeConversationState = cc.state.asData?.value; + if (activeConversationState != null) { + currentRemoteUsersState = currentRemoteUsersState.add( + remoteIdentityPublicKey, + _convertRemoteUser( + remoteIdentityPublicKey, activeConversationState)); + } + } + // Purge remote users we didn't see in the cubit list any more + final cancels = >[]; + for (final deadUser in existing) { + currentRemoteUsersState = currentRemoteUsersState.remove(deadUser); + cancels.add(_conversationSubscriptions.remove(deadUser)!.cancel()); + } + await cancels.wait; + + // Emit change to remote users state + emit(_updateTitle(state.copyWith(remoteUsers: currentRemoteUsersState))); + } + + (ChatComponentState, types.Message?) _messageStateToChatMessage( + ChatComponentState currentState, MessageState message) { final authorIdentityPublicKey = message.content.author.toVeilid(); - final author = - state.remoteUsers[authorIdentityPublicKey] ?? state.localUser; + late final types.User author; + if (authorIdentityPublicKey == _localUserIdentityKey && + currentState.localUser != null) { + author = currentState.localUser!; + } else { + final remoteUser = currentState.remoteUsers[authorIdentityPublicKey]; + if (remoteUser != null) { + author = remoteUser; + } else { + final historicalRemoteUser = + currentState.historicalRemoteUsers[authorIdentityPublicKey]; + if (historicalRemoteUser != null) { + author = historicalRemoteUser; + } else { + final unknownRemoteUser = + currentState.unknownUsers[authorIdentityPublicKey]; + if (unknownRemoteUser != null) { + author = unknownRemoteUser; + } else { + final unknownUser = _convertUnknownUser(authorIdentityPublicKey); + currentState = currentState.copyWith( + unknownUsers: currentState.unknownUsers + .add(authorIdentityPublicKey, unknownUser)); + author = unknownUser; + } + } + } + } types.Status? status; if (message.sendState != null) { - assert(author == state.localUser, + assert(author.id == _localUserIdentityKey.toString(), 'send state should only be on sent messages'); switch (message.sendState!) { case MessageSendState.sending: @@ -192,7 +337,7 @@ class ChatComponentCubit extends Cubit { text: contextText.text, showStatus: status != null, status: status); - return textMessage; + return (currentState, textMessage); case proto.Message_Kind.secret: case proto.Message_Kind.delete: case proto.Message_Kind.erase: @@ -201,17 +346,24 @@ class ChatComponentCubit extends Cubit { case proto.Message_Kind.membership: case proto.Message_Kind.moderation: case proto.Message_Kind.notSet: - return null; + return (currentState, null); } } - AsyncValue> _convertMessages( + ChatComponentState _convertMessages(ChatComponentState currentState, AsyncValue> avMessagesState) { + // Clear out unknown users + currentState = state.copyWith(unknownUsers: const IMap.empty()); + final asError = avMessagesState.asError; if (asError != null) { - return AsyncValue.error(asError.error, asError.stackTrace); + return currentState.copyWith( + unknownUsers: const IMap.empty(), + messageWindow: AsyncValue.error(asError.error, asError.stackTrace)); } else if (avMessagesState.asLoading != null) { - return const AsyncValue.loading(); + return currentState.copyWith( + unknownUsers: const IMap.empty(), + messageWindow: const AsyncValue.loading()); } final messagesState = avMessagesState.asData!.value; @@ -219,7 +371,9 @@ class ChatComponentCubit extends Cubit { final chatMessages = []; final tsSet = {}; for (final message in messagesState.window) { - final chatMessage = _messageStateToChatMessage(message); + final (newState, chatMessage) = + _messageStateToChatMessage(currentState, message); + currentState = newState; if (chatMessage == null) { continue; } @@ -232,12 +386,13 @@ class ChatComponentCubit extends Cubit { assert(false, 'should not have duplicate id'); } } - return AsyncValue.data(WindowState( - window: chatMessages.toIList(), - length: messagesState.length, - windowTail: messagesState.windowTail, - windowCount: messagesState.windowCount, - follow: messagesState.follow)); + return currentState.copyWith( + messageWindow: AsyncValue.data(WindowState( + window: chatMessages.toIList(), + length: messagesState.length, + windowTail: messagesState.windowTail, + windowCount: messagesState.windowCount, + follow: messagesState.follow))); } void _addTextMessage( @@ -265,7 +420,21 @@ class ChatComponentCubit extends Cubit { //////////////////////////////////////////////////////////////////////////// final _initWait = WaitSet(); + final AccountInfo _accountInfo; + final AccountRecordCubit _accountRecordCubit; + final ContactListCubit _contactListCubit; + final List _conversationCubits; final SingleContactMessagesCubit _messagesCubit; + + late final TypedKey _localUserIdentityKey; + late final StreamSubscription> + _accountRecordSubscription; + final Map>> + _conversationSubscriptions = {}; late StreamSubscription _messagesSubscription; + late StreamSubscription< + BlocBusyState< + AsyncValue>>>> + _contactListSubscription; double scrollOffset = 0; } diff --git a/lib/chat/cubits/reconciliation/author_input_queue.dart b/lib/chat/cubits/reconciliation/author_input_queue.dart index d7be3eb..b15d92c 100644 --- a/lib/chat/cubits/reconciliation/author_input_queue.dart +++ b/lib/chat/cubits/reconciliation/author_input_queue.dart @@ -3,6 +3,7 @@ import 'package:veilid_support/veilid_support.dart'; import '../../../proto/proto.dart' as proto; +import '../../../tools/tools.dart'; import 'author_input_source.dart'; import 'message_integrity.dart'; import 'output_position.dart'; @@ -84,6 +85,8 @@ class AuthorInputQueue { if (_lastMessage != null) { // Ensure the timestamp is not moving backward if (nextMessage.value.timestamp < _lastMessage!.timestamp) { + log.warning('timestamp backward: ${nextMessage.value.timestamp}' + ' < ${_lastMessage!.timestamp}'); continue; } } @@ -91,11 +94,14 @@ class AuthorInputQueue { // Verify the id chain for the message final matchId = await _messageIntegrity.generateMessageId(_lastMessage); if (matchId.compare(nextMessage.value.idBytes) != 0) { + log.warning( + 'id chain invalid: $matchId != ${nextMessage.value.idBytes}'); continue; } // Verify the signature for the message if (!await _messageIntegrity.verifyMessage(nextMessage.value)) { + log.warning('invalid message signature: ${nextMessage.value}'); continue; } diff --git a/lib/chat/cubits/single_contact_messages_cubit.dart b/lib/chat/cubits/single_contact_messages_cubit.dart index 0894ac1..9854535 100644 --- a/lib/chat/cubits/single_contact_messages_cubit.dart +++ b/lib/chat/cubits/single_contact_messages_cubit.dart @@ -50,13 +50,13 @@ typedef SingleContactMessagesState = AsyncValue>; // Builds the reconciled chat record from the local and remote conversation keys class SingleContactMessagesCubit extends Cubit { SingleContactMessagesCubit({ - required ActiveAccountInfo activeAccountInfo, + required AccountInfo accountInfo, required TypedKey remoteIdentityPublicKey, required TypedKey localConversationRecordKey, required TypedKey localMessagesRecordKey, required TypedKey remoteConversationRecordKey, required TypedKey remoteMessagesRecordKey, - }) : _activeAccountInfo = activeAccountInfo, + }) : _accountInfo = accountInfo, _remoteIdentityPublicKey = remoteIdentityPublicKey, _localConversationRecordKey = localConversationRecordKey, _localMessagesRecordKey = localMessagesRecordKey, @@ -81,6 +81,16 @@ class SingleContactMessagesCubit extends Cubit { await _sentMessagesCubit?.close(); await _rcvdMessagesCubit?.close(); await _reconciledMessagesCubit?.close(); + + // If the local conversation record is gone, then delete the reconciled + // messages table as well + final conversationDead = await DHTRecordPool.instance + .isDeletedRecordKey(_localConversationRecordKey); + if (conversationDead) { + await SingleContactMessagesCubit.cleanupAndDeleteMessages( + localConversationRecordKey: _localConversationRecordKey); + } + await super.close(); } @@ -111,15 +121,15 @@ class SingleContactMessagesCubit extends Cubit { // Make crypto Future _initCrypto() async { - _conversationCrypto = await _activeAccountInfo - .makeConversationCrypto(_remoteIdentityPublicKey); + _conversationCrypto = + await _accountInfo.makeConversationCrypto(_remoteIdentityPublicKey); _senderMessageIntegrity = await MessageIntegrity.create( - author: _activeAccountInfo.identityTypedPublicKey); + author: _accountInfo.identityTypedPublicKey); } // Open local messages key Future _initSentMessagesCubit() async { - final writer = _activeAccountInfo.identityWriter; + final writer = _accountInfo.identityWriter; _sentMessagesCubit = DHTLogCubit( open: () async => DHTLog.openWrite(_localMessagesRecordKey, writer, @@ -149,7 +159,7 @@ class SingleContactMessagesCubit extends Cubit { Future _makeLocalMessagesCrypto() async => VeilidCryptoPrivate.fromTypedKey( - _activeAccountInfo.userLogin.identitySecret, 'tabledb'); + _accountInfo.userLogin!.identitySecret, 'tabledb'); // Open reconciled chat record key Future _initReconciledMessagesCubit() async { @@ -240,8 +250,8 @@ class SingleContactMessagesCubit extends Cubit { return; } - _reconciliation.reconcileMessages(_activeAccountInfo.identityTypedPublicKey, - sentMessages, _sentMessagesCubit!); + _reconciliation.reconcileMessages( + _accountInfo.identityTypedPublicKey, sentMessages, _sentMessagesCubit!); // Update the view _renderState(); @@ -278,7 +288,7 @@ class SingleContactMessagesCubit extends Cubit { // Now sign it await _senderMessageIntegrity.signMessage( - message, _activeAccountInfo.identitySecretKey); + message, _accountInfo.identitySecretKey); } // Async process to send messages in the background @@ -291,8 +301,14 @@ class SingleContactMessagesCubit extends Cubit { previousMessage = message; } + // _sendingMessages = messages; + + // _renderState(); + await _sentMessagesCubit!.operateAppendEventual((writer) => writer.addAll(messages.map((m) => m.writeToBuffer()).toList())); + + // _sendingMessages = const IList.empty(); } // Produce a state for this cubit from the input cubits and queues @@ -302,8 +318,8 @@ class SingleContactMessagesCubit extends Cubit { _reconciledMessagesCubit?.state.state.asData?.value; // Get all sent messages final sentMessages = _sentMessagesCubit?.state.state.asData?.value; - // Get all items in the unsent queue - // final unsentMessages = _unsentMessagesQueue.queue; + //Get all items in the unsent queue + //final unsentMessages = _unsentMessagesQueue.queue; // If we aren't ready to render a state, say we're loading if (reconciledMessages == null || sentMessages == null) { @@ -315,7 +331,7 @@ class SingleContactMessagesCubit extends Cubit { // final reconciledMessagesMap = // IMap.fromValues( // keyMapper: (x) => x.content.authorUniqueIdString, - // values: reconciledMessages.elements, + // values: reconciledMessages.windowElements, // ); final sentMessagesMap = IMap>.fromValues( @@ -328,10 +344,10 @@ class SingleContactMessagesCubit extends Cubit { // ); final renderedElements = []; - + final renderedIds = {}; for (final m in reconciledMessages.windowElements) { - final isLocal = m.content.author.toVeilid() == - _activeAccountInfo.identityTypedPublicKey; + final isLocal = + m.content.author.toVeilid() == _accountInfo.identityTypedPublicKey; final reconciledTimestamp = Timestamp.fromInt64(m.reconciledTime); final sm = isLocal ? sentMessagesMap[m.content.authorUniqueIdString] : null; @@ -345,8 +361,23 @@ class SingleContactMessagesCubit extends Cubit { sent: sent, sentOffline: sentOffline, )); + + renderedIds.add(m.content.authorUniqueIdString); } + // Render in-flight messages at the bottom + // for (final m in _sendingMessages) { + // if (renderedIds.contains(m.authorUniqueIdString)) { + // continue; + // } + // renderedElements.add(RenderStateElement( + // message: m, + // isLocal: true, + // sent: true, + // sentOffline: true, + // )); + // } + // Render the state final messages = renderedElements .map((x) => MessageState( @@ -369,7 +400,7 @@ class SingleContactMessagesCubit extends Cubit { // Add common fields // id and signature will get set by _processMessageToSend message - ..author = _activeAccountInfo.identityTypedPublicKey.toProto() + ..author = _accountInfo.identityTypedPublicKey.toProto() ..timestamp = Veilid.instance.now().toInt64(); // Put in the queue @@ -402,7 +433,7 @@ class SingleContactMessagesCubit extends Cubit { ///////////////////////////////////////////////////////////////////////// final WaitSet _initWait = WaitSet(); - final ActiveAccountInfo _activeAccountInfo; + late final AccountInfo _accountInfo; final TypedKey _remoteIdentityPublicKey; final TypedKey _localConversationRecordKey; final TypedKey _localMessagesRecordKey; @@ -419,7 +450,7 @@ class SingleContactMessagesCubit extends Cubit { late final MessageReconciliation _reconciliation; late final PersistentQueue _unsentMessagesQueue; - + // IList _sendingMessages = const IList.empty(); StreamSubscription>? _sentSubscription; StreamSubscription>? _rcvdSubscription; StreamSubscription>? diff --git a/lib/chat/models/chat_component_state.dart b/lib/chat/models/chat_component_state.dart index b8da8d4..ae69da7 100644 --- a/lib/chat/models/chat_component_state.dart +++ b/lib/chat/models/chat_component_state.dart @@ -20,9 +20,13 @@ class ChatComponentState with _$ChatComponentState { // ScrollController for the chat required AutoScrollController scrollController, // Local user - required User localUser, - // Remote users + required User? localUser, + // Active remote users required IMap remoteUsers, + // Historical remote users + required IMap historicalRemoteUsers, + // Unknown users + required IMap unknownUsers, // Messages state required AsyncValue> messageWindow, // Title of the chat diff --git a/lib/chat/models/chat_component_state.freezed.dart b/lib/chat/models/chat_component_state.freezed.dart index 859f363..ea7e8ae 100644 --- a/lib/chat/models/chat_component_state.freezed.dart +++ b/lib/chat/models/chat_component_state.freezed.dart @@ -21,8 +21,13 @@ mixin _$ChatComponentState { throw _privateConstructorUsedError; // ScrollController for the chat AutoScrollController get scrollController => throw _privateConstructorUsedError; // Local user - User get localUser => throw _privateConstructorUsedError; // Remote users + User? get localUser => + throw _privateConstructorUsedError; // Active remote users IMap, User> get remoteUsers => + throw _privateConstructorUsedError; // Historical remote users + IMap, User> get historicalRemoteUsers => + throw _privateConstructorUsedError; // Unknown users + IMap, User> get unknownUsers => throw _privateConstructorUsedError; // Messages state AsyncValue> get messageWindow => throw _privateConstructorUsedError; // Title of the chat @@ -42,8 +47,10 @@ abstract class $ChatComponentStateCopyWith<$Res> { $Res call( {GlobalKey chatKey, AutoScrollController scrollController, - User localUser, + User? localUser, IMap, User> remoteUsers, + IMap, User> historicalRemoteUsers, + IMap, User> unknownUsers, AsyncValue> messageWindow, String title}); @@ -65,8 +72,10 @@ class _$ChatComponentStateCopyWithImpl<$Res, $Val extends ChatComponentState> $Res call({ Object? chatKey = null, Object? scrollController = null, - Object? localUser = null, + Object? localUser = freezed, Object? remoteUsers = null, + Object? historicalRemoteUsers = null, + Object? unknownUsers = null, Object? messageWindow = null, Object? title = null, }) { @@ -79,14 +88,22 @@ class _$ChatComponentStateCopyWithImpl<$Res, $Val extends ChatComponentState> ? _value.scrollController : scrollController // ignore: cast_nullable_to_non_nullable as AutoScrollController, - localUser: null == localUser + localUser: freezed == localUser ? _value.localUser : localUser // ignore: cast_nullable_to_non_nullable - as User, + as User?, remoteUsers: null == remoteUsers ? _value.remoteUsers : remoteUsers // ignore: cast_nullable_to_non_nullable as IMap, User>, + historicalRemoteUsers: null == historicalRemoteUsers + ? _value.historicalRemoteUsers + : historicalRemoteUsers // ignore: cast_nullable_to_non_nullable + as IMap, User>, + unknownUsers: null == unknownUsers + ? _value.unknownUsers + : unknownUsers // ignore: cast_nullable_to_non_nullable + as IMap, User>, messageWindow: null == messageWindow ? _value.messageWindow : messageWindow // ignore: cast_nullable_to_non_nullable @@ -119,8 +136,10 @@ abstract class _$$ChatComponentStateImplCopyWith<$Res> $Res call( {GlobalKey chatKey, AutoScrollController scrollController, - User localUser, + User? localUser, IMap, User> remoteUsers, + IMap, User> historicalRemoteUsers, + IMap, User> unknownUsers, AsyncValue> messageWindow, String title}); @@ -141,8 +160,10 @@ class __$$ChatComponentStateImplCopyWithImpl<$Res> $Res call({ Object? chatKey = null, Object? scrollController = null, - Object? localUser = null, + Object? localUser = freezed, Object? remoteUsers = null, + Object? historicalRemoteUsers = null, + Object? unknownUsers = null, Object? messageWindow = null, Object? title = null, }) { @@ -155,14 +176,22 @@ class __$$ChatComponentStateImplCopyWithImpl<$Res> ? _value.scrollController : scrollController // ignore: cast_nullable_to_non_nullable as AutoScrollController, - localUser: null == localUser + localUser: freezed == localUser ? _value.localUser : localUser // ignore: cast_nullable_to_non_nullable - as User, + as User?, remoteUsers: null == remoteUsers ? _value.remoteUsers : remoteUsers // ignore: cast_nullable_to_non_nullable as IMap, User>, + historicalRemoteUsers: null == historicalRemoteUsers + ? _value.historicalRemoteUsers + : historicalRemoteUsers // ignore: cast_nullable_to_non_nullable + as IMap, User>, + unknownUsers: null == unknownUsers + ? _value.unknownUsers + : unknownUsers // ignore: cast_nullable_to_non_nullable + as IMap, User>, messageWindow: null == messageWindow ? _value.messageWindow : messageWindow // ignore: cast_nullable_to_non_nullable @@ -183,6 +212,8 @@ class _$ChatComponentStateImpl implements _ChatComponentState { required this.scrollController, required this.localUser, required this.remoteUsers, + required this.historicalRemoteUsers, + required this.unknownUsers, required this.messageWindow, required this.title}); @@ -194,10 +225,16 @@ class _$ChatComponentStateImpl implements _ChatComponentState { final AutoScrollController scrollController; // Local user @override - final User localUser; -// Remote users + final User? localUser; +// Active remote users @override final IMap, User> remoteUsers; +// Historical remote users + @override + final IMap, User> historicalRemoteUsers; +// Unknown users + @override + final IMap, User> unknownUsers; // Messages state @override final AsyncValue> messageWindow; @@ -207,7 +244,7 @@ class _$ChatComponentStateImpl implements _ChatComponentState { @override String toString() { - return 'ChatComponentState(chatKey: $chatKey, scrollController: $scrollController, localUser: $localUser, remoteUsers: $remoteUsers, messageWindow: $messageWindow, title: $title)'; + return 'ChatComponentState(chatKey: $chatKey, scrollController: $scrollController, localUser: $localUser, remoteUsers: $remoteUsers, historicalRemoteUsers: $historicalRemoteUsers, unknownUsers: $unknownUsers, messageWindow: $messageWindow, title: $title)'; } @override @@ -222,14 +259,26 @@ class _$ChatComponentStateImpl implements _ChatComponentState { other.localUser == localUser) && (identical(other.remoteUsers, remoteUsers) || other.remoteUsers == remoteUsers) && + (identical(other.historicalRemoteUsers, historicalRemoteUsers) || + other.historicalRemoteUsers == historicalRemoteUsers) && + (identical(other.unknownUsers, unknownUsers) || + other.unknownUsers == unknownUsers) && (identical(other.messageWindow, messageWindow) || other.messageWindow == messageWindow) && (identical(other.title, title) || other.title == title)); } @override - int get hashCode => Object.hash(runtimeType, chatKey, scrollController, - localUser, remoteUsers, messageWindow, title); + int get hashCode => Object.hash( + runtimeType, + chatKey, + scrollController, + localUser, + remoteUsers, + historicalRemoteUsers, + unknownUsers, + messageWindow, + title); @JsonKey(ignore: true) @override @@ -243,8 +292,11 @@ abstract class _ChatComponentState implements ChatComponentState { const factory _ChatComponentState( {required final GlobalKey chatKey, required final AutoScrollController scrollController, - required final User localUser, + required final User? localUser, required final IMap, User> remoteUsers, + required final IMap, User> + historicalRemoteUsers, + required final IMap, User> unknownUsers, required final AsyncValue> messageWindow, required final String title}) = _$ChatComponentStateImpl; @@ -253,9 +305,13 @@ abstract class _ChatComponentState implements ChatComponentState { @override // ScrollController for the chat AutoScrollController get scrollController; @override // Local user - User get localUser; - @override // Remote users + User? get localUser; + @override // Active remote users IMap, User> get remoteUsers; + @override // Historical remote users + IMap, User> get historicalRemoteUsers; + @override // Unknown users + IMap, User> get unknownUsers; @override // Messages state AsyncValue> get messageWindow; @override // Title of the chat diff --git a/lib/chat/views/chat_component_widget.dart b/lib/chat/views/chat_component_widget.dart index a3b2e33..6d0fa73 100644 --- a/lib/chat/views/chat_component_widget.dart +++ b/lib/chat/views/chat_component_widget.dart @@ -9,7 +9,8 @@ import 'package:flutter_chat_ui/flutter_chat_ui.dart'; import 'package:veilid_support/veilid_support.dart'; import '../../account_manager/account_manager.dart'; -import '../../chat_list/chat_list.dart'; +import '../../contacts/contacts.dart'; +import '../../conversation/conversation.dart'; import '../../theme/theme.dart'; import '../chat.dart'; @@ -22,31 +23,29 @@ class ChatComponentWidget extends StatelessWidget { static Widget builder( {required TypedKey localConversationRecordKey, Key? key}) => Builder(builder: (context) { - // Get all watched dependendies - final activeAccountInfo = context.watch(); - final accountRecordInfo = - context.watch().state.asData?.value; - if (accountRecordInfo == null) { - return debugPage('should always have an account record here'); - } + // Get the account info + final accountInfo = context.watch().state; - final avconversation = context.select?>( - (x) => x.state[localConversationRecordKey]); - if (avconversation == null) { + // Get the account record cubit + final accountRecordCubit = context.read(); + + // Get the contact list cubit + final contactListCubit = context.watch(); + + // Get the active conversation cubit + final activeConversationCubit = context + .select( + (x) => x.tryOperateSync(localConversationRecordKey, + closure: (cubit) => cubit)); + if (activeConversationCubit == null) { return waitingPage(); } - final activeConversationState = avconversation.asData?.value; - if (activeConversationState == null) { - return avconversation.buildNotData(); - } - // Get the messages cubit final messagesCubit = context.select< ActiveSingleContactChatBlocMapCubit, SingleContactMessagesCubit?>( - (x) => x.tryOperate(localConversationRecordKey, + (x) => x.tryOperateSync(localConversationRecordKey, closure: (cubit) => cubit)); if (messagesCubit == null) { return waitingPage(); @@ -54,10 +53,12 @@ class ChatComponentWidget extends StatelessWidget { // Make chat component state return BlocProvider( + key: key, create: (context) => ChatComponentCubit.singleContact( - activeAccountInfo: activeAccountInfo, - accountRecordInfo: accountRecordInfo, - activeConversationState: activeConversationState, + accountInfo: accountInfo, + accountRecordCubit: accountRecordCubit, + contactListCubit: contactListCubit, + activeConversationCubit: activeConversationCubit, messagesCubit: messagesCubit, ), child: ChatComponentWidget._(key: key)); @@ -159,6 +160,11 @@ class ChatComponentWidget extends StatelessWidget { final chatComponentCubit = context.watch(); final chatComponentState = chatComponentCubit.state; + final localUser = chatComponentState.localUser; + if (localUser == null) { + return waitingPage(); + } + final messageWindow = chatComponentState.messageWindow.asData?.value; if (messageWindow == null) { return chatComponentState.messageWindow.buildNotData(); @@ -281,7 +287,7 @@ class ChatComponentWidget extends StatelessWidget { _handleSendPressed(chatComponentCubit, pt), //showUserAvatars: false, //showUserNames: true, - user: chatComponentState.localUser, + user: localUser, emptyState: const EmptyChatWidget())), ), ), diff --git a/lib/chat_list/cubits/active_conversations_bloc_map_cubit.dart b/lib/chat_list/cubits/active_conversations_bloc_map_cubit.dart deleted file mode 100644 index c497941..0000000 --- a/lib/chat_list/cubits/active_conversations_bloc_map_cubit.dart +++ /dev/null @@ -1,102 +0,0 @@ -import 'package:async_tools/async_tools.dart'; -import 'package:bloc_advanced_tools/bloc_advanced_tools.dart'; -import 'package:equatable/equatable.dart'; -import 'package:meta/meta.dart'; -import 'package:veilid_support/veilid_support.dart'; - -import '../../account_manager/account_manager.dart'; -import '../../contacts/contacts.dart'; -import '../../proto/proto.dart' as proto; -import 'cubits.dart'; - -@immutable -class ActiveConversationState extends Equatable { - const ActiveConversationState({ - required this.contact, - required this.localConversation, - required this.remoteConversation, - }); - - final proto.Contact contact; - final proto.Conversation localConversation; - final proto.Conversation remoteConversation; - - @override - List get props => [contact, localConversation, remoteConversation]; -} - -typedef ActiveConversationCubit = TransformerCubit< - AsyncValue, AsyncValue>; - -typedef ActiveConversationsBlocMapState - = BlocMapState>; - -// Map of localConversationRecordKey to ActiveConversationCubit -// Wraps a conversation cubit to only expose completely built conversations -// Automatically follows the state of a ChatListCubit. -// Even though 'conversations' are per-contact and not per-chat -// We currently only build the cubits for the chats that are active, not -// archived chats or contacts that are not actively in a chat. -class ActiveConversationsBlocMapCubit extends BlocMapCubit, ActiveConversationCubit> - with StateMapFollower { - ActiveConversationsBlocMapCubit( - {required ActiveAccountInfo activeAccountInfo, - required ContactListCubit contactListCubit}) - : _activeAccountInfo = activeAccountInfo, - _contactListCubit = contactListCubit; - - // Add an active conversation to be tracked for changes - Future _addConversation({required proto.Contact contact}) async => - add(() => MapEntry( - contact.localConversationRecordKey.toVeilid(), - TransformerCubit( - ConversationCubit( - activeAccountInfo: _activeAccountInfo, - remoteIdentityPublicKey: contact.identityPublicKey.toVeilid(), - localConversationRecordKey: - contact.localConversationRecordKey.toVeilid(), - remoteConversationRecordKey: - contact.remoteConversationRecordKey.toVeilid(), - ), - // Transformer that only passes through completed conversations - // along with the contact that corresponds to the completed - // conversation - transform: (avstate) => avstate.when( - data: (data) => (data.localConversation == null || - data.remoteConversation == null) - ? const AsyncValue.loading() - : AsyncValue.data(ActiveConversationState( - contact: contact, - localConversation: data.localConversation!, - remoteConversation: data.remoteConversation!)), - loading: AsyncValue.loading, - error: AsyncValue.error)))); - - /// StateFollower ///////////////////////// - - @override - Future removeFromState(TypedKey key) => remove(key); - - @override - Future updateState(TypedKey key, proto.Chat value) async { - final contactList = _contactListCubit.state.state.asData?.value; - if (contactList == null) { - await addState(key, const AsyncValue.loading()); - return; - } - final contactIndex = contactList.indexWhere( - (c) => c.value.localConversationRecordKey.toVeilid() == key); - if (contactIndex == -1) { - await addState(key, AsyncValue.error('Contact not found')); - return; - } - final contact = contactList[contactIndex]; - await _addConversation(contact: contact.value); - } - - //// - - final ActiveAccountInfo _activeAccountInfo; - final ContactListCubit _contactListCubit; -} diff --git a/lib/chat_list/cubits/active_single_contact_chat_bloc_map_cubit.dart b/lib/chat_list/cubits/active_single_contact_chat_bloc_map_cubit.dart deleted file mode 100644 index 914d357..0000000 --- a/lib/chat_list/cubits/active_single_contact_chat_bloc_map_cubit.dart +++ /dev/null @@ -1,101 +0,0 @@ -import 'dart:async'; - -import 'package:async_tools/async_tools.dart'; -import 'package:bloc_advanced_tools/bloc_advanced_tools.dart'; -import 'package:veilid_support/veilid_support.dart'; - -import '../../account_manager/account_manager.dart'; -import '../../chat/chat.dart'; -import '../../contacts/contacts.dart'; -import '../../proto/proto.dart' as proto; -import 'active_conversations_bloc_map_cubit.dart'; -import 'chat_list_cubit.dart'; - -// Map of localConversationRecordKey to MessagesCubit -// Wraps a MessagesCubit to stream the latest messages to the state -// Automatically follows the state of a ActiveConversationsBlocMapCubit. -class ActiveSingleContactChatBlocMapCubit extends BlocMapCubit - with - StateMapFollower> { - ActiveSingleContactChatBlocMapCubit( - {required ActiveAccountInfo activeAccountInfo, - required ContactListCubit contactListCubit, - required ChatListCubit chatListCubit}) - : _activeAccountInfo = activeAccountInfo, - _contactListCubit = contactListCubit, - _chatListCubit = chatListCubit; - - Future _addConversationMessages( - {required proto.Contact contact, - required proto.Chat chat, - required proto.Conversation localConversation, - required proto.Conversation remoteConversation}) async => - add(() => MapEntry( - contact.localConversationRecordKey.toVeilid(), - SingleContactMessagesCubit( - activeAccountInfo: _activeAccountInfo, - remoteIdentityPublicKey: contact.identityPublicKey.toVeilid(), - localConversationRecordKey: - contact.localConversationRecordKey.toVeilid(), - remoteConversationRecordKey: - contact.remoteConversationRecordKey.toVeilid(), - localMessagesRecordKey: localConversation.messages.toVeilid(), - remoteMessagesRecordKey: remoteConversation.messages.toVeilid(), - ))); - - /// StateFollower ///////////////////////// - - @override - Future removeFromState(TypedKey key) => remove(key); - - @override - Future updateState( - TypedKey key, AsyncValue value) async { - // Get the contact object for this single contact chat - final contactList = _contactListCubit.state.state.asData?.value; - if (contactList == null) { - await addState(key, const AsyncValue.loading()); - return; - } - final contactIndex = contactList.indexWhere( - (c) => c.value.localConversationRecordKey.toVeilid() == key); - if (contactIndex == -1) { - await addState( - key, AsyncValue.error('Contact not found for conversation')); - return; - } - final contact = contactList[contactIndex].value; - - // Get the chat object for this single contact chat - final chatList = _chatListCubit.state.state.asData?.value; - if (chatList == null) { - await addState(key, const AsyncValue.loading()); - return; - } - final chatIndex = chatList.indexWhere( - (c) => c.value.localConversationRecordKey.toVeilid() == key); - if (contactIndex == -1) { - await addState(key, AsyncValue.error('Chat not found for conversation')); - return; - } - final chat = chatList[chatIndex].value; - - await value.when( - data: (state) => _addConversationMessages( - contact: contact, - chat: chat, - localConversation: state.localConversation, - remoteConversation: state.remoteConversation), - loading: () => addState(key, const AsyncValue.loading()), - error: (error, stackTrace) => - addState(key, AsyncValue.error(error, stackTrace))); - } - - //// - - final ActiveAccountInfo _activeAccountInfo; - final ContactListCubit _contactListCubit; - final ChatListCubit _chatListCubit; -} diff --git a/lib/chat_list/cubits/chat_list_cubit.dart b/lib/chat_list/cubits/chat_list_cubit.dart index 0c36f52..6bb88c1 100644 --- a/lib/chat_list/cubits/chat_list_cubit.dart +++ b/lib/chat_list/cubits/chat_list_cubit.dart @@ -8,7 +8,6 @@ import 'package:veilid_support/veilid_support.dart'; import '../../account_manager/account_manager.dart'; import '../../chat/chat.dart'; import '../../proto/proto.dart' as proto; -import '../../tools/tools.dart'; ////////////////////////////////////////////////// @@ -19,33 +18,30 @@ typedef ChatListCubitState = DHTShortArrayBusyState; class ChatListCubit extends DHTShortArrayCubit with StateMapFollowable { ChatListCubit({ - required ActiveAccountInfo activeAccountInfo, - required proto.Account account, - required this.activeChatCubit, - }) : super( - open: () => _open(activeAccountInfo, account), + required AccountInfo accountInfo, + required OwnedDHTRecordPointer chatListRecordPointer, + required ActiveChatCubit activeChatCubit, + }) : _activeChatCubit = activeChatCubit, + super( + open: () => _open(accountInfo, chatListRecordPointer), decodeElement: proto.Chat.fromBuffer); - static Future _open( - ActiveAccountInfo activeAccountInfo, proto.Account account) async { - final accountRecordKey = - activeAccountInfo.userLogin.accountRecordInfo.accountRecord.recordKey; - - final chatListRecordKey = account.chatList.toVeilid(); - - final dhtRecord = await DHTShortArray.openOwned(chatListRecordKey, - debugName: 'ChatListCubit::_open::ChatList', parent: accountRecordKey); + static Future _open(AccountInfo accountInfo, + OwnedDHTRecordPointer chatListRecordPointer) async { + final dhtRecord = await DHTShortArray.openOwned(chatListRecordPointer, + debugName: 'ChatListCubit::_open::ChatList', + parent: accountInfo.accountRecordKey); return dhtRecord; } Future getDefaultChatSettings( proto.Contact contact) async { - final pronouns = contact.editedProfile.pronouns.isEmpty + final pronouns = contact.profile.pronouns.isEmpty ? '' - : ' (${contact.editedProfile.pronouns})'; + : ' [${contact.profile.pronouns}])'; return proto.ChatSettings() - ..title = '${contact.editedProfile.name}$pronouns' + ..title = '${contact.displayName}$pronouns' ..description = '' ..defaultExpiration = Int64.ZERO; } @@ -57,12 +53,24 @@ class ChatListCubit extends DHTShortArrayCubit // Make local copy so we don't share the buffer final localConversationRecordKey = contact.localConversationRecordKey.toVeilid(); + final remoteIdentityPublicKey = contact.identityPublicKey.toVeilid(); final remoteConversationRecordKey = contact.remoteConversationRecordKey.toVeilid(); + // Create 1:1 conversation type Chat + final chatMember = proto.ChatMember() + ..remoteIdentityPublicKey = remoteIdentityPublicKey.toProto() + ..remoteConversationRecordKey = remoteConversationRecordKey.toProto(); + + final directChat = proto.DirectChat() + ..settings = await getDefaultChatSettings(contact) + ..localConversationRecordKey = localConversationRecordKey.toProto() + ..remoteMember = chatMember; + + final chat = proto.Chat()..direct = directChat; + // Add Chat to account's list - // if this fails, don't keep retrying, user can try again later - await operateWrite((writer) async { + await operateWriteEventual((writer) async { // See if we have added this chat already for (var i = 0; i < writer.length; i++) { final cbuf = await writer.get(i); @@ -70,19 +78,27 @@ class ChatListCubit extends DHTShortArrayCubit throw Exception('Failed to get chat'); } final c = proto.Chat.fromBuffer(cbuf); - if (c.localConversationRecordKey == - contact.localConversationRecordKey) { - // Nothing to do here - return; + + switch (c.whichKind()) { + case proto.Chat_Kind.direct: + if (c.direct.localConversationRecordKey == + contact.localConversationRecordKey) { + // Nothing to do here + return; + } + break; + case proto.Chat_Kind.group: + if (c.group.localConversationRecordKey == + contact.localConversationRecordKey) { + throw StateError('direct conversation record key should' + ' not be used for group chats!'); + } + break; + case proto.Chat_Kind.notSet: + throw StateError('unknown chat kind'); } } - // Create 1:1 conversation type Chat - final chat = proto.Chat() - ..settings = await getDefaultChatSettings(contact) - ..localConversationRecordKey = localConversationRecordKey.toProto() - ..remoteConversationRecordKey = remoteConversationRecordKey.toProto(); - // Add chat await writer.add(chat.writeToBuffer()); }); @@ -91,41 +107,23 @@ class ChatListCubit extends DHTShortArrayCubit /// Delete a chat Future deleteChat( {required TypedKey localConversationRecordKey}) async { - final localConversationRecordKeyProto = - localConversationRecordKey.toProto(); - // Remove Chat from account's list - // if this fails, don't keep retrying, user can try again later - final deletedItem = - // Ensure followers get their changes before we return - await syncFollowers(() => operateWrite((writer) async { - if (activeChatCubit.state == localConversationRecordKey) { - activeChatCubit.setActiveChat(null); - } - for (var i = 0; i < writer.length; i++) { - final c = await writer.getProtobuf(proto.Chat.fromBuffer, i); - if (c == null) { - throw Exception('Failed to get chat'); - } - if (c.localConversationRecordKey == - localConversationRecordKeyProto) { - // Found the right chat - await writer.remove(i); - return c; - } - } - return null; - })); - // Since followers are synced, we can safetly remove the reconciled - // chat record now - if (deletedItem != null) { - try { - await SingleContactMessagesCubit.cleanupAndDeleteMessages( - localConversationRecordKey: localConversationRecordKey); - } on Exception catch (e) { - log.debug('error removing reconciled chat table: $e', e); + await operateWriteEventual((writer) async { + if (_activeChatCubit.state == localConversationRecordKey) { + _activeChatCubit.setActiveChat(null); } - } + for (var i = 0; i < writer.length; i++) { + final c = await writer.getProtobuf(proto.Chat.fromBuffer, i); + if (c == null) { + throw Exception('Failed to get chat'); + } + + if (c.localConversationRecordKey == localConversationRecordKey) { + await writer.remove(i); + return; + } + } + }); } /// StateMapFollowable ///////////////////////// @@ -136,9 +134,11 @@ class ChatListCubit extends DHTShortArrayCubit return IMap(); } return IMap.fromIterable(stateValue, - keyMapper: (e) => e.value.localConversationRecordKey.toVeilid(), + keyMapper: (e) => e.value.localConversationRecordKey, valueMapper: (e) => e.value); } - final ActiveChatCubit activeChatCubit; + //////////////////////////////////////////////////////////////////////////// + + final ActiveChatCubit _activeChatCubit; } diff --git a/lib/chat_list/cubits/cubits.dart b/lib/chat_list/cubits/cubits.dart index 35595db..cafafff 100644 --- a/lib/chat_list/cubits/cubits.dart +++ b/lib/chat_list/cubits/cubits.dart @@ -1,3 +1 @@ -export 'active_single_contact_chat_bloc_map_cubit.dart'; -export 'active_conversations_bloc_map_cubit.dart'; export 'chat_list_cubit.dart'; diff --git a/lib/chat_list/views/chat_list_widget.dart b/lib/chat_list/views/chat_list_widget.dart new file mode 100644 index 0000000..e91cbba --- /dev/null +++ b/lib/chat_list/views/chat_list_widget.dart @@ -0,0 +1,96 @@ +import 'package:awesome_extensions/awesome_extensions.dart'; +import 'package:fast_immutable_collections/fast_immutable_collections.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_translate/flutter_translate.dart'; +import 'package:searchable_listview/searchable_listview.dart'; +import 'package:veilid_support/veilid_support.dart'; + +import '../../contacts/contacts.dart'; +import '../../proto/proto.dart' as proto; +import '../../proto/proto.dart'; +import '../../theme/theme.dart'; +import '../chat_list.dart'; + +class ChatListWidget extends StatelessWidget { + const ChatListWidget({super.key}); + + Widget _itemBuilderDirect(proto.DirectChat direct, + IMap contactMap, bool busy) { + final contact = contactMap[direct.localConversationRecordKey]; + if (contact == null) { + return const Text('...'); + } + return ChatSingleContactItemWidget(contact: contact, disabled: busy) + .paddingLTRB(0, 4, 0, 0); + } + + List _itemFilter(IMap contactMap, + IList> chatList, String filter) { + final lowerValue = filter.toLowerCase(); + return chatList.map((x) => x.value).where((c) { + switch (c.whichKind()) { + case proto.Chat_Kind.direct: + final contact = contactMap[c.direct.localConversationRecordKey]; + if (contact == null) { + return false; + } + return contact.nickname.toLowerCase().contains(lowerValue) || + contact.profile.name.toLowerCase().contains(lowerValue) || + contact.profile.pronouns.toLowerCase().contains(lowerValue); + case proto.Chat_Kind.group: + // xxx: how to filter group chats + return true; + case proto.Chat_Kind.notSet: + throw StateError('unknown chat kind'); + } + }).toList(); + } + + @override + // ignore: prefer_expression_function_bodies + Widget build(BuildContext context) { + final contactListV = context.watch().state; + + return contactListV.builder((context, contactList) { + final contactMap = IMap.fromIterable(contactList, + keyMapper: (c) => c.value.localConversationRecordKey, + valueMapper: (c) => c.value); + + final chatListV = context.watch().state; + return chatListV + .builder((context, chatList) => SizedBox.expand( + child: styledTitleContainer( + context: context, + title: translate('chat_list.chats'), + child: SizedBox.expand( + child: (chatList.isEmpty) + ? const EmptyChatListWidget() + : SearchableList( + initialList: chatList.map((x) => x.value).toList(), + itemBuilder: (c) { + switch (c.whichKind()) { + case proto.Chat_Kind.direct: + return _itemBuilderDirect( + c.direct, + contactMap, + contactListV.busy || chatListV.busy); + case proto.Chat_Kind.group: + return const Text( + 'group chats not yet supported!'); + case proto.Chat_Kind.notSet: + throw StateError('unknown chat kind'); + } + }, + filter: (value) => + _itemFilter(contactMap, chatList, value), + spaceBetweenSearchAndList: 4, + inputDecoration: InputDecoration( + labelText: translate('chat_list.search'), + ), + ), + ).paddingAll(8)))) + .paddingLTRB(8, 0, 8, 8); + }); + } +} diff --git a/lib/chat_list/views/chat_single_contact_item_widget.dart b/lib/chat_list/views/chat_single_contact_item_widget.dart index ce9cf0e..7fd38cd 100644 --- a/lib/chat_list/views/chat_single_contact_item_widget.dart +++ b/lib/chat_list/views/chat_single_contact_item_widget.dart @@ -28,13 +28,31 @@ class ChatSingleContactItemWidget extends StatelessWidget { _contact.localConversationRecordKey.toVeilid(); final selected = activeChatCubit.state == localConversationRecordKey; + late final String title; + late final String subtitle; + if (_contact.nickname.isNotEmpty) { + title = _contact.nickname; + if (_contact.profile.pronouns.isNotEmpty) { + subtitle = '${_contact.profile.name} (${_contact.profile.pronouns})'; + } else { + subtitle = _contact.profile.name; + } + } else { + title = _contact.profile.name; + if (_contact.profile.pronouns.isNotEmpty) { + subtitle = '(${_contact.profile.pronouns})'; + } else { + subtitle = ''; + } + } + return SliderTile( key: ObjectKey(_contact), disabled: _disabled, selected: selected, tileScale: ScaleKind.secondary, - title: _contact.editedProfile.name, - subtitle: _contact.editedProfile.pronouns, + title: title, + subtitle: subtitle, icon: Icons.chat, onTap: () { singleFuture(activeChatCubit, () async { diff --git a/lib/chat_list/views/chat_single_contact_list_widget.dart b/lib/chat_list/views/chat_single_contact_list_widget.dart deleted file mode 100644 index 9053bc6..0000000 --- a/lib/chat_list/views/chat_single_contact_list_widget.dart +++ /dev/null @@ -1,73 +0,0 @@ -import 'package:awesome_extensions/awesome_extensions.dart'; -import 'package:fast_immutable_collections/fast_immutable_collections.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_translate/flutter_translate.dart'; -import 'package:searchable_listview/searchable_listview.dart'; - -import '../../contacts/contacts.dart'; -import '../../proto/proto.dart' as proto; -import '../../theme/theme.dart'; -import '../chat_list.dart'; - -class ChatSingleContactListWidget extends StatelessWidget { - const ChatSingleContactListWidget({super.key}); - - @override - // ignore: prefer_expression_function_bodies - Widget build(BuildContext context) { - final contactListV = context.watch().state; - - return contactListV.builder((context, contactList) { - final contactMap = IMap.fromIterable(contactList, - keyMapper: (c) => c.value.localConversationRecordKey, - valueMapper: (c) => c.value); - - final chatListV = context.watch().state; - return chatListV - .builder((context, chatList) => SizedBox.expand( - child: styledTitleContainer( - context: context, - title: translate('chat_list.chats'), - child: SizedBox.expand( - child: (chatList.isEmpty) - ? const EmptyChatListWidget() - : SearchableList( - initialList: chatList.map((x) => x.value).toList(), - itemBuilder: (c) { - final contact = - contactMap[c.localConversationRecordKey]; - if (contact == null) { - return const Text('...'); - } - return ChatSingleContactItemWidget( - contact: contact, - disabled: contactListV.busy) - .paddingLTRB(0, 4, 0, 0); - }, - filter: (value) { - final lowerValue = value.toLowerCase(); - return chatList.map((x) => x.value).where((c) { - final contact = - contactMap[c.localConversationRecordKey]; - if (contact == null) { - return false; - } - return contact.editedProfile.name - .toLowerCase() - .contains(lowerValue) || - contact.editedProfile.pronouns - .toLowerCase() - .contains(lowerValue); - }).toList(); - }, - spaceBetweenSearchAndList: 4, - inputDecoration: InputDecoration( - labelText: translate('chat_list.search'), - ), - ), - ).paddingAll(8)))) - .paddingLTRB(8, 0, 8, 8); - }); - } -} diff --git a/lib/chat_list/views/views.dart b/lib/chat_list/views/views.dart index 311d02e..1420794 100644 --- a/lib/chat_list/views/views.dart +++ b/lib/chat_list/views/views.dart @@ -1,3 +1,3 @@ +export 'chat_list_widget.dart'; export 'chat_single_contact_item_widget.dart'; -export 'chat_single_contact_list_widget.dart'; export 'empty_chat_list_widget.dart'; diff --git a/lib/contact_invitation/cubits/contact_invitation_list_cubit.dart b/lib/contact_invitation/cubits/contact_invitation_list_cubit.dart index f2f44e9..9bd589e 100644 --- a/lib/contact_invitation/cubits/contact_invitation_list_cubit.dart +++ b/lib/contact_invitation/cubits/contact_invitation_list_cubit.dart @@ -36,22 +36,16 @@ class ContactInvitationListCubit StateMapFollowable { ContactInvitationListCubit({ - required ActiveAccountInfo activeAccountInfo, - required proto.Account account, - }) : _activeAccountInfo = activeAccountInfo, - _account = account, + required AccountInfo accountInfo, + required OwnedDHTRecordPointer contactInvitationListRecordPointer, + }) : _accountInfo = accountInfo, super( - open: () => _open(activeAccountInfo, account), + open: () => _open(accountInfo.accountRecordKey, + contactInvitationListRecordPointer), decodeElement: proto.ContactInvitationRecord.fromBuffer); - static Future _open( - ActiveAccountInfo activeAccountInfo, proto.Account account) async { - final accountRecordKey = - activeAccountInfo.userLogin.accountRecordInfo.accountRecord.recordKey; - - final contactInvitationListRecordPointer = - account.contactInvitationRecords.toVeilid(); - + static Future _open(TypedKey accountRecordKey, + OwnedDHTRecordPointer contactInvitationListRecordPointer) async { final dhtRecord = await DHTShortArray.openOwned( contactInvitationListRecordPointer, debugName: 'ContactInvitationListCubit::_open::ContactInvitationList', @@ -61,7 +55,8 @@ class ContactInvitationListCubit } Future createInvitation( - {required EncryptionKeyType encryptionKeyType, + {required proto.Profile profile, + required EncryptionKeyType encryptionKeyType, required String encryptionKey, required String message, required Timestamp? expiration}) async { @@ -71,8 +66,8 @@ class ContactInvitationListCubit final crcs = await pool.veilid.bestCryptoSystem(); final contactRequestWriter = await crcs.generateKeyPair(); - final idcs = await _activeAccountInfo.identityCryptoSystem; - final identityWriter = _activeAccountInfo.identityWriter; + final idcs = await _accountInfo.identityCryptoSystem; + final identityWriter = _accountInfo.identityWriter; // Encrypt the writer secret with the encryption key final encryptedSecret = await encryptionKeyType.encryptSecretToBytes( @@ -90,7 +85,7 @@ class ContactInvitationListCubit await (await pool.createRecord( debugName: 'ContactInvitationListCubit::createInvitation::' 'LocalConversation', - parent: _activeAccountInfo.accountRecordKey, + parent: _accountInfo.accountRecordKey, schema: DHTSchema.smpl( oCnt: 0, members: [DHTSchemaMember(mKey: identityWriter.key, mCnt: 1)]))) @@ -99,9 +94,8 @@ class ContactInvitationListCubit // Make ContactRequestPrivate and encrypt with the writer secret final crpriv = proto.ContactRequestPrivate() ..writerKey = contactRequestWriter.key.toProto() - ..profile = _account.profile - ..superIdentityRecordKey = - _activeAccountInfo.userLogin.superIdentityRecordKey.toProto() + ..profile = profile + ..superIdentityRecordKey = _accountInfo.superIdentityRecordKey.toProto() ..chatRecordKey = localConversation.key.toProto() ..expiration = expiration?.toInt64() ?? Int64.ZERO; final crprivbytes = crpriv.writeToBuffer(); @@ -119,7 +113,7 @@ class ContactInvitationListCubit await (await pool.createRecord( debugName: 'ContactInvitationListCubit::createInvitation::' 'ContactRequestInbox', - parent: _activeAccountInfo.accountRecordKey, + parent: _accountInfo.accountRecordKey, schema: DHTSchema.smpl(oCnt: 1, members: [ DHTSchemaMember(mCnt: 1, mKey: contactRequestWriter.key) ]), @@ -158,8 +152,7 @@ class ContactInvitationListCubit ..message = message; // Add ContactInvitationRecord to account's list - // if this fails, don't keep retrying, user can try again later - await operateWrite((writer) async { + await operateWriteEventual((writer) async { await writer.add(cinvrec.writeToBuffer()); }); }); @@ -172,8 +165,6 @@ class ContactInvitationListCubit {required bool accepted, required TypedKey contactRequestInboxRecordKey}) async { final pool = DHTRecordPool.instance; - final accountRecordKey = - _activeAccountInfo.userLogin.accountRecordInfo.accountRecord.recordKey; // Remove ContactInvitationRecord from account's list final deletedItem = await operateWrite((writer) async { @@ -198,7 +189,7 @@ class ContactInvitationListCubit await (await pool.openRecordOwned(contactRequestInbox, debugName: 'ContactInvitationListCubit::deleteInvitation::' 'ContactRequestInbox', - parent: accountRecordKey)) + parent: _accountInfo.accountRecordKey)) .scope((contactRequestInbox) async { // Wipe out old invitation so it shows up as invalid await contactRequestInbox.tryWriteBytes(Uint8List(0)); @@ -240,7 +231,12 @@ class ContactInvitationListCubit // inbox with our list of extant invitations // If we're chatting to ourselves, // we are validating an invitation we have created - final isSelf = state.state.asData!.value.indexWhere((cir) => + final contactInvitationList = state.state.asData?.value; + if (contactInvitationList == null) { + return null; + } + + final isSelf = contactInvitationList.indexWhere((cir) => cir.value.contactRequestInbox.recordKey.toVeilid() == contactRequestInboxKey) != -1; @@ -248,7 +244,8 @@ class ContactInvitationListCubit await (await pool.openRecordRead(contactRequestInboxKey, debugName: 'ContactInvitationListCubit::validateInvitation::' 'ContactRequestInbox', - parent: _activeAccountInfo.accountRecordKey)) + parent: pool.getParentRecordKey(contactRequestInboxKey) ?? + _accountInfo.accountRecordKey)) .maybeDeleteScope(!isSelf, (contactRequestInbox) async { // final contactRequest = await contactRequestInbox @@ -293,8 +290,7 @@ class ContactInvitationListCubit secret: writerSecret); out = ValidContactInvitation( - activeAccountInfo: _activeAccountInfo, - account: _account, + accountInfo: _accountInfo, contactRequestInboxKey: contactRequestInboxKey, contactRequestPrivate: contactRequestPrivate, contactSuperIdentity: contactSuperIdentity, @@ -318,6 +314,5 @@ class ContactInvitationListCubit } // - final ActiveAccountInfo _activeAccountInfo; - final proto.Account _account; + final AccountInfo _accountInfo; } diff --git a/lib/contact_invitation/cubits/contact_request_inbox_cubit.dart b/lib/contact_invitation/cubits/contact_request_inbox_cubit.dart index 214d08b..714201b 100644 --- a/lib/contact_invitation/cubits/contact_request_inbox_cubit.dart +++ b/lib/contact_invitation/cubits/contact_request_inbox_cubit.dart @@ -7,27 +7,22 @@ import '../../proto/proto.dart' as proto; class ContactRequestInboxCubit extends DefaultDHTRecordCubit { ContactRequestInboxCubit( - {required this.activeAccountInfo, required this.contactInvitationRecord}) + {required AccountInfo accountInfo, required this.contactInvitationRecord}) : super( open: () => _open( - activeAccountInfo: activeAccountInfo, + accountInfo: accountInfo, contactInvitationRecord: contactInvitationRecord), decodeState: (buf) => buf.isEmpty ? null : proto.SignedContactResponse.fromBuffer(buf)); - // ContactRequestInboxCubit.value( - // {required super.record, - // required this.activeAccountInfo, - // required this.contactInvitationRecord}) - // : super.value(decodeState: proto.SignedContactResponse.fromBuffer); - static Future _open( - {required ActiveAccountInfo activeAccountInfo, + {required AccountInfo accountInfo, required proto.ContactInvitationRecord contactInvitationRecord}) async { final pool = DHTRecordPool.instance; - final accountRecordKey = - activeAccountInfo.userLogin.accountRecordInfo.accountRecord.recordKey; + + final accountRecordKey = accountInfo.accountRecordKey; + final writerSecret = contactInvitationRecord.writerSecret.toVeilid(); final recordKey = contactInvitationRecord.contactRequestInbox.recordKey.toVeilid(); @@ -42,6 +37,5 @@ class ContactRequestInboxCubit defaultSubkey: 1); } - final ActiveAccountInfo activeAccountInfo; final proto.ContactInvitationRecord contactInvitationRecord; } diff --git a/lib/contact_invitation/cubits/waiting_invitation_cubit.dart b/lib/contact_invitation/cubits/waiting_invitation_cubit.dart index 120a2d7..47addc2 100644 --- a/lib/contact_invitation/cubits/waiting_invitation_cubit.dart +++ b/lib/contact_invitation/cubits/waiting_invitation_cubit.dart @@ -7,7 +7,7 @@ import 'package:meta/meta.dart'; import 'package:veilid_support/veilid_support.dart'; import '../../account_manager/account_manager.dart'; -import '../../contacts/contacts.dart'; +import '../../conversation/conversation.dart'; import '../../proto/proto.dart' as proto; import '../../tools/tools.dart'; import '../models/accepted_contact.dart'; @@ -24,25 +24,27 @@ class InvitationStatus extends Equatable { class WaitingInvitationCubit extends AsyncTransformerCubit { - WaitingInvitationCubit(ContactRequestInboxCubit super.input, - {required ActiveAccountInfo activeAccountInfo, - required proto.Account account, - required proto.ContactInvitationRecord contactInvitationRecord}) - : super( + WaitingInvitationCubit( + ContactRequestInboxCubit super.input, { + required AccountInfo accountInfo, + required AccountRecordCubit accountRecordCubit, + required proto.ContactInvitationRecord contactInvitationRecord, + }) : super( transform: (signedContactResponse) => _transform( signedContactResponse, - activeAccountInfo: activeAccountInfo, - account: account, + accountInfo: accountInfo, + accountRecordCubit: accountRecordCubit, contactInvitationRecord: contactInvitationRecord)); static Future> _transform( proto.SignedContactResponse? signedContactResponse, - {required ActiveAccountInfo activeAccountInfo, - required proto.Account account, + {required AccountInfo accountInfo, + required AccountRecordCubit accountRecordCubit, required proto.ContactInvitationRecord contactInvitationRecord}) async { if (signedContactResponse == null) { return const AsyncValue.loading(); } + final contactResponseBytes = Uint8List.fromList(signedContactResponse.contactResponse); final contactResponse = @@ -57,8 +59,11 @@ class WaitingInvitationCubit extends AsyncTransformerCubit AsyncValue.data(InvitationStatus( + acceptedContact: AcceptedContact( + remoteProfile: remoteProfile, + remoteIdentity: contactSuperIdentity, + remoteConversationRecordKey: remoteConversationRecordKey, + localConversationRecordKey: localConversationRecordKey)))); } } diff --git a/lib/contact_invitation/cubits/waiting_invitations_bloc_map_cubit.dart b/lib/contact_invitation/cubits/waiting_invitations_bloc_map_cubit.dart index 968e108..d6b089d 100644 --- a/lib/contact_invitation/cubits/waiting_invitations_bloc_map_cubit.dart +++ b/lib/contact_invitation/cubits/waiting_invitations_bloc_map_cubit.dart @@ -3,6 +3,7 @@ import 'package:bloc_advanced_tools/bloc_advanced_tools.dart'; import 'package:veilid_support/veilid_support.dart'; import '../../account_manager/account_manager.dart'; +import '../../contacts/contacts.dart'; import '../../proto/proto.dart' as proto; import 'cubits.dart'; @@ -18,7 +19,27 @@ class WaitingInvitationsBlocMapCubit extends BlocMapCubit, TypedKey, proto.ContactInvitationRecord> { WaitingInvitationsBlocMapCubit( - {required this.activeAccountInfo, required this.account}); + {required AccountInfo accountInfo, + required AccountRecordCubit accountRecordCubit, + required ContactInvitationListCubit contactInvitationListCubit, + required ContactListCubit contactListCubit}) + : _accountInfo = accountInfo, + _accountRecordCubit = accountRecordCubit, + _contactInvitationListCubit = contactInvitationListCubit, + _contactListCubit = contactListCubit { + // React to invitation status changes + _singleInvitationStatusProcessor.follow( + stream, state, _invitationStatusListener); + + // Follow the contact invitation list cubit + follow(contactInvitationListCubit); + } + + @override + Future close() async { + await _singleInvitationStatusProcessor.unfollow(); + await super.close(); + } Future _addWaitingInvitation( {required proto.ContactInvitationRecord @@ -27,22 +48,66 @@ class WaitingInvitationsBlocMapCubit extends BlocMapCubit _invitationStatusListener( + WaitingInvitationsBlocMapState newState) async { + for (final entry in newState.entries) { + final contactRequestInboxRecordKey = entry.key; + final invStatus = entry.value.asData?.value; + // Skip invitations that have not yet been accepted or rejected + if (invStatus == null) { + continue; + } + + // Delete invitation and process the accepted or rejected contact + final acceptedContact = invStatus.acceptedContact; + if (acceptedContact != null) { + await _contactInvitationListCubit.deleteInvitation( + accepted: true, + contactRequestInboxRecordKey: contactRequestInboxRecordKey); + + // Accept + await _contactListCubit.createContact( + profile: acceptedContact.remoteProfile, + remoteSuperIdentity: acceptedContact.remoteIdentity, + remoteConversationRecordKey: + acceptedContact.remoteConversationRecordKey, + localConversationRecordKey: + acceptedContact.localConversationRecordKey, + ); + } else { + // Reject + await _contactInvitationListCubit.deleteInvitation( + accepted: false, + contactRequestInboxRecordKey: contactRequestInboxRecordKey); + } + } + } + /// StateFollower ///////////////////////// @override Future removeFromState(TypedKey key) => remove(key); @override - Future updateState(TypedKey key, proto.ContactInvitationRecord value) => - _addWaitingInvitation(contactInvitationRecord: value); + Future updateState( + TypedKey key, + proto.ContactInvitationRecord? oldValue, + proto.ContactInvitationRecord newValue) async { + await _addWaitingInvitation(contactInvitationRecord: newValue); + } //// - final ActiveAccountInfo activeAccountInfo; - final proto.Account account; + final AccountInfo _accountInfo; + final AccountRecordCubit _accountRecordCubit; + final ContactInvitationListCubit _contactInvitationListCubit; + final ContactListCubit _contactListCubit; + final _singleInvitationStatusProcessor = + SingleStateProcessor(); } diff --git a/lib/contact_invitation/models/valid_contact_invitation.dart b/lib/contact_invitation/models/valid_contact_invitation.dart index 3b48bfe..fb8b8de 100644 --- a/lib/contact_invitation/models/valid_contact_invitation.dart +++ b/lib/contact_invitation/models/valid_contact_invitation.dart @@ -2,7 +2,7 @@ import 'package:meta/meta.dart'; import 'package:veilid_support/veilid_support.dart'; import '../../account_manager/account_manager.dart'; -import '../../contacts/contacts.dart'; +import '../../conversation/conversation.dart'; import '../../proto/proto.dart' as proto; import '../../tools/tools.dart'; import 'models.dart'; @@ -13,14 +13,12 @@ import 'models.dart'; class ValidContactInvitation { @internal ValidContactInvitation( - {required ActiveAccountInfo activeAccountInfo, - required proto.Account account, + {required AccountInfo accountInfo, required TypedKey contactRequestInboxKey, required proto.ContactRequestPrivate contactRequestPrivate, required SuperIdentity contactSuperIdentity, required KeyPair writer}) - : _activeAccountInfo = activeAccountInfo, - _account = account, + : _accountInfo = accountInfo, _contactRequestInboxKey = contactRequestInboxKey, _contactRequestPrivate = contactRequestPrivate, _contactSuperIdentity = contactSuperIdentity, @@ -28,44 +26,40 @@ class ValidContactInvitation { proto.Profile get remoteProfile => _contactRequestPrivate.profile; - Future accept() async { + Future accept(proto.Profile profile) async { final pool = DHTRecordPool.instance; try { // Ensure we don't delete this if we're trying to chat to self // The initiating side will delete the records in deleteInvitation() final isSelf = _contactSuperIdentity.currentInstance.publicKey == - _activeAccountInfo.identityPublicKey; - final accountRecordKey = _activeAccountInfo.accountRecordKey; + _accountInfo.identityPublicKey; return (await pool.openRecordWrite(_contactRequestInboxKey, _writer, debugName: 'ValidContactInvitation::accept::' 'ContactRequestInbox', - parent: accountRecordKey)) + parent: pool.getParentRecordKey(_contactRequestInboxKey) ?? + _accountInfo.accountRecordKey)) // ignore: prefer_expression_function_bodies .maybeDeleteScope(!isSelf, (contactRequestInbox) async { // Create local conversation key for this // contact and send via contact response final conversation = ConversationCubit( - activeAccountInfo: _activeAccountInfo, + accountInfo: _accountInfo, remoteIdentityPublicKey: _contactSuperIdentity.currentInstance.typedPublicKey); return conversation.initLocalConversation( - profile: _account.profile, + profile: profile, callback: (localConversation) async { final contactResponse = proto.ContactResponse() ..accept = true ..remoteConversationRecordKey = localConversation.key.toProto() ..superIdentityRecordKey = - _activeAccountInfo.superIdentityRecordKey.toProto(); + _accountInfo.superIdentityRecordKey.toProto(); final contactResponseBytes = contactResponse.writeToBuffer(); - final cs = await pool.veilid - .getCryptoSystem(_contactRequestInboxKey.kind); - - final identitySignature = await cs.sign( - _activeAccountInfo.identityWriter.key, - _activeAccountInfo.identityWriter.secret, - contactResponseBytes); + final cs = await _accountInfo.identityCryptoSystem; + final identitySignature = await cs.signWithKeyPair( + _accountInfo.identityWriter, contactResponseBytes); final signedContactResponse = proto.SignedContactResponse() ..contactResponse = contactResponseBytes @@ -95,27 +89,22 @@ class ValidContactInvitation { // Ensure we don't delete this if we're trying to chat to self final isSelf = _contactSuperIdentity.currentInstance.publicKey == - _activeAccountInfo.identityPublicKey; - final accountRecordKey = _activeAccountInfo.accountRecordKey; + _accountInfo.identityPublicKey; return (await pool.openRecordWrite(_contactRequestInboxKey, _writer, debugName: 'ValidContactInvitation::reject::' 'ContactRequestInbox', - parent: accountRecordKey)) + parent: _accountInfo.accountRecordKey)) .maybeDeleteScope(!isSelf, (contactRequestInbox) async { - final cs = - await pool.veilid.getCryptoSystem(_contactRequestInboxKey.kind); - final contactResponse = proto.ContactResponse() ..accept = false ..superIdentityRecordKey = - _activeAccountInfo.superIdentityRecordKey.toProto(); + _accountInfo.superIdentityRecordKey.toProto(); final contactResponseBytes = contactResponse.writeToBuffer(); - final identitySignature = await cs.sign( - _activeAccountInfo.identityWriter.key, - _activeAccountInfo.identityWriter.secret, - contactResponseBytes); + final cs = await _accountInfo.identityCryptoSystem; + final identitySignature = await cs.signWithKeyPair( + _accountInfo.identityWriter, contactResponseBytes); final signedContactResponse = proto.SignedContactResponse() ..contactResponse = contactResponseBytes @@ -129,8 +118,7 @@ class ValidContactInvitation { } // - final ActiveAccountInfo _activeAccountInfo; - final proto.Account _account; + final AccountInfo _accountInfo; final TypedKey _contactRequestInboxKey; final SuperIdentity _contactSuperIdentity; final KeyPair _writer; diff --git a/lib/contact_invitation/views/contact_invitation_display.dart b/lib/contact_invitation/views/contact_invitation_display.dart index 374a309..2e4acad 100644 --- a/lib/contact_invitation/views/contact_invitation_display.dart +++ b/lib/contact_invitation/views/contact_invitation_display.dart @@ -46,7 +46,6 @@ class ContactInvitationDisplayDialog extends StatelessWidget { // ignore: prefer_expression_function_bodies Widget build(BuildContext context) { final theme = Theme.of(context); - //final scale = theme.extension()!; final textTheme = theme.textTheme; final signedContactInvitationBytesV = @@ -58,6 +57,9 @@ class ContactInvitationDisplayDialog extends StatelessWidget { return PopControl( dismissible: !signedContactInvitationBytesV.isLoading, child: Dialog( + shape: RoundedRectangleBorder( + side: const BorderSide(width: 2), + borderRadius: BorderRadius.circular(16)), backgroundColor: Colors.white, child: ConstrainedBox( constraints: BoxConstraints( @@ -90,6 +92,10 @@ class ContactInvitationDisplayDialog extends StatelessWidget { .paddingAll(8), ElevatedButton.icon( icon: const Icon(Icons.copy), + style: ElevatedButton.styleFrom( + foregroundColor: Colors.black, + backgroundColor: Colors.white, + side: const BorderSide()), label: Text(translate( 'create_invitation_dialog.copy_invitation')), onPressed: () async { diff --git a/lib/contact_invitation/views/create_invitation_dialog.dart b/lib/contact_invitation/views/create_invitation_dialog.dart index ace71d5..93b5796 100644 --- a/lib/contact_invitation/views/create_invitation_dialog.dart +++ b/lib/contact_invitation/views/create_invitation_dialog.dart @@ -140,8 +140,18 @@ class CreateInvitationDialogState extends State { // Start generation final contactInvitationListCubit = widget.modalContext.read(); + final profile = widget.modalContext + .read() + .state + .asData + ?.value + .profile; + if (profile == null) { + return; + } final generator = contactInvitationListCubit.createInvitation( + profile: profile, encryptionKeyType: _encryptionKeyType, encryptionKey: _encryptionKey, message: _messageTextController.text, diff --git a/lib/contact_invitation/views/invitation_dialog.dart b/lib/contact_invitation/views/invitation_dialog.dart index f97869b..cd962ab 100644 --- a/lib/contact_invitation/views/invitation_dialog.dart +++ b/lib/contact_invitation/views/invitation_dialog.dart @@ -3,8 +3,8 @@ import 'dart:async'; import 'package:awesome_extensions/awesome_extensions.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_translate/flutter_translate.dart'; +import 'package:provider/provider.dart'; import 'package:veilid_support/veilid_support.dart'; import '../../account_manager/account_manager.dart'; @@ -15,13 +15,14 @@ import '../contact_invitation.dart'; class InvitationDialog extends StatefulWidget { const InvitationDialog( - {required this.modalContext, + {required Locator locator, required this.onValidationCancelled, required this.onValidationSuccess, required this.onValidationFailed, required this.inviteControlIsValid, required this.buildInviteControl, - super.key}); + super.key}) + : _locator = locator; final void Function() onValidationCancelled; final void Function() onValidationSuccess; @@ -32,7 +33,7 @@ class InvitationDialog extends StatefulWidget { InvitationDialogState dialogState, Future Function({required Uint8List inviteData}) validateInviteData) buildInviteControl; - final BuildContext modalContext; + final Locator _locator; @override InvitationDialogState createState() => InvitationDialogState(); @@ -54,8 +55,7 @@ class InvitationDialog extends StatefulWidget { InvitationDialogState dialogState, Future Function({required Uint8List inviteData}) validateInviteData)>.has( - 'buildInviteControl', buildInviteControl)) - ..add(DiagnosticsProperty('modalContext', modalContext)); + 'buildInviteControl', buildInviteControl)); } } @@ -74,23 +74,25 @@ class InvitationDialogState extends State { Future _onAccept() async { final navigator = Navigator.of(context); - final activeAccountInfo = widget.modalContext.read(); - final contactList = widget.modalContext.read(); + final accountInfo = widget._locator().state; + final contactList = widget._locator(); + final profile = + widget._locator().state.asData!.value.profile; setState(() { _isAccepting = true; }); final validInvitation = _validInvitation; if (validInvitation != null) { - final acceptedContact = await validInvitation.accept(); + final acceptedContact = await validInvitation.accept(profile); if (acceptedContact != null) { // initiator when accept is received will create // contact in the case of a 'note to self' - final isSelf = activeAccountInfo.identityPublicKey == + final isSelf = accountInfo.identityPublicKey == acceptedContact.remoteIdentity.currentInstance.publicKey; if (!isSelf) { await contactList.createContact( - remoteProfile: acceptedContact.remoteProfile, + profile: acceptedContact.remoteProfile, remoteSuperIdentity: acceptedContact.remoteIdentity, remoteConversationRecordKey: acceptedContact.remoteConversationRecordKey, @@ -137,7 +139,7 @@ class InvitationDialogState extends State { }) async { try { final contactInvitationListCubit = - widget.modalContext.read(); + widget._locator(); setState(() { _isValidating = true; diff --git a/lib/contact_invitation/views/paste_invitation_dialog.dart b/lib/contact_invitation/views/paste_invitation_dialog.dart index ead492b..377d13f 100644 --- a/lib/contact_invitation/views/paste_invitation_dialog.dart +++ b/lib/contact_invitation/views/paste_invitation_dialog.dart @@ -4,6 +4,7 @@ import 'package:awesome_extensions/awesome_extensions.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_translate/flutter_translate.dart'; +import 'package:provider/provider.dart'; import 'package:veilid_support/veilid_support.dart'; import '../../theme/theme.dart'; @@ -11,29 +12,23 @@ import '../../tools/tools.dart'; import 'invitation_dialog.dart'; class PasteInvitationDialog extends StatefulWidget { - const PasteInvitationDialog({required this.modalContext, super.key}); + const PasteInvitationDialog({required Locator locator, super.key}) + : _locator = locator; @override PasteInvitationDialogState createState() => PasteInvitationDialogState(); static Future show(BuildContext context) async { - final modalContext = context; + final locator = context.read; await showPopControlDialog( context: context, builder: (context) => StyledDialog( title: translate('paste_invitation_dialog.title'), - child: PasteInvitationDialog(modalContext: modalContext))); + child: PasteInvitationDialog(locator: locator))); } - final BuildContext modalContext; - - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties - .add(DiagnosticsProperty('modalContext', modalContext)); - } + final Locator _locator; } class PasteInvitationDialogState extends State { @@ -138,7 +133,7 @@ class PasteInvitationDialogState extends State { // ignore: prefer_expression_function_bodies Widget build(BuildContext context) { return InvitationDialog( - modalContext: widget.modalContext, + locator: widget._locator, onValidationCancelled: onValidationCancelled, onValidationSuccess: onValidationSuccess, onValidationFailed: onValidationFailed, diff --git a/lib/contact_invitation/views/scan_invitation_dialog.dart b/lib/contact_invitation/views/scan_invitation_dialog.dart index ab47df0..d3881c2 100644 --- a/lib/contact_invitation/views/scan_invitation_dialog.dart +++ b/lib/contact_invitation/views/scan_invitation_dialog.dart @@ -9,6 +9,7 @@ import 'package:flutter_translate/flutter_translate.dart'; import 'package:image/image.dart' as img; import 'package:mobile_scanner/mobile_scanner.dart'; import 'package:pasteboard/pasteboard.dart'; +import 'package:provider/provider.dart'; import 'package:zxing2/qrcode.dart'; import '../../theme/theme.dart'; @@ -102,28 +103,22 @@ class ScannerOverlay extends CustomPainter { } class ScanInvitationDialog extends StatefulWidget { - const ScanInvitationDialog({required this.modalContext, super.key}); + const ScanInvitationDialog({required Locator locator, super.key}) + : _locator = locator; @override ScanInvitationDialogState createState() => ScanInvitationDialogState(); static Future show(BuildContext context) async { - final modalContext = context; + final locator = context.read; await showPopControlDialog( context: context, builder: (context) => StyledDialog( title: translate('scan_invitation_dialog.title'), - child: ScanInvitationDialog(modalContext: modalContext))); + child: ScanInvitationDialog(locator: locator))); } - final BuildContext modalContext; - - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties - .add(DiagnosticsProperty('modalContext', modalContext)); - } + final Locator _locator; } class ScanInvitationDialogState extends State { @@ -396,7 +391,7 @@ class ScanInvitationDialogState extends State { // ignore: prefer_expression_function_bodies Widget build(BuildContext context) { return InvitationDialog( - modalContext: widget.modalContext, + locator: widget._locator, onValidationCancelled: onValidationCancelled, onValidationSuccess: onValidationSuccess, onValidationFailed: onValidationFailed, diff --git a/lib/contacts/cubits/contact_list_cubit.dart b/lib/contacts/cubits/contact_list_cubit.dart index 5ab14ea..aaecca4 100644 --- a/lib/contacts/cubits/contact_list_cubit.dart +++ b/lib/contacts/cubits/contact_list_cubit.dart @@ -1,79 +1,117 @@ import 'dart:async'; import 'dart:convert'; +import 'package:async_tools/async_tools.dart'; +import 'package:protobuf/protobuf.dart'; import 'package:veilid_support/veilid_support.dart'; import '../../account_manager/account_manager.dart'; import '../../proto/proto.dart' as proto; import '../../tools/tools.dart'; -import 'conversation_cubit.dart'; ////////////////////////////////////////////////// // Mutable state for per-account contacts class ContactListCubit extends DHTShortArrayCubit { ContactListCubit({ - required ActiveAccountInfo activeAccountInfo, - required proto.Account account, - }) : _activeAccountInfo = activeAccountInfo, - super( - open: () => _open(activeAccountInfo, account), + required AccountInfo accountInfo, + required OwnedDHTRecordPointer contactListRecordPointer, + }) : super( + open: () => + _open(accountInfo.accountRecordKey, contactListRecordPointer), decodeElement: proto.Contact.fromBuffer); - static Future _open( - ActiveAccountInfo activeAccountInfo, proto.Account account) async { - final accountRecordKey = - activeAccountInfo.userLogin.accountRecordInfo.accountRecord.recordKey; - - final contactListRecordKey = account.contactList.toVeilid(); - - final dhtRecord = await DHTShortArray.openOwned(contactListRecordKey, + static Future _open(TypedKey accountRecordKey, + OwnedDHTRecordPointer contactListRecordPointer) async { + final dhtRecord = await DHTShortArray.openOwned(contactListRecordPointer, debugName: 'ContactListCubit::_open::ContactList', parent: accountRecordKey); return dhtRecord; } - Future createContact({ - required proto.Profile remoteProfile, - required SuperIdentity remoteSuperIdentity, - required TypedKey remoteConversationRecordKey, + @override + Future close() async { + await _contactProfileUpdateMap.close(); + await super.close(); + } + //////////////////////////////////////////////////////////////////////////// + // Public Interface + + void followContactProfileChanges(TypedKey localConversationRecordKey, + Stream profileStream, proto.Profile? profileState) { + _contactProfileUpdateMap + .follow(localConversationRecordKey, profileStream, profileState, + (remoteProfile) async { + if (remoteProfile == null) { + return; + } + return updateContactProfile( + localConversationRecordKey: localConversationRecordKey, + profile: remoteProfile); + }); + } + + Future updateContactProfile({ required TypedKey localConversationRecordKey, + required proto.Profile profile, + }) async { + // Update contact's remoteProfile + await operateWriteEventual((writer) async { + for (var pos = 0; pos < writer.length; pos++) { + final c = await writer.getProtobuf(proto.Contact.fromBuffer, pos); + if (c != null && + c.localConversationRecordKey.toVeilid() == + localConversationRecordKey) { + if (c.profile == profile) { + // Unchanged + break; + } + final newContact = c.deepCopy()..profile = profile; + final updated = await writer.tryWriteItemProtobuf( + proto.Contact.fromBuffer, pos, newContact); + if (!updated) { + throw DHTExceptionTryAgain(); + } + break; + } + } + }); + } + + Future createContact({ + required proto.Profile profile, + required SuperIdentity remoteSuperIdentity, + required TypedKey localConversationRecordKey, + required TypedKey remoteConversationRecordKey, }) async { // Create Contact final contact = proto.Contact() - ..editedProfile = remoteProfile - ..remoteProfile = remoteProfile + ..profile = profile ..superIdentityJson = jsonEncode(remoteSuperIdentity.toJson()) ..identityPublicKey = remoteSuperIdentity.currentInstance.typedPublicKey.toProto() - ..remoteConversationRecordKey = remoteConversationRecordKey.toProto() ..localConversationRecordKey = localConversationRecordKey.toProto() + ..remoteConversationRecordKey = remoteConversationRecordKey.toProto() ..showAvailability = false; // Add Contact to account's list - // if this fails, don't keep retrying, user can try again later - await operateWrite((writer) async { + await operateWriteEventual((writer) async { await writer.add(contact.writeToBuffer()); }); } - Future deleteContact({required proto.Contact contact}) async { - final remoteIdentityPublicKey = contact.identityPublicKey.toVeilid(); - final localConversationRecordKey = - contact.localConversationRecordKey.toVeilid(); - final remoteConversationRecordKey = - contact.remoteConversationRecordKey.toVeilid(); - + Future deleteContact( + {required TypedKey localConversationRecordKey}) async { // Remove Contact from account's list - final deletedItem = await operateWrite((writer) async { + final deletedItem = await operateWriteEventual((writer) async { for (var i = 0; i < writer.length; i++) { final item = await writer.getProtobuf(proto.Contact.fromBuffer, i); if (item == null) { throw Exception('Failed to get contact'); } - if (item.localConversationRecordKey == - contact.localConversationRecordKey) { + if (item.localConversationRecordKey.toVeilid() == + localConversationRecordKey) { await writer.remove(i); return item; } @@ -83,21 +121,17 @@ class ContactListCubit extends DHTShortArrayCubit { if (deletedItem != null) { try { - // Make a conversation cubit to manipulate the conversation - final conversationCubit = ConversationCubit( - activeAccountInfo: _activeAccountInfo, - remoteIdentityPublicKey: remoteIdentityPublicKey, - localConversationRecordKey: localConversationRecordKey, - remoteConversationRecordKey: remoteConversationRecordKey, - ); - - // Delete the local and remote conversation records - await conversationCubit.delete(); + // Mark the conversation records for deletion + await DHTRecordPool.instance + .deleteRecord(deletedItem.localConversationRecordKey.toVeilid()); + await DHTRecordPool.instance + .deleteRecord(deletedItem.remoteConversationRecordKey.toVeilid()); } on Exception catch (e) { log.debug('error deleting conversation records: $e', e); } } } - final ActiveAccountInfo _activeAccountInfo; + final _contactProfileUpdateMap = + SingleStateProcessorMap(); } diff --git a/lib/contacts/cubits/cubits.dart b/lib/contacts/cubits/cubits.dart index 3d16d52..795d497 100644 --- a/lib/contacts/cubits/cubits.dart +++ b/lib/contacts/cubits/cubits.dart @@ -1,2 +1 @@ export 'contact_list_cubit.dart'; -export 'conversation_cubit.dart'; diff --git a/lib/contacts/views/contact_item_widget.dart b/lib/contacts/views/contact_item_widget.dart index 3deae23..a7441e9 100644 --- a/lib/contacts/views/contact_item_widget.dart +++ b/lib/contacts/views/contact_item_widget.dart @@ -1,4 +1,3 @@ -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -11,18 +10,9 @@ import '../contacts.dart'; class ContactItemWidget extends StatelessWidget { const ContactItemWidget( - {required this.contact, required this.disabled, super.key}); - - final proto.Contact contact; - final bool disabled; - - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties - ..add(DiagnosticsProperty('contact', contact)) - ..add(DiagnosticsProperty('disabled', disabled)); - } + {required proto.Contact contact, required bool disabled, super.key}) + : _disabled = disabled, + _contact = contact; @override // ignore: prefer_expression_function_bodies @@ -30,26 +20,44 @@ class ContactItemWidget extends StatelessWidget { BuildContext context, ) { final localConversationRecordKey = - contact.localConversationRecordKey.toVeilid(); + _contact.localConversationRecordKey.toVeilid(); const selected = false; // xxx: eventually when we have selectable contacts: // activeContactCubit.state == localConversationRecordKey; - final tileDisabled = disabled || context.watch().isBusy; + final tileDisabled = _disabled || context.watch().isBusy; + + late final String title; + late final String subtitle; + if (_contact.nickname.isNotEmpty) { + title = _contact.nickname; + if (_contact.profile.pronouns.isNotEmpty) { + subtitle = '${_contact.profile.name} (${_contact.profile.pronouns})'; + } else { + subtitle = _contact.profile.name; + } + } else { + title = _contact.profile.name; + if (_contact.profile.pronouns.isNotEmpty) { + subtitle = '(${_contact.profile.pronouns})'; + } else { + subtitle = ''; + } + } return SliderTile( - key: ObjectKey(contact), + key: ObjectKey(_contact), disabled: tileDisabled, selected: selected, tileScale: ScaleKind.primary, - title: contact.editedProfile.name, - subtitle: contact.editedProfile.pronouns, + title: title, + subtitle: subtitle, icon: Icons.person, onTap: () async { // Start a chat final chatListCubit = context.read(); - await chatListCubit.getOrCreateChatSingleContact(contact: contact); + await chatListCubit.getOrCreateChatSingleContact(contact: _contact); // Click over to chats if (context.mounted) { await MainPager.of(context) @@ -66,14 +74,20 @@ class ContactItemWidget extends StatelessWidget { final contactListCubit = context.read(); final chatListCubit = context.read(); + // Delete the contact itself + await contactListCubit.deleteContact( + localConversationRecordKey: localConversationRecordKey); + // Remove any chats for this contact await chatListCubit.deleteChat( localConversationRecordKey: localConversationRecordKey); - - // Delete the contact itself - await contactListCubit.deleteContact(contact: contact); }) ], ); } + + //////////////////////////////////////////////////////////////////////////// + + final proto.Contact _contact; + final bool _disabled; } diff --git a/lib/contacts/views/contact_list_widget.dart b/lib/contacts/views/contact_list_widget.dart index 6ef3ca0..eda6776 100644 --- a/lib/contacts/views/contact_list_widget.dart +++ b/lib/contacts/views/contact_list_widget.dart @@ -45,10 +45,13 @@ class ContactListWidget extends StatelessWidget { final lowerValue = value.toLowerCase(); return contactList .where((element) => - element.editedProfile.name + element.nickname .toLowerCase() .contains(lowerValue) || - element.editedProfile.pronouns + element.profile.name + .toLowerCase() + .contains(lowerValue) || + element.profile.pronouns .toLowerCase() .contains(lowerValue)) .toList(); diff --git a/lib/conversation/conversation.dart b/lib/conversation/conversation.dart new file mode 100644 index 0000000..d09042f --- /dev/null +++ b/lib/conversation/conversation.dart @@ -0,0 +1 @@ +export 'cubits/cubits.dart'; diff --git a/lib/conversation/cubits/active_conversations_bloc_map_cubit.dart b/lib/conversation/cubits/active_conversations_bloc_map_cubit.dart new file mode 100644 index 0000000..b983265 --- /dev/null +++ b/lib/conversation/cubits/active_conversations_bloc_map_cubit.dart @@ -0,0 +1,180 @@ +import 'package:async_tools/async_tools.dart'; +import 'package:bloc_advanced_tools/bloc_advanced_tools.dart'; +import 'package:equatable/equatable.dart'; +import 'package:meta/meta.dart'; +import 'package:veilid_support/veilid_support.dart'; + +import '../../account_manager/account_manager.dart'; +import '../../chat_list/cubits/cubits.dart'; +import '../../contacts/contacts.dart'; +import '../../proto/proto.dart' as proto; +import '../conversation.dart'; + +@immutable +class ActiveConversationState extends Equatable { + const ActiveConversationState({ + required this.remoteIdentityPublicKey, + required this.localConversationRecordKey, + required this.remoteConversationRecordKey, + required this.localConversation, + required this.remoteConversation, + }); + + final TypedKey remoteIdentityPublicKey; + final TypedKey localConversationRecordKey; + final TypedKey remoteConversationRecordKey; + final proto.Conversation localConversation; + final proto.Conversation remoteConversation; + + @override + List get props => [ + remoteIdentityPublicKey, + localConversationRecordKey, + remoteConversationRecordKey, + localConversation, + remoteConversation + ]; +} + +typedef ActiveConversationCubit = TransformerCubit< + AsyncValue, + AsyncValue, + ConversationCubit>; + +typedef ActiveConversationsBlocMapState + = BlocMapState>; + +// Map of localConversationRecordKey to ActiveConversationCubit +// Wraps a conversation cubit to only expose completely built conversations +// Automatically follows the state of a ChatListCubit. +// We currently only build the cubits for the chats that are active, not +// archived chats or contacts that are not actively in a chat. +// +// TODO: Polling contacts for new inactive chats is yet to be done +// +class ActiveConversationsBlocMapCubit extends BlocMapCubit, ActiveConversationCubit> + with StateMapFollower { + ActiveConversationsBlocMapCubit({ + required AccountInfo accountInfo, + required AccountRecordCubit accountRecordCubit, + required ChatListCubit chatListCubit, + required ContactListCubit contactListCubit, + }) : _accountInfo = accountInfo, + _accountRecordCubit = accountRecordCubit, + _contactListCubit = contactListCubit { + // Follow the chat list cubit + follow(chatListCubit); + } + + //////////////////////////////////////////////////////////////////////////// + // Public Interface + + //////////////////////////////////////////////////////////////////////////// + // Private Implementation + + // Add an active conversation to be tracked for changes + Future _addDirectConversation( + {required TypedKey remoteIdentityPublicKey, + required TypedKey localConversationRecordKey, + required TypedKey remoteConversationRecordKey}) async => + add(() { + // Conversation cubit the tracks the state between the local + // and remote halves of a contact's relationship with this account + final conversationCubit = ConversationCubit( + accountInfo: _accountInfo, + remoteIdentityPublicKey: remoteIdentityPublicKey, + localConversationRecordKey: localConversationRecordKey, + remoteConversationRecordKey: remoteConversationRecordKey, + ); + + // When remote conversation changes its profile, + // update our local contact + _contactListCubit.followContactProfileChanges( + localConversationRecordKey, + conversationCubit.stream.map((x) => x.map( + data: (d) => d.value.remoteConversation?.profile, + loading: (_) => null, + error: (_) => null)), + conversationCubit.state.asData?.value.remoteConversation?.profile); + + // When our local account profile changes, send it to the conversation + conversationCubit.watchAccountChanges( + _accountRecordCubit.stream, _accountRecordCubit.state); + + // Transformer that only passes through completed/active conversations + // along with the contact that corresponds to the completed + // conversation + final transformedCubit = TransformerCubit< + AsyncValue, + AsyncValue, + ConversationCubit>(conversationCubit, + transform: (avstate) => avstate.when( + data: (data) => (data.localConversation == null || + data.remoteConversation == null) + ? const AsyncValue.loading() + : AsyncValue.data(ActiveConversationState( + localConversation: data.localConversation!, + remoteConversation: data.remoteConversation!, + remoteIdentityPublicKey: remoteIdentityPublicKey, + localConversationRecordKey: localConversationRecordKey, + remoteConversationRecordKey: + remoteConversationRecordKey)), + loading: AsyncValue.loading, + error: AsyncValue.error)); + + return MapEntry(localConversationRecordKey, transformedCubit); + }); + + /// StateFollower ///////////////////////// + + @override + Future removeFromState(TypedKey key) => remove(key); + + @override + Future updateState( + TypedKey key, proto.Chat? oldValue, proto.Chat newValue) async { + switch (newValue.whichKind()) { + case proto.Chat_Kind.notSet: + throw StateError('unknown chat kind'); + case proto.Chat_Kind.direct: + final localConversationRecordKey = + newValue.direct.localConversationRecordKey.toVeilid(); + final remoteIdentityPublicKey = + newValue.direct.remoteMember.remoteIdentityPublicKey.toVeilid(); + final remoteConversationRecordKey = + newValue.direct.remoteMember.remoteConversationRecordKey.toVeilid(); + + if (oldValue != null) { + final oldLocalConversationRecordKey = + oldValue.direct.localConversationRecordKey.toVeilid(); + final oldRemoteIdentityPublicKey = + oldValue.direct.remoteMember.remoteIdentityPublicKey.toVeilid(); + final oldRemoteConversationRecordKey = oldValue + .direct.remoteMember.remoteConversationRecordKey + .toVeilid(); + + if (oldLocalConversationRecordKey == localConversationRecordKey && + oldRemoteIdentityPublicKey == remoteIdentityPublicKey && + oldRemoteConversationRecordKey == remoteConversationRecordKey) { + return; + } + } + + await _addDirectConversation( + remoteIdentityPublicKey: remoteIdentityPublicKey, + localConversationRecordKey: localConversationRecordKey, + remoteConversationRecordKey: remoteConversationRecordKey); + + break; + case proto.Chat_Kind.group: + break; + } + } + + //// + + final AccountInfo _accountInfo; + final AccountRecordCubit _accountRecordCubit; + final ContactListCubit _contactListCubit; +} diff --git a/lib/conversation/cubits/active_single_contact_chat_bloc_map_cubit.dart b/lib/conversation/cubits/active_single_contact_chat_bloc_map_cubit.dart new file mode 100644 index 0000000..88860c4 --- /dev/null +++ b/lib/conversation/cubits/active_single_contact_chat_bloc_map_cubit.dart @@ -0,0 +1,114 @@ +import 'dart:async'; + +import 'package:async_tools/async_tools.dart'; +import 'package:bloc_advanced_tools/bloc_advanced_tools.dart'; +import 'package:equatable/equatable.dart'; +import 'package:meta/meta.dart'; +import 'package:veilid_support/veilid_support.dart'; + +import '../../account_manager/account_manager.dart'; +import '../../chat/chat.dart'; +import '../../proto/proto.dart' as proto; +import '../conversation.dart'; +import 'active_conversations_bloc_map_cubit.dart'; + +@immutable +class _SingleContactChatState extends Equatable { + const _SingleContactChatState( + {required this.remoteIdentityPublicKey, + required this.localConversationRecordKey, + required this.remoteConversationRecordKey, + required this.localMessagesRecordKey, + required this.remoteMessagesRecordKey}); + + final TypedKey remoteIdentityPublicKey; + final TypedKey localConversationRecordKey; + final TypedKey remoteConversationRecordKey; + final TypedKey localMessagesRecordKey; + final TypedKey remoteMessagesRecordKey; + + @override + List get props => [ + remoteIdentityPublicKey, + localConversationRecordKey, + remoteConversationRecordKey, + localMessagesRecordKey, + remoteMessagesRecordKey + ]; +} + +// Map of localConversationRecordKey to MessagesCubit +// Wraps a MessagesCubit to stream the latest messages to the state +// Automatically follows the state of a ActiveConversationsBlocMapCubit. +class ActiveSingleContactChatBlocMapCubit extends BlocMapCubit + with + StateMapFollower> { + ActiveSingleContactChatBlocMapCubit({ + required AccountInfo accountInfo, + required ActiveConversationsBlocMapCubit activeConversationsBlocMapCubit, + }) : _accountInfo = accountInfo { + // Follow the active conversations bloc map cubit + follow(activeConversationsBlocMapCubit); + } + + Future _addConversationMessages(_SingleContactChatState state) async => + add(() => MapEntry( + state.localConversationRecordKey, + SingleContactMessagesCubit( + accountInfo: _accountInfo, + remoteIdentityPublicKey: state.remoteIdentityPublicKey, + localConversationRecordKey: state.localConversationRecordKey, + remoteConversationRecordKey: state.remoteConversationRecordKey, + localMessagesRecordKey: state.localMessagesRecordKey, + remoteMessagesRecordKey: state.remoteMessagesRecordKey, + ))); + + _SingleContactChatState? _mapStateValue( + AsyncValue avInputState) { + final inputState = avInputState.asData?.value; + if (inputState == null) { + return null; + } + return _SingleContactChatState( + remoteIdentityPublicKey: inputState.remoteIdentityPublicKey, + localConversationRecordKey: inputState.localConversationRecordKey, + remoteConversationRecordKey: inputState.remoteConversationRecordKey, + localMessagesRecordKey: + inputState.localConversation.messages.toVeilid(), + remoteMessagesRecordKey: + inputState.remoteConversation.messages.toVeilid()); + } + + /// StateFollower ///////////////////////// + + @override + Future removeFromState(TypedKey key) => remove(key); + + @override + Future updateState( + TypedKey key, + AsyncValue? oldValue, + AsyncValue newValue) async { + final newState = _mapStateValue(newValue); + if (oldValue != null) { + final oldState = _mapStateValue(oldValue); + if (oldState == newState) { + return; + } + } + if (newState != null) { + await _addConversationMessages(newState); + } else if (newValue.isLoading) { + await addState(key, const AsyncValue.loading()); + } else { + final (error, stackTrace) = + (newValue.asError!.error, newValue.asError!.stackTrace); + await addState(key, AsyncValue.error(error, stackTrace)); + } + } + + //// + final AccountInfo _accountInfo; +} diff --git a/lib/contacts/cubits/conversation_cubit.dart b/lib/conversation/cubits/conversation_cubit.dart similarity index 72% rename from lib/contacts/cubits/conversation_cubit.dart rename to lib/conversation/cubits/conversation_cubit.dart index 115ec84..1947504 100644 --- a/lib/contacts/cubits/conversation_cubit.dart +++ b/lib/conversation/cubits/conversation_cubit.dart @@ -9,11 +9,13 @@ import 'package:async_tools/async_tools.dart'; import 'package:equatable/equatable.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:meta/meta.dart'; +import 'package:protobuf/protobuf.dart'; import 'package:veilid_support/veilid_support.dart'; import '../../account_manager/account_manager.dart'; import '../../proto/proto.dart' as proto; -import '../../tools/tools.dart'; + +const _sfUpdateAccountChange = 'updateAccountChange'; @immutable class ConversationState extends Equatable { @@ -27,32 +29,35 @@ class ConversationState extends Equatable { List get props => [localConversation, remoteConversation]; } +/// Represents the control channel between two contacts +/// Used to pass profile, identity and status changes, and the messages key for +/// 1-1 chats class ConversationCubit extends Cubit> { ConversationCubit( - {required ActiveAccountInfo activeAccountInfo, + {required AccountInfo accountInfo, required TypedKey remoteIdentityPublicKey, TypedKey? localConversationRecordKey, TypedKey? remoteConversationRecordKey}) - : _activeAccountInfo = activeAccountInfo, + : _accountInfo = accountInfo, _localConversationRecordKey = localConversationRecordKey, _remoteIdentityPublicKey = remoteIdentityPublicKey, _remoteConversationRecordKey = remoteConversationRecordKey, super(const AsyncValue.loading()) { + _identityWriter = _accountInfo.identityWriter; + if (_localConversationRecordKey != null) { _initWait.add(() async { await _setLocalConversation(() async { - final accountRecordKey = _activeAccountInfo - .userLogin.accountRecordInfo.accountRecord.recordKey; - // Open local record key if it is specified final pool = DHTRecordPool.instance; final crypto = await _cachedConversationCrypto(); - final writer = _activeAccountInfo.identityWriter; + final writer = _identityWriter; final record = await pool.openRecordWrite( _localConversationRecordKey!, writer, debugName: 'ConversationCubit::LocalConversation', - parent: accountRecordKey, + parent: accountInfo.accountRecordKey, crypto: crypto); + return record; }); }); @@ -61,15 +66,13 @@ class ConversationCubit extends Cubit> { if (_remoteConversationRecordKey != null) { _initWait.add(() async { await _setRemoteConversation(() async { - final accountRecordKey = _activeAccountInfo - .userLogin.accountRecordInfo.accountRecord.recordKey; - // Open remote record key if it is specified final pool = DHTRecordPool.instance; final crypto = await _cachedConversationCrypto(); final record = await pool.openRecordRead(_remoteConversationRecordKey, debugName: 'ConversationCubit::RemoteConversation', - parent: accountRecordKey, + parent: pool.getParentRecordKey(_remoteConversationRecordKey) ?? + accountInfo.accountRecordKey, crypto: crypto); return record; }); @@ -80,6 +83,7 @@ class ConversationCubit extends Cubit> { @override Future close() async { await _initWait(); + await _accountSubscription?.cancel(); await _localSubscription?.cancel(); await _remoteSubscription?.cancel(); await _localConversationCubit?.close(); @@ -88,6 +92,130 @@ class ConversationCubit extends Cubit> { await super.close(); } + //////////////////////////////////////////////////////////////////////////// + // Public Interface + + /// Initialize a local conversation + /// If we were the initiator of the conversation there may be an + /// incomplete 'existingConversationRecord' that we need to fill + /// in now that we have the remote identity key + /// The ConversationCubit must not already have a local conversation + /// The callback allows for more initialization to occur and for + /// cleanup to delete records upon failure of the callback + Future initLocalConversation( + {required proto.Profile profile, + required FutureOr Function(DHTRecord) callback, + TypedKey? existingConversationRecordKey}) async { + assert(_localConversationRecordKey == null, + 'must not have a local conversation yet'); + + final pool = DHTRecordPool.instance; + + final crypto = await _cachedConversationCrypto(); + final accountRecordKey = _accountInfo.accountRecordKey; + final writer = _accountInfo.identityWriter; + + // Open with SMPL schema for identity writer + late final DHTRecord localConversationRecord; + if (existingConversationRecordKey != null) { + localConversationRecord = await pool.openRecordWrite( + existingConversationRecordKey, writer, + debugName: + 'ConversationCubit::initLocalConversation::LocalConversation', + parent: accountRecordKey, + crypto: crypto); + } else { + localConversationRecord = await pool.createRecord( + debugName: + 'ConversationCubit::initLocalConversation::LocalConversation', + parent: accountRecordKey, + crypto: crypto, + writer: writer, + schema: DHTSchema.smpl( + oCnt: 0, members: [DHTSchemaMember(mKey: writer.key, mCnt: 1)])); + } + final out = localConversationRecord + // ignore: prefer_expression_function_bodies + .deleteScope((localConversation) async { + // Make messages log + return _initLocalMessages( + localConversationKey: localConversation.key, + callback: (messages) async { + // Create initial local conversation key contents + final conversation = proto.Conversation() + ..profile = profile + ..superIdentityJson = + jsonEncode(_accountInfo.localAccount.superIdentity.toJson()) + ..messages = messages.recordKey.toProto(); + + // Write initial conversation to record + final update = await localConversation.tryWriteProtobuf( + proto.Conversation.fromBuffer, conversation); + if (update != null) { + throw Exception('Failed to write local conversation'); + } + final out = await callback(localConversation); + + // Upon success emit the local conversation record to the state + _updateLocalConversationState(AsyncValue.data(conversation)); + + return out; + }); + }); + + // If success, save the new local conversation record key in this object + _localConversationRecordKey = localConversationRecord.key; + await _setLocalConversation(() async => localConversationRecord); + + return out; + } + + /// Force refresh of conversation keys + Future refresh() async { + await _initWait(); + + final lcc = _localConversationCubit; + final rcc = _remoteConversationCubit; + + if (lcc != null) { + await lcc.refreshDefault(); + } + if (rcc != null) { + await rcc.refreshDefault(); + } + } + + /// Watch for account record changes and update the conversation + void watchAccountChanges(Stream> accountStream, + AsyncValue currentState) { + assert(_accountSubscription == null, 'only watch account once'); + _accountSubscription = accountStream.listen(_updateAccountChange); + _updateAccountChange(currentState); + } + + //////////////////////////////////////////////////////////////////////////// + // Private Implementation + + void _updateAccountChange(AsyncValue avaccount) { + final account = avaccount.asData?.value; + if (account == null) { + return; + } + final cubit = _localConversationCubit; + if (cubit == null) { + return; + } + serialFuture((this, _sfUpdateAccountChange), () async { + await cubit.record.eventualUpdateProtobuf(proto.Conversation.fromBuffer, + (old) async { + if (old == null || old.profile == account.profile) { + return null; + } + return old.deepCopy()..profile = account.profile; + }); + }); + } + void _updateLocalConversationState(AsyncValue avconv) { final newState = avconv.when( data: (conv) { @@ -140,6 +268,7 @@ class ConversationCubit extends Cubit> { open: open, decodeState: proto.Conversation.fromBuffer); _localSubscription = _localConversationCubit!.stream.listen(_updateLocalConversationState); + _updateLocalConversationState(_localConversationCubit!.state); } // Open remote converation key @@ -150,146 +279,16 @@ class ConversationCubit extends Cubit> { open: open, decodeState: proto.Conversation.fromBuffer); _remoteSubscription = _remoteConversationCubit!.stream.listen(_updateRemoteConversationState); - } - - Future delete() async { - final pool = DHTRecordPool.instance; - - await _initWait(); - final localConversationCubit = _localConversationCubit; - final remoteConversationCubit = _remoteConversationCubit; - - final deleteSet = DelayedWaitSet(); - - if (localConversationCubit != null) { - final data = localConversationCubit.state.asData; - if (data == null) { - log.warning('could not delete local conversation'); - return false; - } - - deleteSet.add(() async { - _localConversationCubit = null; - await localConversationCubit.close(); - final conversation = data.value; - final messagesKey = conversation.messages.toVeilid(); - await pool.deleteRecord(messagesKey); - await pool.deleteRecord(_localConversationRecordKey!); - _localConversationRecordKey = null; - }); - } - - if (remoteConversationCubit != null) { - final data = remoteConversationCubit.state.asData; - if (data == null) { - log.warning('could not delete remote conversation'); - return false; - } - - deleteSet.add(() async { - _remoteConversationCubit = null; - await remoteConversationCubit.close(); - final conversation = data.value; - final messagesKey = conversation.messages.toVeilid(); - await pool.deleteRecord(messagesKey); - await pool.deleteRecord(_remoteConversationRecordKey!); - }); - } - - // Commit the delete futures - await deleteSet(); - - return true; - } - - // Initialize a local conversation - // If we were the initiator of the conversation there may be an - // incomplete 'existingConversationRecord' that we need to fill - // in now that we have the remote identity key - // The ConversationCubit must not already have a local conversation - // The callback allows for more initialization to occur and for - // cleanup to delete records upon failure of the callback - Future initLocalConversation( - {required proto.Profile profile, - required FutureOr Function(DHTRecord) callback, - TypedKey? existingConversationRecordKey}) async { - assert(_localConversationRecordKey == null, - 'must not have a local conversation yet'); - - final pool = DHTRecordPool.instance; - final accountRecordKey = - _activeAccountInfo.userLogin.accountRecordInfo.accountRecord.recordKey; - - final crypto = await _cachedConversationCrypto(); - final writer = _activeAccountInfo.identityWriter; - - // Open with SMPL scheme for identity writer - late final DHTRecord localConversationRecord; - if (existingConversationRecordKey != null) { - localConversationRecord = await pool.openRecordWrite( - existingConversationRecordKey, writer, - debugName: - 'ConversationCubit::initLocalConversation::LocalConversation', - parent: accountRecordKey, - crypto: crypto); - } else { - localConversationRecord = await pool.createRecord( - debugName: - 'ConversationCubit::initLocalConversation::LocalConversation', - parent: accountRecordKey, - crypto: crypto, - writer: writer, - schema: DHTSchema.smpl( - oCnt: 0, members: [DHTSchemaMember(mKey: writer.key, mCnt: 1)])); - } - final out = localConversationRecord - // ignore: prefer_expression_function_bodies - .deleteScope((localConversation) async { - // Make messages log - return _initLocalMessages( - activeAccountInfo: _activeAccountInfo, - remoteIdentityPublicKey: _remoteIdentityPublicKey, - localConversationKey: localConversation.key, - callback: (messages) async { - // Create initial local conversation key contents - final conversation = proto.Conversation() - ..profile = profile - ..superIdentityJson = jsonEncode( - _activeAccountInfo.localAccount.superIdentity.toJson()) - ..messages = messages.recordKey.toProto(); - - // Write initial conversation to record - final update = await localConversation.tryWriteProtobuf( - proto.Conversation.fromBuffer, conversation); - if (update != null) { - throw Exception('Failed to write local conversation'); - } - final out = await callback(localConversation); - - // Upon success emit the local conversation record to the state - _updateLocalConversationState(AsyncValue.data(conversation)); - - return out; - }); - }); - - // If success, save the new local conversation record key in this object - _localConversationRecordKey = localConversationRecord.key; - await _setLocalConversation(() async => localConversationRecord); - - return out; + _updateRemoteConversationState(_remoteConversationCubit!.state); } // Initialize local messages Future _initLocalMessages({ - required ActiveAccountInfo activeAccountInfo, - required TypedKey remoteIdentityPublicKey, required TypedKey localConversationKey, required FutureOr Function(DHTLog) callback, }) async { - final crypto = - await activeAccountInfo.makeConversationCrypto(remoteIdentityPublicKey); - final writer = activeAccountInfo.identityWriter; + final crypto = await _cachedConversationCrypto(); + final writer = _identityWriter; return (await DHTLog.create( debugName: 'ConversationCubit::initLocalMessages::LocalMessages', @@ -299,47 +298,23 @@ class ConversationCubit extends Cubit> { .deleteScope((messages) async => await callback(messages)); } - // Force refresh of conversation keys - Future refresh() async { - await _initWait(); - - final lcc = _localConversationCubit; - final rcc = _remoteConversationCubit; - - if (lcc != null) { - await lcc.refreshDefault(); - } - if (rcc != null) { - await rcc.refreshDefault(); - } - } - - Future writeLocalConversation({ - required proto.Conversation conversation, - }) async { - final update = await _localConversationCubit!.record - .tryWriteProtobuf(proto.Conversation.fromBuffer, conversation); - - if (update != null) { - _updateLocalConversationState(AsyncValue.data(conversation)); - } - - return update; - } - Future _cachedConversationCrypto() async { var conversationCrypto = _conversationCrypto; if (conversationCrypto != null) { return conversationCrypto; } - conversationCrypto = await _activeAccountInfo - .makeConversationCrypto(_remoteIdentityPublicKey); - + conversationCrypto = + await _accountInfo.makeConversationCrypto(_remoteIdentityPublicKey); _conversationCrypto = conversationCrypto; return conversationCrypto; } - final ActiveAccountInfo _activeAccountInfo; + //////////////////////////////////////////////////////////////////////////// + // Fields + TypedKey get remoteIdentityPublicKey => _remoteIdentityPublicKey; + + final AccountInfo _accountInfo; + late final KeyPair _identityWriter; final TypedKey _remoteIdentityPublicKey; TypedKey? _localConversationRecordKey; final TypedKey? _remoteConversationRecordKey; @@ -347,9 +322,9 @@ class ConversationCubit extends Cubit> { DefaultDHTRecordCubit? _remoteConversationCubit; StreamSubscription>? _localSubscription; StreamSubscription>? _remoteSubscription; + StreamSubscription>? _accountSubscription; ConversationState _incrementalState = const ConversationState( localConversation: null, remoteConversation: null); - // VeilidCrypto? _conversationCrypto; final WaitSet _initWait = WaitSet(); } diff --git a/lib/conversation/cubits/cubits.dart b/lib/conversation/cubits/cubits.dart new file mode 100644 index 0000000..029764f --- /dev/null +++ b/lib/conversation/cubits/cubits.dart @@ -0,0 +1,3 @@ +export 'active_conversations_bloc_map_cubit.dart'; +export 'active_single_contact_chat_bloc_map_cubit.dart'; +export 'conversation_cubit.dart'; diff --git a/lib/layout/home/active_account_page_controller_wrapper.dart b/lib/layout/home/active_account_page_controller_wrapper.dart new file mode 100644 index 0000000..79314a1 --- /dev/null +++ b/lib/layout/home/active_account_page_controller_wrapper.dart @@ -0,0 +1,37 @@ +import 'dart:async'; + +import 'package:async_tools/async_tools.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:veilid_support/veilid_support.dart'; + +import '../../account_manager/account_manager.dart'; + +class ActiveAccountPageControllerWrapper { + ActiveAccountPageControllerWrapper(Locator locator, int initialPage) { + pageController = PageController(initialPage: initialPage, keepPage: false); + + final activeLocalAccountCubit = locator(); + _subscription = + activeLocalAccountCubit.stream.listen((activeLocalAccountRecordKey) { + singleFuture(this, () async { + final localAccounts = locator().state; + final activeIndex = localAccounts.indexWhere( + (x) => x.superIdentity.recordKey == activeLocalAccountRecordKey); + if (pageController.page == activeIndex) { + return; + } + await pageController.animateToPage(activeIndex, + duration: const Duration(milliseconds: 250), + curve: Curves.fastOutSlowIn); + }); + }); + } + + void dispose() { + unawaited(_subscription.cancel()); + } + + late PageController pageController; + late StreamSubscription _subscription; +} diff --git a/lib/layout/home/drawer_menu/drawer_menu.dart b/lib/layout/home/drawer_menu/drawer_menu.dart new file mode 100644 index 0000000..218d7ed --- /dev/null +++ b/lib/layout/home/drawer_menu/drawer_menu.dart @@ -0,0 +1,316 @@ +import 'package:async_tools/async_tools.dart'; +import 'package:awesome_extensions/awesome_extensions.dart'; +import 'package:fast_immutable_collections/fast_immutable_collections.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:flutter_translate/flutter_translate.dart'; +import 'package:go_router/go_router.dart'; +import 'package:veilid_support/veilid_support.dart'; + +import '../../../account_manager/account_manager.dart'; +import '../../../proto/proto.dart' as proto; +import '../../../theme/theme.dart'; +import '../../../tools/tools.dart'; +import '../../../veilid_processor/veilid_processor.dart'; +import 'menu_item_widget.dart'; + +class DrawerMenu extends StatefulWidget { + const DrawerMenu({super.key}); + + @override + State createState() => _DrawerMenuState(); +} + +class _DrawerMenuState extends State { + @override + void initState() { + super.initState(); + } + + @override + void dispose() { + super.dispose(); + } + + void _doSwitchClick(TypedKey superIdentityRecordKey) { + singleFuture(this, () async { + await AccountRepository.instance.switchToAccount(superIdentityRecordKey); + }); + } + + void _doEditClick( + TypedKey superIdentityRecordKey, proto.Profile existingProfile) { + singleFuture(this, () async { + await GoRouterHelper(context).push('/edit_account', + extra: [superIdentityRecordKey, existingProfile]); + }); + } + + Widget _wrapInBox({required Widget child, required Color color}) => + DecoratedBox( + decoration: ShapeDecoration( + color: color, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16))), + child: child); + + Widget _makeAccountWidget( + {required String name, + required bool selected, + required ScaleColor scale, + required bool loggedIn, + required void Function()? callback, + required void Function()? footerCallback}) { + final theme = Theme.of(context); + final abbrev = name.split(' ').map((s) => s.isEmpty ? '' : s[0]).join(); + late final String shortname; + if (abbrev.length >= 3) { + shortname = abbrev[0] + abbrev[1] + abbrev[abbrev.length - 1]; + } else { + shortname = abbrev; + } + + final avatar = Container( + height: 34, + width: 34, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: loggedIn ? scale.border : scale.subtleBorder, + width: 2, + strokeAlign: BorderSide.strokeAlignOutside), + color: Colors.blue, + ), + child: AvatarImage( + //size: 32, + backgroundColor: loggedIn ? scale.primary : scale.elementBackground, + foregroundColor: loggedIn ? scale.primaryText : scale.subtleText, + child: Text(shortname, style: theme.textTheme.titleLarge))); + + return AnimatedPadding( + padding: EdgeInsets.fromLTRB(selected ? 0 : 0, 0, selected ? 0 : 8, 0), + duration: const Duration(milliseconds: 50), + child: MenuItemWidget( + title: name, + headerWidget: avatar, + titleStyle: theme.textTheme.titleLarge!, + foregroundColor: scale.primary, + backgroundColor: selected + ? scale.activeElementBackground + : scale.elementBackground, + backgroundHoverColor: scale.hoverElementBackground, + backgroundFocusColor: scale.activeElementBackground, + borderColor: scale.border, + borderHoverColor: scale.hoverBorder, + borderFocusColor: scale.primary, + callback: callback, + footerButtonIcon: loggedIn ? Icons.edit_outlined : null, + footerCallback: footerCallback, + footerButtonIconColor: scale.border, + footerButtonIconHoverColor: scale.hoverElementBackground, + footerButtonIconFocusColor: scale.activeElementBackground, + )); + } + + Widget _getAccountList( + {required IList localAccounts, + required TypedKey? activeLocalAccount, + required PerAccountCollectionBlocMapState + perAccountCollectionBlocMapState}) { + final theme = Theme.of(context); + final scaleScheme = theme.extension()!; + + final loggedInAccounts = []; + final loggedOutAccounts = []; + + for (final la in localAccounts) { + final superIdentityRecordKey = la.superIdentity.recordKey; + + // See if this account is logged in + final avAccountRecordState = perAccountCollectionBlocMapState + .get(superIdentityRecordKey) + ?.avAccountRecordState; + if (avAccountRecordState != null) { + // Account is logged in + final scale = theme.extension()!.tertiaryScale; + final loggedInAccount = avAccountRecordState.when( + data: (value) => _makeAccountWidget( + name: value.profile.name, + scale: scale, + selected: superIdentityRecordKey == activeLocalAccount, + loggedIn: true, + callback: () { + _doSwitchClick(superIdentityRecordKey); + }, + footerCallback: () { + _doEditClick(superIdentityRecordKey, value.profile); + }), + loading: () => _wrapInBox( + child: buildProgressIndicator(), + color: scaleScheme.grayScale.subtleBorder), + error: (err, st) => _wrapInBox( + child: errorPage(err, st), + color: scaleScheme.errorScale.subtleBorder), + ); + loggedInAccounts.add(loggedInAccount.paddingLTRB(0, 0, 0, 8)); + } else { + // Account is not logged in + final scale = theme.extension()!.grayScale; + final loggedOutAccount = _makeAccountWidget( + name: la.name, + scale: scale, + selected: superIdentityRecordKey == activeLocalAccount, + loggedIn: false, + callback: () => {_doSwitchClick(superIdentityRecordKey)}, + footerCallback: null, + ); + loggedOutAccounts.add(loggedOutAccount); + } + } + + // Assemble main menu + final mainMenu = [...loggedInAccounts, ...loggedOutAccounts]; + + // Return main menu widgets + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [...mainMenu], + ); + } + + Widget _getButton( + {required Icon icon, + required ScaleColor scale, + required String tooltip, + required void Function()? onPressed}) => + IconButton( + icon: icon, + color: scale.hoverBorder, + constraints: const BoxConstraints.expand(height: 64, width: 64), + style: ButtonStyle( + backgroundColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.hovered)) { + return scale.hoverElementBackground; + } + if (states.contains(WidgetState.focused)) { + return scale.activeElementBackground; + } + return scale.elementBackground; + }), shape: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.hovered)) { + return RoundedRectangleBorder( + side: BorderSide(color: scale.hoverBorder), + borderRadius: const BorderRadius.all(Radius.circular(16))); + } + if (states.contains(WidgetState.focused)) { + return RoundedRectangleBorder( + side: BorderSide(color: scale.primary), + borderRadius: const BorderRadius.all(Radius.circular(16))); + } + return RoundedRectangleBorder( + side: BorderSide(color: scale.border), + borderRadius: const BorderRadius.all(Radius.circular(16))); + })), + tooltip: tooltip, + onPressed: onPressed); + + Widget _getBottomButtons() { + final theme = Theme.of(context); + final scale = theme.extension()!; + + final settingsButton = _getButton( + icon: const Icon(Icons.settings), + tooltip: translate('menu.settings_tooltip'), + scale: scale.tertiaryScale, + onPressed: () async { + await GoRouterHelper(context).push('/settings'); + }).paddingLTRB(0, 0, 16, 0); + + final addButton = _getButton( + icon: const Icon(Icons.add), + tooltip: translate('menu.add_account_tooltip'), + scale: scale.tertiaryScale, + onPressed: () async { + await GoRouterHelper(context).push('/new_account'); + }).paddingLTRB(0, 0, 16, 0); + + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [settingsButton, addButton]).paddingLTRB(0, 16, 0, 0); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scale = theme.extension()!; + final scaleConfig = theme.extension()!; + //final textTheme = theme.textTheme; + final localAccounts = context.watch().state; + final perAccountCollectionBlocMapState = + context.watch().state; + final activeLocalAccount = context.watch().state; + final gradient = LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + scale.tertiaryScale.hoverElementBackground, + scale.tertiaryScale.subtleBackground, + ]); + + return DecoratedBox( + decoration: ShapeDecoration( + shadows: [ + BoxShadow( + color: scale.tertiaryScale.appBackground, + blurRadius: 6, + offset: const Offset( + 0, + 3, + ), + ), + ], + gradient: gradient, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topRight: Radius.circular(16), + bottomRight: Radius.circular(16)))), + child: Column(children: [ + FittedBox( + fit: BoxFit.scaleDown, + child: Row(children: [ + SvgPicture.asset( + height: 48, + 'assets/images/icon.svg', + colorFilter: scaleConfig.useVisualIndicators + ? grayColorFilter + : null) + .paddingLTRB(0, 0, 16, 0), + SvgPicture.asset( + height: 48, + 'assets/images/title.svg', + colorFilter: + scaleConfig.useVisualIndicators ? grayColorFilter : null), + ])), + const Spacer(), + _getAccountList( + localAccounts: localAccounts, + activeLocalAccount: activeLocalAccount, + perAccountCollectionBlocMapState: perAccountCollectionBlocMapState), + _getBottomButtons(), + const Spacer(), + Row(children: [ + Text('Version $packageInfoVersion', + style: theme.textTheme.labelMedium! + .copyWith(color: scale.tertiaryScale.hoverBorder)), + const Spacer(), + SignalStrengthMeterWidget( + color: scale.tertiaryScale.hoverBorder, + inactiveColor: scale.tertiaryScale.border, + ), + ]) + ]).paddingAll(16), + ); + } +} diff --git a/lib/layout/home/drawer_menu/menu_item_widget.dart b/lib/layout/home/drawer_menu/menu_item_widget.dart new file mode 100644 index 0000000..260646f --- /dev/null +++ b/lib/layout/home/drawer_menu/menu_item_widget.dart @@ -0,0 +1,130 @@ +import 'package:awesome_extensions/awesome_extensions.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; + +class MenuItemWidget extends StatelessWidget { + const MenuItemWidget({ + required this.title, + required this.titleStyle, + required this.foregroundColor, + this.headerWidget, + this.widthBox, + this.callback, + this.backgroundColor, + this.backgroundHoverColor, + this.backgroundFocusColor, + this.borderColor, + this.borderHoverColor, + this.borderFocusColor, + this.footerButtonIcon, + this.footerButtonIconColor, + this.footerButtonIconHoverColor, + this.footerButtonIconFocusColor, + this.footerCallback, + super.key, + }); + + @override + Widget build(BuildContext context) => TextButton( + onPressed: callback, + style: TextButton.styleFrom(foregroundColor: foregroundColor).copyWith( + backgroundColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.hovered)) { + return backgroundHoverColor; + } + if (states.contains(WidgetState.focused)) { + return backgroundFocusColor; + } + return backgroundColor; + }), + side: WidgetStateBorderSide.resolveWith((states) { + if (states.contains(WidgetState.hovered)) { + return borderColor != null + ? BorderSide(color: borderHoverColor!) + : null; + } + if (states.contains(WidgetState.focused)) { + return borderColor != null + ? BorderSide(color: borderFocusColor!) + : null; + } + return borderColor != null ? BorderSide(color: borderColor!) : null; + }), + shape: WidgetStateProperty.all( + RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)))), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (headerWidget != null) headerWidget!, + if (widthBox != null) widthBox!, + Expanded( + child: FittedBox( + alignment: Alignment.centerLeft, + fit: BoxFit.scaleDown, + child: Text( + title, + style: titleStyle, + ).paddingAll(8)), + ), + if (footerButtonIcon != null) + IconButton.outlined( + color: footerButtonIconColor, + focusColor: footerButtonIconFocusColor, + hoverColor: footerButtonIconHoverColor, + icon: Icon( + footerButtonIcon, + size: 24, + ), + onPressed: footerCallback), + ], + ), + )); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('textStyle', titleStyle)) + ..add(ObjectFlagProperty.has('callback', callback)) + ..add(DiagnosticsProperty('foregroundColor', foregroundColor)) + ..add(StringProperty('title', title)) + ..add( + DiagnosticsProperty('footerButtonIcon', footerButtonIcon)) + ..add(ObjectFlagProperty.has( + 'footerCallback', footerCallback)) + ..add(ColorProperty('footerButtonIconColor', footerButtonIconColor)) + ..add(ColorProperty( + 'footerButtonIconHoverColor', footerButtonIconHoverColor)) + ..add(ColorProperty( + 'footerButtonIconFocusColor', footerButtonIconFocusColor)) + ..add(ColorProperty('backgroundColor', backgroundColor)) + ..add(ColorProperty('backgroundHoverColor', backgroundHoverColor)) + ..add(ColorProperty('backgroundFocusColor', backgroundFocusColor)) + ..add(ColorProperty('borderColor', borderColor)) + ..add(ColorProperty('borderHoverColor', borderHoverColor)) + ..add(ColorProperty('borderFocusColor', borderFocusColor)); + } + + //////////////////////////////////////////////////////////////////////////// + + final String title; + final Widget? headerWidget; + final Widget? widthBox; + final TextStyle titleStyle; + final Color foregroundColor; + final void Function()? callback; + final IconData? footerButtonIcon; + final void Function()? footerCallback; + final Color? backgroundColor; + final Color? backgroundHoverColor; + final Color? backgroundFocusColor; + final Color? borderColor; + final Color? borderHoverColor; + final Color? borderFocusColor; + final Color? footerButtonIconColor; + final Color? footerButtonIconHoverColor; + final Color? footerButtonIconFocusColor; +} diff --git a/lib/layout/home/home.dart b/lib/layout/home/home.dart index 5b1b3d1..cb0cef7 100644 --- a/lib/layout/home/home.dart +++ b/lib/layout/home/home.dart @@ -1,6 +1,8 @@ +export 'active_account_page_controller_wrapper.dart'; +export 'drawer_menu/drawer_menu.dart'; export 'home_account_invalid.dart'; export 'home_account_locked.dart'; export 'home_account_missing.dart'; export 'home_account_ready/home_account_ready.dart'; export 'home_no_active.dart'; -export 'home_shell.dart'; +export 'home_screen.dart'; diff --git a/lib/layout/home/home_account_missing.dart b/lib/layout/home/home_account_missing.dart index d9c0aad..a2e4db4 100644 --- a/lib/layout/home/home_account_missing.dart +++ b/lib/layout/home/home_account_missing.dart @@ -21,13 +21,3 @@ class HomeAccountMissingState extends State { @override Widget build(BuildContext context) => const Text('Account missing'); } - -// xxx click to delete missing account or add to postframecallback - // Future.delayed(0.ms, () async { - // await showErrorModal(context, translate('home.missing_account_title'), - // translate('home.missing_account_text')); - // // Delete account - // await AccountRepository.instance.deleteLocalAccount(activeUserLogin); - // // Switch to no active user login - // await AccountRepository.instance.switchToAccount(null); - // }); \ No newline at end of file diff --git a/lib/layout/home/home_account_ready/home_account_ready.dart b/lib/layout/home/home_account_ready/home_account_ready.dart index b198f0b..5171239 100644 --- a/lib/layout/home/home_account_ready/home_account_ready.dart +++ b/lib/layout/home/home_account_ready/home_account_ready.dart @@ -1,3 +1,2 @@ export 'home_account_ready_chat.dart'; export 'home_account_ready_main.dart'; -export 'home_account_ready_shell.dart'; diff --git a/lib/layout/home/home_account_ready/home_account_ready_main.dart b/lib/layout/home/home_account_ready/home_account_ready_main.dart index 9fec3ce..aa9b1ba 100644 --- a/lib/layout/home/home_account_ready/home_account_ready_main.dart +++ b/lib/layout/home/home_account_ready/home_account_ready_main.dart @@ -2,10 +2,11 @@ import 'package:awesome_extensions/awesome_extensions.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_translate/flutter_translate.dart'; -import 'package:go_router/go_router.dart'; +import 'package:flutter_zoom_drawer/flutter_zoom_drawer.dart'; import '../../../account_manager/account_manager.dart'; import '../../../chat/chat.dart'; +import '../../../proto/proto.dart' as proto; import '../../../theme/theme.dart'; import '../../../tools/tools.dart'; import 'main_pager/main_pager.dart'; @@ -29,14 +30,15 @@ class _HomeAccountReadyMainState extends State { } Widget buildUserPanel() => Builder(builder: (context) { - final account = context.watch().state; + final profile = context.select( + (c) => c.state.asData!.value.profile); final theme = Theme.of(context); final scale = theme.extension()!; return Column(children: [ Row(children: [ IconButton( - icon: const Icon(Icons.settings), + icon: const Icon(Icons.menu), color: scale.secondaryScale.borderText, constraints: const BoxConstraints.expand(height: 64, width: 64), style: ButtonStyle( @@ -44,13 +46,13 @@ class _HomeAccountReadyMainState extends State { WidgetStateProperty.all(scale.primaryScale.hoverBorder), shape: WidgetStateProperty.all(const RoundedRectangleBorder( borderRadius: BorderRadius.all(Radius.circular(16))))), - tooltip: translate('app_bar.settings_tooltip'), + tooltip: translate('menu.settings_tooltip'), onPressed: () async { - await GoRouterHelper(context).push('/settings'); + final ctrl = context.read(); + await ctrl.toggle?.call(); + //await GoRouterHelper(context).push('/settings'); }).paddingLTRB(0, 0, 8, 0), - asyncValueBuilder(account, - (_, account) => ProfileWidget(profile: account.profile)) - .expanded(), + ProfileWidget(profile: profile).expanded(), ]).paddingAll(8), const MainPager().expanded() ]); @@ -70,8 +72,8 @@ class _HomeAccountReadyMainState extends State { return const NoConversationWidget(); } return ChatComponentWidget.builder( - localConversationRecordKey: activeChatLocalConversationKey, - ); + localConversationRecordKey: activeChatLocalConversationKey, + key: ValueKey(activeChatLocalConversationKey)); } // ignore: prefer_expression_function_bodies diff --git a/lib/layout/home/home_account_ready/home_account_ready_shell.dart b/lib/layout/home/home_account_ready/home_account_ready_shell.dart deleted file mode 100644 index c41185b..0000000 --- a/lib/layout/home/home_account_ready/home_account_ready_shell.dart +++ /dev/null @@ -1,159 +0,0 @@ -import 'package:async_tools/async_tools.dart'; -import 'package:bloc_advanced_tools/bloc_advanced_tools.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:veilid_support/veilid_support.dart'; - -import '../../../account_manager/account_manager.dart'; -import '../../../chat/chat.dart'; -import '../../../chat_list/chat_list.dart'; -import '../../../contact_invitation/contact_invitation.dart'; -import '../../../contacts/contacts.dart'; -import '../../../router/router.dart'; -import '../../../theme/theme.dart'; - -class HomeAccountReadyShell extends StatefulWidget { - factory HomeAccountReadyShell( - {required BuildContext context, required Widget child, Key? key}) { - // These must exist in order for the account to - // be considered 'ready' for this widget subtree - final activeLocalAccount = context.read().state!; - final activeAccountInfo = context.read(); - final routerCubit = context.read(); - - return HomeAccountReadyShell._( - activeLocalAccount: activeLocalAccount, - activeAccountInfo: activeAccountInfo, - routerCubit: routerCubit, - key: key, - child: child); - } - const HomeAccountReadyShell._( - {required this.activeLocalAccount, - required this.activeAccountInfo, - required this.routerCubit, - required this.child, - super.key}); - - @override - HomeAccountReadyShellState createState() => HomeAccountReadyShellState(); - - final Widget child; - final TypedKey activeLocalAccount; - final ActiveAccountInfo activeAccountInfo; - final RouterCubit routerCubit; - - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties - ..add(DiagnosticsProperty( - 'activeLocalAccount', activeLocalAccount)) - ..add(DiagnosticsProperty( - 'activeAccountInfo', activeAccountInfo)) - ..add(DiagnosticsProperty('routerCubit', routerCubit)); - } -} - -class HomeAccountReadyShellState extends State { - final SingleStateProcessor - _singleInvitationStatusProcessor = SingleStateProcessor(); - - @override - void initState() { - super.initState(); - } - - // Process all accepted or rejected invitations - void _invitationStatusListener( - BuildContext context, WaitingInvitationsBlocMapState state) { - _singleInvitationStatusProcessor.updateState(state, (newState) async { - final contactListCubit = context.read(); - final contactInvitationListCubit = - context.read(); - - for (final entry in newState.entries) { - final contactRequestInboxRecordKey = entry.key; - final invStatus = entry.value.asData?.value; - // Skip invitations that have not yet been accepted or rejected - if (invStatus == null) { - continue; - } - - // Delete invitation and process the accepted or rejected contact - final acceptedContact = invStatus.acceptedContact; - if (acceptedContact != null) { - await contactInvitationListCubit.deleteInvitation( - accepted: true, - contactRequestInboxRecordKey: contactRequestInboxRecordKey); - - // Accept - await contactListCubit.createContact( - remoteProfile: acceptedContact.remoteProfile, - remoteSuperIdentity: acceptedContact.remoteIdentity, - remoteConversationRecordKey: - acceptedContact.remoteConversationRecordKey, - localConversationRecordKey: - acceptedContact.localConversationRecordKey, - ); - } else { - // Reject - await contactInvitationListCubit.deleteInvitation( - accepted: false, - contactRequestInboxRecordKey: contactRequestInboxRecordKey); - } - } - }); - } - - @override - Widget build(BuildContext context) { - final account = context.watch().state.asData?.value; - if (account == null) { - return waitingPage(); - } - return MultiBlocProvider( - providers: [ - BlocProvider( - create: (context) => ContactInvitationListCubit( - activeAccountInfo: widget.activeAccountInfo, - account: account)), - BlocProvider( - create: (context) => ContactListCubit( - activeAccountInfo: widget.activeAccountInfo, - account: account)), - BlocProvider( - create: (context) => ActiveChatCubit(null) - ..withStateListen((event) { - widget.routerCubit.setHasActiveChat(event != null); - })), - BlocProvider( - create: (context) => ChatListCubit( - activeAccountInfo: widget.activeAccountInfo, - activeChatCubit: context.read(), - account: account)), - BlocProvider( - create: (context) => ActiveConversationsBlocMapCubit( - activeAccountInfo: widget.activeAccountInfo, - contactListCubit: context.read()) - ..follow(context.read())), - BlocProvider( - create: (context) => ActiveSingleContactChatBlocMapCubit( - activeAccountInfo: widget.activeAccountInfo, - contactListCubit: context.read(), - chatListCubit: context.read()) - ..follow(context.read())), - BlocProvider( - create: (context) => WaitingInvitationsBlocMapCubit( - activeAccountInfo: widget.activeAccountInfo, account: account) - ..follow(context.read())) - ], - child: MultiBlocListener(listeners: [ - BlocListener( - listener: _invitationStatusListener, - ) - ], child: widget.child)); - } -} diff --git a/lib/layout/home/home_account_ready/main_pager/chats_page.dart b/lib/layout/home/home_account_ready/main_pager/chats_page.dart index bdea8e3..8811607 100644 --- a/lib/layout/home/home_account_ready/main_pager/chats_page.dart +++ b/lib/layout/home/home_account_ready/main_pager/chats_page.dart @@ -25,7 +25,7 @@ class ChatsPageState extends State { // ignore: prefer_expression_function_bodies Widget build(BuildContext context) { return Column(children: [ - const ChatSingleContactListWidget().expanded(), + const ChatListWidget().expanded(), ]); } } diff --git a/lib/layout/home/home_account_ready/main_pager/main_pager.dart b/lib/layout/home/home_account_ready/main_pager/main_pager.dart index cdd6ac5..b51d72f 100644 --- a/lib/layout/home/home_account_ready/main_pager/main_pager.dart +++ b/lib/layout/home/home_account_ready/main_pager/main_pager.dart @@ -5,6 +5,7 @@ import 'package:flutter/rendering.dart'; import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_translate/flutter_translate.dart'; import 'package:preload_page_view/preload_page_view.dart'; +import 'package:provider/provider.dart'; import 'package:stylish_bottom_bar/stylish_bottom_bar.dart'; import '../../../../chat/chat.dart'; @@ -117,7 +118,7 @@ class MainPagerState extends State with TickerProviderStateMixin { style: TextStyle(fontSize: 24), ), content: ScanInvitationDialog( - modalContext: context, + locator: context.read, )); }); } diff --git a/lib/layout/home/home_screen.dart b/lib/layout/home/home_screen.dart new file mode 100644 index 0000000..8ade6f5 --- /dev/null +++ b/lib/layout/home/home_screen.dart @@ -0,0 +1,160 @@ +import 'dart:math'; + +import 'package:async_tools/async_tools.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_zoom_drawer/flutter_zoom_drawer.dart'; +import 'package:provider/provider.dart'; +import 'package:veilid_support/veilid_support.dart'; + +import '../../account_manager/account_manager.dart'; +import '../../chat/chat.dart'; +import '../../theme/theme.dart'; +import '../../tools/tools.dart'; +import 'active_account_page_controller_wrapper.dart'; +import 'drawer_menu/drawer_menu.dart'; +import 'home_account_invalid.dart'; +import 'home_account_locked.dart'; +import 'home_account_missing.dart'; +import 'home_account_ready/home_account_ready.dart'; +import 'home_no_active.dart'; + +class HomeScreen extends StatefulWidget { + const HomeScreen({super.key}); + + @override + HomeScreenState createState() => HomeScreenState(); +} + +class HomeScreenState extends State { + @override + void initState() { + super.initState(); + } + + @override + void dispose() { + super.dispose(); + } + + Widget _buildAccountReadyDeviceSpecific(BuildContext context) { + final hasActiveChat = context.watch().state != null; + if (responsiveVisibility( + context: context, + tablet: false, + tabletLandscape: false, + desktop: false)) { + if (hasActiveChat) { + return const HomeAccountReadyChat(); + } + } + return const HomeAccountReadyMain(); + } + + Widget _buildAccount(BuildContext context, TypedKey superIdentityRecordKey, + PerAccountCollectionState perAccountCollectionState) { + switch (perAccountCollectionState.accountInfo.status) { + case AccountInfoStatus.accountInvalid: + return const HomeAccountInvalid(); + case AccountInfoStatus.accountLocked: + return const HomeAccountLocked(); + case AccountInfoStatus.accountUnlocked: + // Are we ready to render? + if (!perAccountCollectionState.isReady) { + return waitingPage(); + } + + // Re-export all ready blocs to the account display subtree + return perAccountCollectionState.provide( + child: Builder(builder: _buildAccountReadyDeviceSpecific)); + } + } + + Widget _buildAccountPageView(BuildContext context) { + final localAccounts = context.watch().state; + final activeLocalAccount = context.watch().state; + final perAccountCollectionBlocMapState = + context.watch().state; + + final activeIndex = localAccounts + .indexWhere((x) => x.superIdentity.recordKey == activeLocalAccount); + if (activeIndex == -1) { + return const HomeNoActive(); + } + + return Provider( + lazy: false, + create: (context) => + ActiveAccountPageControllerWrapper(context.read, activeIndex), + dispose: (context, value) { + value.dispose(); + }, + child: Builder( + builder: (context) => PageView.builder( + onPageChanged: (idx) { + singleFuture(this, () async { + await AccountRepository.instance.switchToAccount( + localAccounts[idx].superIdentity.recordKey); + }); + }, + controller: context + .read() + .pageController, + itemCount: localAccounts.length, + itemBuilder: (context, index) { + final superIdentityRecordKey = + localAccounts[index].superIdentity.recordKey; + final perAccountCollectionState = + perAccountCollectionBlocMapState + .get(superIdentityRecordKey); + if (perAccountCollectionState == null) { + return HomeAccountMissing( + key: ValueKey(superIdentityRecordKey)); + } + return _buildAccount(context, superIdentityRecordKey, + perAccountCollectionState); + }))); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scale = theme.extension()!; + + final gradient = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + scale.tertiaryScale.subtleBackground, + scale.tertiaryScale.appBackground, + ]); + + return SafeArea( + child: DecoratedBox( + decoration: BoxDecoration(gradient: gradient), + child: ZoomDrawer( + controller: _zoomDrawerController, + //menuBackgroundColor: Colors.transparent, + menuScreen: const DrawerMenu(), + mainScreen: DecoratedBox( + decoration: BoxDecoration( + color: scale.primaryScale.activeElementBackground), + child: Provider.value( + value: _zoomDrawerController, + child: Builder(builder: _buildAccountPageView))), + borderRadius: 24, + showShadow: true, + angle: 0, + drawerShadowsBackgroundColor: theme.shadowColor, + mainScreenOverlayColor: theme.shadowColor.withAlpha(0x3F), + openCurve: Curves.fastEaseInToSlowEaseOut, + // duration: const Duration(milliseconds: 250), + // reverseDuration: const Duration(milliseconds: 250), + menuScreenTapClose: true, + mainScreenTapClose: true, + mainScreenScale: .25, + slideWidth: min(360, MediaQuery.of(context).size.width * 0.9), + ))); + } + + final _zoomDrawerController = ZoomDrawerController(); +} diff --git a/lib/layout/home/home_shell.dart b/lib/layout/home/home_shell.dart deleted file mode 100644 index 8851730..0000000 --- a/lib/layout/home/home_shell.dart +++ /dev/null @@ -1,74 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:provider/provider.dart'; - -import '../../account_manager/account_manager.dart'; -import '../../theme/theme.dart'; -import 'home_account_invalid.dart'; -import 'home_account_locked.dart'; -import 'home_account_missing.dart'; -import 'home_no_active.dart'; - -class HomeShell extends StatefulWidget { - const HomeShell({required this.accountReadyBuilder, super.key}); - - @override - HomeShellState createState() => HomeShellState(); - - final Builder accountReadyBuilder; -} - -class HomeShellState extends State { - @override - void initState() { - super.initState(); - } - - @override - void dispose() { - super.dispose(); - } - - Widget buildWithLogin(BuildContext context) { - final activeLocalAccount = context.watch().state; - - if (activeLocalAccount == null) { - // If no logged in user is active, show the loading panel - return const HomeNoActive(); - } - - final accountInfo = - AccountRepository.instance.getAccountInfo(activeLocalAccount); - - switch (accountInfo.status) { - case AccountInfoStatus.noAccount: - return const HomeAccountMissing(); - case AccountInfoStatus.accountInvalid: - return const HomeAccountInvalid(); - case AccountInfoStatus.accountLocked: - return const HomeAccountLocked(); - case AccountInfoStatus.accountReady: - return Provider.value( - value: accountInfo.activeAccountInfo!, - child: BlocProvider( - create: (context) => AccountRecordCubit( - open: () async => AccountRepository.instance - .openAccountRecord( - accountInfo.activeAccountInfo!.userLogin)), - child: widget.accountReadyBuilder)); - } - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final scale = theme.extension()!; - - // XXX: eventually write account switcher here - return SafeArea( - child: DecoratedBox( - decoration: BoxDecoration( - color: scale.primaryScale.activeElementBackground), - child: buildWithLogin(context))); - } -} diff --git a/lib/main.dart b/lib/main.dart index d8bd6df..4edaa5b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -6,6 +6,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_translate/flutter_translate.dart'; import 'package:intl/date_symbol_data_local.dart'; + import 'package:stack_trace/stack_trace.dart'; import 'app.dart'; @@ -45,6 +46,9 @@ void main() async { fallbackLocale: 'en_US', supportedLocales: ['en_US']); await initializeDateFormatting(); + // Get package info + await initPackageInfo(); + // Run the app // Hot reloads will only restart this part, not Veilid runApp(LocalizedApp(localizationDelegate, diff --git a/lib/proto/extensions.dart b/lib/proto/extensions.dart index 25b8558..4491f89 100644 --- a/lib/proto/extensions.dart +++ b/lib/proto/extensions.dart @@ -29,3 +29,21 @@ extension MessageExt on proto.Message { static int compareTimestamp(proto.Message a, proto.Message b) => a.timestamp.compareTo(b.timestamp); } + +extension ContactExt on proto.Contact { + String get displayName => + nickname.isNotEmpty ? '$nickname (${profile.name})' : profile.name; +} + +extension ChatExt on proto.Chat { + TypedKey get localConversationRecordKey { + switch (whichKind()) { + case proto.Chat_Kind.direct: + return direct.localConversationRecordKey.toVeilid(); + case proto.Chat_Kind.group: + return group.localConversationRecordKey.toVeilid(); + case proto.Chat_Kind.notSet: + throw StateError('unknown chat kind'); + } + } +} diff --git a/lib/proto/veilidchat.pb.dart b/lib/proto/veilidchat.pb.dart index 1e0395b..63bd910 100644 --- a/lib/proto/veilidchat.pb.dart +++ b/lib/proto/veilidchat.pb.dart @@ -20,6 +20,177 @@ import 'veilidchat.pbenum.dart'; export 'veilidchat.pbenum.dart'; +class DHTDataReference extends $pb.GeneratedMessage { + factory DHTDataReference() => create(); + DHTDataReference._() : super(); + factory DHTDataReference.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory DHTDataReference.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'DHTDataReference', package: const $pb.PackageName(_omitMessageNames ? '' : 'veilidchat'), createEmptyInstance: create) + ..aOM<$0.TypedKey>(1, _omitFieldNames ? '' : 'dhtData', subBuilder: $0.TypedKey.create) + ..aOM<$0.TypedKey>(2, _omitFieldNames ? '' : 'hash', subBuilder: $0.TypedKey.create) + ..hasRequiredFields = false + ; + + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + DHTDataReference clone() => DHTDataReference()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + DHTDataReference copyWith(void Function(DHTDataReference) updates) => super.copyWith((message) => updates(message as DHTDataReference)) as DHTDataReference; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static DHTDataReference create() => DHTDataReference._(); + DHTDataReference createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static DHTDataReference getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static DHTDataReference? _defaultInstance; + + @$pb.TagNumber(1) + $0.TypedKey get dhtData => $_getN(0); + @$pb.TagNumber(1) + set dhtData($0.TypedKey v) { setField(1, v); } + @$pb.TagNumber(1) + $core.bool hasDhtData() => $_has(0); + @$pb.TagNumber(1) + void clearDhtData() => clearField(1); + @$pb.TagNumber(1) + $0.TypedKey ensureDhtData() => $_ensure(0); + + @$pb.TagNumber(2) + $0.TypedKey get hash => $_getN(1); + @$pb.TagNumber(2) + set hash($0.TypedKey v) { setField(2, v); } + @$pb.TagNumber(2) + $core.bool hasHash() => $_has(1); + @$pb.TagNumber(2) + void clearHash() => clearField(2); + @$pb.TagNumber(2) + $0.TypedKey ensureHash() => $_ensure(1); +} + +class BlockStoreDataReference extends $pb.GeneratedMessage { + factory BlockStoreDataReference() => create(); + BlockStoreDataReference._() : super(); + factory BlockStoreDataReference.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory BlockStoreDataReference.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'BlockStoreDataReference', package: const $pb.PackageName(_omitMessageNames ? '' : 'veilidchat'), createEmptyInstance: create) + ..aOM<$0.TypedKey>(1, _omitFieldNames ? '' : 'block', subBuilder: $0.TypedKey.create) + ..hasRequiredFields = false + ; + + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + BlockStoreDataReference clone() => BlockStoreDataReference()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + BlockStoreDataReference copyWith(void Function(BlockStoreDataReference) updates) => super.copyWith((message) => updates(message as BlockStoreDataReference)) as BlockStoreDataReference; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static BlockStoreDataReference create() => BlockStoreDataReference._(); + BlockStoreDataReference createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static BlockStoreDataReference getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static BlockStoreDataReference? _defaultInstance; + + @$pb.TagNumber(1) + $0.TypedKey get block => $_getN(0); + @$pb.TagNumber(1) + set block($0.TypedKey v) { setField(1, v); } + @$pb.TagNumber(1) + $core.bool hasBlock() => $_has(0); + @$pb.TagNumber(1) + void clearBlock() => clearField(1); + @$pb.TagNumber(1) + $0.TypedKey ensureBlock() => $_ensure(0); +} + +enum DataReference_Kind { + dhtData, + blockStoreData, + notSet +} + +class DataReference extends $pb.GeneratedMessage { + factory DataReference() => create(); + DataReference._() : super(); + factory DataReference.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory DataReference.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static const $core.Map<$core.int, DataReference_Kind> _DataReference_KindByTag = { + 1 : DataReference_Kind.dhtData, + 2 : DataReference_Kind.blockStoreData, + 0 : DataReference_Kind.notSet + }; + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'DataReference', package: const $pb.PackageName(_omitMessageNames ? '' : 'veilidchat'), createEmptyInstance: create) + ..oo(0, [1, 2]) + ..aOM(1, _omitFieldNames ? '' : 'dhtData', subBuilder: DHTDataReference.create) + ..aOM(2, _omitFieldNames ? '' : 'blockStoreData', subBuilder: BlockStoreDataReference.create) + ..hasRequiredFields = false + ; + + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + DataReference clone() => DataReference()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + DataReference copyWith(void Function(DataReference) updates) => super.copyWith((message) => updates(message as DataReference)) as DataReference; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static DataReference create() => DataReference._(); + DataReference createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static DataReference getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static DataReference? _defaultInstance; + + DataReference_Kind whichKind() => _DataReference_KindByTag[$_whichOneof(0)]!; + void clearKind() => clearField($_whichOneof(0)); + + @$pb.TagNumber(1) + DHTDataReference get dhtData => $_getN(0); + @$pb.TagNumber(1) + set dhtData(DHTDataReference v) { setField(1, v); } + @$pb.TagNumber(1) + $core.bool hasDhtData() => $_has(0); + @$pb.TagNumber(1) + void clearDhtData() => clearField(1); + @$pb.TagNumber(1) + DHTDataReference ensureDhtData() => $_ensure(0); + + @$pb.TagNumber(2) + BlockStoreDataReference get blockStoreData => $_getN(1); + @$pb.TagNumber(2) + set blockStoreData(BlockStoreDataReference v) { setField(2, v); } + @$pb.TagNumber(2) + $core.bool hasBlockStoreData() => $_has(1); + @$pb.TagNumber(2) + void clearBlockStoreData() => clearField(2); + @$pb.TagNumber(2) + BlockStoreDataReference ensureBlockStoreData() => $_ensure(1); +} + enum Attachment_Kind { media, notSet @@ -98,7 +269,7 @@ class AttachmentMedia extends $pb.GeneratedMessage { static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'AttachmentMedia', package: const $pb.PackageName(_omitMessageNames ? '' : 'veilidchat'), createEmptyInstance: create) ..aOS(1, _omitFieldNames ? '' : 'mime') ..aOS(2, _omitFieldNames ? '' : 'name') - ..aOM<$1.DataReference>(3, _omitFieldNames ? '' : 'content', subBuilder: $1.DataReference.create) + ..aOM(3, _omitFieldNames ? '' : 'content', subBuilder: DataReference.create) ..hasRequiredFields = false ; @@ -142,15 +313,15 @@ class AttachmentMedia extends $pb.GeneratedMessage { void clearName() => clearField(2); @$pb.TagNumber(3) - $1.DataReference get content => $_getN(2); + DataReference get content => $_getN(2); @$pb.TagNumber(3) - set content($1.DataReference v) { setField(3, v); } + set content(DataReference v) { setField(3, v); } @$pb.TagNumber(3) $core.bool hasContent() => $_has(2); @$pb.TagNumber(3) void clearContent() => clearField(3); @$pb.TagNumber(3) - $1.DataReference ensureContent() => $_ensure(2); + DataReference ensureContent() => $_ensure(2); } class Permissions extends $pb.GeneratedMessage { @@ -276,7 +447,7 @@ class ChatSettings extends $pb.GeneratedMessage { static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'ChatSettings', package: const $pb.PackageName(_omitMessageNames ? '' : 'veilidchat'), createEmptyInstance: create) ..aOS(1, _omitFieldNames ? '' : 'title') ..aOS(2, _omitFieldNames ? '' : 'description') - ..aOM<$1.DataReference>(3, _omitFieldNames ? '' : 'icon', subBuilder: $1.DataReference.create) + ..aOM(3, _omitFieldNames ? '' : 'icon', subBuilder: DataReference.create) ..a<$fixnum.Int64>(4, _omitFieldNames ? '' : 'defaultExpiration', $pb.PbFieldType.OU6, defaultOrMaker: $fixnum.Int64.ZERO) ..hasRequiredFields = false ; @@ -321,15 +492,15 @@ class ChatSettings extends $pb.GeneratedMessage { void clearDescription() => clearField(2); @$pb.TagNumber(3) - $1.DataReference get icon => $_getN(2); + DataReference get icon => $_getN(2); @$pb.TagNumber(3) - set icon($1.DataReference v) { setField(3, v); } + set icon(DataReference v) { setField(3, v); } @$pb.TagNumber(3) $core.bool hasIcon() => $_has(2); @$pb.TagNumber(3) void clearIcon() => clearField(3); @$pb.TagNumber(3) - $1.DataReference ensureIcon() => $_ensure(2); + DataReference ensureIcon() => $_ensure(2); @$pb.TagNumber(4) $fixnum.Int64 get defaultExpiration => $_getI64(3); @@ -1084,16 +1255,15 @@ class Conversation extends $pb.GeneratedMessage { $0.TypedKey ensureMessages() => $_ensure(2); } -class Chat extends $pb.GeneratedMessage { - factory Chat() => create(); - Chat._() : super(); - factory Chat.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); - factory Chat.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); +class ChatMember extends $pb.GeneratedMessage { + factory ChatMember() => create(); + ChatMember._() : super(); + factory ChatMember.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory ChatMember.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); - static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'Chat', package: const $pb.PackageName(_omitMessageNames ? '' : 'veilidchat'), createEmptyInstance: create) - ..aOM(1, _omitFieldNames ? '' : 'settings', subBuilder: ChatSettings.create) - ..aOM<$0.TypedKey>(2, _omitFieldNames ? '' : 'localConversationRecordKey', subBuilder: $0.TypedKey.create) - ..aOM<$0.TypedKey>(3, _omitFieldNames ? '' : 'remoteConversationRecordKey', subBuilder: $0.TypedKey.create) + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'ChatMember', package: const $pb.PackageName(_omitMessageNames ? '' : 'veilidchat'), createEmptyInstance: create) + ..aOM<$0.TypedKey>(1, _omitFieldNames ? '' : 'remoteIdentityPublicKey', subBuilder: $0.TypedKey.create) + ..aOM<$0.TypedKey>(2, _omitFieldNames ? '' : 'remoteConversationRecordKey', subBuilder: $0.TypedKey.create) ..hasRequiredFields = false ; @@ -1101,22 +1271,79 @@ class Chat extends $pb.GeneratedMessage { 'Using this can add significant overhead to your binary. ' 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' 'Will be removed in next major version') - Chat clone() => Chat()..mergeFromMessage(this); + ChatMember clone() => ChatMember()..mergeFromMessage(this); @$core.Deprecated( 'Using this can add significant overhead to your binary. ' 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' 'Will be removed in next major version') - Chat copyWith(void Function(Chat) updates) => super.copyWith((message) => updates(message as Chat)) as Chat; + ChatMember copyWith(void Function(ChatMember) updates) => super.copyWith((message) => updates(message as ChatMember)) as ChatMember; $pb.BuilderInfo get info_ => _i; @$core.pragma('dart2js:noInline') - static Chat create() => Chat._(); - Chat createEmptyInstance() => create(); - static $pb.PbList createRepeated() => $pb.PbList(); + static ChatMember create() => ChatMember._(); + ChatMember createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); @$core.pragma('dart2js:noInline') - static Chat getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); - static Chat? _defaultInstance; + static ChatMember getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static ChatMember? _defaultInstance; + + @$pb.TagNumber(1) + $0.TypedKey get remoteIdentityPublicKey => $_getN(0); + @$pb.TagNumber(1) + set remoteIdentityPublicKey($0.TypedKey v) { setField(1, v); } + @$pb.TagNumber(1) + $core.bool hasRemoteIdentityPublicKey() => $_has(0); + @$pb.TagNumber(1) + void clearRemoteIdentityPublicKey() => clearField(1); + @$pb.TagNumber(1) + $0.TypedKey ensureRemoteIdentityPublicKey() => $_ensure(0); + + @$pb.TagNumber(2) + $0.TypedKey get remoteConversationRecordKey => $_getN(1); + @$pb.TagNumber(2) + set remoteConversationRecordKey($0.TypedKey v) { setField(2, v); } + @$pb.TagNumber(2) + $core.bool hasRemoteConversationRecordKey() => $_has(1); + @$pb.TagNumber(2) + void clearRemoteConversationRecordKey() => clearField(2); + @$pb.TagNumber(2) + $0.TypedKey ensureRemoteConversationRecordKey() => $_ensure(1); +} + +class DirectChat extends $pb.GeneratedMessage { + factory DirectChat() => create(); + DirectChat._() : super(); + factory DirectChat.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory DirectChat.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'DirectChat', package: const $pb.PackageName(_omitMessageNames ? '' : 'veilidchat'), createEmptyInstance: create) + ..aOM(1, _omitFieldNames ? '' : 'settings', subBuilder: ChatSettings.create) + ..aOM<$0.TypedKey>(2, _omitFieldNames ? '' : 'localConversationRecordKey', subBuilder: $0.TypedKey.create) + ..aOM(3, _omitFieldNames ? '' : 'remoteMember', subBuilder: ChatMember.create) + ..hasRequiredFields = false + ; + + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + DirectChat clone() => DirectChat()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + DirectChat copyWith(void Function(DirectChat) updates) => super.copyWith((message) => updates(message as DirectChat)) as DirectChat; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static DirectChat create() => DirectChat._(); + DirectChat createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static DirectChat getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static DirectChat? _defaultInstance; @$pb.TagNumber(1) ChatSettings get settings => $_getN(0); @@ -1141,15 +1368,15 @@ class Chat extends $pb.GeneratedMessage { $0.TypedKey ensureLocalConversationRecordKey() => $_ensure(1); @$pb.TagNumber(3) - $0.TypedKey get remoteConversationRecordKey => $_getN(2); + ChatMember get remoteMember => $_getN(2); @$pb.TagNumber(3) - set remoteConversationRecordKey($0.TypedKey v) { setField(3, v); } + set remoteMember(ChatMember v) { setField(3, v); } @$pb.TagNumber(3) - $core.bool hasRemoteConversationRecordKey() => $_has(2); + $core.bool hasRemoteMember() => $_has(2); @$pb.TagNumber(3) - void clearRemoteConversationRecordKey() => clearField(3); + void clearRemoteMember() => clearField(3); @$pb.TagNumber(3) - $0.TypedKey ensureRemoteConversationRecordKey() => $_ensure(2); + ChatMember ensureRemoteMember() => $_ensure(2); } class GroupChat extends $pb.GeneratedMessage { @@ -1160,8 +1387,10 @@ class GroupChat extends $pb.GeneratedMessage { static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'GroupChat', package: const $pb.PackageName(_omitMessageNames ? '' : 'veilidchat'), createEmptyInstance: create) ..aOM(1, _omitFieldNames ? '' : 'settings', subBuilder: ChatSettings.create) - ..aOM<$0.TypedKey>(2, _omitFieldNames ? '' : 'localConversationRecordKey', subBuilder: $0.TypedKey.create) - ..pc<$0.TypedKey>(3, _omitFieldNames ? '' : 'remoteConversationRecordKeys', $pb.PbFieldType.PM, subBuilder: $0.TypedKey.create) + ..aOM(2, _omitFieldNames ? '' : 'membership', subBuilder: Membership.create) + ..aOM(3, _omitFieldNames ? '' : 'permissions', subBuilder: Permissions.create) + ..aOM<$0.TypedKey>(4, _omitFieldNames ? '' : 'localConversationRecordKey', subBuilder: $0.TypedKey.create) + ..pc(5, _omitFieldNames ? '' : 'remoteMembers', $pb.PbFieldType.PM, subBuilder: ChatMember.create) ..hasRequiredFields = false ; @@ -1198,18 +1427,111 @@ class GroupChat extends $pb.GeneratedMessage { ChatSettings ensureSettings() => $_ensure(0); @$pb.TagNumber(2) - $0.TypedKey get localConversationRecordKey => $_getN(1); + Membership get membership => $_getN(1); @$pb.TagNumber(2) - set localConversationRecordKey($0.TypedKey v) { setField(2, v); } + set membership(Membership v) { setField(2, v); } @$pb.TagNumber(2) - $core.bool hasLocalConversationRecordKey() => $_has(1); + $core.bool hasMembership() => $_has(1); @$pb.TagNumber(2) - void clearLocalConversationRecordKey() => clearField(2); + void clearMembership() => clearField(2); @$pb.TagNumber(2) - $0.TypedKey ensureLocalConversationRecordKey() => $_ensure(1); + Membership ensureMembership() => $_ensure(1); @$pb.TagNumber(3) - $core.List<$0.TypedKey> get remoteConversationRecordKeys => $_getList(2); + Permissions get permissions => $_getN(2); + @$pb.TagNumber(3) + set permissions(Permissions v) { setField(3, v); } + @$pb.TagNumber(3) + $core.bool hasPermissions() => $_has(2); + @$pb.TagNumber(3) + void clearPermissions() => clearField(3); + @$pb.TagNumber(3) + Permissions ensurePermissions() => $_ensure(2); + + @$pb.TagNumber(4) + $0.TypedKey get localConversationRecordKey => $_getN(3); + @$pb.TagNumber(4) + set localConversationRecordKey($0.TypedKey v) { setField(4, v); } + @$pb.TagNumber(4) + $core.bool hasLocalConversationRecordKey() => $_has(3); + @$pb.TagNumber(4) + void clearLocalConversationRecordKey() => clearField(4); + @$pb.TagNumber(4) + $0.TypedKey ensureLocalConversationRecordKey() => $_ensure(3); + + @$pb.TagNumber(5) + $core.List get remoteMembers => $_getList(4); +} + +enum Chat_Kind { + direct, + group, + notSet +} + +class Chat extends $pb.GeneratedMessage { + factory Chat() => create(); + Chat._() : super(); + factory Chat.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory Chat.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static const $core.Map<$core.int, Chat_Kind> _Chat_KindByTag = { + 1 : Chat_Kind.direct, + 2 : Chat_Kind.group, + 0 : Chat_Kind.notSet + }; + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'Chat', package: const $pb.PackageName(_omitMessageNames ? '' : 'veilidchat'), createEmptyInstance: create) + ..oo(0, [1, 2]) + ..aOM(1, _omitFieldNames ? '' : 'direct', subBuilder: DirectChat.create) + ..aOM(2, _omitFieldNames ? '' : 'group', subBuilder: GroupChat.create) + ..hasRequiredFields = false + ; + + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + Chat clone() => Chat()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + Chat copyWith(void Function(Chat) updates) => super.copyWith((message) => updates(message as Chat)) as Chat; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static Chat create() => Chat._(); + Chat createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static Chat getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static Chat? _defaultInstance; + + Chat_Kind whichKind() => _Chat_KindByTag[$_whichOneof(0)]!; + void clearKind() => clearField($_whichOneof(0)); + + @$pb.TagNumber(1) + DirectChat get direct => $_getN(0); + @$pb.TagNumber(1) + set direct(DirectChat v) { setField(1, v); } + @$pb.TagNumber(1) + $core.bool hasDirect() => $_has(0); + @$pb.TagNumber(1) + void clearDirect() => clearField(1); + @$pb.TagNumber(1) + DirectChat ensureDirect() => $_ensure(0); + + @$pb.TagNumber(2) + GroupChat get group => $_getN(1); + @$pb.TagNumber(2) + set group(GroupChat v) { setField(2, v); } + @$pb.TagNumber(2) + $core.bool hasGroup() => $_has(1); + @$pb.TagNumber(2) + void clearGroup() => clearField(2); + @$pb.TagNumber(2) + GroupChat ensureGroup() => $_ensure(1); } class Profile extends $pb.GeneratedMessage { @@ -1224,7 +1546,8 @@ class Profile extends $pb.GeneratedMessage { ..aOS(3, _omitFieldNames ? '' : 'about') ..aOS(4, _omitFieldNames ? '' : 'status') ..e(5, _omitFieldNames ? '' : 'availability', $pb.PbFieldType.OE, defaultOrMaker: Availability.AVAILABILITY_UNSPECIFIED, valueOf: Availability.valueOf, enumValues: Availability.values) - ..aOM<$0.TypedKey>(6, _omitFieldNames ? '' : 'avatar', subBuilder: $0.TypedKey.create) + ..aOM(6, _omitFieldNames ? '' : 'avatar', subBuilder: DataReference.create) + ..a<$fixnum.Int64>(7, _omitFieldNames ? '' : 'timestamp', $pb.PbFieldType.OU6, defaultOrMaker: $fixnum.Int64.ZERO) ..hasRequiredFields = false ; @@ -1295,15 +1618,24 @@ class Profile extends $pb.GeneratedMessage { void clearAvailability() => clearField(5); @$pb.TagNumber(6) - $0.TypedKey get avatar => $_getN(5); + DataReference get avatar => $_getN(5); @$pb.TagNumber(6) - set avatar($0.TypedKey v) { setField(6, v); } + set avatar(DataReference v) { setField(6, v); } @$pb.TagNumber(6) $core.bool hasAvatar() => $_has(5); @$pb.TagNumber(6) void clearAvatar() => clearField(6); @$pb.TagNumber(6) - $0.TypedKey ensureAvatar() => $_ensure(5); + DataReference ensureAvatar() => $_ensure(5); + + @$pb.TagNumber(7) + $fixnum.Int64 get timestamp => $_getI64(6); + @$pb.TagNumber(7) + set timestamp($fixnum.Int64 v) { $_setInt64(6, v); } + @$pb.TagNumber(7) + $core.bool hasTimestamp() => $_has(6); + @$pb.TagNumber(7) + void clearTimestamp() => clearField(7); } class Account extends $pb.GeneratedMessage { @@ -1425,13 +1757,14 @@ class Contact extends $pb.GeneratedMessage { factory Contact.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'Contact', package: const $pb.PackageName(_omitMessageNames ? '' : 'veilidchat'), createEmptyInstance: create) - ..aOM(1, _omitFieldNames ? '' : 'editedProfile', subBuilder: Profile.create) - ..aOM(2, _omitFieldNames ? '' : 'remoteProfile', subBuilder: Profile.create) + ..aOS(1, _omitFieldNames ? '' : 'nickname') + ..aOM(2, _omitFieldNames ? '' : 'profile', subBuilder: Profile.create) ..aOS(3, _omitFieldNames ? '' : 'superIdentityJson') ..aOM<$0.TypedKey>(4, _omitFieldNames ? '' : 'identityPublicKey', subBuilder: $0.TypedKey.create) ..aOM<$0.TypedKey>(5, _omitFieldNames ? '' : 'remoteConversationRecordKey', subBuilder: $0.TypedKey.create) ..aOM<$0.TypedKey>(6, _omitFieldNames ? '' : 'localConversationRecordKey', subBuilder: $0.TypedKey.create) ..aOB(7, _omitFieldNames ? '' : 'showAvailability') + ..aOS(8, _omitFieldNames ? '' : 'notes') ..hasRequiredFields = false ; @@ -1457,26 +1790,24 @@ class Contact extends $pb.GeneratedMessage { static Contact? _defaultInstance; @$pb.TagNumber(1) - Profile get editedProfile => $_getN(0); + $core.String get nickname => $_getSZ(0); @$pb.TagNumber(1) - set editedProfile(Profile v) { setField(1, v); } + set nickname($core.String v) { $_setString(0, v); } @$pb.TagNumber(1) - $core.bool hasEditedProfile() => $_has(0); + $core.bool hasNickname() => $_has(0); @$pb.TagNumber(1) - void clearEditedProfile() => clearField(1); - @$pb.TagNumber(1) - Profile ensureEditedProfile() => $_ensure(0); + void clearNickname() => clearField(1); @$pb.TagNumber(2) - Profile get remoteProfile => $_getN(1); + Profile get profile => $_getN(1); @$pb.TagNumber(2) - set remoteProfile(Profile v) { setField(2, v); } + set profile(Profile v) { setField(2, v); } @$pb.TagNumber(2) - $core.bool hasRemoteProfile() => $_has(1); + $core.bool hasProfile() => $_has(1); @$pb.TagNumber(2) - void clearRemoteProfile() => clearField(2); + void clearProfile() => clearField(2); @$pb.TagNumber(2) - Profile ensureRemoteProfile() => $_ensure(1); + Profile ensureProfile() => $_ensure(1); @$pb.TagNumber(3) $core.String get superIdentityJson => $_getSZ(2); @@ -1528,6 +1859,15 @@ class Contact extends $pb.GeneratedMessage { $core.bool hasShowAvailability() => $_has(6); @$pb.TagNumber(7) void clearShowAvailability() => clearField(7); + + @$pb.TagNumber(8) + $core.String get notes => $_getSZ(7); + @$pb.TagNumber(8) + set notes($core.String v) { $_setString(7, v); } + @$pb.TagNumber(8) + $core.bool hasNotes() => $_has(7); + @$pb.TagNumber(8) + void clearNotes() => clearField(8); } class ContactInvitation extends $pb.GeneratedMessage { diff --git a/lib/proto/veilidchat.pbjson.dart b/lib/proto/veilidchat.pbjson.dart index ed0bda4..fe6cac3 100644 --- a/lib/proto/veilidchat.pbjson.dart +++ b/lib/proto/veilidchat.pbjson.dart @@ -65,6 +65,51 @@ final $typed_data.Uint8List scopeDescriptor = $convert.base64Decode( 'CgVTY29wZRIMCghXQVRDSEVSUxAAEg0KCU1PREVSQVRFRBABEgsKB1RBTEtFUlMQAhIOCgpNT0' 'RFUkFUT1JTEAMSCgoGQURNSU5TEAQ='); +@$core.Deprecated('Use dHTDataReferenceDescriptor instead') +const DHTDataReference$json = { + '1': 'DHTDataReference', + '2': [ + {'1': 'dht_data', '3': 1, '4': 1, '5': 11, '6': '.veilid.TypedKey', '10': 'dhtData'}, + {'1': 'hash', '3': 2, '4': 1, '5': 11, '6': '.veilid.TypedKey', '10': 'hash'}, + ], +}; + +/// Descriptor for `DHTDataReference`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List dHTDataReferenceDescriptor = $convert.base64Decode( + 'ChBESFREYXRhUmVmZXJlbmNlEisKCGRodF9kYXRhGAEgASgLMhAudmVpbGlkLlR5cGVkS2V5Ug' + 'dkaHREYXRhEiQKBGhhc2gYAiABKAsyEC52ZWlsaWQuVHlwZWRLZXlSBGhhc2g='); + +@$core.Deprecated('Use blockStoreDataReferenceDescriptor instead') +const BlockStoreDataReference$json = { + '1': 'BlockStoreDataReference', + '2': [ + {'1': 'block', '3': 1, '4': 1, '5': 11, '6': '.veilid.TypedKey', '10': 'block'}, + ], +}; + +/// Descriptor for `BlockStoreDataReference`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List blockStoreDataReferenceDescriptor = $convert.base64Decode( + 'ChdCbG9ja1N0b3JlRGF0YVJlZmVyZW5jZRImCgVibG9jaxgBIAEoCzIQLnZlaWxpZC5UeXBlZE' + 'tleVIFYmxvY2s='); + +@$core.Deprecated('Use dataReferenceDescriptor instead') +const DataReference$json = { + '1': 'DataReference', + '2': [ + {'1': 'dht_data', '3': 1, '4': 1, '5': 11, '6': '.veilidchat.DHTDataReference', '9': 0, '10': 'dhtData'}, + {'1': 'block_store_data', '3': 2, '4': 1, '5': 11, '6': '.veilidchat.BlockStoreDataReference', '9': 0, '10': 'blockStoreData'}, + ], + '8': [ + {'1': 'kind'}, + ], +}; + +/// Descriptor for `DataReference`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List dataReferenceDescriptor = $convert.base64Decode( + 'Cg1EYXRhUmVmZXJlbmNlEjkKCGRodF9kYXRhGAEgASgLMhwudmVpbGlkY2hhdC5ESFREYXRhUm' + 'VmZXJlbmNlSABSB2RodERhdGESTwoQYmxvY2tfc3RvcmVfZGF0YRgCIAEoCzIjLnZlaWxpZGNo' + 'YXQuQmxvY2tTdG9yZURhdGFSZWZlcmVuY2VIAFIOYmxvY2tTdG9yZURhdGFCBgoEa2luZA=='); + @$core.Deprecated('Use attachmentDescriptor instead') const Attachment$json = { '1': 'Attachment', @@ -89,14 +134,14 @@ const AttachmentMedia$json = { '2': [ {'1': 'mime', '3': 1, '4': 1, '5': 9, '10': 'mime'}, {'1': 'name', '3': 2, '4': 1, '5': 9, '10': 'name'}, - {'1': 'content', '3': 3, '4': 1, '5': 11, '6': '.dht.DataReference', '10': 'content'}, + {'1': 'content', '3': 3, '4': 1, '5': 11, '6': '.veilidchat.DataReference', '10': 'content'}, ], }; /// Descriptor for `AttachmentMedia`. Decode as a `google.protobuf.DescriptorProto`. final $typed_data.Uint8List attachmentMediaDescriptor = $convert.base64Decode( 'Cg9BdHRhY2htZW50TWVkaWESEgoEbWltZRgBIAEoCVIEbWltZRISCgRuYW1lGAIgASgJUgRuYW' - '1lEiwKB2NvbnRlbnQYAyABKAsyEi5kaHQuRGF0YVJlZmVyZW5jZVIHY29udGVudA=='); + '1lEjMKB2NvbnRlbnQYAyABKAsyGS52ZWlsaWRjaGF0LkRhdGFSZWZlcmVuY2VSB2NvbnRlbnQ='); @$core.Deprecated('Use permissionsDescriptor instead') const Permissions$json = { @@ -140,7 +185,7 @@ const ChatSettings$json = { '2': [ {'1': 'title', '3': 1, '4': 1, '5': 9, '10': 'title'}, {'1': 'description', '3': 2, '4': 1, '5': 9, '10': 'description'}, - {'1': 'icon', '3': 3, '4': 1, '5': 11, '6': '.dht.DataReference', '9': 0, '10': 'icon', '17': true}, + {'1': 'icon', '3': 3, '4': 1, '5': 11, '6': '.veilidchat.DataReference', '9': 0, '10': 'icon', '17': true}, {'1': 'default_expiration', '3': 4, '4': 1, '5': 4, '10': 'defaultExpiration'}, ], '8': [ @@ -151,9 +196,9 @@ const ChatSettings$json = { /// Descriptor for `ChatSettings`. Decode as a `google.protobuf.DescriptorProto`. final $typed_data.Uint8List chatSettingsDescriptor = $convert.base64Decode( 'CgxDaGF0U2V0dGluZ3MSFAoFdGl0bGUYASABKAlSBXRpdGxlEiAKC2Rlc2NyaXB0aW9uGAIgAS' - 'gJUgtkZXNjcmlwdGlvbhIrCgRpY29uGAMgASgLMhIuZGh0LkRhdGFSZWZlcmVuY2VIAFIEaWNv' - 'bogBARItChJkZWZhdWx0X2V4cGlyYXRpb24YBCABKARSEWRlZmF1bHRFeHBpcmF0aW9uQgcKBV' - '9pY29u'); + 'gJUgtkZXNjcmlwdGlvbhIyCgRpY29uGAMgASgLMhkudmVpbGlkY2hhdC5EYXRhUmVmZXJlbmNl' + 'SABSBGljb26IAQESLQoSZGVmYXVsdF9leHBpcmF0aW9uGAQgASgEUhFkZWZhdWx0RXhwaXJhdG' + 'lvbkIHCgVfaWNvbg=='); @$core.Deprecated('Use messageDescriptor instead') const Message$json = { @@ -320,41 +365,76 @@ final $typed_data.Uint8List conversationDescriptor = $convert.base64Decode( 'JvZmlsZRIuChNzdXBlcl9pZGVudGl0eV9qc29uGAIgASgJUhFzdXBlcklkZW50aXR5SnNvbhIs' 'CghtZXNzYWdlcxgDIAEoCzIQLnZlaWxpZC5UeXBlZEtleVIIbWVzc2FnZXM='); -@$core.Deprecated('Use chatDescriptor instead') -const Chat$json = { - '1': 'Chat', +@$core.Deprecated('Use chatMemberDescriptor instead') +const ChatMember$json = { + '1': 'ChatMember', '2': [ - {'1': 'settings', '3': 1, '4': 1, '5': 11, '6': '.veilidchat.ChatSettings', '10': 'settings'}, - {'1': 'local_conversation_record_key', '3': 2, '4': 1, '5': 11, '6': '.veilid.TypedKey', '10': 'localConversationRecordKey'}, - {'1': 'remote_conversation_record_key', '3': 3, '4': 1, '5': 11, '6': '.veilid.TypedKey', '10': 'remoteConversationRecordKey'}, + {'1': 'remote_identity_public_key', '3': 1, '4': 1, '5': 11, '6': '.veilid.TypedKey', '10': 'remoteIdentityPublicKey'}, + {'1': 'remote_conversation_record_key', '3': 2, '4': 1, '5': 11, '6': '.veilid.TypedKey', '10': 'remoteConversationRecordKey'}, ], }; -/// Descriptor for `Chat`. Decode as a `google.protobuf.DescriptorProto`. -final $typed_data.Uint8List chatDescriptor = $convert.base64Decode( - 'CgRDaGF0EjQKCHNldHRpbmdzGAEgASgLMhgudmVpbGlkY2hhdC5DaGF0U2V0dGluZ3NSCHNldH' - 'RpbmdzElMKHWxvY2FsX2NvbnZlcnNhdGlvbl9yZWNvcmRfa2V5GAIgASgLMhAudmVpbGlkLlR5' - 'cGVkS2V5Uhpsb2NhbENvbnZlcnNhdGlvblJlY29yZEtleRJVCh5yZW1vdGVfY29udmVyc2F0aW' - '9uX3JlY29yZF9rZXkYAyABKAsyEC52ZWlsaWQuVHlwZWRLZXlSG3JlbW90ZUNvbnZlcnNhdGlv' - 'blJlY29yZEtleQ=='); +/// Descriptor for `ChatMember`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List chatMemberDescriptor = $convert.base64Decode( + 'CgpDaGF0TWVtYmVyEk0KGnJlbW90ZV9pZGVudGl0eV9wdWJsaWNfa2V5GAEgASgLMhAudmVpbG' + 'lkLlR5cGVkS2V5UhdyZW1vdGVJZGVudGl0eVB1YmxpY0tleRJVCh5yZW1vdGVfY29udmVyc2F0' + 'aW9uX3JlY29yZF9rZXkYAiABKAsyEC52ZWlsaWQuVHlwZWRLZXlSG3JlbW90ZUNvbnZlcnNhdG' + 'lvblJlY29yZEtleQ=='); + +@$core.Deprecated('Use directChatDescriptor instead') +const DirectChat$json = { + '1': 'DirectChat', + '2': [ + {'1': 'settings', '3': 1, '4': 1, '5': 11, '6': '.veilidchat.ChatSettings', '10': 'settings'}, + {'1': 'local_conversation_record_key', '3': 2, '4': 1, '5': 11, '6': '.veilid.TypedKey', '10': 'localConversationRecordKey'}, + {'1': 'remote_member', '3': 3, '4': 1, '5': 11, '6': '.veilidchat.ChatMember', '10': 'remoteMember'}, + ], +}; + +/// Descriptor for `DirectChat`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List directChatDescriptor = $convert.base64Decode( + 'CgpEaXJlY3RDaGF0EjQKCHNldHRpbmdzGAEgASgLMhgudmVpbGlkY2hhdC5DaGF0U2V0dGluZ3' + 'NSCHNldHRpbmdzElMKHWxvY2FsX2NvbnZlcnNhdGlvbl9yZWNvcmRfa2V5GAIgASgLMhAudmVp' + 'bGlkLlR5cGVkS2V5Uhpsb2NhbENvbnZlcnNhdGlvblJlY29yZEtleRI7Cg1yZW1vdGVfbWVtYm' + 'VyGAMgASgLMhYudmVpbGlkY2hhdC5DaGF0TWVtYmVyUgxyZW1vdGVNZW1iZXI='); @$core.Deprecated('Use groupChatDescriptor instead') const GroupChat$json = { '1': 'GroupChat', '2': [ {'1': 'settings', '3': 1, '4': 1, '5': 11, '6': '.veilidchat.ChatSettings', '10': 'settings'}, - {'1': 'local_conversation_record_key', '3': 2, '4': 1, '5': 11, '6': '.veilid.TypedKey', '10': 'localConversationRecordKey'}, - {'1': 'remote_conversation_record_keys', '3': 3, '4': 3, '5': 11, '6': '.veilid.TypedKey', '10': 'remoteConversationRecordKeys'}, + {'1': 'membership', '3': 2, '4': 1, '5': 11, '6': '.veilidchat.Membership', '10': 'membership'}, + {'1': 'permissions', '3': 3, '4': 1, '5': 11, '6': '.veilidchat.Permissions', '10': 'permissions'}, + {'1': 'local_conversation_record_key', '3': 4, '4': 1, '5': 11, '6': '.veilid.TypedKey', '10': 'localConversationRecordKey'}, + {'1': 'remote_members', '3': 5, '4': 3, '5': 11, '6': '.veilidchat.ChatMember', '10': 'remoteMembers'}, ], }; /// Descriptor for `GroupChat`. Decode as a `google.protobuf.DescriptorProto`. final $typed_data.Uint8List groupChatDescriptor = $convert.base64Decode( 'CglHcm91cENoYXQSNAoIc2V0dGluZ3MYASABKAsyGC52ZWlsaWRjaGF0LkNoYXRTZXR0aW5nc1' - 'IIc2V0dGluZ3MSUwodbG9jYWxfY29udmVyc2F0aW9uX3JlY29yZF9rZXkYAiABKAsyEC52ZWls' - 'aWQuVHlwZWRLZXlSGmxvY2FsQ29udmVyc2F0aW9uUmVjb3JkS2V5ElcKH3JlbW90ZV9jb252ZX' - 'JzYXRpb25fcmVjb3JkX2tleXMYAyADKAsyEC52ZWlsaWQuVHlwZWRLZXlSHHJlbW90ZUNvbnZl' - 'cnNhdGlvblJlY29yZEtleXM='); + 'IIc2V0dGluZ3MSNgoKbWVtYmVyc2hpcBgCIAEoCzIWLnZlaWxpZGNoYXQuTWVtYmVyc2hpcFIK' + 'bWVtYmVyc2hpcBI5CgtwZXJtaXNzaW9ucxgDIAEoCzIXLnZlaWxpZGNoYXQuUGVybWlzc2lvbn' + 'NSC3Blcm1pc3Npb25zElMKHWxvY2FsX2NvbnZlcnNhdGlvbl9yZWNvcmRfa2V5GAQgASgLMhAu' + 'dmVpbGlkLlR5cGVkS2V5Uhpsb2NhbENvbnZlcnNhdGlvblJlY29yZEtleRI9Cg5yZW1vdGVfbW' + 'VtYmVycxgFIAMoCzIWLnZlaWxpZGNoYXQuQ2hhdE1lbWJlclINcmVtb3RlTWVtYmVycw=='); + +@$core.Deprecated('Use chatDescriptor instead') +const Chat$json = { + '1': 'Chat', + '2': [ + {'1': 'direct', '3': 1, '4': 1, '5': 11, '6': '.veilidchat.DirectChat', '9': 0, '10': 'direct'}, + {'1': 'group', '3': 2, '4': 1, '5': 11, '6': '.veilidchat.GroupChat', '9': 0, '10': 'group'}, + ], + '8': [ + {'1': 'kind'}, + ], +}; + +/// Descriptor for `Chat`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List chatDescriptor = $convert.base64Decode( + 'CgRDaGF0EjAKBmRpcmVjdBgBIAEoCzIWLnZlaWxpZGNoYXQuRGlyZWN0Q2hhdEgAUgZkaXJlY3' + 'QSLQoFZ3JvdXAYAiABKAsyFS52ZWlsaWRjaGF0Lkdyb3VwQ2hhdEgAUgVncm91cEIGCgRraW5k'); @$core.Deprecated('Use profileDescriptor instead') const Profile$json = { @@ -365,7 +445,8 @@ const Profile$json = { {'1': 'about', '3': 3, '4': 1, '5': 9, '10': 'about'}, {'1': 'status', '3': 4, '4': 1, '5': 9, '10': 'status'}, {'1': 'availability', '3': 5, '4': 1, '5': 14, '6': '.veilidchat.Availability', '10': 'availability'}, - {'1': 'avatar', '3': 6, '4': 1, '5': 11, '6': '.veilid.TypedKey', '9': 0, '10': 'avatar', '17': true}, + {'1': 'avatar', '3': 6, '4': 1, '5': 11, '6': '.veilidchat.DataReference', '9': 0, '10': 'avatar', '17': true}, + {'1': 'timestamp', '3': 7, '4': 1, '5': 4, '10': 'timestamp'}, ], '8': [ {'1': '_avatar'}, @@ -376,9 +457,9 @@ const Profile$json = { final $typed_data.Uint8List profileDescriptor = $convert.base64Decode( 'CgdQcm9maWxlEhIKBG5hbWUYASABKAlSBG5hbWUSGgoIcHJvbm91bnMYAiABKAlSCHByb25vdW' '5zEhQKBWFib3V0GAMgASgJUgVhYm91dBIWCgZzdGF0dXMYBCABKAlSBnN0YXR1cxI8CgxhdmFp' - 'bGFiaWxpdHkYBSABKA4yGC52ZWlsaWRjaGF0LkF2YWlsYWJpbGl0eVIMYXZhaWxhYmlsaXR5Ei' - '0KBmF2YXRhchgGIAEoCzIQLnZlaWxpZC5UeXBlZEtleUgAUgZhdmF0YXKIAQFCCQoHX2F2YXRh' - 'cg=='); + 'bGFiaWxpdHkYBSABKA4yGC52ZWlsaWRjaGF0LkF2YWlsYWJpbGl0eVIMYXZhaWxhYmlsaXR5Ej' + 'YKBmF2YXRhchgGIAEoCzIZLnZlaWxpZGNoYXQuRGF0YVJlZmVyZW5jZUgAUgZhdmF0YXKIAQES' + 'HAoJdGltZXN0YW1wGAcgASgEUgl0aW1lc3RhbXBCCQoHX2F2YXRhcg=='); @$core.Deprecated('Use accountDescriptor instead') const Account$json = { @@ -409,27 +490,27 @@ final $typed_data.Uint8List accountDescriptor = $convert.base64Decode( const Contact$json = { '1': 'Contact', '2': [ - {'1': 'edited_profile', '3': 1, '4': 1, '5': 11, '6': '.veilidchat.Profile', '10': 'editedProfile'}, - {'1': 'remote_profile', '3': 2, '4': 1, '5': 11, '6': '.veilidchat.Profile', '10': 'remoteProfile'}, + {'1': 'nickname', '3': 1, '4': 1, '5': 9, '10': 'nickname'}, + {'1': 'profile', '3': 2, '4': 1, '5': 11, '6': '.veilidchat.Profile', '10': 'profile'}, {'1': 'super_identity_json', '3': 3, '4': 1, '5': 9, '10': 'superIdentityJson'}, {'1': 'identity_public_key', '3': 4, '4': 1, '5': 11, '6': '.veilid.TypedKey', '10': 'identityPublicKey'}, {'1': 'remote_conversation_record_key', '3': 5, '4': 1, '5': 11, '6': '.veilid.TypedKey', '10': 'remoteConversationRecordKey'}, {'1': 'local_conversation_record_key', '3': 6, '4': 1, '5': 11, '6': '.veilid.TypedKey', '10': 'localConversationRecordKey'}, {'1': 'show_availability', '3': 7, '4': 1, '5': 8, '10': 'showAvailability'}, + {'1': 'notes', '3': 8, '4': 1, '5': 9, '10': 'notes'}, ], }; /// Descriptor for `Contact`. Decode as a `google.protobuf.DescriptorProto`. final $typed_data.Uint8List contactDescriptor = $convert.base64Decode( - 'CgdDb250YWN0EjoKDmVkaXRlZF9wcm9maWxlGAEgASgLMhMudmVpbGlkY2hhdC5Qcm9maWxlUg' - '1lZGl0ZWRQcm9maWxlEjoKDnJlbW90ZV9wcm9maWxlGAIgASgLMhMudmVpbGlkY2hhdC5Qcm9m' - 'aWxlUg1yZW1vdGVQcm9maWxlEi4KE3N1cGVyX2lkZW50aXR5X2pzb24YAyABKAlSEXN1cGVySW' - 'RlbnRpdHlKc29uEkAKE2lkZW50aXR5X3B1YmxpY19rZXkYBCABKAsyEC52ZWlsaWQuVHlwZWRL' - 'ZXlSEWlkZW50aXR5UHVibGljS2V5ElUKHnJlbW90ZV9jb252ZXJzYXRpb25fcmVjb3JkX2tleR' - 'gFIAEoCzIQLnZlaWxpZC5UeXBlZEtleVIbcmVtb3RlQ29udmVyc2F0aW9uUmVjb3JkS2V5ElMK' - 'HWxvY2FsX2NvbnZlcnNhdGlvbl9yZWNvcmRfa2V5GAYgASgLMhAudmVpbGlkLlR5cGVkS2V5Uh' - 'psb2NhbENvbnZlcnNhdGlvblJlY29yZEtleRIrChFzaG93X2F2YWlsYWJpbGl0eRgHIAEoCFIQ' - 'c2hvd0F2YWlsYWJpbGl0eQ=='); + 'CgdDb250YWN0EhoKCG5pY2tuYW1lGAEgASgJUghuaWNrbmFtZRItCgdwcm9maWxlGAIgASgLMh' + 'MudmVpbGlkY2hhdC5Qcm9maWxlUgdwcm9maWxlEi4KE3N1cGVyX2lkZW50aXR5X2pzb24YAyAB' + 'KAlSEXN1cGVySWRlbnRpdHlKc29uEkAKE2lkZW50aXR5X3B1YmxpY19rZXkYBCABKAsyEC52ZW' + 'lsaWQuVHlwZWRLZXlSEWlkZW50aXR5UHVibGljS2V5ElUKHnJlbW90ZV9jb252ZXJzYXRpb25f' + 'cmVjb3JkX2tleRgFIAEoCzIQLnZlaWxpZC5UeXBlZEtleVIbcmVtb3RlQ29udmVyc2F0aW9uUm' + 'Vjb3JkS2V5ElMKHWxvY2FsX2NvbnZlcnNhdGlvbl9yZWNvcmRfa2V5GAYgASgLMhAudmVpbGlk' + 'LlR5cGVkS2V5Uhpsb2NhbENvbnZlcnNhdGlvblJlY29yZEtleRIrChFzaG93X2F2YWlsYWJpbG' + 'l0eRgHIAEoCFIQc2hvd0F2YWlsYWJpbGl0eRIUCgVub3RlcxgIIAEoCVIFbm90ZXM='); @$core.Deprecated('Use contactInvitationDescriptor instead') const ContactInvitation$json = { diff --git a/lib/proto/veilidchat.proto b/lib/proto/veilidchat.proto index dd2de0b..794cef8 100644 --- a/lib/proto/veilidchat.proto +++ b/lib/proto/veilidchat.proto @@ -47,6 +47,31 @@ enum Scope { ADMINS = 4; } +//////////////////////////////////////////////////////////////////////////////////// +// Data +//////////////////////////////////////////////////////////////////////////////////// + +// Reference to data on the DHT +message DHTDataReference { + veilid.TypedKey dht_data = 1; + veilid.TypedKey hash = 2; +} + +// Reference to data on the BlockStore +message BlockStoreDataReference { + veilid.TypedKey block = 1; +} + +// DataReference +// Pointer to data somewhere in Veilid +// Abstraction over DHTData and BlockStore +message DataReference { + oneof kind { + DHTDataReference dht_data = 1; + BlockStoreDataReference block_store_data = 2; + } +} + //////////////////////////////////////////////////////////////////////////////////// // Attachments //////////////////////////////////////////////////////////////////////////////////// @@ -67,10 +92,9 @@ message AttachmentMedia { // Title or filename string name = 2; // Pointer to the data content - dht.DataReference content = 3; + DataReference content = 3; } - //////////////////////////////////////////////////////////////////////////////////// // Chat room controls //////////////////////////////////////////////////////////////////////////////////// @@ -106,7 +130,7 @@ message ChatSettings { // Description for the chat string description = 2; // Icon for the chat - optional dht.DataReference icon = 3; + optional DataReference icon = 3; // Default message expiration duration (in us) uint64 default_expiration = 4; } @@ -243,15 +267,23 @@ message Conversation { veilid.TypedKey messages = 3; } -// Either a 1-1 conversation or a group chat +// A member of chat which may or may not be associated with a contact +message ChatMember { + // The identity public key most recently associated with the chat member + veilid.TypedKey remote_identity_public_key = 1; + // Conversation key for the other party + veilid.TypedKey remote_conversation_record_key = 2; +} + +// A 1-1 chat // Privately encrypted, this is the local user's copy of the chat -message Chat { +message DirectChat { // Settings ChatSettings settings = 1; // Conversation key for this user veilid.TypedKey local_conversation_record_key = 2; // Conversation key for the other party - veilid.TypedKey remote_conversation_record_key = 3; + ChatMember remote_member = 3; } // A group chat @@ -259,10 +291,22 @@ message Chat { message GroupChat { // Settings ChatSettings settings = 1; + // Membership + Membership membership = 2; + // Permissions + Permissions permissions = 3; // Conversation key for this user - veilid.TypedKey local_conversation_record_key = 2; + veilid.TypedKey local_conversation_record_key = 4; // Conversation keys for the other parties - repeated veilid.TypedKey remote_conversation_record_keys = 3; + repeated ChatMember remote_members = 5; +} + +// Some kind of chat +message Chat { + oneof kind { + DirectChat direct = 1; + GroupChat group = 2; + } } //////////////////////////////////////////////////////////////////////////////////// @@ -285,8 +329,10 @@ message Profile { string status = 4; // Availability Availability availability = 5; - // Avatar DHTData - optional veilid.TypedKey avatar = 6; + // Avatar + optional DataReference avatar = 6; + // Timestamp of last change + uint64 timestamp = 7; } // A record of an individual account @@ -323,10 +369,10 @@ message Account { // // Stored in ContactList DHTList message Contact { - // Friend's profile as locally edited - Profile edited_profile = 1; + // Friend's nickname + string nickname = 1; // Copy of friend's profile from remote conversation - Profile remote_profile = 2; + Profile profile = 2; // Copy of friend's SuperIdentity in JSON from remote conversation string super_identity_json = 3; // Copy of friend's most recent identity public key from their identityMaster @@ -337,6 +383,8 @@ message Contact { veilid.TypedKey local_conversation_record_key = 6; // Show availability to this contact bool show_availability = 7; + // Notes about this friend + string notes = 8; } //////////////////////////////////////////////////////////////////////////////////// diff --git a/lib/router/cubit/router_cubit.dart b/lib/router/cubit/router_cubit.dart index f30a617..4b4061e 100644 --- a/lib/router/cubit/router_cubit.dart +++ b/lib/router/cubit/router_cubit.dart @@ -7,9 +7,11 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:go_router/go_router.dart'; import 'package:stream_transform/stream_transform.dart'; +import 'package:veilid_support/veilid_support.dart'; import '../../../account_manager/account_manager.dart'; import '../../layout/layout.dart'; +import '../../proto/proto.dart' as proto; import '../../settings/settings.dart'; import '../../tools/tools.dart'; import '../../veilid_processor/views/developer.dart'; @@ -18,13 +20,12 @@ part 'router_cubit.freezed.dart'; part 'router_cubit.g.dart'; final _rootNavKey = GlobalKey(debugLabel: 'rootNavKey'); -final _homeNavKey = GlobalKey(debugLabel: 'homeNavKey'); @freezed class RouterState with _$RouterState { - const factory RouterState( - {required bool hasAnyAccount, - required bool hasActiveChat}) = _RouterState; + const factory RouterState({ + required bool hasAnyAccount, + }) = _RouterState; factory RouterState.fromJson(dynamic json) => _$RouterStateFromJson(json as Map); @@ -34,7 +35,6 @@ class RouterCubit extends Cubit { RouterCubit(AccountRepository accountRepository) : super(RouterState( hasAnyAccount: accountRepository.getLocalAccounts().isNotEmpty, - hasActiveChat: false, )) { // Subscribe to repository streams _accountRepositorySubscription = accountRepository.stream.listen((event) { @@ -50,10 +50,6 @@ class RouterCubit extends Cubit { }); } - void setHasActiveChat(bool active) { - emit(state.copyWith(hasActiveChat: active)); - } - @override Future close() async { await _accountRepositorySubscription.cancel(); @@ -62,27 +58,29 @@ class RouterCubit extends Cubit { /// Our application routes List get routes => [ - ShellRoute( - navigatorKey: _homeNavKey, - builder: (context, state, child) => HomeShell( - accountReadyBuilder: Builder( - builder: (context) => - HomeAccountReadyShell(context: context, child: child))), - routes: [ - GoRoute( - path: '/', - builder: (context, state) => const HomeAccountReadyMain(), - ), - GoRoute( - path: '/chat', - builder: (context, state) => const HomeAccountReadyChat(), - ), - ], + GoRoute( + path: '/', + builder: (context, state) => const HomeScreen(), + ), + GoRoute( + path: '/edit_account', + builder: (context, state) { + final extra = state.extra! as List; + return EditAccountPage( + superIdentityRecordKey: extra[0]! as TypedKey, + existingProfile: extra[1]! as proto.Profile, + ); + }, ), GoRoute( path: '/new_account', builder: (context, state) => const NewAccountPage(), ), + GoRoute( + path: '/new_account/recovery_key', + builder: (context, state) => + ShowRecoveryKeyPage(secretKey: state.extra! as SecretKey), + ), GoRoute( path: '/settings', builder: (context, state) => const SettingsPage(), @@ -98,37 +96,14 @@ class RouterCubit extends Cubit { // No matter where we are, if there's not switch (goRouterState.matchedLocation) { - case '/new_account': - return state.hasAnyAccount ? '/' : null; case '/': if (!state.hasAnyAccount) { return '/new_account'; } - if (responsiveVisibility( - context: context, - tablet: false, - tabletLandscape: false, - desktop: false)) { - if (state.hasActiveChat) { - return '/chat'; - } - } return null; - case '/chat': - if (!state.hasAnyAccount) { - return '/new_account'; - } - if (responsiveVisibility( - context: context, - tablet: false, - tabletLandscape: false, - desktop: false)) { - if (!state.hasActiveChat) { - return '/'; - } - } else { - return '/'; - } + case '/new_account': + return null; + case '/new_account/recovery_key': return null; case '/settings': return null; diff --git a/lib/router/cubit/router_cubit.freezed.dart b/lib/router/cubit/router_cubit.freezed.dart index c36db7d..e44cd91 100644 --- a/lib/router/cubit/router_cubit.freezed.dart +++ b/lib/router/cubit/router_cubit.freezed.dart @@ -21,7 +21,6 @@ RouterState _$RouterStateFromJson(Map json) { /// @nodoc mixin _$RouterState { bool get hasAnyAccount => throw _privateConstructorUsedError; - bool get hasActiveChat => throw _privateConstructorUsedError; Map toJson() => throw _privateConstructorUsedError; @JsonKey(ignore: true) @@ -35,7 +34,7 @@ abstract class $RouterStateCopyWith<$Res> { RouterState value, $Res Function(RouterState) then) = _$RouterStateCopyWithImpl<$Res, RouterState>; @useResult - $Res call({bool hasAnyAccount, bool hasActiveChat}); + $Res call({bool hasAnyAccount}); } /// @nodoc @@ -52,17 +51,12 @@ class _$RouterStateCopyWithImpl<$Res, $Val extends RouterState> @override $Res call({ Object? hasAnyAccount = null, - Object? hasActiveChat = null, }) { return _then(_value.copyWith( hasAnyAccount: null == hasAnyAccount ? _value.hasAnyAccount : hasAnyAccount // ignore: cast_nullable_to_non_nullable as bool, - hasActiveChat: null == hasActiveChat - ? _value.hasActiveChat - : hasActiveChat // ignore: cast_nullable_to_non_nullable - as bool, ) as $Val); } } @@ -75,7 +69,7 @@ abstract class _$$RouterStateImplCopyWith<$Res> __$$RouterStateImplCopyWithImpl<$Res>; @override @useResult - $Res call({bool hasAnyAccount, bool hasActiveChat}); + $Res call({bool hasAnyAccount}); } /// @nodoc @@ -90,17 +84,12 @@ class __$$RouterStateImplCopyWithImpl<$Res> @override $Res call({ Object? hasAnyAccount = null, - Object? hasActiveChat = null, }) { return _then(_$RouterStateImpl( hasAnyAccount: null == hasAnyAccount ? _value.hasAnyAccount : hasAnyAccount // ignore: cast_nullable_to_non_nullable as bool, - hasActiveChat: null == hasActiveChat - ? _value.hasActiveChat - : hasActiveChat // ignore: cast_nullable_to_non_nullable - as bool, )); } } @@ -108,20 +97,17 @@ class __$$RouterStateImplCopyWithImpl<$Res> /// @nodoc @JsonSerializable() class _$RouterStateImpl with DiagnosticableTreeMixin implements _RouterState { - const _$RouterStateImpl( - {required this.hasAnyAccount, required this.hasActiveChat}); + const _$RouterStateImpl({required this.hasAnyAccount}); factory _$RouterStateImpl.fromJson(Map json) => _$$RouterStateImplFromJson(json); @override final bool hasAnyAccount; - @override - final bool hasActiveChat; @override String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) { - return 'RouterState(hasAnyAccount: $hasAnyAccount, hasActiveChat: $hasActiveChat)'; + return 'RouterState(hasAnyAccount: $hasAnyAccount)'; } @override @@ -129,8 +115,7 @@ class _$RouterStateImpl with DiagnosticableTreeMixin implements _RouterState { super.debugFillProperties(properties); properties ..add(DiagnosticsProperty('type', 'RouterState')) - ..add(DiagnosticsProperty('hasAnyAccount', hasAnyAccount)) - ..add(DiagnosticsProperty('hasActiveChat', hasActiveChat)); + ..add(DiagnosticsProperty('hasAnyAccount', hasAnyAccount)); } @override @@ -139,14 +124,12 @@ class _$RouterStateImpl with DiagnosticableTreeMixin implements _RouterState { (other.runtimeType == runtimeType && other is _$RouterStateImpl && (identical(other.hasAnyAccount, hasAnyAccount) || - other.hasAnyAccount == hasAnyAccount) && - (identical(other.hasActiveChat, hasActiveChat) || - other.hasActiveChat == hasActiveChat)); + other.hasAnyAccount == hasAnyAccount)); } @JsonKey(ignore: true) @override - int get hashCode => Object.hash(runtimeType, hasAnyAccount, hasActiveChat); + int get hashCode => Object.hash(runtimeType, hasAnyAccount); @JsonKey(ignore: true) @override @@ -163,9 +146,8 @@ class _$RouterStateImpl with DiagnosticableTreeMixin implements _RouterState { } abstract class _RouterState implements RouterState { - const factory _RouterState( - {required final bool hasAnyAccount, - required final bool hasActiveChat}) = _$RouterStateImpl; + const factory _RouterState({required final bool hasAnyAccount}) = + _$RouterStateImpl; factory _RouterState.fromJson(Map json) = _$RouterStateImpl.fromJson; @@ -173,8 +155,6 @@ abstract class _RouterState implements RouterState { @override bool get hasAnyAccount; @override - bool get hasActiveChat; - @override @JsonKey(ignore: true) _$$RouterStateImplCopyWith<_$RouterStateImpl> get copyWith => throw _privateConstructorUsedError; diff --git a/lib/router/cubit/router_cubit.g.dart b/lib/router/cubit/router_cubit.g.dart index 31ca24a..4d9241c 100644 --- a/lib/router/cubit/router_cubit.g.dart +++ b/lib/router/cubit/router_cubit.g.dart @@ -9,11 +9,9 @@ part of 'router_cubit.dart'; _$RouterStateImpl _$$RouterStateImplFromJson(Map json) => _$RouterStateImpl( hasAnyAccount: json['has_any_account'] as bool, - hasActiveChat: json['has_active_chat'] as bool, ); Map _$$RouterStateImplToJson(_$RouterStateImpl instance) => { 'has_any_account': instance.hasAnyAccount, - 'has_active_chat': instance.hasActiveChat, }; diff --git a/lib/theme/models/scale_scheme.dart b/lib/theme/models/scale_scheme.dart index 990fe1e..642dfee 100644 --- a/lib/theme/models/scale_scheme.dart +++ b/lib/theme/models/scale_scheme.dart @@ -89,11 +89,9 @@ class ScaleScheme extends ThemeExtension { onError: errorScale.primaryText, // errorContainer: errorScale.hoverElementBackground, // onErrorContainer: errorScale.subtleText, - background: grayScale.appBackground, // reviewed - onBackground: grayScale.appText, // reviewed surface: primaryScale.primary, // reviewed onSurface: primaryScale.primaryText, // reviewed - surfaceVariant: secondaryScale.primary, + surfaceContainerHighest: secondaryScale.primary, onSurfaceVariant: secondaryScale.primaryText, // ?? reviewed a little outline: primaryScale.border, outlineVariant: secondaryScale.border, diff --git a/lib/theme/views/widget_helpers.dart b/lib/theme/views/widget_helpers.dart index 52f26ac..e3dfd94 100644 --- a/lib/theme/views/widget_helpers.dart +++ b/lib/theme/views/widget_helpers.dart @@ -5,7 +5,6 @@ import 'package:blurry_modal_progress_hud/blurry_modal_progress_hud.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_spinkit/flutter_spinkit.dart'; -import 'package:flutter_translate/flutter_translate.dart'; import 'package:motion_toast/motion_toast.dart'; import 'package:quickalert/quickalert.dart'; @@ -122,36 +121,45 @@ Future showErrorModal( } void showErrorToast(BuildContext context, String message) { - MotionToast.error( - title: Text(translate('toast.error')), + final theme = Theme.of(context); + final scale = theme.extension()!; + final scaleConfig = theme.extension()!; + + MotionToast( + //title: Text(translate('toast.error')), description: Text(message), + constraints: BoxConstraints.loose(const Size(400, 100)), + contentPadding: const EdgeInsets.all(16), + primaryColor: scale.errorScale.elementBackground, + secondaryColor: scale.errorScale.calloutBackground, + borderRadius: 16, + toastDuration: const Duration(seconds: 4), + animationDuration: const Duration(milliseconds: 1000), + displayBorder: scaleConfig.useVisualIndicators, + icon: Icons.error, ).show(context); } void showInfoToast(BuildContext context, String message) { - MotionToast.info( - title: Text(translate('toast.info')), + final theme = Theme.of(context); + final scale = theme.extension()!; + final scaleConfig = theme.extension()!; + + MotionToast( + //title: Text(translate('toast.info')), description: Text(message), + constraints: BoxConstraints.loose(const Size(400, 100)), + contentPadding: const EdgeInsets.all(16), + primaryColor: scale.tertiaryScale.elementBackground, + secondaryColor: scale.tertiaryScale.calloutBackground, + borderRadius: 16, + toastDuration: const Duration(seconds: 2), + animationDuration: const Duration(milliseconds: 500), + displayBorder: scaleConfig.useVisualIndicators, + icon: Icons.info, ).show(context); } -// Widget insetBorder( -// {required BuildContext context, -// required bool enabled, -// required Color color, -// required Widget child}) { -// if (!enabled) { -// return child; -// } - -// return Stack({ -// children: [] { -// DecoratedBox(decoration: BoxDecoration() -// child, -// } -// }) -// } - Widget styledTitleContainer({ required BuildContext context, required String title, @@ -230,3 +238,26 @@ Widget styledBottomSheet({ bool get isPlatformDark => WidgetsBinding.instance.platformDispatcher.platformBrightness == Brightness.dark; + +const grayColorFilter = ColorFilter.matrix([ + 0.2126, + 0.7152, + 0.0722, + 0, + 0, + 0.2126, + 0.7152, + 0.0722, + 0, + 0, + 0.2126, + 0.7152, + 0.0722, + 0, + 0, + 0, + 0, + 0, + 1, + 0, +]); diff --git a/lib/tools/package_info.dart b/lib/tools/package_info.dart new file mode 100644 index 0000000..7acd109 --- /dev/null +++ b/lib/tools/package_info.dart @@ -0,0 +1,14 @@ +import 'package:package_info_plus/package_info_plus.dart'; + +String packageInfoAppName = ''; +String packageInfoPackageName = ''; +String packageInfoVersion = ''; +String packageInfoBuildNumber = ''; + +Future initPackageInfo() async { + final packageInfo = await PackageInfo.fromPlatform(); + packageInfoAppName = packageInfo.appName; + packageInfoPackageName = packageInfo.packageName; + packageInfoVersion = packageInfo.version; + packageInfoBuildNumber = packageInfo.buildNumber; +} diff --git a/lib/tools/tools.dart b/lib/tools/tools.dart index 6b48001..b61570e 100644 --- a/lib/tools/tools.dart +++ b/lib/tools/tools.dart @@ -1,8 +1,10 @@ + export 'animations.dart'; export 'enter_password.dart'; export 'enter_pin.dart'; export 'loggy.dart'; export 'misc.dart'; +export 'package_info.dart'; export 'phono_byte.dart'; export 'pop_control.dart'; export 'responsive.dart'; diff --git a/lib/veilid_processor/views/signal_strength_meter.dart b/lib/veilid_processor/views/signal_strength_meter.dart index 4691e87..73842f1 100644 --- a/lib/veilid_processor/views/signal_strength_meter.dart +++ b/lib/veilid_processor/views/signal_strength_meter.dart @@ -1,5 +1,6 @@ import 'package:async_tools/async_tools.dart'; import 'package:awesome_extensions/awesome_extensions.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; @@ -11,7 +12,7 @@ import '../../theme/theme.dart'; import '../cubit/connection_state_cubit.dart'; class SignalStrengthMeterWidget extends StatelessWidget { - const SignalStrengthMeterWidget({super.key}); + const SignalStrengthMeterWidget({super.key, this.color, this.inactiveColor}); @override // ignore: prefer_expression_function_bodies @@ -33,32 +34,35 @@ class SignalStrengthMeterWidget extends StatelessWidget { switch (connectionState.attachment.state) { case AttachmentState.detached: iconWidget = Icon(Icons.signal_cellular_nodata, - size: iconSize, color: scale.primaryScale.primaryText); + size: iconSize, + color: this.color ?? scale.primaryScale.primaryText); return; case AttachmentState.detaching: iconWidget = Icon(Icons.signal_cellular_off, - size: iconSize, color: scale.primaryScale.primaryText); + size: iconSize, + color: this.color ?? scale.primaryScale.primaryText); return; case AttachmentState.attaching: value = 0; - color = scale.primaryScale.primaryText; + color = this.color ?? scale.primaryScale.primaryText; case AttachmentState.attachedWeak: value = 1; - color = scale.primaryScale.primaryText; + color = this.color ?? scale.primaryScale.primaryText; case AttachmentState.attachedStrong: value = 2; - color = scale.primaryScale.primaryText; + color = this.color ?? scale.primaryScale.primaryText; case AttachmentState.attachedGood: value = 3; - color = scale.primaryScale.primaryText; + color = this.color ?? scale.primaryScale.primaryText; case AttachmentState.fullyAttached: value = 4; - color = scale.primaryScale.primaryText; + color = this.color ?? scale.primaryScale.primaryText; case AttachmentState.overAttached: value = 4; - color = scale.primaryScale.primaryText; + color = this.color ?? scale.primaryScale.primaryText; } - inactiveColor = scale.primaryScale.primaryText; + inactiveColor = + this.inactiveColor ?? scale.primaryScale.primaryText; iconWidget = SignalStrengthIndicator.bars( value: value, @@ -86,4 +90,16 @@ class SignalStrengthMeterWidget extends StatelessWidget { child: iconWidget); }); } + + //////////////////////////////////////////////////////////////////////////// + final Color? color; + final Color? inactiveColor; + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(ColorProperty('color', color)) + ..add(ColorProperty('inactiveColor', inactiveColor)); + } } diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index fa5dd07..b888e6d 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,6 +6,7 @@ import FlutterMacOS import Foundation import mobile_scanner +import package_info_plus import pasteboard import path_provider_foundation import screen_retriever @@ -19,6 +20,7 @@ import window_manager func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin")) + FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) PasteboardPlugin.register(with: registry.registrar(forPlugin: "PasteboardPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) ScreenRetrieverPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverPlugin")) diff --git a/macos/Podfile.lock b/macos/Podfile.lock index faa2836..98cf433 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -2,6 +2,8 @@ PODS: - FlutterMacOS (1.0.0) - mobile_scanner (5.1.1): - FlutterMacOS + - package_info_plus (0.0.1): + - FlutterMacOS - pasteboard (0.0.1): - FlutterMacOS - path_provider_foundation (0.0.1): @@ -29,6 +31,7 @@ PODS: DEPENDENCIES: - FlutterMacOS (from `Flutter/ephemeral`) - mobile_scanner (from `Flutter/ephemeral/.symlinks/plugins/mobile_scanner/macos`) + - package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`) - pasteboard (from `Flutter/ephemeral/.symlinks/plugins/pasteboard/macos`) - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`) - screen_retriever (from `Flutter/ephemeral/.symlinks/plugins/screen_retriever/macos`) @@ -45,6 +48,8 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral mobile_scanner: :path: Flutter/ephemeral/.symlinks/plugins/mobile_scanner/macos + package_info_plus: + :path: Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos pasteboard: :path: Flutter/ephemeral/.symlinks/plugins/pasteboard/macos path_provider_foundation: @@ -69,6 +74,7 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 mobile_scanner: 1efac1e53c294b24e3bb55bcc7f4deee0233a86b + package_info_plus: fa739dd842b393193c5ca93c26798dff6e3d0e0c pasteboard: 9b69dba6fedbb04866be632205d532fe2f6b1d99 path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 screen_retriever: 59634572a57080243dd1bf715e55b6c54f241a38 diff --git a/packages/veilid_support/example/pubspec.yaml b/packages/veilid_support/example/pubspec.yaml index 2599f5f..8f76235 100644 --- a/packages/veilid_support/example/pubspec.yaml +++ b/packages/veilid_support/example/pubspec.yaml @@ -14,7 +14,7 @@ dependencies: path: ../ dev_dependencies: - async_tools: ^0.1.2 + async_tools: ^0.1.3 integration_test: sdk: flutter lint_hard: ^4.0.0 diff --git a/packages/veilid_support/lib/dht_support/proto/dht.proto b/packages/veilid_support/lib/dht_support/proto/dht.proto index c27915c..da1aa15 100644 --- a/packages/veilid_support/lib/dht_support/proto/dht.proto +++ b/packages/veilid_support/lib/dht_support/proto/dht.proto @@ -23,7 +23,6 @@ message DHTData { uint32 size = 4; } - // DHTLog - represents a ring buffer of many elements with append/truncate semantics // Header in subkey 0 of first key follows this structure message DHTLog { @@ -62,27 +61,6 @@ message DHTShortArray { // calculated through iteration } -// Reference to data on the DHT -message DHTDataReference { - veilid.TypedKey dht_data = 1; - veilid.TypedKey hash = 2; -} - -// Reference to data on the BlockStore -message BlockStoreDataReference { - veilid.TypedKey block = 1; -} - -// DataReference -// Pointer to data somewhere in Veilid -// Abstraction over DHTData and BlockStore -message DataReference { - oneof kind { - DHTDataReference dht_data = 1; - BlockStoreDataReference block_store_data = 2; - } -} - // A pointer to an child DHT record message OwnedDHTRecordPointer { // DHT Record key diff --git a/packages/veilid_support/lib/dht_support/src/dht_log/dht_log_spine.dart b/packages/veilid_support/lib/dht_support/src/dht_log/dht_log_spine.dart index 3105fa8..ca0074f 100644 --- a/packages/veilid_support/lib/dht_support/src/dht_log/dht_log_spine.dart +++ b/packages/veilid_support/lib/dht_support/src/dht_log/dht_log_spine.dart @@ -71,6 +71,12 @@ class _DHTLogSpine { // Write new spine head record to the network await spine.operate((spine) async { + // Write first empty subkey + final subkeyData = _makeEmptySubkey(); + final existingSubkeyData = + await spineRecord.tryWriteBytes(subkeyData, subkey: 1); + assert(existingSubkeyData == null, 'Should never conflict on create'); + final success = await spine.writeSpineHead(); assert(success, 'false return should never happen on create'); }); @@ -603,7 +609,7 @@ class _DHTLogSpine { // Don't watch for local changes because this class already handles // notifying listeners and knows when it makes local changes _subscription ??= - await _spineRecord.listen(localChanges: false, _onSpineChanged); + await _spineRecord.listen(localChanges: true, _onSpineChanged); } on Exception { // If anything fails, try to cancel the watches await cancelWatch(); diff --git a/packages/veilid_support/lib/dht_support/src/dht_record/default_dht_record_cubit.dart b/packages/veilid_support/lib/dht_support/src/dht_record/default_dht_record_cubit.dart index a333160..5ea6761 100644 --- a/packages/veilid_support/lib/dht_support/src/dht_record/default_dht_record_cubit.dart +++ b/packages/veilid_support/lib/dht_support/src/dht_record/default_dht_record_cubit.dart @@ -12,14 +12,6 @@ class DefaultDHTRecordCubit extends DHTRecordCubit { stateFunction: _makeStateFunction(decodeState), watchFunction: _makeWatchFunction()); - // DefaultDHTRecordCubit.value({ - // required super.record, - // required T Function(List data) decodeState, - // }) : super.value( - // initialStateFunction: _makeInitialStateFunction(decodeState), - // stateFunction: _makeStateFunction(decodeState), - // watchFunction: _makeWatchFunction()); - static InitialStateFunction _makeInitialStateFunction( T Function(List data) decodeState) => (record) async { diff --git a/packages/veilid_support/lib/dht_support/src/dht_record/dht_record.dart b/packages/veilid_support/lib/dht_support/src/dht_record/dht_record.dart index cd6c859..e04af10 100644 --- a/packages/veilid_support/lib/dht_support/src/dht_record/dht_record.dart +++ b/packages/veilid_support/lib/dht_support/src/dht_record/dht_record.dart @@ -1,7 +1,5 @@ part of 'dht_record_pool.dart'; -const _sfListen = 'listen'; - @immutable class DHTRecordWatchChange extends Equatable { const DHTRecordWatchChange( @@ -41,7 +39,7 @@ enum DHTRecordRefreshMode { class DHTRecord implements DHTDeleteable { DHTRecord._( {required VeilidRoutingContext routingContext, - required SharedDHTRecordData sharedDHTRecordData, + required _SharedDHTRecordData sharedDHTRecordData, required int defaultSubkey, required KeyPair? writer, required VeilidCrypto crypto, @@ -241,7 +239,7 @@ class DHTRecord implements DHTDeleteable { // if so, shortcut and don't bother decrypting it if (newValueData.data.equals(encryptedNewValue)) { if (isUpdated) { - DHTRecordPool.instance.processLocalValueChange(key, newValue, subkey); + DHTRecordPool.instance._processLocalValueChange(key, newValue, subkey); } return null; } @@ -251,7 +249,7 @@ class DHTRecord implements DHTDeleteable { await (crypto ?? _crypto).decrypt(newValueData.data); if (isUpdated) { DHTRecordPool.instance - .processLocalValueChange(key, decryptedNewValue, subkey); + ._processLocalValueChange(key, decryptedNewValue, subkey); } return decryptedNewValue; } @@ -298,7 +296,7 @@ class DHTRecord implements DHTDeleteable { final isUpdated = newValueData.seq != lastSeq; if (isUpdated) { - DHTRecordPool.instance.processLocalValueChange(key, newValue, subkey); + DHTRecordPool.instance._processLocalValueChange(key, newValue, subkey); } } @@ -308,7 +306,7 @@ class DHTRecord implements DHTDeleteable { /// Each attempt to write the value calls an update function with the /// old value to determine what new value should be attempted for that write. Future eventualUpdateBytes( - Future Function(Uint8List? oldValue) update, + Future Function(Uint8List? oldValue) update, {int subkey = -1, VeilidCrypto? crypto, KeyPair? writer, @@ -323,7 +321,10 @@ class DHTRecord implements DHTDeleteable { do { // Update the data final updatedValue = await update(oldValue); - + if (updatedValue == null) { + // If null is returned from the update, stop trying to do the update + break; + } // Try to write it back to the network oldValue = await tryWriteBytes(updatedValue, subkey: subkey, crypto: crypto, writer: writer, outSeqNum: outSeqNum); @@ -389,7 +390,7 @@ class DHTRecord implements DHTDeleteable { /// Like 'eventualUpdateBytes' but with JSON marshal/unmarshal of the value Future eventualUpdateJson( - T Function(dynamic) fromJson, Future Function(T?) update, + T Function(dynamic) fromJson, Future Function(T?) update, {int subkey = -1, VeilidCrypto? crypto, KeyPair? writer, @@ -399,7 +400,7 @@ class DHTRecord implements DHTDeleteable { /// Like 'eventualUpdateBytes' but with protobuf marshal/unmarshal of the value Future eventualUpdateProtobuf( - T Function(List) fromBuffer, Future Function(T?) update, + T Function(List) fromBuffer, Future Function(T?) update, {int subkey = -1, VeilidCrypto? crypto, KeyPair? writer, @@ -416,7 +417,7 @@ class DHTRecord implements DHTDeleteable { // Set up watch requirements which will get picked up by the next tick final oldWatchState = watchState; watchState = - WatchState(subkeys: subkeys, expiration: expiration, count: count); + _WatchState(subkeys: subkeys, expiration: expiration, count: count); if (oldWatchState != watchState) { _sharedDHTRecordData.needsWatchStateUpdate = true; } @@ -541,7 +542,7 @@ class DHTRecord implements DHTDeleteable { ////////////////////////////////////////////////////////////// - final SharedDHTRecordData _sharedDHTRecordData; + final _SharedDHTRecordData _sharedDHTRecordData; final VeilidRoutingContext _routingContext; final int _defaultSubkey; final KeyPair? _writer; @@ -551,5 +552,5 @@ class DHTRecord implements DHTDeleteable { int _openCount; StreamController? _watchController; @internal - WatchState? watchState; + _WatchState? watchState; } diff --git a/packages/veilid_support/lib/dht_support/src/dht_record/dht_record_cubit.dart b/packages/veilid_support/lib/dht_support/src/dht_record/dht_record_cubit.dart index 1cfcfcd..54d1dec 100644 --- a/packages/veilid_support/lib/dht_support/src/dht_record/dht_record_cubit.dart +++ b/packages/veilid_support/lib/dht_support/src/dht_record/dht_record_cubit.dart @@ -29,20 +29,6 @@ class DHTRecordCubit extends Cubit> { }); } - // DHTRecordCubit.value({ - // required DHTRecord record, - // required InitialStateFunction initialStateFunction, - // required StateFunction stateFunction, - // required WatchFunction watchFunction, - // }) : _record = record, - // _stateFunction = stateFunction, - // _wantsCloseRecord = false, - // super(const AsyncValue.loading()) { - // Future.delayed(Duration.zero, () async { - // await _init(initialStateFunction, stateFunction, watchFunction); - // }); - // } - Future _init( InitialStateFunction initialStateFunction, StateFunction stateFunction, diff --git a/packages/veilid_support/lib/dht_support/src/dht_record/dht_record_pool.dart b/packages/veilid_support/lib/dht_support/src/dht_record/dht_record_pool.dart index 8b65d41..b80db1f 100644 --- a/packages/veilid_support/lib/dht_support/src/dht_record/dht_record_pool.dart +++ b/packages/veilid_support/lib/dht_support/src/dht_record/dht_record_pool.dart @@ -16,20 +16,11 @@ export 'package:fast_immutable_collections/fast_immutable_collections.dart' part 'dht_record_pool.freezed.dart'; part 'dht_record_pool.g.dart'; part 'dht_record.dart'; - -const int watchBackoffMultiplier = 2; -const int watchBackoffMax = 30; - -const int? defaultWatchDurationSecs = null; // 600 -const int watchRenewalNumerator = 4; -const int watchRenewalDenominator = 5; +part 'dht_record_pool_private.dart'; // Maximum number of concurrent DHT operations to perform on the network const int maxDHTConcurrency = 8; -// DHT crypto domain -const String cryptoDomainDHT = 'dht'; - typedef DHTRecordPoolLogger = void Function(String message); /// Record pool that managed DHTRecords and allows for tagged deletion @@ -62,114 +53,18 @@ class OwnedDHTRecordPointer with _$OwnedDHTRecordPointer { _$OwnedDHTRecordPointerFromJson(json as Map); } -/// Watch state -@immutable -class WatchState extends Equatable { - const WatchState( - {required this.subkeys, - required this.expiration, - required this.count, - this.realExpiration, - this.renewalTime}); - final List? subkeys; - final Timestamp? expiration; - final int? count; - final Timestamp? realExpiration; - final Timestamp? renewalTime; - - @override - List get props => - [subkeys, expiration, count, realExpiration, renewalTime]; -} - -/// Data shared amongst all DHTRecord instances -class SharedDHTRecordData { - SharedDHTRecordData( - {required this.recordDescriptor, - required this.defaultWriter, - required this.defaultRoutingContext}); - DHTRecordDescriptor recordDescriptor; - KeyPair? defaultWriter; - VeilidRoutingContext defaultRoutingContext; - bool needsWatchStateUpdate = false; - WatchState? unionWatchState; -} - -// Per opened record data -class OpenedRecordInfo { - OpenedRecordInfo( - {required DHTRecordDescriptor recordDescriptor, - required KeyPair? defaultWriter, - required VeilidRoutingContext defaultRoutingContext}) - : shared = SharedDHTRecordData( - recordDescriptor: recordDescriptor, - defaultWriter: defaultWriter, - defaultRoutingContext: defaultRoutingContext); - SharedDHTRecordData shared; - Set records = {}; - - String get debugNames { - final r = records.toList() - ..sort((a, b) => a.key.toString().compareTo(b.key.toString())); - return '[${r.map((x) => x.debugName).join(',')}]'; - } - - String get details { - final r = records.toList() - ..sort((a, b) => a.key.toString().compareTo(b.key.toString())); - return '[${r.map((x) => "writer=${x._writer} " - "defaultSubkey=${x._defaultSubkey}").join(',')}]'; - } - - String get sharedDetails => shared.toString(); -} +////////////////////////////////////////////////////////////////////////////// +/// Allocator and management system for DHTRecord class DHTRecordPool with TableDBBackedJson { DHTRecordPool._(Veilid veilid, VeilidRoutingContext routingContext) : _state = const DHTRecordPoolAllocations(), _mutex = Mutex(), - _opened = {}, + _opened = {}, _markedForDelete = {}, _routingContext = routingContext, _veilid = veilid; - // Logger - DHTRecordPoolLogger? _logger; - - // Persistent DHT record list - DHTRecordPoolAllocations _state; - // Create/open Mutex - final Mutex _mutex; - // Which DHT records are currently open - final Map _opened; - // Which DHT records are marked for deletion - final Set _markedForDelete; - // Default routing context to use for new keys - final VeilidRoutingContext _routingContext; - // Convenience accessor - final Veilid _veilid; - // If tick is already running or not - bool _inTick = false; - // Tick counter for backoff - int _tickCount = 0; - // Backoff timer - int _watchBackoffTimer = 1; - - static DHTRecordPool? _singleton; - - ////////////////////////////////////////////////////////////// - /// AsyncTableDBBacked - @override - String tableName() => 'dht_record_pool'; - @override - String tableKeyName() => 'pool_allocations'; - @override - DHTRecordPoolAllocations valueFromJson(Object? obj) => obj != null - ? DHTRecordPoolAllocations.fromJson(obj) - : const DHTRecordPoolAllocations(); - @override - Object? valueToJson(DHTRecordPoolAllocations? val) => val?.toJson(); - ////////////////////////////////////////////////////////////// static DHTRecordPool get instance => _singleton!; @@ -190,337 +85,8 @@ class DHTRecordPool with TableDBBackedJson { } } - Veilid get veilid => _veilid; - - void log(String message) { - _logger?.call(message); - } - - Future _recordCreateInner( - {required String debugName, - required VeilidRoutingContext dhtctx, - required DHTSchema schema, - KeyPair? writer, - TypedKey? parent}) async { - if (!_mutex.isLocked) { - throw StateError('should be locked here'); - } - // Create the record - final recordDescriptor = await dhtctx.createDHTRecord(schema); - - log('createDHTRecord: debugName=$debugName key=${recordDescriptor.key}'); - - // Reopen if a writer is specified to ensure - // we switch the default writer - if (writer != null) { - await dhtctx.openDHTRecord(recordDescriptor.key, writer: writer); - } - final openedRecordInfo = OpenedRecordInfo( - recordDescriptor: recordDescriptor, - defaultWriter: writer ?? recordDescriptor.ownerKeyPair(), - defaultRoutingContext: dhtctx); - _opened[recordDescriptor.key] = openedRecordInfo; - - // Register the dependency - await _addDependencyInner( - parent, - recordDescriptor.key, - debugName: debugName, - ); - - return openedRecordInfo; - } - - Future _recordOpenInner( - {required String debugName, - required VeilidRoutingContext dhtctx, - required TypedKey recordKey, - KeyPair? writer, - TypedKey? parent}) async { - if (!_mutex.isLocked) { - throw StateError('should be locked here'); - } - log('openDHTRecord: debugName=$debugName key=$recordKey'); - - // If we are opening a key that already exists - // make sure we are using the same parent if one was specified - _validateParentInner(parent, recordKey); - - // See if this has been opened yet - final openedRecordInfo = _opened[recordKey]; - if (openedRecordInfo == null) { - // Fresh open, just open the record - final recordDescriptor = - await dhtctx.openDHTRecord(recordKey, writer: writer); - final newOpenedRecordInfo = OpenedRecordInfo( - recordDescriptor: recordDescriptor, - defaultWriter: writer, - defaultRoutingContext: dhtctx); - _opened[recordDescriptor.key] = newOpenedRecordInfo; - - // Register the dependency - await _addDependencyInner( - parent, - recordKey, - debugName: debugName, - ); - - return newOpenedRecordInfo; - } - - // Already opened - - // See if we need to reopen the record with a default writer and possibly - // a different routing context - if (writer != null && openedRecordInfo.shared.defaultWriter == null) { - final newRecordDescriptor = - await dhtctx.openDHTRecord(recordKey, writer: writer); - openedRecordInfo.shared.defaultWriter = writer; - openedRecordInfo.shared.defaultRoutingContext = dhtctx; - if (openedRecordInfo.shared.recordDescriptor.ownerSecret == null) { - openedRecordInfo.shared.recordDescriptor = newRecordDescriptor; - } - } - - // Register the dependency - await _addDependencyInner( - parent, - recordKey, - debugName: debugName, - ); - - return openedRecordInfo; - } - - // Called when a DHTRecord is closed - // Cleans up the opened record housekeeping and processes any late deletions - Future _recordClosed(DHTRecord record) async { - await _mutex.protect(() async { - final key = record.key; - - log('closeDHTRecord: debugName=${record.debugName} key=$key'); - - final openedRecordInfo = _opened[key]; - if (openedRecordInfo == null || - !openedRecordInfo.records.remove(record)) { - throw StateError('record already closed'); - } - if (openedRecordInfo.records.isEmpty) { - await _routingContext.closeDHTRecord(key); - _opened.remove(key); - - await _checkForLateDeletesInner(key); - } - }); - } - - // Check to see if this key can finally be deleted - // If any parents are marked for deletion, try them first - Future _checkForLateDeletesInner(TypedKey key) async { - // Get parent list in bottom up order including our own key - final parents = []; - TypedKey? nextParent = key; - while (nextParent != null) { - parents.add(nextParent); - nextParent = getParentRecordKey(nextParent); - } - - // If any parent is ready to delete all its children do it - for (final parent in parents) { - if (_markedForDelete.contains(parent)) { - final deleted = await _deleteRecordInner(parent); - if (!deleted) { - // If we couldn't delete a child then no 'marked for delete' parents - // above us will be ready to delete either - break; - } - } - } - } - - // Collect all dependencies (including the record itself) - // in reverse (bottom-up/delete order) - List _collectChildrenInner(TypedKey recordKey) { - if (!_mutex.isLocked) { - throw StateError('should be locked here'); - } - final allDeps = []; - final currentDeps = [recordKey]; - while (currentDeps.isNotEmpty) { - final nextDep = currentDeps.removeLast(); - - allDeps.add(nextDep); - final childDeps = - _state.childrenByParent[nextDep.toJson()]?.toList() ?? []; - currentDeps.addAll(childDeps); - } - return allDeps.reversedView; - } - - /// Collect all dependencies (including the record itself) - /// in reverse (bottom-up/delete order) - Future> collectChildren(TypedKey recordKey) => - _mutex.protect(() async => _collectChildrenInner(recordKey)); - - /// Print children - String debugChildren(TypedKey recordKey, {List? allDeps}) { - allDeps ??= _collectChildrenInner(recordKey); - // ignore: avoid_print - var out = - 'Parent: $recordKey (${_state.debugNames[recordKey.toString()]})\n'; - for (final dep in allDeps) { - if (dep != recordKey) { - // ignore: avoid_print - out += ' Child: $dep (${_state.debugNames[dep.toString()]})\n'; - } - } - return out; - } - - // Actual delete function - Future _finalizeDeleteRecordInner(TypedKey recordKey) async { - log('_finalizeDeleteRecordInner: key=$recordKey'); - - // Remove this child from parents - await _removeDependenciesInner([recordKey]); - await _routingContext.deleteDHTRecord(recordKey); - _markedForDelete.remove(recordKey); - } - - // Deep delete mechanism inside mutex - Future _deleteRecordInner(TypedKey recordKey) async { - final toDelete = _readyForDeleteInner(recordKey); - if (toDelete.isNotEmpty) { - // delete now - for (final deleteKey in toDelete) { - await _finalizeDeleteRecordInner(deleteKey); - } - return true; - } - // mark for deletion - _markedForDelete.add(recordKey); - return false; - } - - /// Delete a record and its children if they are all closed - /// otherwise mark that record for deletion eventually - /// Returns true if the deletion was processed immediately - /// Returns false if the deletion was marked for later - Future deleteRecord(TypedKey recordKey) async => - _mutex.protect(() async => _deleteRecordInner(recordKey)); - - // If everything underneath is closed including itself, return the - // list of children (and itself) to finally actually delete - List _readyForDeleteInner(TypedKey recordKey) { - final allDeps = _collectChildrenInner(recordKey); - for (final dep in allDeps) { - if (_opened.containsKey(dep)) { - return []; - } - } - return allDeps; - } - - void _validateParentInner(TypedKey? parent, TypedKey child) { - if (!_mutex.isLocked) { - throw StateError('should be locked here'); - } - - final childJson = child.toJson(); - final existingParent = _state.parentByChild[childJson]; - if (parent == null) { - if (existingParent != null) { - throw StateError('Child is already parented: $child'); - } - } else { - if (_state.rootRecords.contains(child)) { - throw StateError('Child already added as root: $child'); - } - if (existingParent != null && existingParent != parent) { - throw StateError('Child has two parents: $child <- $parent'); - } - } - } - - Future _addDependencyInner(TypedKey? parent, TypedKey child, - {required String debugName}) async { - if (!_mutex.isLocked) { - throw StateError('should be locked here'); - } - if (parent == null) { - if (_state.rootRecords.contains(child)) { - // Dependency already added - return; - } - _state = await store(_state.copyWith( - rootRecords: _state.rootRecords.add(child), - debugNames: _state.debugNames.add(child.toJson(), debugName))); - } else { - final childrenOfParent = - _state.childrenByParent[parent.toJson()] ?? ISet(); - if (childrenOfParent.contains(child)) { - // Dependency already added (consecutive opens, etc) - return; - } - _state = await store(_state.copyWith( - childrenByParent: _state.childrenByParent - .add(parent.toJson(), childrenOfParent.add(child)), - parentByChild: _state.parentByChild.add(child.toJson(), parent), - debugNames: _state.debugNames.add(child.toJson(), debugName))); - } - } - - Future _removeDependenciesInner(List childList) async { - if (!_mutex.isLocked) { - throw StateError('should be locked here'); - } - var state = _state; - - for (final child in childList) { - if (_state.rootRecords.contains(child)) { - state = state.copyWith( - rootRecords: state.rootRecords.remove(child), - debugNames: state.debugNames.remove(child.toJson())); - } else { - final parent = state.parentByChild[child.toJson()]; - if (parent == null) { - continue; - } - final children = state.childrenByParent[parent.toJson()]!.remove(child); - if (children.isEmpty) { - state = state.copyWith( - childrenByParent: state.childrenByParent.remove(parent.toJson()), - parentByChild: state.parentByChild.remove(child.toJson()), - debugNames: state.debugNames.remove(child.toJson())); - } else { - state = state.copyWith( - childrenByParent: - state.childrenByParent.add(parent.toJson(), children), - parentByChild: state.parentByChild.remove(child.toJson()), - debugNames: state.debugNames.remove(child.toJson())); - } - } - } - - if (state != _state) { - _state = await store(state); - } - } - - bool _isValidRecordKeyInner(TypedKey key) { - if (_state.rootRecords.contains(key)) { - return true; - } - if (_state.childrenByParent.containsKey(key.toJson())) { - return true; - } - return false; - } - - Future isValidRecordKey(TypedKey key) => - _mutex.protect(() async => _isValidRecordKeyInner(key)); - - /////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////// + // Public Interface /// Create a root DHTRecord that has no dependent records Future createRecord({ @@ -653,27 +219,56 @@ class DHTRecordPool with TableDBBackedJson { return _state.parentByChild[childJson]; } - /// Handle the DHT record updates coming from internal to this app - void processLocalValueChange(TypedKey key, Uint8List data, int subkey) { - // Change - for (final kv in _opened.entries) { - if (kv.key == key) { - for (final rec in kv.value.records) { - rec._addLocalValueChange(data, subkey); - } - break; + /// Check if record is allocated + Future isValidRecordKey(TypedKey key) => + _mutex.protect(() async => _isValidRecordKeyInner(key)); + + /// Check if record is marked for deletion or already gone + Future isDeletedRecordKey(TypedKey key) => + _mutex.protect(() async => _isDeletedRecordKeyInner(key)); + + /// Delete a record and its children if they are all closed + /// otherwise mark that record for deletion eventually + /// Returns true if the deletion was processed immediately + /// Returns false if the deletion was marked for later + Future deleteRecord(TypedKey recordKey) async => + _mutex.protect(() async => _deleteRecordInner(recordKey)); + + // If everything underneath is closed including itself, return the + // list of children (and itself) to finally actually delete + List _readyForDeleteInner(TypedKey recordKey) { + final allDeps = _collectChildrenInner(recordKey); + for (final dep in allDeps) { + if (_opened.containsKey(dep)) { + return []; } } + return allDeps; } - /// Generate default VeilidCrypto for a writer - static Future privateCryptoFromTypedSecret( - TypedKey typedSecret) async => - VeilidCryptoPrivate.fromTypedKey(typedSecret, cryptoDomainDHT); + /// Collect all dependencies (including the record itself) + /// in reverse (bottom-up/delete order) + Future> collectChildren(TypedKey recordKey) => + _mutex.protect(() async => _collectChildrenInner(recordKey)); + + /// Print children + String debugChildren(TypedKey recordKey, {List? allDeps}) { + allDeps ??= _collectChildrenInner(recordKey); + // ignore: avoid_print + var out = + 'Parent: $recordKey (${_state.debugNames[recordKey.toString()]})\n'; + for (final dep in allDeps) { + if (dep != recordKey) { + // ignore: avoid_print + out += ' Child: $dep (${_state.debugNames[dep.toString()]})\n'; + } + } + return out; + } /// Handle the DHT record updates coming from Veilid void processRemoteValueChange(VeilidUpdateValueChange updateValueChange) { - if (updateValueChange.subkeys.isNotEmpty) { + if (updateValueChange.subkeys.isNotEmpty && updateValueChange.count != 0) { // Change for (final kv in _opened.entries) { if (kv.key == updateValueChange.key) { @@ -691,7 +286,7 @@ class DHTRecordPool with TableDBBackedJson { final openedRecordInfo = entry.value; if (openedKey == updateValueChange.key) { - // Renew watch state for each opened recrod + // Renew watch state for each opened record for (final rec in openedRecordInfo.records) { // See if the watch had an expiration and if it has expired // otherwise the renewal will keep the same parameters @@ -711,7 +306,360 @@ class DHTRecordPool with TableDBBackedJson { } } - WatchState? _collectUnionWatchState(Iterable records) { + /// Log the current record allocations + void debugPrintAllocations() { + final sortedAllocations = _state.debugNames.entries.asList() + ..sort((a, b) => a.key.compareTo(b.key)); + + log('DHTRecordPool Allocations: (count=${sortedAllocations.length})'); + + for (final entry in sortedAllocations) { + log(' ${entry.key}: ${entry.value}'); + } + } + + /// Log the current opened record details + void debugPrintOpened() { + final sortedOpened = _opened.entries.asList() + ..sort((a, b) => a.key.toString().compareTo(b.key.toString())); + + log('DHTRecordPool Opened Records: (count=${sortedOpened.length})'); + + for (final entry in sortedOpened) { + log(' ${entry.key}: \n' + ' debugNames=${entry.value.debugNames}\n' + ' details=${entry.value.details}\n' + ' sharedDetails=${entry.value.sharedDetails}\n'); + } + } + + /// Public interface to DHTRecordPool logger + void log(String message) { + _logger?.call(message); + } + + /// Generate default VeilidCrypto for a writer + static Future privateCryptoFromTypedSecret( + TypedKey typedSecret) async => + VeilidCryptoPrivate.fromTypedKey(typedSecret, _cryptoDomainDHT); + + //////////////////////////////////////////////////////////////////////////// + // Private Implementation + + Future<_OpenedRecordInfo> _recordCreateInner( + {required String debugName, + required VeilidRoutingContext dhtctx, + required DHTSchema schema, + KeyPair? writer, + TypedKey? parent}) async { + if (!_mutex.isLocked) { + throw StateError('should be locked here'); + } + // Create the record + final recordDescriptor = await dhtctx.createDHTRecord(schema); + + log('createDHTRecord: debugName=$debugName key=${recordDescriptor.key}'); + + // Reopen if a writer is specified to ensure + // we switch the default writer + if (writer != null) { + await dhtctx.openDHTRecord(recordDescriptor.key, writer: writer); + } + final openedRecordInfo = _OpenedRecordInfo( + recordDescriptor: recordDescriptor, + defaultWriter: writer ?? recordDescriptor.ownerKeyPair(), + defaultRoutingContext: dhtctx); + _opened[recordDescriptor.key] = openedRecordInfo; + + // Register the dependency + await _addDependencyInner( + parent, + recordDescriptor.key, + debugName: debugName, + ); + + return openedRecordInfo; + } + + Future<_OpenedRecordInfo> _recordOpenInner( + {required String debugName, + required VeilidRoutingContext dhtctx, + required TypedKey recordKey, + KeyPair? writer, + TypedKey? parent}) async { + if (!_mutex.isLocked) { + throw StateError('should be locked here'); + } + log('openDHTRecord: debugName=$debugName key=$recordKey'); + + // If we are opening a key that already exists + // make sure we are using the same parent if one was specified + _validateParentInner(parent, recordKey); + + // See if this has been opened yet + final openedRecordInfo = _opened[recordKey]; + if (openedRecordInfo == null) { + // Fresh open, just open the record + final recordDescriptor = + await dhtctx.openDHTRecord(recordKey, writer: writer); + final newOpenedRecordInfo = _OpenedRecordInfo( + recordDescriptor: recordDescriptor, + defaultWriter: writer, + defaultRoutingContext: dhtctx); + _opened[recordDescriptor.key] = newOpenedRecordInfo; + + // Register the dependency + await _addDependencyInner( + parent, + recordKey, + debugName: debugName, + ); + + return newOpenedRecordInfo; + } + + // Already opened + + // See if we need to reopen the record with a default writer and possibly + // a different routing context + if (writer != null && openedRecordInfo.shared.defaultWriter == null) { + await dhtctx.openDHTRecord(recordKey, writer: writer); + // New writer if we didn't specify one before + openedRecordInfo.shared.defaultWriter = writer; + // New default routing context if we opened it again + openedRecordInfo.shared.defaultRoutingContext = dhtctx; + } + + // Register the dependency + await _addDependencyInner( + parent, + recordKey, + debugName: debugName, + ); + + return openedRecordInfo; + } + + // Called when a DHTRecord is closed + // Cleans up the opened record housekeeping and processes any late deletions + Future _recordClosed(DHTRecord record) async { + await _mutex.protect(() async { + final key = record.key; + + log('closeDHTRecord: debugName=${record.debugName} key=$key'); + + final openedRecordInfo = _opened[key]; + if (openedRecordInfo == null || + !openedRecordInfo.records.remove(record)) { + throw StateError('record already closed'); + } + if (openedRecordInfo.records.isEmpty) { + await _watchStateProcessors.remove(key); + await _routingContext.closeDHTRecord(key); + _opened.remove(key); + + await _checkForLateDeletesInner(key); + } + }); + } + + // Check to see if this key can finally be deleted + // If any parents are marked for deletion, try them first + Future _checkForLateDeletesInner(TypedKey key) async { + // Get parent list in bottom up order including our own key + final parents = []; + TypedKey? nextParent = key; + while (nextParent != null) { + parents.add(nextParent); + nextParent = getParentRecordKey(nextParent); + } + + // If any parent is ready to delete all its children do it + for (final parent in parents) { + if (_markedForDelete.contains(parent)) { + final deleted = await _deleteRecordInner(parent); + if (!deleted) { + // If we couldn't delete a child then no 'marked for delete' parents + // above us will be ready to delete either + break; + } + } + } + } + + // Collect all dependencies (including the record itself) + // in reverse (bottom-up/delete order) + List _collectChildrenInner(TypedKey recordKey) { + if (!_mutex.isLocked) { + throw StateError('should be locked here'); + } + final allDeps = []; + final currentDeps = [recordKey]; + while (currentDeps.isNotEmpty) { + final nextDep = currentDeps.removeLast(); + + allDeps.add(nextDep); + final childDeps = + _state.childrenByParent[nextDep.toJson()]?.toList() ?? []; + currentDeps.addAll(childDeps); + } + return allDeps.reversedView; + } + + // Actual delete function + Future _finalizeDeleteRecordInner(TypedKey recordKey) async { + log('_finalizeDeleteRecordInner: key=$recordKey'); + + // Remove this child from parents + await _removeDependenciesInner([recordKey]); + await _routingContext.deleteDHTRecord(recordKey); + _markedForDelete.remove(recordKey); + } + + // Deep delete mechanism inside mutex + Future _deleteRecordInner(TypedKey recordKey) async { + final toDelete = _readyForDeleteInner(recordKey); + if (toDelete.isNotEmpty) { + // delete now + for (final deleteKey in toDelete) { + await _finalizeDeleteRecordInner(deleteKey); + } + return true; + } + // mark for deletion + _markedForDelete.add(recordKey); + return false; + } + + void _validateParentInner(TypedKey? parent, TypedKey child) { + if (!_mutex.isLocked) { + throw StateError('should be locked here'); + } + + final childJson = child.toJson(); + final existingParent = _state.parentByChild[childJson]; + if (parent == null) { + if (existingParent != null) { + throw StateError('Child is already parented: $child'); + } + } else { + if (_state.rootRecords.contains(child)) { + throw StateError('Child already added as root: $child'); + } + if (existingParent != null && existingParent != parent) { + throw StateError('Child has two parents: $child <- $parent'); + } + } + } + + Future _addDependencyInner(TypedKey? parent, TypedKey child, + {required String debugName}) async { + if (!_mutex.isLocked) { + throw StateError('should be locked here'); + } + if (parent == null) { + if (_state.rootRecords.contains(child)) { + // Dependency already added + return; + } + _state = await store(_state.copyWith( + rootRecords: _state.rootRecords.add(child), + debugNames: _state.debugNames.add(child.toJson(), debugName))); + } else { + final childrenOfParent = + _state.childrenByParent[parent.toJson()] ?? ISet(); + if (childrenOfParent.contains(child)) { + // Dependency already added (consecutive opens, etc) + return; + } + _state = await store(_state.copyWith( + childrenByParent: _state.childrenByParent + .add(parent.toJson(), childrenOfParent.add(child)), + parentByChild: _state.parentByChild.add(child.toJson(), parent), + debugNames: _state.debugNames.add(child.toJson(), debugName))); + } + } + + Future _removeDependenciesInner(List childList) async { + if (!_mutex.isLocked) { + throw StateError('should be locked here'); + } + var state = _state; + + for (final child in childList) { + if (_state.rootRecords.contains(child)) { + state = state.copyWith( + rootRecords: state.rootRecords.remove(child), + debugNames: state.debugNames.remove(child.toJson())); + } else { + final parent = state.parentByChild[child.toJson()]; + if (parent == null) { + continue; + } + final children = state.childrenByParent[parent.toJson()]!.remove(child); + if (children.isEmpty) { + state = state.copyWith( + childrenByParent: state.childrenByParent.remove(parent.toJson()), + parentByChild: state.parentByChild.remove(child.toJson()), + debugNames: state.debugNames.remove(child.toJson())); + } else { + state = state.copyWith( + childrenByParent: + state.childrenByParent.add(parent.toJson(), children), + parentByChild: state.parentByChild.remove(child.toJson()), + debugNames: state.debugNames.remove(child.toJson())); + } + } + } + + if (state != _state) { + _state = await store(state); + } + } + + bool _isValidRecordKeyInner(TypedKey key) { + if (_state.rootRecords.contains(key)) { + return true; + } + if (_state.childrenByParent.containsKey(key.toJson())) { + return true; + } + return false; + } + + bool _isDeletedRecordKeyInner(TypedKey key) { + // Is this key gone? + if (!_isValidRecordKeyInner(key)) { + return true; + } + + // Is this key on its way out because it or one of its parents + // is scheduled to delete everything underneath it? + TypedKey? nextParent = key; + while (nextParent != null) { + if (_markedForDelete.contains(nextParent)) { + return true; + } + nextParent = getParentRecordKey(nextParent); + } + + return false; + } + + /// Handle the DHT record updates coming from internal to this app + void _processLocalValueChange(TypedKey key, Uint8List data, int subkey) { + // Change + for (final kv in _opened.entries) { + if (kv.key == key) { + for (final rec in kv.value.records) { + rec._addLocalValueChange(data, subkey); + } + break; + } + } + } + + static _WatchState? _collectUnionWatchState(Iterable records) { // Collect union of opened record watch states int? totalCount; Timestamp? maxExpiration; @@ -770,19 +718,19 @@ class DHTRecordPool with TableDBBackedJson { return null; } - return WatchState( + return _WatchState( subkeys: allSubkeys, expiration: maxExpiration, count: totalCount, renewalTime: earliestRenewalTime); } - void _updateWatchRealExpirations(Iterable records, + static void _updateWatchRealExpirations(Iterable records, Timestamp realExpiration, Timestamp renewalTime) { for (final rec in records) { final ws = rec.watchState; if (ws != null) { - rec.watchState = WatchState( + rec.watchState = _WatchState( subkeys: ws.subkeys, expiration: ws.expiration, count: ws.count, @@ -792,154 +740,194 @@ class DHTRecordPool with TableDBBackedJson { } } + Future _watchStateChange( + TypedKey openedRecordKey, _WatchState? unionWatchState) async { + // Get the current state for this watch + final openedRecordInfo = _opened[openedRecordKey]; + if (openedRecordInfo == null) { + // Record is gone, nothing to do + return; + } + final currentWatchState = openedRecordInfo.shared.unionWatchState; + final dhtctx = openedRecordInfo.shared.defaultRoutingContext; + + // If it's the same as our desired state there is nothing to do here + if (currentWatchState == unionWatchState) { + return; + } + + // Apply watch changes for record + if (unionWatchState == null) { + // Record needs watch cancel + // Only try this once, if it doesn't succeed then it can just expire + // on its own. + try { + final cancelled = await dhtctx.cancelDHTWatch(openedRecordKey); + + log('cancelDHTWatch: key=$openedRecordKey, cancelled=$cancelled, ' + 'debugNames=${openedRecordInfo.debugNames}'); + + openedRecordInfo.shared.unionWatchState = null; + openedRecordInfo.shared.needsWatchStateUpdate = false; + } on VeilidAPIException catch (e) { + // Failed to cancel DHT watch, try again next tick + log('Exception in watch cancel: $e'); + } + return; + } + + // Record needs new watch + try { + final subkeys = unionWatchState.subkeys?.toList(); + final count = unionWatchState.count; + final expiration = unionWatchState.expiration; + final now = veilid.now(); + + final realExpiration = await dhtctx.watchDHTValues(openedRecordKey, + subkeys: unionWatchState.subkeys?.toList(), + count: unionWatchState.count, + expiration: unionWatchState.expiration ?? + (_defaultWatchDurationSecs == null + ? null + : veilid.now().offset(TimestampDuration.fromMillis( + _defaultWatchDurationSecs! * 1000)))); + + final expirationDuration = realExpiration.diff(now); + final renewalTime = now.offset(TimestampDuration( + value: expirationDuration.value * + BigInt.from(_watchRenewalNumerator) ~/ + BigInt.from(_watchRenewalDenominator))); + + log('watchDHTValues: key=$openedRecordKey, subkeys=$subkeys, ' + 'count=$count, expiration=$expiration, ' + 'realExpiration=$realExpiration, ' + 'renewalTime=$renewalTime, ' + 'debugNames=${openedRecordInfo.debugNames}'); + + // Update watch states with real expiration + if (realExpiration.value != BigInt.zero) { + openedRecordInfo.shared.unionWatchState = unionWatchState; + _updateWatchRealExpirations( + openedRecordInfo.records, realExpiration, renewalTime); + openedRecordInfo.shared.needsWatchStateUpdate = false; + } + } on VeilidAPIException catch (e) { + // Failed to cancel DHT watch, try again next tick + log('Exception in watch update: $e'); + } + } + + void _pollWatch(TypedKey openedRecordKey, _OpenedRecordInfo openedRecordInfo, + _WatchState unionWatchState) { + singleFuture((this, _sfPollWatch, openedRecordKey), () async { + final dhtctx = openedRecordInfo.shared.defaultRoutingContext; + + // Get single subkey to poll + // XXX: veilid api limits this for now until everyone supports + // inspectDHTRecord + final pollSubkey = unionWatchState.subkeys?.firstSubkey; + if (pollSubkey == null) { + return; + } + final pollSubkeys = [ValueSubkeyRange.single(pollSubkey)]; + + final currentReport = + await dhtctx.inspectDHTRecord(openedRecordKey, subkeys: pollSubkeys); + final currentSeq = currentReport.localSeqs.firstOrNull ?? -1; + + final valueData = await dhtctx.getDHTValue(openedRecordKey, pollSubkey, + forceRefresh: true); + if (valueData == null) { + return; + } + if (valueData.seq > currentSeq) { + processRemoteValueChange(VeilidUpdateValueChange( + key: openedRecordKey, + subkeys: pollSubkeys, + count: 0xFFFFFFFF, + value: valueData)); + } + }); + } + /// Ticker to check watch state change requests Future tick() async { - if (_tickCount < _watchBackoffTimer) { - _tickCount++; - return; - } - if (_inTick) { - return; - } - _inTick = true; - _tickCount = 0; final now = veilid.now(); - try { - final allSuccess = await _mutex.protect(() async { - // See if any opened records need watch state changes - final unord = Function()>[]; + await _mutex.protect(() async { + // See if any opened records need watch state changes + for (final kv in _opened.entries) { + final openedRecordKey = kv.key; + final openedRecordInfo = kv.value; - for (final kv in _opened.entries) { - final openedRecordKey = kv.key; - final openedRecordInfo = kv.value; - final dhtctx = openedRecordInfo.shared.defaultRoutingContext; + var wantsWatchStateUpdate = + openedRecordInfo.shared.needsWatchStateUpdate; - var wantsWatchStateUpdate = - openedRecordInfo.shared.needsWatchStateUpdate; - - // Check if we have reached renewal time for the watch - if (openedRecordInfo.shared.unionWatchState != null && - openedRecordInfo.shared.unionWatchState!.renewalTime != null && - now.value > - openedRecordInfo.shared.unionWatchState!.renewalTime!.value) { - wantsWatchStateUpdate = true; - } - - if (wantsWatchStateUpdate) { - // Update union watch state - final unionWatchState = openedRecordInfo.shared.unionWatchState = - _collectUnionWatchState(openedRecordInfo.records); - - // Apply watch changes for record - if (unionWatchState == null) { - unord.add(() async { - // Record needs watch cancel - var success = false; - try { - success = await dhtctx.cancelDHTWatch(openedRecordKey); - - log('cancelDHTWatch: key=$openedRecordKey, success=$success, ' - 'debugNames=${openedRecordInfo.debugNames}'); - - openedRecordInfo.shared.needsWatchStateUpdate = false; - } on VeilidAPIException catch (e) { - // Failed to cancel DHT watch, try again next tick - log('Exception in watch cancel: $e'); - } - return success; - }); - } else { - unord.add(() async { - // Record needs new watch - var success = false; - try { - final subkeys = unionWatchState.subkeys?.toList(); - final count = unionWatchState.count; - final expiration = unionWatchState.expiration; - final now = veilid.now(); - - final realExpiration = await dhtctx.watchDHTValues( - openedRecordKey, - subkeys: unionWatchState.subkeys?.toList(), - count: unionWatchState.count, - expiration: unionWatchState.expiration ?? - (defaultWatchDurationSecs == null - ? null - : veilid.now().offset( - TimestampDuration.fromMillis( - defaultWatchDurationSecs! * 1000)))); - - final expirationDuration = realExpiration.diff(now); - final renewalTime = now.offset(TimestampDuration( - value: expirationDuration.value * - BigInt.from(watchRenewalNumerator) ~/ - BigInt.from(watchRenewalDenominator))); - - log('watchDHTValues: key=$openedRecordKey, subkeys=$subkeys, ' - 'count=$count, expiration=$expiration, ' - 'realExpiration=$realExpiration, ' - 'renewalTime=$renewalTime, ' - 'debugNames=${openedRecordInfo.debugNames}'); - - // Update watch states with real expiration - if (realExpiration.value != BigInt.zero) { - openedRecordInfo.shared.needsWatchStateUpdate = false; - _updateWatchRealExpirations( - openedRecordInfo.records, realExpiration, renewalTime); - success = true; - } - } on VeilidAPIException catch (e) { - // Failed to cancel DHT watch, try again next tick - log('Exception in watch update: $e'); - } - return success; - }); - } - } + // Check if we have reached renewal time for the watch + if (openedRecordInfo.shared.unionWatchState != null && + openedRecordInfo.shared.unionWatchState!.renewalTime != null && + now.value > + openedRecordInfo.shared.unionWatchState!.renewalTime!.value) { + wantsWatchStateUpdate = true; } - // Process all watch changes - return unord.isEmpty || - (await unord.map((f) => f()).wait).reduce((a, b) => a && b); - }); + if (wantsWatchStateUpdate) { + // Update union watch state + final unionWatchState = + _collectUnionWatchState(openedRecordInfo.records); - // If any watched did not success, back off the attempts to - // update the watches for a bit + final processed = _watchStateProcessors.updateState( + openedRecordKey, + unionWatchState, + (newState) => + _watchStateChange(openedRecordKey, unionWatchState)); - if (!allSuccess) { - _watchBackoffTimer *= watchBackoffMultiplier; - _watchBackoffTimer = min(_watchBackoffTimer, watchBackoffMax); - } else { - _watchBackoffTimer = 1; + // In lieu of a completed watch, set off a polling operation + // on the first value of the watched range, which, due to current + // veilid limitations can only be one subkey at a time right now + if (!processed && unionWatchState != null) { + _pollWatch(openedRecordKey, openedRecordInfo, unionWatchState); + } + } } - } finally { - _inTick = false; - } + }); } - void debugPrintAllocations() { - final sortedAllocations = _state.debugNames.entries.asList() - ..sort((a, b) => a.key.compareTo(b.key)); + ////////////////////////////////////////////////////////////// + // AsyncTableDBBacked + @override + String tableName() => 'dht_record_pool'; + @override + String tableKeyName() => 'pool_allocations'; + @override + DHTRecordPoolAllocations valueFromJson(Object? obj) => obj != null + ? DHTRecordPoolAllocations.fromJson(obj) + : const DHTRecordPoolAllocations(); + @override + Object? valueToJson(DHTRecordPoolAllocations? val) => val?.toJson(); - log('DHTRecordPool Allocations: (count=${sortedAllocations.length})'); + //////////////////////////////////////////////////////////////////////////// + // Fields - for (final entry in sortedAllocations) { - log(' ${entry.key}: ${entry.value}'); - } - } + // Logger + DHTRecordPoolLogger? _logger; - void debugPrintOpened() { - final sortedOpened = _opened.entries.asList() - ..sort((a, b) => a.key.toString().compareTo(b.key.toString())); + // Persistent DHT record list + DHTRecordPoolAllocations _state; + // Create/open Mutex + final Mutex _mutex; + // Which DHT records are currently open + final Map _opened; + // Which DHT records are marked for deletion + final Set _markedForDelete; + // Default routing context to use for new keys + final VeilidRoutingContext _routingContext; + // Convenience accessor + final Veilid _veilid; + Veilid get veilid => _veilid; + // Watch state processors + final _watchStateProcessors = + SingleStateProcessorMap(); - log('DHTRecordPool Opened Records: (count=${sortedOpened.length})'); - - for (final entry in sortedOpened) { - log(' ${entry.key}: \n' - ' debugNames=${entry.value.debugNames}\n' - ' details=${entry.value.details}\n' - ' sharedDetails=${entry.value.sharedDetails}\n'); - } - } + static DHTRecordPool? _singleton; } diff --git a/packages/veilid_support/lib/dht_support/src/dht_record/dht_record_pool_private.dart b/packages/veilid_support/lib/dht_support/src/dht_record/dht_record_pool_private.dart new file mode 100644 index 0000000..b7cbba8 --- /dev/null +++ b/packages/veilid_support/lib/dht_support/src/dht_record/dht_record_pool_private.dart @@ -0,0 +1,77 @@ +part of 'dht_record_pool.dart'; + +const int _watchBackoffMultiplier = 2; +const int _watchBackoffMax = 30; + +const int? _defaultWatchDurationSecs = null; // 600 +const int _watchRenewalNumerator = 4; +const int _watchRenewalDenominator = 5; + +// DHT crypto domain +const String _cryptoDomainDHT = 'dht'; + +// Singlefuture keys +const _sfPollWatch = '_pollWatch'; +const _sfListen = 'listen'; + +/// Watch state +@immutable +class _WatchState extends Equatable { + const _WatchState( + {required this.subkeys, + required this.expiration, + required this.count, + this.realExpiration, + this.renewalTime}); + final List? subkeys; + final Timestamp? expiration; + final int? count; + final Timestamp? realExpiration; + final Timestamp? renewalTime; + + @override + List get props => + [subkeys, expiration, count, realExpiration, renewalTime]; +} + +/// Data shared amongst all DHTRecord instances +class _SharedDHTRecordData { + _SharedDHTRecordData( + {required this.recordDescriptor, + required this.defaultWriter, + required this.defaultRoutingContext}); + DHTRecordDescriptor recordDescriptor; + KeyPair? defaultWriter; + VeilidRoutingContext defaultRoutingContext; + bool needsWatchStateUpdate = false; + _WatchState? unionWatchState; +} + +// Per opened record data +class _OpenedRecordInfo { + _OpenedRecordInfo( + {required DHTRecordDescriptor recordDescriptor, + required KeyPair? defaultWriter, + required VeilidRoutingContext defaultRoutingContext}) + : shared = _SharedDHTRecordData( + recordDescriptor: recordDescriptor, + defaultWriter: defaultWriter, + defaultRoutingContext: defaultRoutingContext); + _SharedDHTRecordData shared; + Set records = {}; + + String get debugNames { + final r = records.toList() + ..sort((a, b) => a.key.toString().compareTo(b.key.toString())); + return '[${r.map((x) => x.debugName).join(',')}]'; + } + + String get details { + final r = records.toList() + ..sort((a, b) => a.key.toString().compareTo(b.key.toString())); + return '[${r.map((x) => "writer=${x._writer} " + "defaultSubkey=${x._defaultSubkey}").join(',')}]'; + } + + String get sharedDetails => shared.toString(); +} diff --git a/packages/veilid_support/lib/proto/dht.pb.dart b/packages/veilid_support/lib/proto/dht.pb.dart index 814bd22..7a9ac9a 100644 --- a/packages/veilid_support/lib/proto/dht.pb.dart +++ b/packages/veilid_support/lib/proto/dht.pb.dart @@ -195,177 +195,6 @@ class DHTShortArray extends $pb.GeneratedMessage { $core.List<$core.int> get seqs => $_getList(2); } -class DHTDataReference extends $pb.GeneratedMessage { - factory DHTDataReference() => create(); - DHTDataReference._() : super(); - factory DHTDataReference.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); - factory DHTDataReference.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); - - static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'DHTDataReference', package: const $pb.PackageName(_omitMessageNames ? '' : 'dht'), createEmptyInstance: create) - ..aOM<$0.TypedKey>(1, _omitFieldNames ? '' : 'dhtData', subBuilder: $0.TypedKey.create) - ..aOM<$0.TypedKey>(2, _omitFieldNames ? '' : 'hash', subBuilder: $0.TypedKey.create) - ..hasRequiredFields = false - ; - - @$core.Deprecated( - 'Using this can add significant overhead to your binary. ' - 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' - 'Will be removed in next major version') - DHTDataReference clone() => DHTDataReference()..mergeFromMessage(this); - @$core.Deprecated( - 'Using this can add significant overhead to your binary. ' - 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' - 'Will be removed in next major version') - DHTDataReference copyWith(void Function(DHTDataReference) updates) => super.copyWith((message) => updates(message as DHTDataReference)) as DHTDataReference; - - $pb.BuilderInfo get info_ => _i; - - @$core.pragma('dart2js:noInline') - static DHTDataReference create() => DHTDataReference._(); - DHTDataReference createEmptyInstance() => create(); - static $pb.PbList createRepeated() => $pb.PbList(); - @$core.pragma('dart2js:noInline') - static DHTDataReference getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); - static DHTDataReference? _defaultInstance; - - @$pb.TagNumber(1) - $0.TypedKey get dhtData => $_getN(0); - @$pb.TagNumber(1) - set dhtData($0.TypedKey v) { setField(1, v); } - @$pb.TagNumber(1) - $core.bool hasDhtData() => $_has(0); - @$pb.TagNumber(1) - void clearDhtData() => clearField(1); - @$pb.TagNumber(1) - $0.TypedKey ensureDhtData() => $_ensure(0); - - @$pb.TagNumber(2) - $0.TypedKey get hash => $_getN(1); - @$pb.TagNumber(2) - set hash($0.TypedKey v) { setField(2, v); } - @$pb.TagNumber(2) - $core.bool hasHash() => $_has(1); - @$pb.TagNumber(2) - void clearHash() => clearField(2); - @$pb.TagNumber(2) - $0.TypedKey ensureHash() => $_ensure(1); -} - -class BlockStoreDataReference extends $pb.GeneratedMessage { - factory BlockStoreDataReference() => create(); - BlockStoreDataReference._() : super(); - factory BlockStoreDataReference.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); - factory BlockStoreDataReference.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); - - static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'BlockStoreDataReference', package: const $pb.PackageName(_omitMessageNames ? '' : 'dht'), createEmptyInstance: create) - ..aOM<$0.TypedKey>(1, _omitFieldNames ? '' : 'block', subBuilder: $0.TypedKey.create) - ..hasRequiredFields = false - ; - - @$core.Deprecated( - 'Using this can add significant overhead to your binary. ' - 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' - 'Will be removed in next major version') - BlockStoreDataReference clone() => BlockStoreDataReference()..mergeFromMessage(this); - @$core.Deprecated( - 'Using this can add significant overhead to your binary. ' - 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' - 'Will be removed in next major version') - BlockStoreDataReference copyWith(void Function(BlockStoreDataReference) updates) => super.copyWith((message) => updates(message as BlockStoreDataReference)) as BlockStoreDataReference; - - $pb.BuilderInfo get info_ => _i; - - @$core.pragma('dart2js:noInline') - static BlockStoreDataReference create() => BlockStoreDataReference._(); - BlockStoreDataReference createEmptyInstance() => create(); - static $pb.PbList createRepeated() => $pb.PbList(); - @$core.pragma('dart2js:noInline') - static BlockStoreDataReference getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); - static BlockStoreDataReference? _defaultInstance; - - @$pb.TagNumber(1) - $0.TypedKey get block => $_getN(0); - @$pb.TagNumber(1) - set block($0.TypedKey v) { setField(1, v); } - @$pb.TagNumber(1) - $core.bool hasBlock() => $_has(0); - @$pb.TagNumber(1) - void clearBlock() => clearField(1); - @$pb.TagNumber(1) - $0.TypedKey ensureBlock() => $_ensure(0); -} - -enum DataReference_Kind { - dhtData, - blockStoreData, - notSet -} - -class DataReference extends $pb.GeneratedMessage { - factory DataReference() => create(); - DataReference._() : super(); - factory DataReference.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); - factory DataReference.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); - - static const $core.Map<$core.int, DataReference_Kind> _DataReference_KindByTag = { - 1 : DataReference_Kind.dhtData, - 2 : DataReference_Kind.blockStoreData, - 0 : DataReference_Kind.notSet - }; - static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'DataReference', package: const $pb.PackageName(_omitMessageNames ? '' : 'dht'), createEmptyInstance: create) - ..oo(0, [1, 2]) - ..aOM(1, _omitFieldNames ? '' : 'dhtData', subBuilder: DHTDataReference.create) - ..aOM(2, _omitFieldNames ? '' : 'blockStoreData', subBuilder: BlockStoreDataReference.create) - ..hasRequiredFields = false - ; - - @$core.Deprecated( - 'Using this can add significant overhead to your binary. ' - 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' - 'Will be removed in next major version') - DataReference clone() => DataReference()..mergeFromMessage(this); - @$core.Deprecated( - 'Using this can add significant overhead to your binary. ' - 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' - 'Will be removed in next major version') - DataReference copyWith(void Function(DataReference) updates) => super.copyWith((message) => updates(message as DataReference)) as DataReference; - - $pb.BuilderInfo get info_ => _i; - - @$core.pragma('dart2js:noInline') - static DataReference create() => DataReference._(); - DataReference createEmptyInstance() => create(); - static $pb.PbList createRepeated() => $pb.PbList(); - @$core.pragma('dart2js:noInline') - static DataReference getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); - static DataReference? _defaultInstance; - - DataReference_Kind whichKind() => _DataReference_KindByTag[$_whichOneof(0)]!; - void clearKind() => clearField($_whichOneof(0)); - - @$pb.TagNumber(1) - DHTDataReference get dhtData => $_getN(0); - @$pb.TagNumber(1) - set dhtData(DHTDataReference v) { setField(1, v); } - @$pb.TagNumber(1) - $core.bool hasDhtData() => $_has(0); - @$pb.TagNumber(1) - void clearDhtData() => clearField(1); - @$pb.TagNumber(1) - DHTDataReference ensureDhtData() => $_ensure(0); - - @$pb.TagNumber(2) - BlockStoreDataReference get blockStoreData => $_getN(1); - @$pb.TagNumber(2) - set blockStoreData(BlockStoreDataReference v) { setField(2, v); } - @$pb.TagNumber(2) - $core.bool hasBlockStoreData() => $_has(1); - @$pb.TagNumber(2) - void clearBlockStoreData() => clearField(2); - @$pb.TagNumber(2) - BlockStoreDataReference ensureBlockStoreData() => $_ensure(1); -} - class OwnedDHTRecordPointer extends $pb.GeneratedMessage { factory OwnedDHTRecordPointer() => create(); OwnedDHTRecordPointer._() : super(); diff --git a/packages/veilid_support/lib/proto/dht.pbjson.dart b/packages/veilid_support/lib/proto/dht.pbjson.dart index b8575b9..9d505f0 100644 --- a/packages/veilid_support/lib/proto/dht.pbjson.dart +++ b/packages/veilid_support/lib/proto/dht.pbjson.dart @@ -60,51 +60,6 @@ final $typed_data.Uint8List dHTShortArrayDescriptor = $convert.base64Decode( 'Cg1ESFRTaG9ydEFycmF5EiQKBGtleXMYASADKAsyEC52ZWlsaWQuVHlwZWRLZXlSBGtleXMSFA' 'oFaW5kZXgYAiABKAxSBWluZGV4EhIKBHNlcXMYAyADKA1SBHNlcXM='); -@$core.Deprecated('Use dHTDataReferenceDescriptor instead') -const DHTDataReference$json = { - '1': 'DHTDataReference', - '2': [ - {'1': 'dht_data', '3': 1, '4': 1, '5': 11, '6': '.veilid.TypedKey', '10': 'dhtData'}, - {'1': 'hash', '3': 2, '4': 1, '5': 11, '6': '.veilid.TypedKey', '10': 'hash'}, - ], -}; - -/// Descriptor for `DHTDataReference`. Decode as a `google.protobuf.DescriptorProto`. -final $typed_data.Uint8List dHTDataReferenceDescriptor = $convert.base64Decode( - 'ChBESFREYXRhUmVmZXJlbmNlEisKCGRodF9kYXRhGAEgASgLMhAudmVpbGlkLlR5cGVkS2V5Ug' - 'dkaHREYXRhEiQKBGhhc2gYAiABKAsyEC52ZWlsaWQuVHlwZWRLZXlSBGhhc2g='); - -@$core.Deprecated('Use blockStoreDataReferenceDescriptor instead') -const BlockStoreDataReference$json = { - '1': 'BlockStoreDataReference', - '2': [ - {'1': 'block', '3': 1, '4': 1, '5': 11, '6': '.veilid.TypedKey', '10': 'block'}, - ], -}; - -/// Descriptor for `BlockStoreDataReference`. Decode as a `google.protobuf.DescriptorProto`. -final $typed_data.Uint8List blockStoreDataReferenceDescriptor = $convert.base64Decode( - 'ChdCbG9ja1N0b3JlRGF0YVJlZmVyZW5jZRImCgVibG9jaxgBIAEoCzIQLnZlaWxpZC5UeXBlZE' - 'tleVIFYmxvY2s='); - -@$core.Deprecated('Use dataReferenceDescriptor instead') -const DataReference$json = { - '1': 'DataReference', - '2': [ - {'1': 'dht_data', '3': 1, '4': 1, '5': 11, '6': '.dht.DHTDataReference', '9': 0, '10': 'dhtData'}, - {'1': 'block_store_data', '3': 2, '4': 1, '5': 11, '6': '.dht.BlockStoreDataReference', '9': 0, '10': 'blockStoreData'}, - ], - '8': [ - {'1': 'kind'}, - ], -}; - -/// Descriptor for `DataReference`. Decode as a `google.protobuf.DescriptorProto`. -final $typed_data.Uint8List dataReferenceDescriptor = $convert.base64Decode( - 'Cg1EYXRhUmVmZXJlbmNlEjIKCGRodF9kYXRhGAEgASgLMhUuZGh0LkRIVERhdGFSZWZlcmVuY2' - 'VIAFIHZGh0RGF0YRJIChBibG9ja19zdG9yZV9kYXRhGAIgASgLMhwuZGh0LkJsb2NrU3RvcmVE' - 'YXRhUmVmZXJlbmNlSABSDmJsb2NrU3RvcmVEYXRhQgYKBGtpbmQ='); - @$core.Deprecated('Use ownedDHTRecordPointerDescriptor instead') const OwnedDHTRecordPointer$json = { '1': 'OwnedDHTRecordPointer', diff --git a/packages/veilid_support/lib/src/json_tools.dart b/packages/veilid_support/lib/src/json_tools.dart index c5895d0..dd0f20b 100644 --- a/packages/veilid_support/lib/src/json_tools.dart +++ b/packages/veilid_support/lib/src/json_tools.dart @@ -12,16 +12,19 @@ Uint8List jsonEncodeBytes(Object? object, Uint8List.fromList( utf8.encode(jsonEncode(object, toEncodable: toEncodable))); -Future jsonUpdateBytes(T Function(dynamic) fromJson, - Uint8List? oldBytes, Future Function(T?) update) async { +Future jsonUpdateBytes(T Function(dynamic) fromJson, + Uint8List? oldBytes, Future Function(T?) update) async { final oldObj = oldBytes == null ? null : fromJson(jsonDecode(utf8.decode(oldBytes))); final newObj = await update(oldObj); + if (newObj == null) { + return null; + } return jsonEncodeBytes(newObj); } -Future Function(Uint8List?) jsonUpdate( - T Function(dynamic) fromJson, Future Function(T?) update) => +Future Function(Uint8List?) jsonUpdate( + T Function(dynamic) fromJson, Future Function(T?) update) => (oldBytes) => jsonUpdateBytes(fromJson, oldBytes, update); T Function(Object?) genericFromJson( diff --git a/packages/veilid_support/lib/src/persistent_queue.dart b/packages/veilid_support/lib/src/persistent_queue.dart index f0cf17a..c7abe97 100644 --- a/packages/veilid_support/lib/src/persistent_queue.dart +++ b/packages/veilid_support/lib/src/persistent_queue.dart @@ -8,8 +8,7 @@ import 'package:protobuf/protobuf.dart'; import 'table_db.dart'; class PersistentQueue - /*extends Cubit>>*/ with - TableDBBackedFromBuffer> { + with TableDBBackedFromBuffer> { // PersistentQueue( {required String table, diff --git a/packages/veilid_support/lib/src/protobuf_tools.dart b/packages/veilid_support/lib/src/protobuf_tools.dart index 94dc6d1..0120e06 100644 --- a/packages/veilid_support/lib/src/protobuf_tools.dart +++ b/packages/veilid_support/lib/src/protobuf_tools.dart @@ -2,16 +2,19 @@ import 'dart:typed_data'; import 'package:protobuf/protobuf.dart'; -Future protobufUpdateBytes( +Future protobufUpdateBytes( T Function(List) fromBuffer, Uint8List? oldBytes, - Future Function(T?) update) async { + Future Function(T?) update) async { final oldObj = oldBytes == null ? null : fromBuffer(oldBytes); final newObj = await update(oldObj); + if (newObj == null) { + return null; + } return Uint8List.fromList(newObj.writeToBuffer()); } -Future Function(Uint8List?) +Future Function(Uint8List?) protobufUpdate( - T Function(List) fromBuffer, Future Function(T?) update) => + T Function(List) fromBuffer, Future Function(T?) update) => (oldBytes) => protobufUpdateBytes(fromBuffer, oldBytes, update); diff --git a/packages/veilid_support/pubspec.lock b/packages/veilid_support/pubspec.lock index f74ee28..107e8d4 100644 --- a/packages/veilid_support/pubspec.lock +++ b/packages/veilid_support/pubspec.lock @@ -39,7 +39,7 @@ packages: path: "../../../dart_async_tools" relative: true source: path - version: "0.1.1" + version: "0.1.3" bloc: dependency: "direct main" description: @@ -54,7 +54,7 @@ packages: path: "../../../bloc_advanced_tools" relative: true source: path - version: "0.1.1" + version: "0.1.3" boolean_selector: dependency: transitive description: diff --git a/packages/veilid_support/pubspec.yaml b/packages/veilid_support/pubspec.yaml index 49c5325..b2b0e5c 100644 --- a/packages/veilid_support/pubspec.yaml +++ b/packages/veilid_support/pubspec.yaml @@ -7,9 +7,9 @@ environment: sdk: '>=3.2.0 <4.0.0' dependencies: - async_tools: ^0.1.2 + async_tools: ^0.1.3 bloc: ^8.1.4 - bloc_advanced_tools: ^0.1.2 + bloc_advanced_tools: ^0.1.3 charcode: ^1.3.1 collection: ^1.18.0 equatable: ^2.0.5 @@ -24,11 +24,11 @@ dependencies: # veilid: ^0.0.1 path: ../../../veilid/veilid-flutter -# dependency_overrides: -# async_tools: -# path: ../../../dart_async_tools -# bloc_advanced_tools: -# path: ../../../bloc_advanced_tools +dependency_overrides: + async_tools: + path: ../../../dart_async_tools + bloc_advanced_tools: + path: ../../../bloc_advanced_tools dev_dependencies: build_runner: ^2.4.10 diff --git a/pubspec.lock b/pubspec.lock index 03260fe..0855cbc 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -60,11 +60,10 @@ packages: async_tools: dependency: "direct main" description: - name: async_tools - sha256: "72590010ed6c6f5cbd5d40e33834abc08a43da6a73ac3c3645517d53899b8684" - url: "https://pub.dev" - source: hosted - version: "0.1.2" + path: "../dart_async_tools" + relative: true + source: path + version: "0.1.3" awesome_extensions: dependency: "direct main" description: @@ -100,11 +99,10 @@ packages: bloc_advanced_tools: dependency: "direct main" description: - name: bloc_advanced_tools - sha256: "0cf9b3a73a67addfe22ec3f97a1ac240f6ad53870d6b21a980260f390d7901cd" - url: "https://pub.dev" - source: hosted - version: "0.1.2" + path: "../bloc_advanced_tools" + relative: true + source: path + version: "0.1.3" blurry_modal_progress_hud: dependency: "direct main" description: @@ -585,6 +583,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_zoom_drawer: + dependency: "direct main" + description: + name: flutter_zoom_drawer + sha256: "5a3708548868463fb36e0e3171761ab7cb513df88d2f14053802812d2e855060" + url: "https://pub.dev" + source: hosted + version: "3.2.0" form_builder_validators: dependency: "direct main" description: @@ -857,6 +863,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.0" + package_info_plus: + dependency: "direct main" + description: + name: package_info_plus + sha256: b93d8b4d624b4ea19b0a5a208b2d6eff06004bc3ce74c06040b120eeadd00ce0 + url: "https://pub.dev" + source: hosted + version: "8.0.0" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: f49918f3433a3146047372f9d4f1f847511f2acd5cd030e1f44fe5a50036b70e + url: "https://pub.dev" + source: hosted + version: "3.0.0" pasteboard: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index a77991a..2f28727 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -11,12 +11,12 @@ dependencies: animated_theme_switcher: ^2.0.10 ansicolor: ^2.0.2 archive: ^3.6.1 - async_tools: ^0.1.2 + async_tools: ^0.1.3 awesome_extensions: ^2.0.16 badges: ^3.1.2 basic_utils: ^5.7.0 bloc: ^8.1.4 - bloc_advanced_tools: ^0.1.2 + bloc_advanced_tools: ^0.1.3 blurry_modal_progress_hud: ^1.1.1 change_case: ^2.1.0 charcode: ^1.3.1 @@ -45,6 +45,7 @@ dependencies: flutter_spinkit: ^5.2.1 flutter_svg: ^2.0.10+1 flutter_translate: ^4.1.0 + flutter_zoom_drawer: ^3.2.0 form_builder_validators: ^10.0.1 freezed_annotation: ^2.4.1 go_router: ^14.1.4 @@ -56,6 +57,7 @@ dependencies: meta: ^1.12.0 mobile_scanner: ^5.1.1 motion_toast: ^2.10.0 + package_info_plus: ^8.0.0 pasteboard: ^0.2.0 path: ^1.9.0 path_provider: ^2.1.3 @@ -91,11 +93,11 @@ dependencies: xterm: ^4.0.0 zxing2: ^0.2.3 -# dependency_overrides: -# async_tools: -# path: ../dart_async_tools -# bloc_advanced_tools: -# path: ../bloc_advanced_tools +dependency_overrides: + async_tools: + path: ../dart_async_tools + bloc_advanced_tools: + path: ../bloc_advanced_tools # flutter_chat_ui: # path: ../flutter_chat_ui