From a3c8dafff9599bf1aa5ecad1b5d92c342e9086b1 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Wed, 1 Jan 2025 16:45:37 +0800 Subject: [PATCH] :sparkles: User typing status :bug: Bug fixes --- assets/translations/en-US.json | 4 + assets/translations/zh-CN.json | 4 + lib/controllers/chat_message_controller.dart | 88 ++++++++++++-------- lib/controllers/post_write_controller.dart | 2 +- lib/screens/chat/manage.dart | 2 +- lib/screens/chat/room.dart | 17 ++-- lib/screens/home.dart | 4 +- lib/widgets/chat/chat_message_input.dart | 12 ++- lib/widgets/chat/chat_typing_indicator.dart | 53 ++++++++++++ 9 files changed, 140 insertions(+), 46 deletions(-) create mode 100644 lib/widgets/chat/chat_typing_indicator.dart diff --git a/assets/translations/en-US.json b/assets/translations/en-US.json index f2bf12a..fd36369 100644 --- a/assets/translations/en-US.json +++ b/assets/translations/en-US.json @@ -281,6 +281,10 @@ "one": "{} attachment", "other": "{} attachments" }, + "messageTyping": { + "one": "{} is typing...", + "other": "{} are typing..." + }, "fieldAttachmentRandomId": "Random ID", "fieldAttachmentAlt": "Alternative text", "addAttachmentFromAlbum": "Add from album", diff --git a/assets/translations/zh-CN.json b/assets/translations/zh-CN.json index 0e434cf..e6e3207 100644 --- a/assets/translations/zh-CN.json +++ b/assets/translations/zh-CN.json @@ -279,6 +279,10 @@ "one": "{} 个附件", "other": "{} 个附件" }, + "messageTyping": { + "one": "{} 正在输入", + "other": "{} 正在输入" + }, "fieldAttachmentRandomId": "访问 ID", "fieldAttachmentAlt": "概述文字", "addAttachmentFromAlbum": "从相册中添加附件", diff --git a/lib/controllers/chat_message_controller.dart b/lib/controllers/chat_message_controller.dart index 9b39530..39bd444 100644 --- a/lib/controllers/chat_message_controller.dart +++ b/lib/controllers/chat_message_controller.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:math' as math; import 'package:collection/collection.dart'; @@ -11,6 +12,7 @@ import 'package:surface/providers/sn_network.dart'; import 'package:surface/providers/user_directory.dart'; import 'package:surface/providers/websocket.dart'; import 'package:surface/types/chat.dart'; +import 'package:surface/types/websocket.dart'; import 'package:uuid/uuid.dart'; class ChatMessageController extends ChangeNotifier { @@ -36,8 +38,7 @@ class ChatMessageController extends ChangeNotifier { int? messageTotal; - bool get isAllLoaded => - messageTotal != null && messages.length >= messageTotal!; + bool get isAllLoaded => messageTotal != null && messages.length >= messageTotal!; String? _boxKey; SnChannel? channel; @@ -50,8 +51,10 @@ class ChatMessageController extends ChangeNotifier { /// Stored as a list of nonce to provide the loading state final List unconfirmedMessages = List.empty(growable: true); - Box? get _box => - (_boxKey == null || isPending) ? null : Hive.box(_boxKey!); + Box? get _box => (_boxKey == null || isPending) ? null : Hive.box(_boxKey!); + + final List typingMembers = List.empty(growable: true); + final Map typingInactiveTimer = {}; Future initialize(SnChannel chan) async { channel = chan; @@ -78,22 +81,17 @@ class ChatMessageController extends ChangeNotifier { if (event.payload?['channel_id'] != channel?.id) break; final member = SnChannelMember.fromJson(event.payload!['member']); if (member.id == profile?.id) break; - // TODO impl typing users - // if (!_typingUsers.any((x) => x.id == member.id)) { - // setState(() { - // _typingUsers.add(member); - // }); - // } - // _typingInactiveTimer[member.id]?.cancel(); - // _typingInactiveTimer[member.id] = Timer( - // const Duration(seconds: 3), - // () { - // setState(() { - // _typingUsers.removeWhere((x) => x.id == member.id); - // _typingInactiveTimer.remove(member.id); - // }); - // }, - // ); + if (!typingMembers.any((x) => x.id == member.id)) { + typingMembers.add(member); + print('Typing member: ${typingMembers.map((ele) => member.id)}'); + notifyListeners(); + } + typingInactiveTimer[member.id]?.cancel(); + typingInactiveTimer[member.id] = Timer(const Duration(seconds: 3), () { + typingMembers.removeWhere((x) => x.id == member.id); + typingInactiveTimer.remove(member.id); + notifyListeners(); + }); } }); @@ -101,6 +99,35 @@ class ChatMessageController extends ChangeNotifier { notifyListeners(); } + Timer? _typingNotifyTimer; + bool _typingStatus = false; + + Future _sendTypingStatusPackage() async { + _ws.conn?.sink.add(jsonEncode( + WebSocketPackage( + method: 'status.typing', + endpoint: 'im', + payload: { + 'channel_id': channel!.id, + }, + ).toJson(), + )); + } + + void pingTypingStatus() { + if (!_typingStatus) { + _sendTypingStatusPackage(); + _typingStatus = true; + } + + if (_typingNotifyTimer == null || !_typingNotifyTimer!.isActive) { + _typingNotifyTimer?.cancel(); + _typingNotifyTimer = Timer(const Duration(milliseconds: 1850), () { + _typingStatus = false; + }); + } + } + Future _saveMessageToLocal(Iterable messages) async { if (_box == null) return; await _box!.putAll({ @@ -167,8 +194,7 @@ class ChatMessageController extends ChangeNotifier { switch (message.type) { case 'messages.edit': if (message.relatedEventId != null) { - final idx = - messages.indexWhere((x) => x.id == message.relatedEventId); + final idx = messages.indexWhere((x) => x.id == message.relatedEventId); if (idx != -1) { final newBody = message.body; newBody.remove('related_event'); @@ -207,8 +233,7 @@ class ChatMessageController extends ChangeNotifier { 'algorithm': 'plain', if (quoteId != null) 'quote_event': quoteId, if (relatedId != null) 'related_event': relatedId, - if (attachments != null && attachments.isNotEmpty) - 'attachments': attachments, + if (attachments != null && attachments.isNotEmpty) 'attachments': attachments, }; // Mock the message locally @@ -305,8 +330,7 @@ class ChatMessageController extends ChangeNotifier { if (out == null) { try { - final resp = await _sn.client - .get('/cgi/im/channels/${channel!.keyPath}/events/$id'); + final resp = await _sn.client.get('/cgi/im/channels/${channel!.keyPath}/events/$id'); out = SnChatMessage.fromJson(resp.data); _saveMessageToLocal([out]); } catch (_) { @@ -341,9 +365,7 @@ class ChatMessageController extends ChangeNotifier { bool forceRemote = false, }) async { late List out; - if (_box != null && - (_box!.length >= take + offset || forceLocal) && - !forceRemote) { + if (_box != null && (_box!.length >= take + offset || forceLocal) && !forceRemote) { out = _box!.keys .toList() .cast() @@ -386,8 +408,7 @@ class ChatMessageController extends ChangeNotifier { quoteEvent: quoteEvent, attachments: attachments .where( - (ele) => - out[i].body['attachments']?.contains(ele?.rid) ?? false, + (ele) => out[i].body['attachments']?.contains(ele?.rid) ?? false, ) .toList(), ), @@ -395,10 +416,7 @@ class ChatMessageController extends ChangeNotifier { } // Preload sender accounts - final accountId = out - .where((ele) => ele.sender.accountId >= 0) - .map((ele) => ele.sender.accountId) - .toSet(); + final accountId = out.where((ele) => ele.sender.accountId >= 0).map((ele) => ele.sender.accountId).toSet(); await _ud.listAccount(accountId); return out; diff --git a/lib/controllers/post_write_controller.dart b/lib/controllers/post_write_controller.dart index 4e7c472..d15c43e 100644 --- a/lib/controllers/post_write_controller.dart +++ b/lib/controllers/post_write_controller.dart @@ -104,7 +104,7 @@ class PostWriteMedia { if (attachment != null) { final sn = context.read(); final ImageProvider provider = UniversalImage.provider(sn.getAttachmentUrl(attachment!.rid)); - if (width != null && height != null) { + if (width != null && height != null && !kIsWeb) { return ResizeImage( provider, width: width, diff --git a/lib/screens/chat/manage.dart b/lib/screens/chat/manage.dart index 7c2dee7..51ecbe6 100644 --- a/lib/screens/chat/manage.dart +++ b/lib/screens/chat/manage.dart @@ -87,7 +87,7 @@ class _ChatManageScreenState extends State { try { final resp = await sn.client.request( widget.editingChannelAlias != null - ? '/cgi/im/channels/$scope/${widget.editingChannelAlias}' + ? '/cgi/im/channels/$scope/${_editingChannel!.id}' : '/cgi/im/channels/$scope', data: payload, options: Options( diff --git a/lib/screens/chat/room.dart b/lib/screens/chat/room.dart index f3f3198..b437009 100644 --- a/lib/screens/chat/room.dart +++ b/lib/screens/chat/room.dart @@ -17,6 +17,7 @@ import 'package:surface/types/chat.dart'; import 'package:surface/widgets/chat/call/call_prejoin.dart'; import 'package:surface/widgets/chat/chat_message.dart'; import 'package:surface/widgets/chat/chat_message_input.dart'; +import 'package:surface/widgets/chat/chat_typing_indicator.dart'; import 'package:surface/widgets/dialog.dart'; import 'package:surface/widgets/loading_indicator.dart'; import 'package:very_good_infinite_list/very_good_infinite_list.dart'; @@ -335,11 +336,17 @@ class _ChatRoomScreenState extends State { if (!_messageController.isPending) Material( elevation: 2, - child: ChatMessageInput( - key: _inputGlobalKey, - otherMember: _otherMember, - controller: _messageController, - ).padding(bottom: MediaQuery.of(context).padding.bottom), + child: Column( + children: [ + ChatTypingIndicator(controller: _messageController), + ChatMessageInput( + key: _inputGlobalKey, + otherMember: _otherMember, + controller: _messageController, + ), + Gap(MediaQuery.of(context).padding.bottom), + ], + ), ), ], ); diff --git a/lib/screens/home.dart b/lib/screens/home.dart index e0c4e2f..b990dc0 100644 --- a/lib/screens/home.dart +++ b/lib/screens/home.dart @@ -173,8 +173,8 @@ class _HomeDashSpecialDayWidget extends StatelessWidget { title: Text('celebrate$ele').tr(args: [ua.user?.nick ?? 'user']), subtitle: Text( DateFormat('y/M/d').format(DateTime.now().copyWith( - month: kSpecialDays[ele]!.$1, - day: kSpecialDays[ele]!.$2, + month: kSpecialDays[ele]?.$1, + day: kSpecialDays[ele]?.$2, )), ), ), diff --git a/lib/widgets/chat/chat_message_input.dart b/lib/widgets/chat/chat_message_input.dart index 04c630f..56211fe 100644 --- a/lib/widgets/chat/chat_message_input.dart +++ b/lib/widgets/chat/chat_message_input.dart @@ -32,6 +32,16 @@ class ChatMessageInputState extends State { final TextEditingController _contentController = TextEditingController(); final FocusNode _focusNode = FocusNode(); + @override + void initState() { + super.initState(); + _contentController.addListener(() { + if (_contentController.text.isNotEmpty) { + widget.controller.pingTypingStatus(); + } + }); + } + void setReply(SnChatMessage? value) { setState(() => _replyingMessage = value); } @@ -164,7 +174,6 @@ class ChatMessageInputState extends State { ? Container( padding: const EdgeInsets.only(left: 16, right: 16), decoration: BoxDecoration( - borderRadius: const BorderRadius.all(Radius.circular(8)), border: Border( bottom: BorderSide( color: Theme.of(context).dividerColor, @@ -204,7 +213,6 @@ class ChatMessageInputState extends State { ? Container( padding: const EdgeInsets.only(left: 16, right: 16), decoration: BoxDecoration( - borderRadius: const BorderRadius.all(Radius.circular(8)), border: Border( bottom: BorderSide( color: Theme.of(context).dividerColor, diff --git a/lib/widgets/chat/chat_typing_indicator.dart b/lib/widgets/chat/chat_typing_indicator.dart new file mode 100644 index 0000000..cc7a62c --- /dev/null +++ b/lib/widgets/chat/chat_typing_indicator.dart @@ -0,0 +1,53 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; +import 'package:material_symbols_icons/material_symbols_icons.dart'; +import 'package:provider/provider.dart'; +import 'package:styled_widget/styled_widget.dart'; +import 'package:surface/controllers/chat_message_controller.dart'; +import 'package:surface/providers/user_directory.dart'; + +class ChatTypingIndicator extends StatelessWidget { + final ChatMessageController controller; + + const ChatTypingIndicator({super.key, required this.controller}); + + @override + Widget build(BuildContext context) { + final ud = context.read(); + + return StyledWidget(controller.typingMembers.isEmpty + ? const SizedBox.shrink() + : Container( + padding: const EdgeInsets.only(left: 16, right: 16), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: Theme.of(context).dividerColor, + width: 1 / MediaQuery.of(context).devicePixelRatio, + ), + ), + ), + child: Row( + children: [ + const Icon(Symbols.more_horiz, weight: 600, size: 20), + const Gap(8), + Text( + 'messageTyping'.plural(controller.typingMembers.length, args: [ + controller.typingMembers + .map((ele) => (ele.nick?.isNotEmpty ?? false) + ? ele.nick! + : ud.getAccountFromCache(ele.accountId)?.name ?? 'unknown') + .join(', '), + ]), + ), + ], + ), + )) + .height(controller.typingMembers.isNotEmpty ? 38 : 0, animate: true) + .animate( + const Duration(milliseconds: 300), + Curves.fastLinearToSlowEaseIn, + ); + } +}