checkpoint

This commit is contained in:
Christien Rioux 2024-06-15 00:01:08 -04:00
parent 56d65442f4
commit 751022e743
26 changed files with 482 additions and 303 deletions

View File

@ -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<AccountRecordState> {
AccountRecordCubit(
{required AccountRepository accountRepository,
@ -35,4 +41,16 @@ class AccountRecordCubit extends DefaultDHTRecordCubit<AccountRecordState> {
Future<void> close() async {
await super.close();
}
////////////////////////////////////////////////////////////////////////////
// Public Interface
Future<void> 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;
});
}
}

View File

@ -7,7 +7,8 @@ import '../../account_manager/account_manager.dart';
typedef AccountRecordsBlocMapState
= BlocMapState<TypedKey, AsyncValue<AccountRecordState>>;
// 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<TypedKey,
AsyncValue<AccountRecordState>, AccountRecordCubit>
with StateMapFollower<UserLoginsState, TypedKey, UserLogin> {

View File

@ -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<EditAccountPage> {
final _formKey = GlobalKey<FormBuilderState>();
bool _isInAsyncCall = false;
@override
@ -58,24 +55,37 @@ class _EditAccountPageState extends State<EditAccountPage> {
{required Future<void> Function(GlobalKey<FormBuilderState>)
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<AccountRecordCubit>();
final activeConversationsBlocMapCubit =
context.read<ActiveConversationsBlocMapCubit>();
final contactListCubit = context.read<ContactListCubit>();
final accountRecordsCubit = context.watch<AccountRecordsBlocMapCubit>();
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<EditAccountPage> {
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 = <Future<void>>[];
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(() {

View File

@ -21,7 +21,6 @@ class NewAccountPage extends StatefulWidget {
}
class _NewAccountPageState extends State<NewAccountPage> {
final _formKey = GlobalKey<FormBuilderState>();
bool _isInAsyncCall = false;
@override
@ -61,6 +60,14 @@ class _NewAccountPageState extends State<NewAccountPage> {
// 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<NewAccountPage> {
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? ??

View File

@ -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<void> Function(GlobalKey<FormBuilderState>)? 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<void> Function(
GlobalKey<FormBuilderState> p1)?>.has('onSubmit', onSubmit))
..add(StringProperty('submitText', submitText))
..add(StringProperty('submitDisabledText', submitDisabledText));
..add(StringProperty('submitDisabledText', submitDisabledText))
..add(ObjectFlagProperty<Object? Function(String key)?>.has(
'initialValueCallback', initialValueCallback));
}
static const String formFieldName = 'name';
@ -62,6 +66,8 @@ class _EditProfileFormState extends State<EditProfileForm> {
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<EditProfileForm> {
),
FormBuilderTextField(
name: EditProfileForm.formFieldPronouns,
initialValue: widget.initialValueCallback
?.call(EditProfileForm.formFieldPronouns) as String?,
maxLength: 64,
decoration: InputDecoration(
labelText: translate('account.form_pronouns')),

View File

@ -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<ShowRecoveryKeyPage> {
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));

View File

@ -1,3 +1,4 @@
export 'edit_account_page.dart';
export 'new_account_page.dart';
export 'profile_widget.dart';
export 'show_recovery_key_page.dart';

View File

@ -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<proto.Contact> {
return dhtRecord;
}
@override
Future<void> close() async {
await _contactProfileUpdateMap.close();
await super.close();
}
////////////////////////////////////////////////////////////////////////////
// Public Interface
void followContactProfileChanges(TypedKey localConversationRecordKey,
Stream<proto.Profile?> profileStream, proto.Profile? profileState) {
_contactProfileUpdateMap
.follow(localConversationRecordKey, profileStream, profileState,
(remoteProfile) async {
if (remoteProfile == null) {
return;
}
return updateContactRemoteProfile(
localConversationRecordKey: localConversationRecordKey,
remoteProfile: remoteProfile);
});
}
Future<void> 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<void> createContact({
required proto.Profile remoteProfile,
required SuperIdentity remoteSuperIdentity,
@ -100,4 +150,6 @@ class ContactListCubit extends DHTShortArrayCubit<proto.Contact> {
}
final UnlockedAccountInfo _activeAccountInfo;
final _contactProfileUpdateMap =
SingleStateProcessorMap<TypedKey, proto.Profile?>();
}

View File

@ -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<TypedKey,
AsyncValue<ActiveConversationState>, ActiveConversationCubit>
with StateMapFollower<ChatListCubitState, TypedKey, proto.Chat> {
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<TypedKey,
// Add an active conversation to be tracked for changes
Future<void> _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<ActiveConversationState>,
AsyncValue<ConversationState>,
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<TypedKey,
final UnlockedAccountInfo _activeAccountInfo;
final ContactListCubit _contactListCubit;
final AccountRecordCubit _accountRecordCubit;
}

View File

@ -9,7 +9,7 @@ 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';
import '../../chat_list/cubits/chat_list_cubit.dart';
// Map of localConversationRecordKey to MessagesCubit
// Wraps a MessagesCubit to stream the latest messages to the state

View File

@ -9,12 +9,15 @@ 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 {
const ConversationState(
@ -27,6 +30,9 @@ class ConversationState extends Equatable {
List<Object?> 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<AsyncValue<ConversationState>> {
ConversationCubit(
{required UnlockedAccountInfo activeAccountInfo,
@ -53,6 +59,7 @@ class ConversationCubit extends Cubit<AsyncValue<ConversationState>> {
debugName: 'ConversationCubit::LocalConversation',
parent: accountRecordKey,
crypto: crypto);
return record;
});
});
@ -80,6 +87,7 @@ class ConversationCubit extends Cubit<AsyncValue<ConversationState>> {
@override
Future<void> close() async {
await _initWait();
await _accountSubscription?.cancel();
await _localSubscription?.cancel();
await _remoteSubscription?.cancel();
await _localConversationCubit?.close();
@ -88,127 +96,16 @@ class ConversationCubit extends Cubit<AsyncValue<ConversationState>> {
await super.close();
}
void _updateLocalConversationState(AsyncValue<proto.Conversation> 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<ConversationState>.loading();
}
// state is complete, all required keys are open
return AsyncValue.data(_incrementalState);
},
loading: AsyncValue<ConversationState>.loading,
error: AsyncValue<ConversationState>.error,
);
emit(newState);
}
////////////////////////////////////////////////////////////////////////////
// Public Interface
void _updateRemoteConversationState(AsyncValue<proto.Conversation> 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<ConversationState>.loading();
}
// state is complete, all required keys are open
return AsyncValue.data(_incrementalState);
},
loading: AsyncValue<ConversationState>.loading,
error: AsyncValue<ConversationState>.error,
);
emit(newState);
}
// Open local converation key
Future<void> _setLocalConversation(Future<DHTRecord> 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<void> _setRemoteConversation(Future<DHTRecord> 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<bool> delete() async {
final pool = DHTRecordPool.instance;
await _initWait();
final localConversationCubit = _localConversationCubit;
final remoteConversationCubit = _remoteConversationCubit;
final deleteSet = DelayedWaitSet<void>();
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<T> initLocalConversation<T>(
{required proto.Profile profile,
required FutureOr<T> Function(DHTRecord) callback,
@ -280,6 +177,167 @@ class ConversationCubit extends Cubit<AsyncValue<ConversationState>> {
return out;
}
/// Delete the conversation keys associated with this conversation
Future<bool> delete() async {
final pool = DHTRecordPool.instance;
await _initWait();
final localConversationCubit = _localConversationCubit;
final remoteConversationCubit = _remoteConversationCubit;
final deleteSet = DelayedWaitSet<void>();
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<void> 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<AsyncValue<proto.Account>> accountStream,
AsyncValue<proto.Account> currentState) {
assert(_accountSubscription == null, 'only watch account once');
_accountSubscription = accountStream.listen(_updateAccountChange);
_updateAccountChange(currentState);
}
////////////////////////////////////////////////////////////////////////////
// Private Implementation
void _updateAccountChange(AsyncValue<proto.Account> 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<proto.Conversation> 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<ConversationState>.loading();
}
// state is complete, all required keys are open
return AsyncValue.data(_incrementalState);
},
loading: AsyncValue<ConversationState>.loading,
error: AsyncValue<ConversationState>.error,
);
emit(newState);
}
void _updateRemoteConversationState(AsyncValue<proto.Conversation> 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<ConversationState>.loading();
}
// state is complete, all required keys are open
return AsyncValue.data(_incrementalState);
},
loading: AsyncValue<ConversationState>.loading,
error: AsyncValue<ConversationState>.error,
);
emit(newState);
}
// Open local converation key
Future<void> _setLocalConversation(Future<DHTRecord> 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<void> _setRemoteConversation(Future<DHTRecord> 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<T> _initLocalMessages<T>({
required UnlockedAccountInfo activeAccountInfo,
@ -299,34 +357,6 @@ class ConversationCubit extends Cubit<AsyncValue<ConversationState>> {
.deleteScope((messages) async => await callback(messages));
}
// Force refresh of conversation keys
Future<void> refresh() async {
await _initWait();
final lcc = _localConversationCubit;
final rcc = _remoteConversationCubit;
if (lcc != null) {
await lcc.refreshDefault();
}
if (rcc != null) {
await rcc.refreshDefault();
}
}
Future<proto.Conversation?> 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<VeilidCrypto> _cachedConversationCrypto() async {
var conversationCrypto = _conversationCrypto;
if (conversationCrypto != null) {
@ -339,6 +369,9 @@ class ConversationCubit extends Cubit<AsyncValue<ConversationState>> {
return conversationCrypto;
}
////////////////////////////////////////////////////////////////////////////
// Fields
final UnlockedAccountInfo _unlockedAccountInfo;
final TypedKey _remoteIdentityPublicKey;
TypedKey? _localConversationRecordKey;
@ -347,9 +380,9 @@ class ConversationCubit extends Cubit<AsyncValue<ConversationState>> {
DefaultDHTRecordCubit<proto.Conversation>? _remoteConversationCubit;
StreamSubscription<AsyncValue<proto.Conversation>>? _localSubscription;
StreamSubscription<AsyncValue<proto.Conversation>>? _remoteSubscription;
StreamSubscription<AsyncValue<proto.Account>>? _accountSubscription;
ConversationState _incrementalState = const ConversationState(
localConversation: null, remoteConversation: null);
//
VeilidCrypto? _conversationCrypto;
final WaitSet<void> _initWait = WaitSet();
}

View File

@ -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<DrawerMenu> {
});
}
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<DrawerMenu> {
_doSwitchClick(superIdentityRecordKey);
},
footerCallback: () {
_doEditClick(superIdentityRecordKey);
_doEditClick(superIdentityRecordKey, value.profile);
}),
loading: () => _wrapInBox(
child: buildProgressIndicator(),

View File

@ -102,6 +102,9 @@ class HomeAccountReadyShellState extends State<HomeAccountReadyShell> {
@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<AccountRecordCubit>().state.asData?.value;
if (account == null) {
return waitingPage();
@ -121,28 +124,29 @@ class HomeAccountReadyShellState extends State<HomeAccountReadyShell> {
create: (context) => WaitingInvitationsBlocMapCubit(
unlockedAccountInfo: widget.unlockedAccountInfo,
account: account)
..follow(context.watch<ContactInvitationListCubit>())),
..follow(context.read<ContactInvitationListCubit>())),
// Chat Cubits
BlocProvider(
create: (context) => ActiveChatCubit(null,
routerCubit: context.watch<RouterCubit>())),
routerCubit: context.read<RouterCubit>())),
BlocProvider(
create: (context) => ChatListCubit(
unlockedAccountInfo: widget.unlockedAccountInfo,
activeChatCubit: context.watch<ActiveChatCubit>(),
activeChatCubit: context.read<ActiveChatCubit>(),
account: account)),
// Conversation Cubits
BlocProvider(
create: (context) => ActiveConversationsBlocMapCubit(
unlockedAccountInfo: widget.unlockedAccountInfo,
contactListCubit: context.watch<ContactListCubit>())
..follow(context.watch<ChatListCubit>())),
contactListCubit: context.read<ContactListCubit>(),
accountRecordCubit: context.read<AccountRecordCubit>())
..follow(context.read<ChatListCubit>())),
BlocProvider(
create: (context) => ActiveSingleContactChatBlocMapCubit(
unlockedAccountInfo: widget.unlockedAccountInfo,
contactListCubit: context.watch<ContactListCubit>(),
chatListCubit: context.watch<ChatListCubit>())
..follow(context.watch<ActiveConversationsBlocMapCubit>())),
contactListCubit: context.read<ContactListCubit>(),
chatListCubit: context.read<ChatListCubit>())
..follow(context.read<ActiveConversationsBlocMapCubit>())),
],
child: MultiBlocListener(listeners: [
BlocListener<WaitingInvitationsBlocMapCubit,

View File

@ -1,6 +1,7 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_zoom_drawer/flutter_zoom_drawer.dart';
import 'package:provider/provider.dart';
@ -13,12 +14,12 @@ import 'home_account_missing.dart';
import 'home_no_active.dart';
class HomeShell extends StatefulWidget {
const HomeShell({required this.accountReadyBuilder, super.key});
const HomeShell({required this.child, super.key});
@override
HomeShellState createState() => HomeShellState();
final Builder accountReadyBuilder;
final Widget child;
}
class HomeShellState extends State<HomeShell> {
@ -58,13 +59,17 @@ class HomeShellState extends State<HomeShell> {
case AccountInfoStatus.accountLocked:
return const HomeAccountLocked();
case AccountInfoStatus.accountReady:
return MultiProvider(providers: [
Provider<UnlockedAccountInfo>.value(
value: accountInfo.unlockedAccountInfo!,
),
Provider<AccountRecordCubit>.value(value: activeCubit),
Provider<ZoomDrawerController>.value(value: _zoomDrawerController),
], child: widget.accountReadyBuilder);
return MultiBlocProvider(
providers: [
BlocProvider<AccountRecordCubit>.value(value: activeCubit),
],
child: MultiProvider(providers: [
Provider<UnlockedAccountInfo>.value(
value: accountInfo.unlockedAccountInfo!,
),
Provider<ZoomDrawerController>.value(
value: _zoomDrawerController),
], child: widget.child));
}
}

View File

@ -1396,6 +1396,7 @@ class Profile extends $pb.GeneratedMessage {
..aOS(4, _omitFieldNames ? '' : 'status')
..e<Availability>(5, _omitFieldNames ? '' : 'availability', $pb.PbFieldType.OE, defaultOrMaker: Availability.AVAILABILITY_UNSPECIFIED, valueOf: Availability.valueOf, enumValues: Availability.values)
..aOM<DataReference>(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 {

View File

@ -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 = {

View File

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

View File

@ -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<NavigatorState>(debugLabel: 'rootNavKey');
final _homeNavKey = GlobalKey<NavigatorState>(debugLabel: 'homeNavKey');
final _activeNavKey = GlobalKey<NavigatorState>(debugLabel: 'activeNavKey');
@freezed
class RouterState with _$RouterState {
@ -65,21 +67,34 @@ class RouterCubit extends Cubit<RouterState> {
List<RouteBase> 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<Object?>;
return EditAccountPage(
superIdentityRecordKey: extra[0]! as TypedKey,
existingProfile: extra[1]! as proto.Profile,
);
},
),
GoRoute(
path: '/new_account',
builder: (context, state) => const NewAccountPage(),

View File

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

View File

@ -308,7 +308,7 @@ class DHTRecord implements DHTDeleteable<DHTRecord> {
/// 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<void> eventualUpdateBytes(
Future<Uint8List> Function(Uint8List? oldValue) update,
Future<Uint8List?> Function(Uint8List? oldValue) update,
{int subkey = -1,
VeilidCrypto? crypto,
KeyPair? writer,
@ -323,7 +323,10 @@ class DHTRecord implements DHTDeleteable<DHTRecord> {
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<DHTRecord> {
/// Like 'eventualUpdateBytes' but with JSON marshal/unmarshal of the value
Future<void> eventualUpdateJson<T>(
T Function(dynamic) fromJson, Future<T> Function(T?) update,
T Function(dynamic) fromJson, Future<T?> Function(T?) update,
{int subkey = -1,
VeilidCrypto? crypto,
KeyPair? writer,
@ -399,7 +402,7 @@ class DHTRecord implements DHTDeleteable<DHTRecord> {
/// Like 'eventualUpdateBytes' but with protobuf marshal/unmarshal of the value
Future<void> eventualUpdateProtobuf<T extends GeneratedMessage>(
T Function(List<int>) fromBuffer, Future<T> Function(T?) update,
T Function(List<int>) fromBuffer, Future<T?> Function(T?) update,
{int subkey = -1,
VeilidCrypto? crypto,
KeyPair? writer,

View File

@ -12,16 +12,19 @@ Uint8List jsonEncodeBytes(Object? object,
Uint8List.fromList(
utf8.encode(jsonEncode(object, toEncodable: toEncodable)));
Future<Uint8List> jsonUpdateBytes<T>(T Function(dynamic) fromJson,
Uint8List? oldBytes, Future<T> Function(T?) update) async {
Future<Uint8List?> jsonUpdateBytes<T>(T Function(dynamic) fromJson,
Uint8List? oldBytes, Future<T?> 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<Uint8List> Function(Uint8List?) jsonUpdate<T>(
T Function(dynamic) fromJson, Future<T> Function(T?) update) =>
Future<Uint8List?> Function(Uint8List?) jsonUpdate<T>(
T Function(dynamic) fromJson, Future<T?> Function(T?) update) =>
(oldBytes) => jsonUpdateBytes(fromJson, oldBytes, update);
T Function(Object?) genericFromJson<T>(

View File

@ -2,16 +2,19 @@ import 'dart:typed_data';
import 'package:protobuf/protobuf.dart';
Future<Uint8List> protobufUpdateBytes<T extends GeneratedMessage>(
Future<Uint8List?> protobufUpdateBytes<T extends GeneratedMessage>(
T Function(List<int>) fromBuffer,
Uint8List? oldBytes,
Future<T> Function(T?) update) async {
Future<T?> 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<Uint8List> Function(Uint8List?)
Future<Uint8List?> Function(Uint8List?)
protobufUpdate<T extends GeneratedMessage>(
T Function(List<int>) fromBuffer, Future<T> Function(T?) update) =>
T Function(List<int>) fromBuffer, Future<T?> Function(T?) update) =>
(oldBytes) => protobufUpdateBytes(fromBuffer, oldBytes, update);

View File

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

View File

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

View File

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

View File

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