diff --git a/lib/account_manager/cubits/account_record_cubit.dart b/lib/account_manager/cubits/account_record_cubit.dart index aca66e1..1424ac6 100644 --- a/lib/account_manager/cubits/account_record_cubit.dart +++ b/lib/account_manager/cubits/account_record_cubit.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:protobuf/protobuf.dart'; import 'package:veilid_support/veilid_support.dart'; import '../../proto/proto.dart' as proto; @@ -7,6 +8,11 @@ import '../account_manager.dart'; 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 recrod from the 'userLogin' +/// tabledb-local storage, encrypted by the unlock code for the account. class AccountRecordCubit extends DefaultDHTRecordCubit { AccountRecordCubit( {required AccountRepository accountRepository, @@ -35,4 +41,16 @@ class AccountRecordCubit extends DefaultDHTRecordCubit { 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/account_records_bloc_map_cubit.dart b/lib/account_manager/cubits/account_records_bloc_map_cubit.dart index bf7bce3..32f5856 100644 --- a/lib/account_manager/cubits/account_records_bloc_map_cubit.dart +++ b/lib/account_manager/cubits/account_records_bloc_map_cubit.dart @@ -7,7 +7,8 @@ import '../../account_manager/account_manager.dart'; typedef AccountRecordsBlocMapState = BlocMapState>; -// Map of the logged in user accounts to their account information +/// Map of the logged in user accounts to their AccountRecordCubit +/// Ensures there is an single account record cubit for each logged in account class AccountRecordsBlocMapCubit extends BlocMapCubit, AccountRecordCubit> with StateMapFollower { diff --git a/lib/account_manager/views/edit_account_page.dart b/lib/account_manager/views/edit_account_page.dart index 83e2067..a461bb0 100644 --- a/lib/account_manager/views/edit_account_page.dart +++ b/lib/account_manager/views/edit_account_page.dart @@ -8,8 +8,6 @@ import 'package:go_router/go_router.dart'; import 'package:protobuf/protobuf.dart'; import 'package:veilid_support/veilid_support.dart'; -import '../../contacts/cubits/contact_list_cubit.dart'; -import '../../conversation/conversation.dart'; import '../../layout/default_app_bar.dart'; import '../../proto/proto.dart' as proto; import '../../theme/theme.dart'; @@ -41,7 +39,6 @@ class EditAccountPage extends StatefulWidget { } class _EditAccountPageState extends State { - final _formKey = GlobalKey(); bool _isInAsyncCall = false; @override @@ -58,24 +55,37 @@ class _EditAccountPageState extends State { {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); + 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; - final accountRecordCubit = context.read(); - final activeConversationsBlocMapCubit = - context.read(); - final contactListCubit = context.read(); + final accountRecordsCubit = context.watch(); + final accountRecordCubit = accountRecordsCubit + .operate(widget.superIdentityRecordKey, closure: (c) => c); 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( @@ -92,57 +102,35 @@ class _EditAccountPageState extends State { FocusScope.of(context).unfocus(); try { - final name = _formKey.currentState! + final name = formKey.currentState! .fields[EditProfileForm.formFieldName]!.value as String; - final pronouns = _formKey + final pronouns = formKey .currentState! .fields[EditProfileForm.formFieldPronouns]! .value as String? ?? ''; final newProfile = widget.existingProfile.deepCopy() ..name = name - ..pronouns = pronouns; + ..pronouns = pronouns + ..timestamp = Veilid.instance.now().toInt64(); setState(() { _isInAsyncCall = true; }); try { // Update account profile DHT record - final newValue = await accountRecordCubit.record - .tryWriteProtobuf(proto.Account.fromBuffer, newProfile); - if (newValue != null) { - if (context.mounted) { - await showErrorModal( - context, - translate('edit_account_page.error'), - 'Failed to update profile online'); - return; - } - } + // This triggers ConversationCubits to update + await accountRecordCubit.updateProfile(newProfile); // Update local account profile await AccountRepository.instance.editAccountProfile( widget.superIdentityRecordKey, newProfile); - // Update all conversations with new profile - final updates = >[]; - for (final key in activeConversationsBlocMapCubit.state.keys) { - await activeConversationsBlocMapCubit.operateAsync(key, - closure: (cubit) async { - final newLocalConversation = - cubit.state.asData?.value.localConversation.deepCopy(); - if (newLocalConversation != null) { - newLocalConversation.profile = newProfile; - updates.add(cubit.input.writeLocalConversation( - conversation: newLocalConversation)); - } - }); + if (context.mounted) { + Navigator.canPop(context) + ? GoRouterHelper(context).pop() + : GoRouterHelper(context).go('/'); } - - // Wait for updates - await updates.wait; - - // XXX: how to do this for non-chat contacts? } finally { if (mounted) { setState(() { diff --git a/lib/account_manager/views/new_account_page.dart b/lib/account_manager/views/new_account_page.dart index 88ccef7..65d57ea 100644 --- a/lib/account_manager/views/new_account_page.dart +++ b/lib/account_manager/views/new_account_page.dart @@ -21,7 +21,6 @@ class NewAccountPage extends StatefulWidget { } class _NewAccountPageState extends State { - final _formKey = GlobalKey(); bool _isInAsyncCall = false; @override @@ -61,6 +60,14 @@ class _NewAccountPageState extends State { // 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( @@ -77,9 +84,9 @@ class _NewAccountPageState extends State { FocusScope.of(context).unfocus(); try { - final name = _formKey.currentState! + final name = formKey.currentState! .fields[EditProfileForm.formFieldName]!.value as String; - final pronouns = _formKey + final pronouns = formKey .currentState! .fields[EditProfileForm.formFieldPronouns]! .value as String? ?? diff --git a/lib/account_manager/views/profile_edit_form.dart b/lib/account_manager/views/profile_edit_form.dart index a055396..2e14249 100644 --- a/lib/account_manager/views/profile_edit_form.dart +++ b/lib/account_manager/views/profile_edit_form.dart @@ -13,6 +13,7 @@ class EditProfileForm extends StatefulWidget { required this.submitDisabledText, super.key, this.onSubmit, + this.initialValueCallback, }); @override @@ -23,6 +24,7 @@ class EditProfileForm extends StatefulWidget { final Future Function(GlobalKey)? onSubmit; final String submitText; final String submitDisabledText; + final Object? Function(String key)? initialValueCallback; @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { @@ -34,7 +36,9 @@ class EditProfileForm extends StatefulWidget { Future Function( GlobalKey p1)?>.has('onSubmit', onSubmit)) ..add(StringProperty('submitText', submitText)) - ..add(StringProperty('submitDisabledText', submitDisabledText)); + ..add(StringProperty('submitDisabledText', submitDisabledText)) + ..add(ObjectFlagProperty.has( + 'initialValueCallback', initialValueCallback)); } static const String formFieldName = 'name'; @@ -62,6 +66,8 @@ class _EditProfileFormState extends State { FormBuilderTextField( autofocus: true, name: EditProfileForm.formFieldName, + initialValue: widget.initialValueCallback + ?.call(EditProfileForm.formFieldName) as String?, decoration: InputDecoration(labelText: translate('account.form_name')), maxLength: 64, @@ -73,6 +79,8 @@ class _EditProfileFormState extends State { ), FormBuilderTextField( name: EditProfileForm.formFieldPronouns, + initialValue: widget.initialValueCallback + ?.call(EditProfileForm.formFieldPronouns) as String?, maxLength: 64, decoration: InputDecoration( labelText: translate('account.form_pronouns')), diff --git a/lib/account_manager/views/show_recovery_key_page.dart b/lib/account_manager/views/show_recovery_key_page.dart index 62d4f21..e22e0e1 100644 --- a/lib/account_manager/views/show_recovery_key_page.dart +++ b/lib/account_manager/views/show_recovery_key_page.dart @@ -1,18 +1,12 @@ 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 'package:veilid_support/veilid_support.dart'; import '../../layout/default_app_bar.dart'; -import '../../theme/theme.dart'; import '../../tools/tools.dart'; import '../../veilid_processor/veilid_processor.dart'; -import '../account_manager.dart'; class ShowRecoveryKeyPage extends StatefulWidget { const ShowRecoveryKeyPage({required SecretKey secretKey, super.key}) @@ -57,7 +51,11 @@ class ShowRecoveryKeyPageState extends State { Text('ASS: $secretKey'), ElevatedButton( onPressed: () { - GoRouterHelper(context).go('/'); + 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 4214e05..f554e88 100644 --- a/lib/account_manager/views/views.dart +++ b/lib/account_manager/views/views.dart @@ -1,3 +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/contacts/cubits/contact_list_cubit.dart b/lib/contacts/cubits/contact_list_cubit.dart index 9b05cee..fd2f8dd 100644 --- a/lib/contacts/cubits/contact_list_cubit.dart +++ b/lib/contacts/cubits/contact_list_cubit.dart @@ -1,12 +1,14 @@ 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'; +import '../../conversation/cubits/conversation_cubit.dart'; ////////////////////////////////////////////////// // Mutable state for per-account contacts @@ -34,6 +36,54 @@ class ContactListCubit extends DHTShortArrayCubit { return dhtRecord; } + @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 updateContactRemoteProfile( + localConversationRecordKey: localConversationRecordKey, + remoteProfile: remoteProfile); + }); + } + + Future updateContactRemoteProfile({ + required TypedKey localConversationRecordKey, + required proto.Profile remoteProfile, + }) 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.remoteProfile == remoteProfile) { + // Unchanged + break; + } + final newContact = c.deepCopy()..remoteProfile = remoteProfile; + final updated = await writer.tryWriteItemProtobuf( + proto.Contact.fromBuffer, pos, newContact); + if (!updated) { + throw DHTExceptionTryAgain(); + } + } + } + }); + } + Future createContact({ required proto.Profile remoteProfile, required SuperIdentity remoteSuperIdentity, @@ -100,4 +150,6 @@ class ContactListCubit extends DHTShortArrayCubit { } final UnlockedAccountInfo _activeAccountInfo; + final _contactProfileUpdateMap = + SingleStateProcessorMap(); } diff --git a/lib/conversation/cubits/active_conversations_bloc_map_cubit.dart b/lib/conversation/cubits/active_conversations_bloc_map_cubit.dart index 6a1fbef..d7bb24d 100644 --- a/lib/conversation/cubits/active_conversations_bloc_map_cubit.dart +++ b/lib/conversation/cubits/active_conversations_bloc_map_cubit.dart @@ -5,10 +5,10 @@ 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 '../../conversation/conversation.dart'; import '../../proto/proto.dart' as proto; -import 'cubits.dart'; +import '../conversation.dart'; @immutable class ActiveConversationState extends Equatable { @@ -43,11 +43,13 @@ typedef ActiveConversationsBlocMapState class ActiveConversationsBlocMapCubit extends BlocMapCubit, ActiveConversationCubit> with StateMapFollower { - ActiveConversationsBlocMapCubit( - {required UnlockedAccountInfo unlockedAccountInfo, - required ContactListCubit contactListCubit}) - : _activeAccountInfo = unlockedAccountInfo, - _contactListCubit = contactListCubit; + ActiveConversationsBlocMapCubit({ + required UnlockedAccountInfo unlockedAccountInfo, + required ContactListCubit contactListCubit, + required AccountRecordCubit accountRecordCubit, + }) : _activeAccountInfo = unlockedAccountInfo, + _contactListCubit = contactListCubit, + _accountRecordCubit = accountRecordCubit; //////////////////////////////////////////////////////////////////////////// // Public Interface @@ -57,30 +59,51 @@ class ActiveConversationsBlocMapCubit extends BlocMapCubit _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)))); + add(() { + final remoteIdentityPublicKey = contact.identityPublicKey.toVeilid(); + final localConversationRecordKey = + contact.localConversationRecordKey.toVeilid(); + final remoteConversationRecordKey = + contact.remoteConversationRecordKey.toVeilid(); + + // Conversation cubit the tracks the state between the local + // and remote halves of a contact's relationship with this account + final conversationCubit = ConversationCubit( + activeAccountInfo: _activeAccountInfo, + remoteIdentityPublicKey: remoteIdentityPublicKey, + localConversationRecordKey: localConversationRecordKey, + remoteConversationRecordKey: remoteConversationRecordKey, + )..watchAccountChanges( + _accountRecordCubit.stream, _accountRecordCubit.state); + _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); + + // 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( + contact: contact, + localConversation: data.localConversation!, + remoteConversation: data.remoteConversation!)), + loading: AsyncValue.loading, + error: AsyncValue.error)); + + return MapEntry( + contact.localConversationRecordKey.toVeilid(), transformedCubit); + }); /// StateFollower ///////////////////////// @@ -108,4 +131,5 @@ class ActiveConversationsBlocMapCubit extends BlocMapCubit 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 UnlockedAccountInfo activeAccountInfo, @@ -53,6 +59,7 @@ class ConversationCubit extends Cubit> { debugName: 'ConversationCubit::LocalConversation', parent: accountRecordKey, crypto: crypto); + return record; }); }); @@ -80,6 +87,7 @@ class ConversationCubit extends Cubit> { @override Future close() async { await _initWait(); + await _accountSubscription?.cancel(); await _localSubscription?.cancel(); await _remoteSubscription?.cancel(); await _localConversationCubit?.close(); @@ -88,127 +96,16 @@ class ConversationCubit extends Cubit> { await super.close(); } - void _updateLocalConversationState(AsyncValue avconv) { - final newState = avconv.when( - data: (conv) { - _incrementalState = ConversationState( - localConversation: conv, - remoteConversation: _incrementalState.remoteConversation); - // return loading still if state isn't complete - if ((_localConversationRecordKey != null && - _incrementalState.localConversation == null) || - (_remoteConversationRecordKey != null && - _incrementalState.remoteConversation == null)) { - return const AsyncValue.loading(); - } - // state is complete, all required keys are open - return AsyncValue.data(_incrementalState); - }, - loading: AsyncValue.loading, - error: AsyncValue.error, - ); - emit(newState); - } + //////////////////////////////////////////////////////////////////////////// + // Public Interface - void _updateRemoteConversationState(AsyncValue avconv) { - final newState = avconv.when( - data: (conv) { - _incrementalState = ConversationState( - localConversation: _incrementalState.localConversation, - remoteConversation: conv); - // return loading still if state isn't complete - if ((_localConversationRecordKey != null && - _incrementalState.localConversation == null) || - (_remoteConversationRecordKey != null && - _incrementalState.remoteConversation == null)) { - return const AsyncValue.loading(); - } - // state is complete, all required keys are open - return AsyncValue.data(_incrementalState); - }, - loading: AsyncValue.loading, - error: AsyncValue.error, - ); - emit(newState); - } - - // Open local converation key - Future _setLocalConversation(Future Function() open) async { - assert(_localConversationCubit == null, - 'shoud not set local conversation twice'); - _localConversationCubit = DefaultDHTRecordCubit( - open: open, decodeState: proto.Conversation.fromBuffer); - _localSubscription = - _localConversationCubit!.stream.listen(_updateLocalConversationState); - } - - // Open remote converation key - Future _setRemoteConversation(Future Function() open) async { - assert(_remoteConversationCubit == null, - 'shoud not set remote conversation twice'); - _remoteConversationCubit = DefaultDHTRecordCubit( - 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 + /// 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, @@ -280,6 +177,167 @@ class ConversationCubit extends Cubit> { return out; } + /// Delete the conversation keys associated with this conversation + 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; + } + + /// 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) { + _incrementalState = ConversationState( + localConversation: conv, + remoteConversation: _incrementalState.remoteConversation); + // return loading still if state isn't complete + if ((_localConversationRecordKey != null && + _incrementalState.localConversation == null) || + (_remoteConversationRecordKey != null && + _incrementalState.remoteConversation == null)) { + return const AsyncValue.loading(); + } + // state is complete, all required keys are open + return AsyncValue.data(_incrementalState); + }, + loading: AsyncValue.loading, + error: AsyncValue.error, + ); + emit(newState); + } + + void _updateRemoteConversationState(AsyncValue avconv) { + final newState = avconv.when( + data: (conv) { + _incrementalState = ConversationState( + localConversation: _incrementalState.localConversation, + remoteConversation: conv); + // return loading still if state isn't complete + if ((_localConversationRecordKey != null && + _incrementalState.localConversation == null) || + (_remoteConversationRecordKey != null && + _incrementalState.remoteConversation == null)) { + return const AsyncValue.loading(); + } + // state is complete, all required keys are open + return AsyncValue.data(_incrementalState); + }, + loading: AsyncValue.loading, + error: AsyncValue.error, + ); + emit(newState); + } + + // Open local converation key + Future _setLocalConversation(Future Function() open) async { + assert(_localConversationCubit == null, + 'shoud not set local conversation twice'); + _localConversationCubit = DefaultDHTRecordCubit( + open: open, decodeState: proto.Conversation.fromBuffer); + _localSubscription = + _localConversationCubit!.stream.listen(_updateLocalConversationState); + } + + // Open remote converation key + Future _setRemoteConversation(Future Function() open) async { + assert(_remoteConversationCubit == null, + 'shoud not set remote conversation twice'); + _remoteConversationCubit = DefaultDHTRecordCubit( + open: open, decodeState: proto.Conversation.fromBuffer); + _remoteSubscription = + _remoteConversationCubit!.stream.listen(_updateRemoteConversationState); + } + // Initialize local messages Future _initLocalMessages({ required UnlockedAccountInfo activeAccountInfo, @@ -299,34 +357,6 @@ 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) { @@ -339,6 +369,9 @@ class ConversationCubit extends Cubit> { return conversationCrypto; } + //////////////////////////////////////////////////////////////////////////// + // Fields + final UnlockedAccountInfo _unlockedAccountInfo; final TypedKey _remoteIdentityPublicKey; TypedKey? _localConversationRecordKey; @@ -347,9 +380,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/layout/home/drawer_menu/drawer_menu.dart b/lib/layout/home/drawer_menu/drawer_menu.dart index f1e11ff..7b51346 100644 --- a/lib/layout/home/drawer_menu/drawer_menu.dart +++ b/lib/layout/home/drawer_menu/drawer_menu.dart @@ -8,6 +8,7 @@ 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'; @@ -37,8 +38,12 @@ class _DrawerMenuState extends State { }); } - void _doEditClick(TypedKey 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}) => @@ -127,7 +132,7 @@ class _DrawerMenuState extends State { _doSwitchClick(superIdentityRecordKey); }, footerCallback: () { - _doEditClick(superIdentityRecordKey); + _doEditClick(superIdentityRecordKey, value.profile); }), loading: () => _wrapInBox( child: buildProgressIndicator(), 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 index 7baa37b..5540e77 100644 --- a/lib/layout/home/home_account_ready/home_account_ready_shell.dart +++ b/lib/layout/home/home_account_ready/home_account_ready_shell.dart @@ -102,6 +102,9 @@ class HomeAccountReadyShellState extends State { @override Widget build(BuildContext context) { + // XXX: Should probably eliminate this in favor + // of streaming changes into other cubits. Too much rebuilding! + // should not need to 'watch' all these cubits final account = context.watch().state.asData?.value; if (account == null) { return waitingPage(); @@ -121,28 +124,29 @@ class HomeAccountReadyShellState extends State { create: (context) => WaitingInvitationsBlocMapCubit( unlockedAccountInfo: widget.unlockedAccountInfo, account: account) - ..follow(context.watch())), + ..follow(context.read())), // Chat Cubits BlocProvider( create: (context) => ActiveChatCubit(null, - routerCubit: context.watch())), + routerCubit: context.read())), BlocProvider( create: (context) => ChatListCubit( unlockedAccountInfo: widget.unlockedAccountInfo, - activeChatCubit: context.watch(), + activeChatCubit: context.read(), account: account)), // Conversation Cubits BlocProvider( create: (context) => ActiveConversationsBlocMapCubit( unlockedAccountInfo: widget.unlockedAccountInfo, - contactListCubit: context.watch()) - ..follow(context.watch())), + contactListCubit: context.read(), + accountRecordCubit: context.read()) + ..follow(context.read())), BlocProvider( create: (context) => ActiveSingleContactChatBlocMapCubit( unlockedAccountInfo: widget.unlockedAccountInfo, - contactListCubit: context.watch(), - chatListCubit: context.watch()) - ..follow(context.watch())), + contactListCubit: context.read(), + chatListCubit: context.read()) + ..follow(context.read())), ], child: MultiBlocListener(listeners: [ BlocListener HomeShellState(); - final Builder accountReadyBuilder; + final Widget child; } class HomeShellState extends State { @@ -58,13 +59,17 @@ class HomeShellState extends State { case AccountInfoStatus.accountLocked: return const HomeAccountLocked(); case AccountInfoStatus.accountReady: - return MultiProvider(providers: [ - Provider.value( - value: accountInfo.unlockedAccountInfo!, - ), - Provider.value(value: activeCubit), - Provider.value(value: _zoomDrawerController), - ], child: widget.accountReadyBuilder); + return MultiBlocProvider( + providers: [ + BlocProvider.value(value: activeCubit), + ], + child: MultiProvider(providers: [ + Provider.value( + value: accountInfo.unlockedAccountInfo!, + ), + Provider.value( + value: _zoomDrawerController), + ], child: widget.child)); } } diff --git a/lib/proto/veilidchat.pb.dart b/lib/proto/veilidchat.pb.dart index ff755fe..61018fe 100644 --- a/lib/proto/veilidchat.pb.dart +++ b/lib/proto/veilidchat.pb.dart @@ -1396,6 +1396,7 @@ class Profile extends $pb.GeneratedMessage { ..aOS(4, _omitFieldNames ? '' : 'status') ..e(5, _omitFieldNames ? '' : 'availability', $pb.PbFieldType.OE, defaultOrMaker: Availability.AVAILABILITY_UNSPECIFIED, valueOf: Availability.valueOf, enumValues: Availability.values) ..aOM(6, _omitFieldNames ? '' : 'avatar', subBuilder: DataReference.create) + ..a<$fixnum.Int64>(7, _omitFieldNames ? '' : 'timestamp', $pb.PbFieldType.OU6, defaultOrMaker: $fixnum.Int64.ZERO) ..hasRequiredFields = false ; @@ -1475,6 +1476,15 @@ class Profile extends $pb.GeneratedMessage { void clearAvatar() => clearField(6); @$pb.TagNumber(6) 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 { diff --git a/lib/proto/veilidchat.pbjson.dart b/lib/proto/veilidchat.pbjson.dart index 29bb11b..2978fb5 100644 --- a/lib/proto/veilidchat.pbjson.dart +++ b/lib/proto/veilidchat.pbjson.dart @@ -411,6 +411,7 @@ const Profile$json = { {'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': '.veilidchat.DataReference', '9': 0, '10': 'avatar', '17': true}, + {'1': 'timestamp', '3': 7, '4': 1, '5': 4, '10': 'timestamp'}, ], '8': [ {'1': '_avatar'}, @@ -422,8 +423,8 @@ final $typed_data.Uint8List profileDescriptor = $convert.base64Decode( 'CgdQcm9maWxlEhIKBG5hbWUYASABKAlSBG5hbWUSGgoIcHJvbm91bnMYAiABKAlSCHByb25vdW' '5zEhQKBWFib3V0GAMgASgJUgVhYm91dBIWCgZzdGF0dXMYBCABKAlSBnN0YXR1cxI8CgxhdmFp' 'bGFiaWxpdHkYBSABKA4yGC52ZWlsaWRjaGF0LkF2YWlsYWJpbGl0eVIMYXZhaWxhYmlsaXR5Ej' - 'YKBmF2YXRhchgGIAEoCzIZLnZlaWxpZGNoYXQuRGF0YVJlZmVyZW5jZUgAUgZhdmF0YXKIAQFC' - 'CQoHX2F2YXRhcg=='); + 'YKBmF2YXRhchgGIAEoCzIZLnZlaWxpZGNoYXQuRGF0YVJlZmVyZW5jZUgAUgZhdmF0YXKIAQES' + 'HAoJdGltZXN0YW1wGAcgASgEUgl0aW1lc3RhbXBCCQoHX2F2YXRhcg=='); @$core.Deprecated('Use accountDescriptor instead') const Account$json = { diff --git a/lib/proto/veilidchat.proto b/lib/proto/veilidchat.proto index c99a691..38b4690 100644 --- a/lib/proto/veilidchat.proto +++ b/lib/proto/veilidchat.proto @@ -311,6 +311,8 @@ message Profile { Availability availability = 5; // Avatar optional DataReference avatar = 6; + // Timestamp of last change + uint64 timestamp = 7; } // A record of an individual account diff --git a/lib/router/cubit/router_cubit.dart b/lib/router/cubit/router_cubit.dart index b69037d..901a878 100644 --- a/lib/router/cubit/router_cubit.dart +++ b/lib/router/cubit/router_cubit.dart @@ -11,6 +11,7 @@ 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'; @@ -20,6 +21,7 @@ part 'router_cubit.g.dart'; final _rootNavKey = GlobalKey(debugLabel: 'rootNavKey'); final _homeNavKey = GlobalKey(debugLabel: 'homeNavKey'); +final _activeNavKey = GlobalKey(debugLabel: 'activeNavKey'); @freezed class RouterState with _$RouterState { @@ -65,21 +67,34 @@ class RouterCubit extends Cubit { List get routes => [ ShellRoute( navigatorKey: _homeNavKey, - builder: (context, state, child) => HomeShell( - accountReadyBuilder: Builder( - builder: (context) => - HomeAccountReadyShell(context: context, child: child))), + builder: (context, state, child) => HomeShell(child: child), routes: [ - GoRoute( - path: '/', - builder: (context, state) => const HomeAccountReadyMain(), - ), - GoRoute( - path: '/chat', - builder: (context, state) => const HomeAccountReadyChat(), - ), + ShellRoute( + navigatorKey: _activeNavKey, + builder: (context, state, child) => + HomeAccountReadyShell(context: context, child: child), + routes: [ + GoRoute( + path: '/', + builder: (context, state) => const HomeAccountReadyMain(), + ), + GoRoute( + path: '/chat', + builder: (context, state) => const HomeAccountReadyChat(), + ), + ]), ], ), + 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(), 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/src/dht_record/dht_record.dart b/packages/veilid_support/lib/dht_support/src/dht_record/dht_record.dart index cd6c859..28fa907 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 @@ -308,7 +308,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 +323,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 +392,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 +402,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, 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/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 6236b75..107e8d4 100644 --- a/packages/veilid_support/pubspec.lock +++ b/packages/veilid_support/pubspec.lock @@ -36,11 +36,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" bloc: dependency: "direct main" description: diff --git a/packages/veilid_support/pubspec.yaml b/packages/veilid_support/pubspec.yaml index eb96217..b2b0e5c 100644 --- a/packages/veilid_support/pubspec.yaml +++ b/packages/veilid_support/pubspec.yaml @@ -7,7 +7,7 @@ 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.3 charcode: ^1.3.1 @@ -25,10 +25,10 @@ dependencies: path: ../../../veilid/veilid-flutter dependency_overrides: -# async_tools: -# path: ../../../dart_async_tools - bloc_advanced_tools: - path: ../../../bloc_advanced_tools + 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 d5945ce..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: diff --git a/pubspec.yaml b/pubspec.yaml index 7ef00a0..858c226 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -11,7 +11,7 @@ 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 @@ -94,8 +94,8 @@ dependencies: zxing2: ^0.2.3 dependency_overrides: -# async_tools: -# path: ../dart_async_tools + async_tools: + path: ../dart_async_tools bloc_advanced_tools: path: ../bloc_advanced_tools # flutter_chat_ui: