From 5032cccf3809010a7d26769429b7e2858cfcb264 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Mon, 18 Nov 2024 22:33:03 +0800 Subject: [PATCH] :sparkles: Chat quote and reply --- lib/controllers/chat_message_controller.dart | 69 ++++++++- lib/screens/chat/room.dart | 10 +- lib/types/chat.dart | 4 +- lib/types/chat.freezed.dart | 59 ++++++-- lib/types/chat.g.dart | 39 +---- lib/widgets/chat/chat_message.dart | 143 ++++++++++++------- lib/widgets/chat/chat_message_input.dart | 58 +++++++- pubspec.lock | 12 +- pubspec.yaml | 1 + 9 files changed, 279 insertions(+), 116 deletions(-) diff --git a/lib/controllers/chat_message_controller.dart b/lib/controllers/chat_message_controller.dart index 6e209d7..502bc53 100644 --- a/lib/controllers/chat_message_controller.dart +++ b/lib/controllers/chat_message_controller.dart @@ -119,12 +119,20 @@ class ChatMessageController extends ChangeNotifier { } Future _addUnconfirmedMessage(SnChatMessage message) async { + SnChatMessage? quoteEvent; + if (message.body['quote_event'] != null) { + quoteEvent = await getMessage(message.body['quote_event'] as int); + } + final attachmentRid = List.from( message.body['attachments']?.cast() ?? [], ); final attachments = await _attach.getMultiple(attachmentRid); message = message.copyWith( - preload: SnChatMessagePreload(attachments: attachments), + preload: SnChatMessagePreload( + quoteEvent: quoteEvent, + attachments: attachments, + ), ); messages.insert(0, message); @@ -133,12 +141,20 @@ class ChatMessageController extends ChangeNotifier { } Future _addMessage(SnChatMessage message) async { + SnChatMessage? quoteEvent; + if (message.body['quote_event'] != null) { + quoteEvent = await getMessage(message.body['quote_event'] as int); + } + final attachmentRid = List.from( message.body['attachments']?.cast() ?? [], ); final attachments = await _attach.getMultiple(attachmentRid); message = message.copyWith( - preload: SnChatMessagePreload(attachments: attachments), + preload: SnChatMessagePreload( + quoteEvent: quoteEvent, + attachments: attachments, + ), ); final idx = messages.indexWhere((e) => e.uuid == message.uuid); @@ -199,8 +215,8 @@ class ChatMessageController extends ChangeNotifier { final body = { 'text': content, 'algorithm': 'plain', - if (quoteId != null) 'quote_id': quoteId, - if (relatedId != null) 'related_id': relatedId, + if (quoteId != null) 'quote_event': quoteId, + if (relatedId != null) 'quote_event': relatedId, if (attachments != null && attachments.isNotEmpty) 'attachments': attachments, }; @@ -269,6 +285,42 @@ class ChatMessageController extends ChangeNotifier { } } + /// Get a single event from the current channel + /// If it was not found in local storage we will look it up in remote + Future getMessage(int id) async { + SnChatMessage? out; + if (_box != null && _box!.containsKey(id)) { + out = _box!.get(id); + } + + if (out == null) { + try { + final resp = await _sn.client + .get('/cgi/im/channels/${channel!.keyPath}/events/$id'); + out = SnChatMessage.fromJson(resp.data); + _saveMessageToLocal([out]); + } catch (_) { + // ignore, maybe not found + } + } + + // Preload some related things if found + if (out != null) { + await _ud.listAccount([out.sender.accountId]); + + final attachments = await _attach.getMultiple( + out.body['attachments']?.cast() ?? [], + ); + out = out.copyWith( + preload: SnChatMessagePreload( + attachments: attachments, + ), + ); + } + + return out; + } + /// Get message from local storage first, then from the server. /// Will not check local storage is up to date with the server. /// If you need to do the sync, do the `checkUpdate` instead. @@ -300,9 +352,18 @@ class ChatMessageController extends ChangeNotifier { out.expand((e) => (e.body['attachments'] as List?) ?? []), ); final attachments = await _attach.getMultiple(attachmentRid); + + // Putting preload back to data for (var i = 0; i < out.length; i++) { + // Preload related events (quoted) + SnChatMessage? quoteEvent; + if (out[i].body['quote_event'] != null) { + quoteEvent = await getMessage(out[i].body['quote_event'] as int); + } + out[i] = out[i].copyWith( preload: SnChatMessagePreload( + quoteEvent: quoteEvent, attachments: attachments .where( (ele) => diff --git a/lib/screens/chat/room.dart b/lib/screens/chat/room.dart index 1326d73..f78f748 100644 --- a/lib/screens/chat/room.dart +++ b/lib/screens/chat/room.dart @@ -25,6 +25,7 @@ class _ChatRoomScreenState extends State { SnChannel? _channel; + final GlobalKey _inputGlobalKey = GlobalKey(); late final ChatMessageController _messageController; Future _fetchChannel() async { @@ -117,6 +118,9 @@ class _ChatRoomScreenState extends State { hasMerged: canMergePrevious, isPending: _messageController.unconfirmedMessages .contains(message.uuid), + onReply: () { + _inputGlobalKey.currentState?.setReply(message); + }, ); }, ), @@ -124,8 +128,10 @@ class _ChatRoomScreenState extends State { if (!_messageController.isPending) Material( elevation: 2, - child: ChatMessageInput(controller: _messageController) - .padding(bottom: MediaQuery.of(context).padding.bottom), + child: ChatMessageInput( + key: _inputGlobalKey, + controller: _messageController, + ).padding(bottom: MediaQuery.of(context).padding.bottom), ), ], ); diff --git a/lib/types/chat.dart b/lib/types/chat.dart index 7f61ae9..ba08332 100644 --- a/lib/types/chat.dart +++ b/lib/types/chat.dart @@ -91,9 +91,9 @@ class SnChatMessage with _$SnChatMessage { class SnChatMessagePreload with _$SnChatMessagePreload { const SnChatMessagePreload._(); - @HiveType(typeId: 5) const factory SnChatMessagePreload({ - @HiveField(0) List? attachments, + List? attachments, + SnChatMessage? quoteEvent, }) = _SnChatMessagePreload; factory SnChatMessagePreload.fromJson(Map json) => diff --git a/lib/types/chat.freezed.dart b/lib/types/chat.freezed.dart index fdb3d28..cd2045c 100644 --- a/lib/types/chat.freezed.dart +++ b/lib/types/chat.freezed.dart @@ -1540,8 +1540,8 @@ SnChatMessagePreload _$SnChatMessagePreloadFromJson(Map json) { /// @nodoc mixin _$SnChatMessagePreload { - @HiveField(0) List? get attachments => throw _privateConstructorUsedError; + SnChatMessage? get quoteEvent => throw _privateConstructorUsedError; /// Serializes this SnChatMessagePreload to a JSON map. Map toJson() => throw _privateConstructorUsedError; @@ -1559,7 +1559,9 @@ abstract class $SnChatMessagePreloadCopyWith<$Res> { $Res Function(SnChatMessagePreload) then) = _$SnChatMessagePreloadCopyWithImpl<$Res, SnChatMessagePreload>; @useResult - $Res call({@HiveField(0) List? attachments}); + $Res call({List? attachments, SnChatMessage? quoteEvent}); + + $SnChatMessageCopyWith<$Res>? get quoteEvent; } /// @nodoc @@ -1579,14 +1581,33 @@ class _$SnChatMessagePreloadCopyWithImpl<$Res, @override $Res call({ Object? attachments = freezed, + Object? quoteEvent = freezed, }) { return _then(_value.copyWith( attachments: freezed == attachments ? _value.attachments : attachments // ignore: cast_nullable_to_non_nullable as List?, + quoteEvent: freezed == quoteEvent + ? _value.quoteEvent + : quoteEvent // ignore: cast_nullable_to_non_nullable + as SnChatMessage?, ) as $Val); } + + /// Create a copy of SnChatMessagePreload + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $SnChatMessageCopyWith<$Res>? get quoteEvent { + if (_value.quoteEvent == null) { + return null; + } + + return $SnChatMessageCopyWith<$Res>(_value.quoteEvent!, (value) { + return _then(_value.copyWith(quoteEvent: value) as $Val); + }); + } } /// @nodoc @@ -1597,7 +1618,10 @@ abstract class _$$SnChatMessagePreloadImplCopyWith<$Res> __$$SnChatMessagePreloadImplCopyWithImpl<$Res>; @override @useResult - $Res call({@HiveField(0) List? attachments}); + $Res call({List? attachments, SnChatMessage? quoteEvent}); + + @override + $SnChatMessageCopyWith<$Res>? get quoteEvent; } /// @nodoc @@ -1614,22 +1638,26 @@ class __$$SnChatMessagePreloadImplCopyWithImpl<$Res> @override $Res call({ Object? attachments = freezed, + Object? quoteEvent = freezed, }) { return _then(_$SnChatMessagePreloadImpl( attachments: freezed == attachments ? _value._attachments : attachments // ignore: cast_nullable_to_non_nullable as List?, + quoteEvent: freezed == quoteEvent + ? _value.quoteEvent + : quoteEvent // ignore: cast_nullable_to_non_nullable + as SnChatMessage?, )); } } /// @nodoc @JsonSerializable() -@HiveType(typeId: 5) class _$SnChatMessagePreloadImpl extends _SnChatMessagePreload { const _$SnChatMessagePreloadImpl( - {@HiveField(0) final List? attachments}) + {final List? attachments, this.quoteEvent}) : _attachments = attachments, super._(); @@ -1638,7 +1666,6 @@ class _$SnChatMessagePreloadImpl extends _SnChatMessagePreload { final List? _attachments; @override - @HiveField(0) List? get attachments { final value = _attachments; if (value == null) return null; @@ -1647,9 +1674,12 @@ class _$SnChatMessagePreloadImpl extends _SnChatMessagePreload { return EqualUnmodifiableListView(value); } + @override + final SnChatMessage? quoteEvent; + @override String toString() { - return 'SnChatMessagePreload(attachments: $attachments)'; + return 'SnChatMessagePreload(attachments: $attachments, quoteEvent: $quoteEvent)'; } @override @@ -1658,13 +1688,15 @@ class _$SnChatMessagePreloadImpl extends _SnChatMessagePreload { (other.runtimeType == runtimeType && other is _$SnChatMessagePreloadImpl && const DeepCollectionEquality() - .equals(other._attachments, _attachments)); + .equals(other._attachments, _attachments) && + (identical(other.quoteEvent, quoteEvent) || + other.quoteEvent == quoteEvent)); } @JsonKey(includeFromJson: false, includeToJson: false) @override - int get hashCode => Object.hash( - runtimeType, const DeepCollectionEquality().hash(_attachments)); + int get hashCode => Object.hash(runtimeType, + const DeepCollectionEquality().hash(_attachments), quoteEvent); /// Create a copy of SnChatMessagePreload /// with the given fields replaced by the non-null parameter values. @@ -1686,16 +1718,17 @@ class _$SnChatMessagePreloadImpl extends _SnChatMessagePreload { abstract class _SnChatMessagePreload extends SnChatMessagePreload { const factory _SnChatMessagePreload( - {@HiveField(0) final List? attachments}) = - _$SnChatMessagePreloadImpl; + {final List? attachments, + final SnChatMessage? quoteEvent}) = _$SnChatMessagePreloadImpl; const _SnChatMessagePreload._() : super._(); factory _SnChatMessagePreload.fromJson(Map json) = _$SnChatMessagePreloadImpl.fromJson; @override - @HiveField(0) List? get attachments; + @override + SnChatMessage? get quoteEvent; /// Create a copy of SnChatMessagePreload /// with the given fields replaced by the non-null parameter values. diff --git a/lib/types/chat.g.dart b/lib/types/chat.g.dart index fc6daeb..5ddf145 100644 --- a/lib/types/chat.g.dart +++ b/lib/types/chat.g.dart @@ -204,41 +204,6 @@ class SnChatMessageImplAdapter extends TypeAdapter<_$SnChatMessageImpl> { typeId == other.typeId; } -class SnChatMessagePreloadImplAdapter - extends TypeAdapter<_$SnChatMessagePreloadImpl> { - @override - final int typeId = 5; - - @override - _$SnChatMessagePreloadImpl read(BinaryReader reader) { - final numOfFields = reader.readByte(); - final fields = { - for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), - }; - return _$SnChatMessagePreloadImpl( - attachments: (fields[0] as List?)?.cast(), - ); - } - - @override - void write(BinaryWriter writer, _$SnChatMessagePreloadImpl obj) { - writer - ..writeByte(1) - ..writeByte(0) - ..write(obj.attachments); - } - - @override - int get hashCode => typeId.hashCode; - - @override - bool operator ==(Object other) => - identical(this, other) || - other is SnChatMessagePreloadImplAdapter && - runtimeType == other.runtimeType && - typeId == other.typeId; -} - // ************************************************************************** // JsonSerializableGenerator // ************************************************************************** @@ -374,10 +339,14 @@ _$SnChatMessagePreloadImpl _$$SnChatMessagePreloadImplFromJson( ? null : SnAttachment.fromJson(e as Map)) .toList(), + quoteEvent: json['quote_event'] == null + ? null + : SnChatMessage.fromJson(json['quote_event'] as Map), ); Map _$$SnChatMessagePreloadImplToJson( _$SnChatMessagePreloadImpl instance) => { 'attachments': instance.attachments?.map((e) => e?.toJson()).toList(), + 'quote_event': instance.quoteEvent?.toJson(), }; diff --git a/lib/widgets/chat/chat_message.dart b/lib/widgets/chat/chat_message.dart index 3577c6f..49b7d49 100644 --- a/lib/widgets/chat/chat_message.dart +++ b/lib/widgets/chat/chat_message.dart @@ -1,6 +1,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:gap/gap.dart'; +import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; import 'package:styled_widget/styled_widget.dart'; import 'package:surface/providers/user_directory.dart'; @@ -8,18 +9,23 @@ import 'package:surface/types/chat.dart'; import 'package:surface/widgets/account/account_image.dart'; import 'package:surface/widgets/attachment/attachment_list.dart'; import 'package:surface/widgets/markdown_content.dart'; +import 'package:swipe_to/swipe_to.dart'; class ChatMessage extends StatelessWidget { final SnChatMessage data; + final bool isCompact; final bool isMerged; final bool hasMerged; final bool isPending; + final Function()? onReply; const ChatMessage({ super.key, required this.data, + this.isCompact = false, this.isMerged = false, this.hasMerged = false, this.isPending = false, + this.onReply, }); @override @@ -29,59 +35,92 @@ class ChatMessage extends StatelessWidget { final dateFormatter = DateFormat('MM/dd HH:mm'); - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (!isMerged) - AccountImage( - content: user?.avatar, + return SwipeTo( + key: Key('chat-message-${data.id}'), + iconOnLeftSwipe: Symbols.reply, + swipeSensitivity: 20, + onLeftSwipe: onReply != null ? (_) => onReply!() : null, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (!isMerged && !isCompact) + AccountImage( + content: user?.avatar, + ) + else if (isMerged) + const Gap(40), + const Gap(8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (!isMerged) + Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + if (isCompact) + AccountImage( + content: user?.avatar, + radius: 12, + ).padding(right: 6), + Text( + (data.sender.nick?.isNotEmpty ?? false) + ? data.sender.nick! + : user!.nick, + ).bold(), + const Gap(6), + Text( + dateFormatter.format(data.createdAt.toLocal()), + ).fontSize(13), + ], + ), + if (isCompact) const Gap(4), + if (data.preload?.quoteEvent != null) + StyledWidget(Container( + decoration: BoxDecoration( + borderRadius: + const BorderRadius.all(Radius.circular(8)), + border: Border.all( + color: Theme.of(context).dividerColor, + width: 1, + ), + ), + padding: const EdgeInsets.only( + left: 4, + right: 4, + top: 8, + bottom: 6, + ), + child: ChatMessage( + data: data.preload!.quoteEvent!, + isCompact: true, + ), + )).padding(bottom: 4, top: isMerged ? 4 : 2), + if (data.body['text'] != null) + MarkdownTextContent( + content: data.body['text'], + isAutoWarp: true, + ), + ], + ), ) - else - const Gap(40), - const Gap(8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (!isMerged) - Row( - crossAxisAlignment: CrossAxisAlignment.baseline, - textBaseline: TextBaseline.alphabetic, - children: [ - Text( - (data.sender.nick?.isNotEmpty ?? false) - ? data.sender.nick! - : user!.nick, - ).bold(), - const Gap(6), - Text( - dateFormatter.format(data.createdAt.toLocal()), - ).fontSize(13), - ], - ), - if (data.body['text'] != null) - MarkdownTextContent( - content: data.body['text'], - isAutoWarp: true, - ), - ], - ), - ) - ], - ).opacity(isPending ? 0.5 : 1), - if (data.preload?.attachments?.isNotEmpty ?? false) - AttachmentList( - data: data.preload!.attachments!, - bordered: true, - noGrow: true, - maxHeight: 520, - listPadding: const EdgeInsets.only(top: 8), - ), - if (!hasMerged) const Gap(8), - ], + ], + ).opacity(isPending ? 0.5 : 1), + if (data.preload?.attachments?.isNotEmpty ?? false) + AttachmentList( + data: data.preload!.attachments!, + bordered: true, + noGrow: true, + maxHeight: 520, + listPadding: const EdgeInsets.only(top: 8), + ), + if (!hasMerged && !isCompact) const Gap(12), + ], + ), ); } } diff --git a/lib/widgets/chat/chat_message_input.dart b/lib/widgets/chat/chat_message_input.dart index def0b6d..e665086 100644 --- a/lib/widgets/chat/chat_message_input.dart +++ b/lib/widgets/chat/chat_message_input.dart @@ -8,7 +8,9 @@ import 'package:styled_widget/styled_widget.dart'; import 'package:surface/controllers/chat_message_controller.dart'; import 'package:surface/controllers/post_write_controller.dart'; import 'package:surface/providers/sn_attachment.dart'; +import 'package:surface/types/chat.dart'; import 'package:surface/widgets/dialog.dart'; +import 'package:surface/widgets/markdown_content.dart'; import 'package:surface/widgets/post/post_media_pending_list.dart'; class ChatMessageInput extends StatefulWidget { @@ -16,16 +18,22 @@ class ChatMessageInput extends StatefulWidget { const ChatMessageInput({super.key, required this.controller}); @override - State createState() => _ChatMessageInputState(); + State createState() => ChatMessageInputState(); } -class _ChatMessageInputState extends State { +class ChatMessageInputState extends State { bool _isBusy = false; double? _progress; + SnChatMessage? _replyingMessage; + final TextEditingController _contentController = TextEditingController(); final FocusNode _focusNode = FocusNode(); + void setReply(SnChatMessage? value) { + setState(() => _replyingMessage = value); + } + Future _sendMessage() async { if (_isBusy) return; @@ -69,6 +77,7 @@ class _ChatMessageInputState extends State { attach.putCache( _attachments.where((e) => e.attachment != null).map((e) => e.attachment!), + noCheck: true, ); // Send the message @@ -80,9 +89,11 @@ class _ChatMessageInputState extends State { .where((e) => e.attachment != null) .map((e) => e.attachment!.rid) .toList(), + quoteId: _replyingMessage?.id, ); _contentController.clear(); _attachments.clear(); + _replyingMessage = null; setState(() => _isBusy = false); } @@ -134,10 +145,45 @@ class _ChatMessageInputState extends State { setState(() => _attachments.removeAt(idx)); }, onUpdateBusy: (state) => setState(() => _isBusy = state), - ).height(_attachments.isNotEmpty ? 80 + 8 : 0, animate: true).animate( - const Duration(milliseconds: 300), - Curves.fastEaseInToSlowEaseOut), - ), + ), + ).height(_attachments.isNotEmpty ? 80 + 8 : 0, animate: true).animate( + const Duration(milliseconds: 300), Curves.fastEaseInToSlowEaseOut), + SingleChildScrollView( + physics: const NeverScrollableScrollPhysics(), + child: Padding( + padding: _replyingMessage != null + ? const EdgeInsets.only(top: 8) + : EdgeInsets.zero, + child: _replyingMessage != null + ? MaterialBanner( + padding: const EdgeInsets.only(left: 16.0), + leading: const Icon(Symbols.reply), + content: SingleChildScrollView( + physics: const NeverScrollableScrollPhysics(), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (_replyingMessage?.body['text'] != null) + MarkdownTextContent( + content: _replyingMessage?.body['text'], + ), + ], + ), + ), + actions: [ + TextButton( + child: Text('cancel'.tr()), + onPressed: () { + setState(() => _replyingMessage = null); + }, + ), + ], + ) + : const SizedBox.shrink(), + ), + ).height(_replyingMessage != null ? 54 + 8 : 0, animate: true).animate( + const Duration(milliseconds: 300), Curves.fastEaseInToSlowEaseOut), SizedBox( height: 56, child: Row( diff --git a/pubspec.lock b/pubspec.lock index 480aede..8a6d1d4 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -516,10 +516,10 @@ packages: dependency: "direct dev" description: name: flutter_native_splash - sha256: ee5c9bd2b74ea8676442fd4ab876b5d41681df49276488854d6c81a5377c0ef1 + sha256: "1152ab0067ca5a2ebeb862fe0a762057202cceb22b7e62692dcbabf6483891bb" url: "https://pub.dev" source: hosted - version: "2.4.2" + version: "2.4.3" flutter_plugin_android_lifecycle: dependency: transitive description: @@ -1343,6 +1343,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.4.1" + swipe_to: + dependency: "direct main" + description: + name: swipe_to + sha256: "58f61031803ece9b0efe09006809e78904c640c6d42d48715d1d1c3c28f8499a" + url: "https://pub.dev" + source: hosted + version: "1.0.6" synchronized: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index aac493a..f47eb75 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -78,6 +78,7 @@ dependencies: isar_flutter_libs: ^3.1.0+1 hive: ^2.2.3 hive_flutter: ^1.1.0 + swipe_to: ^1.0.6 dev_dependencies: flutter_test: