From a70e6c71181e11cceac5f41f2cd9684cb5dc0b7a Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Fri, 23 Aug 2024 22:43:04 +0800 Subject: [PATCH] :sparkles: Typing indicator --- lib/models/packet.dart | 4 ++ lib/providers/websocket.dart | 1 + lib/screens/channel/channel_chat.dart | 71 +++++++++++++++------ lib/translations/en_us.dart | 1 + lib/translations/zh_cn.dart | 1 + lib/widgets/chat/chat_message_input.dart | 49 ++++++++++++++ lib/widgets/chat/chat_typing_indicator.dart | 58 +++++++++++++++++ 7 files changed, 166 insertions(+), 19 deletions(-) create mode 100644 lib/widgets/chat/chat_typing_indicator.dart diff --git a/lib/models/packet.dart b/lib/models/packet.dart index c633a4c..dd73c56 100644 --- a/lib/models/packet.dart +++ b/lib/models/packet.dart @@ -1,22 +1,26 @@ class NetworkPackage { String method; + String? endpoint; String? message; Map? payload; NetworkPackage({ required this.method, + this.endpoint, this.message, this.payload, }); factory NetworkPackage.fromJson(Map json) => NetworkPackage( method: json['w'], + endpoint: json['e'], message: json['m'], payload: json['p'], ); Map toJson() => { 'w': method, + 'e': endpoint, 'm': message, 'p': payload, }; diff --git a/lib/providers/websocket.dart b/lib/providers/websocket.dart index e460488..7a0bf74 100644 --- a/lib/providers/websocket.dart +++ b/lib/providers/websocket.dart @@ -88,6 +88,7 @@ class WebSocketProvider extends GetxController { websocket?.stream.listen( (event) { final packet = NetworkPackage.fromJson(jsonDecode(event)); + log('Websocket incoming message: ${packet.method} ${packet.message}'); stream.sink.add(packet); }, onDone: () { diff --git a/lib/screens/channel/channel_chat.dart b/lib/screens/channel/channel_chat.dart index 7bf6e7b..5569267 100644 --- a/lib/screens/channel/channel_chat.dart +++ b/lib/screens/channel/channel_chat.dart @@ -24,6 +24,7 @@ import 'package:solian/widgets/channel/channel_call_indicator.dart'; import 'package:solian/widgets/chat/call/chat_call_action.dart'; import 'package:solian/widgets/chat/chat_event_list.dart'; import 'package:solian/widgets/chat/chat_message_input.dart'; +import 'package:solian/widgets/chat/chat_typing_indicator.dart'; import 'package:solian/widgets/current_state_action.dart'; class ChannelChatScreen extends StatefulWidget { @@ -103,12 +104,18 @@ class _ChannelChatScreenState extends State setState(() => _isBusy = false); } + List _typingUsers = List.empty(growable: true); + Map _typingInactiveTimer = {}; + void _listenMessages() { - final WebSocketProvider provider = Get.find(); - _subscription = provider.stream.stream.listen((event) { + final WebSocketProvider ws = Get.find(); + _subscription = ws.stream.stream.listen((event) { switch (event.method) { case 'events.new': final payload = Event.fromJson(event.payload!); + final typingIdx = + _typingUsers.indexWhere((x) => x.id == payload.senderId); + if (typingIdx != -1) _typingUsers.removeAt(typingIdx); _chatController.receiveEvent(payload); break; case 'calls.new': @@ -123,6 +130,24 @@ class _ChannelChatScreenState extends State setState(() => _ongoingCall = null); } break; + case 'status.typing': + if (event.payload?['channel_id'] != _channel!.id) break; + final member = ChannelMember.fromJson(event.payload!['member']); + 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); + }); + }, + ); } }); } @@ -280,23 +305,28 @@ class _ChannelChatScreenState extends State child: BackdropFilter( filter: ImageFilter.blur(sigmaX: 50, sigmaY: 50), child: SafeArea( - child: ChatMessageInput( - edit: _messageToEditing, - reply: _messageToReplying, - realm: widget.realm, - placeholder: placeholder, - channel: _channel!, - onSent: (Event item) { - setState(() { - _chatController.addPendingEvent(item); - }); - }, - onReset: () { - setState(() { - _messageToReplying = null; - _messageToEditing = null; - }); - }, + child: Column( + children: [ + ChatTypingIndicator(users: _typingUsers), + ChatMessageInput( + edit: _messageToEditing, + reply: _messageToReplying, + realm: widget.realm, + placeholder: placeholder, + channel: _channel!, + onSent: (Event item) { + setState(() { + _chatController.addPendingEvent(item); + }); + }, + onReset: () { + setState(() { + _messageToReplying = null; + _messageToEditing = null; + }); + }, + ), + ], ), ), ), @@ -329,6 +359,9 @@ class _ChannelChatScreenState extends State @override void dispose() { + for (var timer in _typingInactiveTimer.values) { + timer.cancel(); + } _subscription?.cancel(); WidgetsBinding.instance.removeObserver(this); super.dispose(); diff --git a/lib/translations/en_us.dart b/lib/translations/en_us.dart index 035c72c..4605781 100644 --- a/lib/translations/en_us.dart +++ b/lib/translations/en_us.dart @@ -385,4 +385,5 @@ const i18nEnglish = { 'unknown': 'Unknown', 'collapse': 'Collapse', 'expand': 'Expand', + 'typingMessage': '@user are typing...', }; diff --git a/lib/translations/zh_cn.dart b/lib/translations/zh_cn.dart index a3a3029..05596e4 100644 --- a/lib/translations/zh_cn.dart +++ b/lib/translations/zh_cn.dart @@ -355,4 +355,5 @@ const i18nSimplifiedChinese = { 'unknown': '未知', 'collapse': '折叠', 'expand': '展开', + 'typingMessage': '@user 正在输入中…', }; diff --git a/lib/widgets/chat/chat_message_input.dart b/lib/widgets/chat/chat_message_input.dart index f0b2b63..12501b4 100644 --- a/lib/widgets/chat/chat_message_input.dart +++ b/lib/widgets/chat/chat_message_input.dart @@ -1,3 +1,6 @@ +import 'dart:async'; +import 'dart:convert'; + import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_typeahead/flutter_typeahead.dart'; @@ -7,10 +10,12 @@ import 'package:solian/exts.dart'; import 'package:solian/models/account.dart'; import 'package:solian/models/channel.dart'; import 'package:solian/models/event.dart'; +import 'package:solian/models/packet.dart'; import 'package:solian/platform.dart'; import 'package:solian/providers/attachment_uploader.dart'; import 'package:solian/providers/auth.dart'; import 'package:solian/providers/stickers.dart'; +import 'package:solian/providers/websocket.dart'; import 'package:solian/widgets/account/account_avatar.dart'; import 'package:solian/widgets/attachments/attachment_editor.dart'; import 'package:solian/widgets/chat/chat_event.dart'; @@ -196,6 +201,36 @@ class _ChatMessageInputState extends State { } } + Timer? _typingNotifyTimer; + bool _typingStatus = false; + + Future _sendTypingStatus() async { + final WebSocketProvider ws = Get.find(); + ws.websocket?.sink.add(jsonEncode( + NetworkPackage( + method: 'status.typing', + endpoint: 'messaging', + payload: { + 'channel_id': widget.channel.id, + }, + ).toJson(), + )); + } + + void _pingEnterMessageStatus() { + if (!_typingStatus) { + _sendTypingStatus(); + _typingStatus = true; + } + + if (_typingNotifyTimer == null || !_typingNotifyTimer!.isActive) { + _typingNotifyTimer?.cancel(); + _typingNotifyTimer = Timer(const Duration(milliseconds: 1850), () { + _typingStatus = false; + }); + } + } + void _resetInput() { if (widget.onReset != null) widget.onReset!(); _editTo = null; @@ -269,6 +304,20 @@ class _ChatMessageInputState extends State { super.didUpdateWidget(oldWidget); } + @override + void initState() { + super.initState(); + _textController.addListener(_pingEnterMessageStatus); + } + + @override + void dispose() { + _textController.removeListener(_pingEnterMessageStatus); + _textController.dispose(); + _typingNotifyTimer?.cancel(); + super.dispose(); + } + @override Widget build(BuildContext context) { final notifyBannerActions = [ diff --git a/lib/widgets/chat/chat_typing_indicator.dart b/lib/widgets/chat/chat_typing_indicator.dart new file mode 100644 index 0000000..4bc4445 --- /dev/null +++ b/lib/widgets/chat/chat_typing_indicator.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:solian/models/channel.dart'; + +class ChatTypingIndicator extends StatefulWidget { + final List users; + + const ChatTypingIndicator({super.key, required this.users}); + + @override + State createState() => _ChatTypingIndicatorState(); +} + +class _ChatTypingIndicatorState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller = AnimationController( + duration: const Duration(milliseconds: 250), + vsync: this, + ); + late final Animation _animation = CurvedAnimation( + parent: _controller, + curve: Curves.easeInOut, + ); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(covariant ChatTypingIndicator oldWidget) { + if (widget.users.isNotEmpty) { + _controller.animateTo(1); + } else { + _controller.animateTo(0); + } + super.didUpdateWidget(oldWidget); + } + + @override + Widget build(BuildContext context) { + return SizeTransition( + sizeFactor: _animation, + axis: Axis.vertical, + axisAlignment: -1, + child: Row( + children: [ + const Icon(Icons.more_horiz), + const SizedBox(width: 6), + Text('typingMessage'.trParams({ + 'user': widget.users.map((x) => x.account.nick).join(', '), + })), + ], + ).paddingSymmetric(horizontal: 16), + ); + } +}