import 'dart:async'; import 'dart:typed_data'; import 'package:async_tools/async_tools.dart'; import 'package:fast_immutable_collections/fast_immutable_collections.dart'; import 'package:fixnum/fixnum.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_chat_types/flutter_chat_types.dart' as types; import 'package:flutter_chat_ui/flutter_chat_ui.dart'; import 'package:scroll_to_index/scroll_to_index.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 '../models/chat_component_state.dart'; import '../models/message_state.dart'; import '../models/window_state.dart'; import 'cubits.dart'; const metadataKeyIdentityPublicKey = 'identityPublicKey'; const metadataKeyExpirationDuration = 'expiration'; const metadataKeyViewLimit = 'view_limit'; const metadataKeyAttachments = 'attachments'; class ChatComponentCubit extends Cubit { ChatComponentCubit._({ required AccountInfo accountInfo, required AccountRecordCubit accountRecordCubit, required ContactListCubit contactListCubit, required List conversationCubits, required SingleContactMessagesCubit messagesCubit, }) : _accountInfo = accountInfo, _accountRecordCubit = accountRecordCubit, _contactListCubit = contactListCubit, _conversationCubits = conversationCubits, _messagesCubit = messagesCubit, super(ChatComponentState( chatKey: GlobalKey(), scrollController: AutoScrollController(), localUser: null, remoteUsers: const IMap.empty(), historicalRemoteUsers: const IMap.empty(), unknownUsers: const IMap.empty(), messageWindow: const AsyncLoading(), title: '', )) { // Async Init _initWait.add(_init); } 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 { // 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); } @override Future close() async { await _initWait(); await _accountRecordSubscription.cancel(); await _messagesSubscription.cancel(); await _conversationSubscriptions.values.map((v) => v.cancel()).wait; await super.close(); } //////////////////////////////////////////////////////////////////////////// // Public Interface // Set the tail position of the log for pagination. // If tail is 0, the end of the log is used. // If tail is negative, the position is subtracted from the current log // length. // If tail is positive, the position is absolute from the head of the log // If follow is enabled, the tail offset will update when the log changes Future setWindow( {int? tail, int? count, bool? follow, bool forceRefresh = false}) async { //await _initWait(); await _messagesCubit.setWindow( tail: tail, count: count, follow: follow, forceRefresh: forceRefresh); } // Send a message void sendMessage(types.PartialText message) { final text = message.text; final replyId = (message.repliedMessage != null) ? base64UrlNoPadDecode(message.repliedMessage!.id) : null; Timestamp? expiration; int? viewLimit; List? attachments; final metadata = message.metadata; if (metadata != null) { final expirationValue = metadata[metadataKeyExpirationDuration] as TimestampDuration?; if (expirationValue != null) { expiration = Veilid.instance.now().offset(expirationValue); } final viewLimitValue = metadata[metadataKeyViewLimit] as int?; if (viewLimitValue != null) { viewLimit = viewLimitValue; } final attachmentsValue = metadata[metadataKeyAttachments] as List?; if (attachmentsValue != null) { attachments = attachmentsValue; } } _addTextMessage( text: text, replyId: replyId, expiration: expiration, viewLimit: viewLimit, attachments: attachments ?? []); } // Run a chat command void runCommand(String command) { _messagesCubit.runCommand(command); } //////////////////////////////////////////////////////////////////////////// // Private Implementation 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)); } void _onChangedMessages( AsyncValue> avMessagesState) { emit(_convertMessages(state, avMessagesState)); } 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(state.copyWith( remoteUsers: state.remoteUsers.add( remoteIdentityPublicKey, _convertRemoteUser( remoteIdentityPublicKey, activeConversationState)))); } 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)) { continue; } // 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) { final remoteUser = _convertRemoteUser( remoteIdentityPublicKey, activeConversationState); currentRemoteUsersState = currentRemoteUsersState.add(remoteIdentityPublicKey, remoteUser); } } // 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 _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: ''); } (ChatComponentState, types.Message?) _messageStateToChatMessage( ChatComponentState currentState, MessageState message) { final authorIdentityPublicKey = message.content.author.toVeilid(); 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.id == _localUserIdentityKey.toString(), 'send state should only be on sent messages'); switch (message.sendState!) { case MessageSendState.sending: status = types.Status.sending; case MessageSendState.sent: status = types.Status.sent; case MessageSendState.delivered: status = types.Status.delivered; } } switch (message.content.whichKind()) { case proto.Message_Kind.text: final contextText = message.content.text; final textMessage = types.TextMessage( author: author, createdAt: (message.sentTimestamp.value ~/ BigInt.from(1000)).toInt(), id: message.content.authorUniqueIdString, text: contextText.text, showStatus: status != null, status: status); return (currentState, textMessage); case proto.Message_Kind.secret: case proto.Message_Kind.delete: case proto.Message_Kind.erase: case proto.Message_Kind.settings: case proto.Message_Kind.permissions: case proto.Message_Kind.membership: case proto.Message_Kind.moderation: case proto.Message_Kind.notSet: return (currentState, null); } } 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 currentState.copyWith( unknownUsers: const IMap.empty(), messageWindow: AsyncValue.error(asError.error, asError.stackTrace)); } else if (avMessagesState.asLoading != null) { return currentState.copyWith( unknownUsers: const IMap.empty(), messageWindow: const AsyncValue.loading()); } final messagesState = avMessagesState.asData!.value; // Convert protobuf messages to chat messages final chatMessages = []; final tsSet = {}; for (final message in messagesState.window) { final (newState, chatMessage) = _messageStateToChatMessage(currentState, message); currentState = newState; if (chatMessage == null) { continue; } chatMessages.insert(0, chatMessage); if (!tsSet.add(chatMessage.id)) { // ignore: avoid_print print('duplicate id found: ${chatMessage.id}:\n' 'Messages:\n${messagesState.window}\n' 'ChatMessages:\n$chatMessages'); assert(false, 'should not have duplicate id'); } } return currentState.copyWith( messageWindow: AsyncValue.data(WindowState( window: chatMessages.toIList(), length: messagesState.length, windowTail: messagesState.windowTail, windowCount: messagesState.windowCount, follow: messagesState.follow))); } void _addTextMessage( {required String text, String? topic, Uint8List? replyId, Timestamp? expiration, int? viewLimit, List attachments = const []}) { final protoMessageText = proto.Message_Text()..text = text; if (topic != null) { protoMessageText.topic = topic; } if (replyId != null) { protoMessageText.replyId = replyId; } protoMessageText ..expiration = expiration?.toInt64() ?? Int64.ZERO ..viewLimit = viewLimit ?? 0; protoMessageText.attachments.addAll(attachments); _messagesCubit.sendTextMessage(messageText: protoMessageText); } //////////////////////////////////////////////////////////////////////////// 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; double scrollOffset = 0; }