Typing indicator

This commit is contained in:
LittleSheep 2024-08-23 22:43:04 +08:00
parent 48ca885a2c
commit a70e6c7118
7 changed files with 166 additions and 19 deletions

View File

@ -1,22 +1,26 @@
class NetworkPackage { class NetworkPackage {
String method; String method;
String? endpoint;
String? message; String? message;
Map<String, dynamic>? payload; Map<String, dynamic>? payload;
NetworkPackage({ NetworkPackage({
required this.method, required this.method,
this.endpoint,
this.message, this.message,
this.payload, this.payload,
}); });
factory NetworkPackage.fromJson(Map<String, dynamic> json) => NetworkPackage( factory NetworkPackage.fromJson(Map<String, dynamic> json) => NetworkPackage(
method: json['w'], method: json['w'],
endpoint: json['e'],
message: json['m'], message: json['m'],
payload: json['p'], payload: json['p'],
); );
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => {
'w': method, 'w': method,
'e': endpoint,
'm': message, 'm': message,
'p': payload, 'p': payload,
}; };

View File

@ -88,6 +88,7 @@ class WebSocketProvider extends GetxController {
websocket?.stream.listen( websocket?.stream.listen(
(event) { (event) {
final packet = NetworkPackage.fromJson(jsonDecode(event)); final packet = NetworkPackage.fromJson(jsonDecode(event));
log('Websocket incoming message: ${packet.method} ${packet.message}');
stream.sink.add(packet); stream.sink.add(packet);
}, },
onDone: () { onDone: () {

View File

@ -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/call/chat_call_action.dart';
import 'package:solian/widgets/chat/chat_event_list.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_message_input.dart';
import 'package:solian/widgets/chat/chat_typing_indicator.dart';
import 'package:solian/widgets/current_state_action.dart'; import 'package:solian/widgets/current_state_action.dart';
class ChannelChatScreen extends StatefulWidget { class ChannelChatScreen extends StatefulWidget {
@ -103,12 +104,18 @@ class _ChannelChatScreenState extends State<ChannelChatScreen>
setState(() => _isBusy = false); setState(() => _isBusy = false);
} }
List<ChannelMember> _typingUsers = List.empty(growable: true);
Map<int, Timer> _typingInactiveTimer = {};
void _listenMessages() { void _listenMessages() {
final WebSocketProvider provider = Get.find(); final WebSocketProvider ws = Get.find();
_subscription = provider.stream.stream.listen((event) { _subscription = ws.stream.stream.listen((event) {
switch (event.method) { switch (event.method) {
case 'events.new': case 'events.new':
final payload = Event.fromJson(event.payload!); final payload = Event.fromJson(event.payload!);
final typingIdx =
_typingUsers.indexWhere((x) => x.id == payload.senderId);
if (typingIdx != -1) _typingUsers.removeAt(typingIdx);
_chatController.receiveEvent(payload); _chatController.receiveEvent(payload);
break; break;
case 'calls.new': case 'calls.new':
@ -123,6 +130,24 @@ class _ChannelChatScreenState extends State<ChannelChatScreen>
setState(() => _ongoingCall = null); setState(() => _ongoingCall = null);
} }
break; 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( child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 50, sigmaY: 50), filter: ImageFilter.blur(sigmaX: 50, sigmaY: 50),
child: SafeArea( child: SafeArea(
child: ChatMessageInput( child: Column(
edit: _messageToEditing, children: [
reply: _messageToReplying, ChatTypingIndicator(users: _typingUsers),
realm: widget.realm, ChatMessageInput(
placeholder: placeholder, edit: _messageToEditing,
channel: _channel!, reply: _messageToReplying,
onSent: (Event item) { realm: widget.realm,
setState(() { placeholder: placeholder,
_chatController.addPendingEvent(item); channel: _channel!,
}); onSent: (Event item) {
}, setState(() {
onReset: () { _chatController.addPendingEvent(item);
setState(() { });
_messageToReplying = null; },
_messageToEditing = null; onReset: () {
}); setState(() {
}, _messageToReplying = null;
_messageToEditing = null;
});
},
),
],
), ),
), ),
), ),
@ -329,6 +359,9 @@ class _ChannelChatScreenState extends State<ChannelChatScreen>
@override @override
void dispose() { void dispose() {
for (var timer in _typingInactiveTimer.values) {
timer.cancel();
}
_subscription?.cancel(); _subscription?.cancel();
WidgetsBinding.instance.removeObserver(this); WidgetsBinding.instance.removeObserver(this);
super.dispose(); super.dispose();

View File

@ -385,4 +385,5 @@ const i18nEnglish = {
'unknown': 'Unknown', 'unknown': 'Unknown',
'collapse': 'Collapse', 'collapse': 'Collapse',
'expand': 'Expand', 'expand': 'Expand',
'typingMessage': '@user are typing...',
}; };

View File

@ -355,4 +355,5 @@ const i18nSimplifiedChinese = {
'unknown': '未知', 'unknown': '未知',
'collapse': '折叠', 'collapse': '折叠',
'expand': '展开', 'expand': '展开',
'typingMessage': '@user 正在输入中…',
}; };

View File

@ -1,3 +1,6 @@
import 'dart:async';
import 'dart:convert';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_typeahead/flutter_typeahead.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/account.dart';
import 'package:solian/models/channel.dart'; import 'package:solian/models/channel.dart';
import 'package:solian/models/event.dart'; import 'package:solian/models/event.dart';
import 'package:solian/models/packet.dart';
import 'package:solian/platform.dart'; import 'package:solian/platform.dart';
import 'package:solian/providers/attachment_uploader.dart'; import 'package:solian/providers/attachment_uploader.dart';
import 'package:solian/providers/auth.dart'; import 'package:solian/providers/auth.dart';
import 'package:solian/providers/stickers.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/account/account_avatar.dart';
import 'package:solian/widgets/attachments/attachment_editor.dart'; import 'package:solian/widgets/attachments/attachment_editor.dart';
import 'package:solian/widgets/chat/chat_event.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() { void _resetInput() {
if (widget.onReset != null) widget.onReset!(); if (widget.onReset != null) widget.onReset!();
_editTo = null; _editTo = null;
@ -269,6 +304,20 @@ class _ChatMessageInputState extends State<ChatMessageInput> {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
} }
@override
void initState() {
super.initState();
_textController.addListener(_pingEnterMessageStatus);
}
@override
void dispose() {
_textController.removeListener(_pingEnterMessageStatus);
_textController.dispose();
_typingNotifyTimer?.cancel();
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final notifyBannerActions = [ final notifyBannerActions = [

View 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),
);
}
}