✨ Typing indicator
This commit is contained in:
		| @@ -1,22 +1,26 @@ | ||||
| class NetworkPackage { | ||||
|   String method; | ||||
|   String? endpoint; | ||||
|   String? message; | ||||
|   Map<String, dynamic>? payload; | ||||
|  | ||||
|   NetworkPackage({ | ||||
|     required this.method, | ||||
|     this.endpoint, | ||||
|     this.message, | ||||
|     this.payload, | ||||
|   }); | ||||
|  | ||||
|   factory NetworkPackage.fromJson(Map<String, dynamic> json) => NetworkPackage( | ||||
|         method: json['w'], | ||||
|         endpoint: json['e'], | ||||
|         message: json['m'], | ||||
|         payload: json['p'], | ||||
|       ); | ||||
|  | ||||
|   Map<String, dynamic> toJson() => { | ||||
|         'w': method, | ||||
|         'e': endpoint, | ||||
|         'm': message, | ||||
|         'p': payload, | ||||
|       }; | ||||
|   | ||||
| @@ -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: () { | ||||
|   | ||||
| @@ -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<ChannelChatScreen> | ||||
|     setState(() => _isBusy = false); | ||||
|   } | ||||
|  | ||||
|   List<ChannelMember> _typingUsers = List.empty(growable: true); | ||||
|   Map<int, Timer> _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<ChannelChatScreen> | ||||
|             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<ChannelChatScreen> | ||||
|                     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<ChannelChatScreen> | ||||
|  | ||||
|   @override | ||||
|   void dispose() { | ||||
|     for (var timer in _typingInactiveTimer.values) { | ||||
|       timer.cancel(); | ||||
|     } | ||||
|     _subscription?.cancel(); | ||||
|     WidgetsBinding.instance.removeObserver(this); | ||||
|     super.dispose(); | ||||
|   | ||||
| @@ -385,4 +385,5 @@ const i18nEnglish = { | ||||
|   'unknown': 'Unknown', | ||||
|   'collapse': 'Collapse', | ||||
|   'expand': 'Expand', | ||||
|   'typingMessage': '@user are typing...', | ||||
| }; | ||||
|   | ||||
| @@ -355,4 +355,5 @@ const i18nSimplifiedChinese = { | ||||
|   'unknown': '未知', | ||||
|   'collapse': '折叠', | ||||
|   'expand': '展开', | ||||
|   'typingMessage': '@user 正在输入中…', | ||||
| }; | ||||
|   | ||||
| @@ -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<ChatMessageInput> { | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   Timer? _typingNotifyTimer; | ||||
|   bool _typingStatus = false; | ||||
|  | ||||
|   Future<void> _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<ChatMessageInput> { | ||||
|     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 = [ | ||||
|   | ||||
							
								
								
									
										58
									
								
								lib/widgets/chat/chat_typing_indicator.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										58
									
								
								lib/widgets/chat/chat_typing_indicator.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -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<ChannelMember> users; | ||||
|  | ||||
|   const ChatTypingIndicator({super.key, required this.users}); | ||||
|  | ||||
|   @override | ||||
|   State<ChatTypingIndicator> createState() => _ChatTypingIndicatorState(); | ||||
| } | ||||
|  | ||||
| class _ChatTypingIndicatorState extends State<ChatTypingIndicator> | ||||
|     with SingleTickerProviderStateMixin { | ||||
|   late final AnimationController _controller = AnimationController( | ||||
|     duration: const Duration(milliseconds: 250), | ||||
|     vsync: this, | ||||
|   ); | ||||
|   late final Animation<double> _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), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
		Reference in New Issue
	
	Block a user