diff --git a/lib/chat/cubits/single_contact_messages_cubit.dart b/lib/chat/cubits/single_contact_messages_cubit.dart index bc2d8c5..9ee68f7 100644 --- a/lib/chat/cubits/single_contact_messages_cubit.dart +++ b/lib/chat/cubits/single_contact_messages_cubit.dart @@ -2,11 +2,13 @@ import 'dart:async'; import 'package:async_tools/async_tools.dart'; import 'package:fast_immutable_collections/fast_immutable_collections.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter_bloc/flutter_bloc.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 '../models/models.dart'; import 'reconciliation/reconciliation.dart'; @@ -42,7 +44,7 @@ class RenderStateElement { bool sentOffline; } -typedef SingleContactMessagesState = AsyncValue>; +typedef SingleContactMessagesState = AsyncValue; // Cubit that processes single-contact chats // Builds the reconciled chat record from the local and remote conversation keys @@ -60,6 +62,7 @@ class SingleContactMessagesCubit extends Cubit { _localMessagesRecordKey = localMessagesRecordKey, _remoteConversationRecordKey = remoteConversationRecordKey, _remoteMessagesRecordKey = remoteMessagesRecordKey, + _commandController = StreamController(), super(const AsyncValue.loading()) { // Async Init _initWait.add(_init); @@ -69,6 +72,8 @@ class SingleContactMessagesCubit extends Cubit { Future close() async { await _initWait(); + await _commandController.close(); + await _commandRunnerFut; await _unsentMessagesQueue.close(); await _sentSubscription?.cancel(); await _rcvdSubscription?.cancel(); @@ -99,6 +104,9 @@ class SingleContactMessagesCubit extends Cubit { // Remote messages key await _initRcvdMessagesCubit(); + + // Command execution background process + _commandRunnerFut = Future.delayed(Duration.zero, _commandRunner); } // Make crypto @@ -191,6 +199,34 @@ class SingleContactMessagesCubit extends Cubit { _sendMessage(message: message); } + // Run a chat command + void runCommand(String command) { + final (cmd, rest) = command.splitOnce(' '); + + if (kDebugMode) { + if (cmd == '/repeat' && rest != null) { + final (countStr, text) = rest.splitOnce(' '); + final count = int.tryParse(countStr); + if (count != null) { + runCommandRepeat(count, text ?? ''); + } + } + } + } + + // Run a repeat command + void runCommandRepeat(int count, String text) { + _commandController.sink.add(() async { + for (var i = 0; i < count; i++) { + final protoMessageText = proto.Message_Text() + ..text = text.replaceAll(RegExp(r'\$n\b'), i.toString()); + final message = proto.Message()..text = protoMessageText; + _sendMessage(message: message); + await Future.delayed(const Duration(milliseconds: 50)); + } + }); + } + //////////////////////////////////////////////////////////////////////////// // Internal implementation @@ -220,9 +256,6 @@ class SingleContactMessagesCubit extends Cubit { _reconciliation.reconcileMessages( _remoteIdentityPublicKey, rcvdMessages, _rcvdMessagesCubit!); - - // Update the view - _renderState(); } // Called when the reconciled messages window gets a change @@ -296,7 +329,7 @@ class SingleContactMessagesCubit extends Cubit { final renderedElements = []; - for (final m in reconciledMessages.elements) { + for (final m in reconciledMessages.windowElements) { final isLocal = m.content.author.toVeilid() == _activeAccountInfo.localAccount.identityMaster .identityPublicTypedKey(); @@ -316,7 +349,7 @@ class SingleContactMessagesCubit extends Cubit { } // Render the state - final renderedState = renderedElements + final messages = renderedElements .map((x) => MessageState( content: x.message, sentTimestamp: Timestamp.fromInt64(x.message.timestamp), @@ -325,7 +358,12 @@ class SingleContactMessagesCubit extends Cubit { .toIList(); // Emit the rendered state - emit(AsyncValue.data(renderedState)); + emit(AsyncValue.data(MessagesState( + windowMessages: messages, + length: reconciledMessages.length, + windowTail: reconciledMessages.windowTail, + windowCount: reconciledMessages.windowCount, + follow: reconciledMessages.follow))); } void _sendMessage({required proto.Message message}) { @@ -344,6 +382,12 @@ class SingleContactMessagesCubit extends Cubit { _renderState(); } + Future _commandRunner() async { + await for (final command in _commandController.stream) { + await command(); + } + } + ///////////////////////////////////////////////////////////////////////// // Static utility functions @@ -383,4 +427,6 @@ class SingleContactMessagesCubit extends Cubit { StreamSubscription>? _rcvdSubscription; StreamSubscription>? _reconciledSubscription; + final StreamController Function()> _commandController; + late final Future _commandRunnerFut; } diff --git a/lib/chat/models/messages_state.dart b/lib/chat/models/messages_state.dart new file mode 100644 index 0000000..4a08376 --- /dev/null +++ b/lib/chat/models/messages_state.dart @@ -0,0 +1,27 @@ +import 'package:fast_immutable_collections/fast_immutable_collections.dart'; +import 'package:flutter/foundation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +import 'message_state.dart'; + +part 'messages_state.freezed.dart'; +part 'messages_state.g.dart'; + +@freezed +class MessagesState with _$MessagesState { + const factory MessagesState({ + // List of messages in the window + required IList windowMessages, + // Total number of messages + required int length, + // One past the end of the last element + required int windowTail, + // The total number of elements to try to keep in 'messages' + required int windowCount, + // If we should have the tail following the array + required bool follow, + }) = _MessagesState; + + factory MessagesState.fromJson(dynamic json) => + _$MessagesStateFromJson(json as Map); +} diff --git a/lib/chat/models/messages_state.freezed.dart b/lib/chat/models/messages_state.freezed.dart new file mode 100644 index 0000000..368ca94 --- /dev/null +++ b/lib/chat/models/messages_state.freezed.dart @@ -0,0 +1,268 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'messages_state.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); + +MessagesState _$MessagesStateFromJson(Map json) { + return _MessagesState.fromJson(json); +} + +/// @nodoc +mixin _$MessagesState { +// List of messages in the window + IList get windowMessages => + throw _privateConstructorUsedError; // Total number of messages + int get length => + throw _privateConstructorUsedError; // One past the end of the last element + int get windowTail => + throw _privateConstructorUsedError; // The total number of elements to try to keep in 'messages' + int get windowCount => + throw _privateConstructorUsedError; // If we should have the tail following the array + bool get follow => throw _privateConstructorUsedError; + + Map toJson() => throw _privateConstructorUsedError; + @JsonKey(ignore: true) + $MessagesStateCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $MessagesStateCopyWith<$Res> { + factory $MessagesStateCopyWith( + MessagesState value, $Res Function(MessagesState) then) = + _$MessagesStateCopyWithImpl<$Res, MessagesState>; + @useResult + $Res call( + {IList windowMessages, + int length, + int windowTail, + int windowCount, + bool follow}); +} + +/// @nodoc +class _$MessagesStateCopyWithImpl<$Res, $Val extends MessagesState> + implements $MessagesStateCopyWith<$Res> { + _$MessagesStateCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? windowMessages = null, + Object? length = null, + Object? windowTail = null, + Object? windowCount = null, + Object? follow = null, + }) { + return _then(_value.copyWith( + windowMessages: null == windowMessages + ? _value.windowMessages + : windowMessages // ignore: cast_nullable_to_non_nullable + as IList, + length: null == length + ? _value.length + : length // ignore: cast_nullable_to_non_nullable + as int, + windowTail: null == windowTail + ? _value.windowTail + : windowTail // ignore: cast_nullable_to_non_nullable + as int, + windowCount: null == windowCount + ? _value.windowCount + : windowCount // ignore: cast_nullable_to_non_nullable + as int, + follow: null == follow + ? _value.follow + : follow // ignore: cast_nullable_to_non_nullable + as bool, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$MessagesStateImplCopyWith<$Res> + implements $MessagesStateCopyWith<$Res> { + factory _$$MessagesStateImplCopyWith( + _$MessagesStateImpl value, $Res Function(_$MessagesStateImpl) then) = + __$$MessagesStateImplCopyWithImpl<$Res>; + @override + @useResult + $Res call( + {IList windowMessages, + int length, + int windowTail, + int windowCount, + bool follow}); +} + +/// @nodoc +class __$$MessagesStateImplCopyWithImpl<$Res> + extends _$MessagesStateCopyWithImpl<$Res, _$MessagesStateImpl> + implements _$$MessagesStateImplCopyWith<$Res> { + __$$MessagesStateImplCopyWithImpl( + _$MessagesStateImpl _value, $Res Function(_$MessagesStateImpl) _then) + : super(_value, _then); + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? windowMessages = null, + Object? length = null, + Object? windowTail = null, + Object? windowCount = null, + Object? follow = null, + }) { + return _then(_$MessagesStateImpl( + windowMessages: null == windowMessages + ? _value.windowMessages + : windowMessages // ignore: cast_nullable_to_non_nullable + as IList, + length: null == length + ? _value.length + : length // ignore: cast_nullable_to_non_nullable + as int, + windowTail: null == windowTail + ? _value.windowTail + : windowTail // ignore: cast_nullable_to_non_nullable + as int, + windowCount: null == windowCount + ? _value.windowCount + : windowCount // ignore: cast_nullable_to_non_nullable + as int, + follow: null == follow + ? _value.follow + : follow // ignore: cast_nullable_to_non_nullable + as bool, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$MessagesStateImpl + with DiagnosticableTreeMixin + implements _MessagesState { + const _$MessagesStateImpl( + {required this.windowMessages, + required this.length, + required this.windowTail, + required this.windowCount, + required this.follow}); + + factory _$MessagesStateImpl.fromJson(Map json) => + _$$MessagesStateImplFromJson(json); + +// List of messages in the window + @override + final IList windowMessages; +// Total number of messages + @override + final int length; +// One past the end of the last element + @override + final int windowTail; +// The total number of elements to try to keep in 'messages' + @override + final int windowCount; +// If we should have the tail following the array + @override + final bool follow; + + @override + String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) { + return 'MessagesState(windowMessages: $windowMessages, length: $length, windowTail: $windowTail, windowCount: $windowCount, follow: $follow)'; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('type', 'MessagesState')) + ..add(DiagnosticsProperty('windowMessages', windowMessages)) + ..add(DiagnosticsProperty('length', length)) + ..add(DiagnosticsProperty('windowTail', windowTail)) + ..add(DiagnosticsProperty('windowCount', windowCount)) + ..add(DiagnosticsProperty('follow', follow)); + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$MessagesStateImpl && + const DeepCollectionEquality() + .equals(other.windowMessages, windowMessages) && + (identical(other.length, length) || other.length == length) && + (identical(other.windowTail, windowTail) || + other.windowTail == windowTail) && + (identical(other.windowCount, windowCount) || + other.windowCount == windowCount) && + (identical(other.follow, follow) || other.follow == follow)); + } + + @JsonKey(ignore: true) + @override + int get hashCode => Object.hash( + runtimeType, + const DeepCollectionEquality().hash(windowMessages), + length, + windowTail, + windowCount, + follow); + + @JsonKey(ignore: true) + @override + @pragma('vm:prefer-inline') + _$$MessagesStateImplCopyWith<_$MessagesStateImpl> get copyWith => + __$$MessagesStateImplCopyWithImpl<_$MessagesStateImpl>(this, _$identity); + + @override + Map toJson() { + return _$$MessagesStateImplToJson( + this, + ); + } +} + +abstract class _MessagesState implements MessagesState { + const factory _MessagesState( + {required final IList windowMessages, + required final int length, + required final int windowTail, + required final int windowCount, + required final bool follow}) = _$MessagesStateImpl; + + factory _MessagesState.fromJson(Map json) = + _$MessagesStateImpl.fromJson; + + @override // List of messages in the window + IList get windowMessages; + @override // Total number of messages + int get length; + @override // One past the end of the last element + int get windowTail; + @override // The total number of elements to try to keep in 'messages' + int get windowCount; + @override // If we should have the tail following the array + bool get follow; + @override + @JsonKey(ignore: true) + _$$MessagesStateImplCopyWith<_$MessagesStateImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/lib/chat/models/messages_state.g.dart b/lib/chat/models/messages_state.g.dart new file mode 100644 index 0000000..cf44e5b --- /dev/null +++ b/lib/chat/models/messages_state.g.dart @@ -0,0 +1,28 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'messages_state.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_$MessagesStateImpl _$$MessagesStateImplFromJson(Map json) => + _$MessagesStateImpl( + windowMessages: IList.fromJson( + json['window_messages'], (value) => MessageState.fromJson(value)), + length: (json['length'] as num).toInt(), + windowTail: (json['window_tail'] as num).toInt(), + windowCount: (json['window_count'] as num).toInt(), + follow: json['follow'] as bool, + ); + +Map _$$MessagesStateImplToJson(_$MessagesStateImpl instance) => + { + 'window_messages': instance.windowMessages.toJson( + (value) => value.toJson(), + ), + 'length': instance.length, + 'window_tail': instance.windowTail, + 'window_count': instance.windowCount, + 'follow': instance.follow, + }; diff --git a/lib/chat/models/models.dart b/lib/chat/models/models.dart index 2d92e01..3620563 100644 --- a/lib/chat/models/models.dart +++ b/lib/chat/models/models.dart @@ -1 +1,2 @@ export 'message_state.dart'; +export 'messages_state.dart'; diff --git a/lib/chat/views/chat_component.dart b/lib/chat/views/chat_component.dart index 327b82e..ee339d3 100644 --- a/lib/chat/views/chat_component.dart +++ b/lib/chat/views/chat_component.dart @@ -1,8 +1,9 @@ -import 'dart:typed_data'; +import 'dart:math'; import 'package:async_tools/async_tools.dart'; import 'package:awesome_extensions/awesome_extensions.dart'; import 'package:fixnum/fixnum.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_chat_types/flutter_chat_types.dart' as types; @@ -166,8 +167,9 @@ class ChatComponent extends StatelessWidget { _messagesCubit.sendTextMessage(messageText: protoMessageText); } - void _handleSendPressed(types.PartialText message) { + void _sendMessage(types.PartialText message) { final text = message.text; + final replyId = (message.repliedMessage != null) ? base64UrlNoPadDecode(message.repliedMessage!.id) : null; @@ -200,6 +202,17 @@ class ChatComponent extends StatelessWidget { attachments: attachments ?? []); } + void _handleSendPressed(types.PartialText message) { + final text = message.text; + + if (text.startsWith('/')) { + _messagesCubit.runCommand(text); + return; + } + + _sendMessage(message); + } + // void _handleAttachmentPressed() async { // // // } @@ -211,15 +224,15 @@ class ChatComponent extends StatelessWidget { final textTheme = Theme.of(context).textTheme; final chatTheme = makeChatTheme(scale, textTheme); - final messages = _messagesState.asData?.value; - if (messages == null) { + final messagesState = _messagesState.asData?.value; + if (messagesState == null) { return _messagesState.buildNotData(); } // Convert protobuf messages to chat messages final chatMessages = []; final tsSet = {}; - for (final message in messages) { + for (final message in messagesState.windowMessages) { final chatMessage = messageStateToChatMessage(message); if (chatMessage == null) { continue; @@ -228,12 +241,17 @@ class ChatComponent extends StatelessWidget { if (!tsSet.add(chatMessage.id)) { // ignore: avoid_print print('duplicate id found: ${chatMessage.id}:\n' - 'Messages:\n$messages\n' + 'Messages:\n${messagesState.windowMessages}\n' 'ChatMessages:\n$chatMessages'); assert(false, 'should not have duplicate id'); } } + final isLastPage = + (messagesState.windowTail - messagesState.windowMessages.length) <= 0; + final follow = messagesState.windowTail == 0 || + messagesState.windowTail == messagesState.length; xxx finish calculating pagination and get scroll position here somehow + return DefaultTextStyle( style: textTheme.bodySmall!, child: Align( @@ -272,9 +290,17 @@ class ChatComponent extends StatelessWidget { decoration: const BoxDecoration(), child: Chat( theme: chatTheme, - // emojiEnlargementBehavior: - // EmojiEnlargementBehavior.multi, messages: chatMessages, + onEndReached: () async { + final tail = await _messagesCubit.setWindow( + tail: max( + 0, + (messagesState.windowTail - + (messagesState.windowCount ~/ 2))), + count: messagesState.windowCount, + follow: follow); + }, + isLastPage: isLastPage, //onAttachmentPressed: _handleAttachmentPressed, //onMessageTap: _handleMessageTap, //onPreviewDataFetched: _handlePreviewDataFetched, diff --git a/lib/theme/models/radix_generator.dart b/lib/theme/models/radix_generator.dart index b1f510a..4bd593f 100644 --- a/lib/theme/models/radix_generator.dart +++ b/lib/theme/models/radix_generator.dart @@ -609,6 +609,29 @@ ThemeData radixGenerator(Brightness brightness, RadixThemeColor themeColor) { final themeData = ThemeData.from( colorScheme: colorScheme, textTheme: textTheme, useMaterial3: true); return themeData.copyWith( + scrollbarTheme: themeData.scrollbarTheme.copyWith( + thumbColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.pressed)) { + return scaleScheme.primaryScale.border; + } else if (states.contains(WidgetState.hovered)) { + return scaleScheme.primaryScale.hoverBorder; + } + return scaleScheme.primaryScale.subtleBorder; + }), trackColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.pressed)) { + return scaleScheme.primaryScale.activeElementBackground; + } else if (states.contains(WidgetState.hovered)) { + return scaleScheme.primaryScale.hoverElementBackground; + } + return scaleScheme.primaryScale.elementBackground; + }), trackBorderColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.pressed)) { + return scaleScheme.primaryScale.subtleBorder; + } else if (states.contains(WidgetState.hovered)) { + return scaleScheme.primaryScale.subtleBorder; + } + return scaleScheme.primaryScale.subtleBorder; + })), bottomSheetTheme: themeData.bottomSheetTheme.copyWith( elevation: 0, modalElevation: 0, diff --git a/lib/tools/misc.dart b/lib/tools/misc.dart new file mode 100644 index 0000000..01dcbc0 --- /dev/null +++ b/lib/tools/misc.dart @@ -0,0 +1,18 @@ +extension StringExt on String { + (String, String?) splitOnce(Pattern p) { + final pos = indexOf(p); + if (pos == -1) { + return (this, null); + } + final rest = substring(pos); + var offset = 0; + while (true) { + final match = p.matchAsPrefix(rest, offset); + if (match == null) { + break; + } + offset = match.end; + } + return (substring(0, pos), rest.substring(offset)); + } +} diff --git a/lib/tools/state_logger.dart b/lib/tools/state_logger.dart index 4c8e17a..db0ea2a 100644 --- a/lib/tools/state_logger.dart +++ b/lib/tools/state_logger.dart @@ -6,8 +6,9 @@ const Map _blocChangeLogLevels = { 'ConnectionStateCubit': LogLevel.off, 'ActiveSingleContactChatBlocMapCubit': LogLevel.off, 'ActiveConversationsBlocMapCubit': LogLevel.off, - 'DHTShortArrayCubit': LogLevel.off, 'PersistentQueueCubit': LogLevel.off, + 'TableDBArrayProtobufCubit': LogLevel.off, + 'DHTLogCubit': LogLevel.off, 'SingleContactMessagesCubit': LogLevel.off, }; const Map _blocCreateCloseLogLevels = {}; diff --git a/lib/tools/tools.dart b/lib/tools/tools.dart index c556f98..6b48001 100644 --- a/lib/tools/tools.dart +++ b/lib/tools/tools.dart @@ -2,6 +2,7 @@ export 'animations.dart'; export 'enter_password.dart'; export 'enter_pin.dart'; export 'loggy.dart'; +export 'misc.dart'; export 'phono_byte.dart'; export 'pop_control.dart'; export 'responsive.dart'; diff --git a/packages/veilid_support/lib/src/table_db_array_protobuf_cubit.dart b/packages/veilid_support/lib/src/table_db_array_protobuf_cubit.dart index 927ca59..702a2ad 100644 --- a/packages/veilid_support/lib/src/table_db_array_protobuf_cubit.dart +++ b/packages/veilid_support/lib/src/table_db_array_protobuf_cubit.dart @@ -14,22 +14,25 @@ import '../../../veilid_support.dart'; class TableDBArrayProtobufStateData extends Equatable { const TableDBArrayProtobufStateData( - {required this.elements, - required this.tail, - required this.count, + {required this.windowElements, + required this.length, + required this.windowTail, + required this.windowCount, required this.follow}); // The view of the elements in the dhtlog // Span is from [tail-length, tail) - final IList elements; + final IList windowElements; + // The length of the entire array + final int length; // One past the end of the last element - final int tail; + final int windowTail; // The total number of elements to try to keep in 'elements' - final int count; + final int windowCount; // If we should have the tail following the array final bool follow; @override - List get props => [elements, tail, count, follow]; + List get props => [windowElements, windowTail, windowCount, follow]; } typedef TableDBArrayProtobufState @@ -99,7 +102,10 @@ class TableDBArrayProtobufCubit } final elements = avElements.asData!.value; emit(AsyncValue.data(TableDBArrayProtobufStateData( - elements: elements, tail: _tail, count: _count, follow: _follow))); + windowElements: elements, + windowTail: _tail, + windowCount: _count, + follow: _follow))); } Future>> _loadElements(