User typing status

🐛 Bug fixes
This commit is contained in:
LittleSheep 2025-01-01 16:45:37 +08:00
parent fa978a7cd1
commit a3c8dafff9
9 changed files with 140 additions and 46 deletions

View File

@ -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",

View File

@ -279,6 +279,10 @@
"one": "{} 个附件",
"other": "{} 个附件"
},
"messageTyping": {
"one": "{} 正在输入",
"other": "{} 正在输入"
},
"fieldAttachmentRandomId": "访问 ID",
"fieldAttachmentAlt": "概述文字",
"addAttachmentFromAlbum": "从相册中添加附件",

View File

@ -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<String> unconfirmedMessages = List.empty(growable: true);
Box<SnChatMessage>? get _box =>
(_boxKey == null || isPending) ? null : Hive.box<SnChatMessage>(_boxKey!);
Box<SnChatMessage>? get _box => (_boxKey == null || isPending) ? null : Hive.box<SnChatMessage>(_boxKey!);
final List<SnChannelMember> typingMembers = List.empty(growable: true);
final Map<int, Timer> typingInactiveTimer = {};
Future<void> 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<void> _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<void> _saveMessageToLocal(Iterable<SnChatMessage> 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<SnChatMessage> out;
if (_box != null &&
(_box!.length >= take + offset || forceLocal) &&
!forceRemote) {
if (_box != null && (_box!.length >= take + offset || forceLocal) && !forceRemote) {
out = _box!.keys
.toList()
.cast<int>()
@ -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;

View File

@ -104,7 +104,7 @@ class PostWriteMedia {
if (attachment != null) {
final sn = context.read<SnNetworkProvider>();
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,

View File

@ -87,7 +87,7 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
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(

View File

@ -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<ChatRoomScreen> {
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),
],
),
),
],
);

View File

@ -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,
)),
),
),

View File

@ -32,6 +32,16 @@ class ChatMessageInputState extends State<ChatMessageInput> {
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<ChatMessageInput> {
? 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<ChatMessageInput> {
? 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,

View File

@ -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<UserDirectoryProvider>();
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,
);
}
}