✨ Typing indicator
This commit is contained in:
parent
48ca885a2c
commit
a70e6c7118
@ -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),
|
||||
);
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user