From 20e6cc4283bc1f2eb9ccbcb159647b4e2d5f5a17 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Sun, 8 Jun 2025 01:16:48 +0800 Subject: [PATCH] :sparkles: Typing indicator --- assets/i18n/en-US.json | 6 +- lib/models/chat.dart | 1 + lib/models/chat.freezed.dart | 31 ++- lib/models/chat.g.dart | 5 + lib/route.gr.dart | 4 +- lib/screens/chat/room.dart | 243 ++++++++++++++---- .../account/account_session_sheet.g.dart | 2 +- 7 files changed, 223 insertions(+), 69 deletions(-) diff --git a/assets/i18n/en-US.json b/assets/i18n/en-US.json index 917c513..84b6631 100644 --- a/assets/i18n/en-US.json +++ b/assets/i18n/en-US.json @@ -353,5 +353,9 @@ "authDeviceLabelTitle": "Edit Device Label", "authDeviceLabelHint": "Enter a name for this device", "authDeviceSwipeEditHint": "Swipe left to edit label", - "authDeviceSwipeLogoutHint": "Swipe right to logout device" + "authDeviceSwipeLogoutHint": "Swipe right to logout device", + "typingHint": { + "one": "{} is typing...", + "other": "{} are typing..." + } } diff --git a/lib/models/chat.dart b/lib/models/chat.dart index b339e92..09e7fa3 100644 --- a/lib/models/chat.dart +++ b/lib/models/chat.dart @@ -91,6 +91,7 @@ sealed class SnChatMember with _$SnChatMember { required int notify, required DateTime? joinedAt, required bool isBot, + DateTime? lastTyped, }) = _SnChatMember; factory SnChatMember.fromJson(Map json) => diff --git a/lib/models/chat.freezed.dart b/lib/models/chat.freezed.dart index e0ee55b..d58798a 100644 --- a/lib/models/chat.freezed.dart +++ b/lib/models/chat.freezed.dart @@ -663,7 +663,7 @@ $SnChatMemberCopyWith<$Res> get sender { /// @nodoc mixin _$SnChatMember { - DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt; String get id; String get chatRoomId; SnChatRoom? get chatRoom; String get accountId; SnAccount get account; String? get nick; int get role; int get notify; DateTime? get joinedAt; bool get isBot; + DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt; String get id; String get chatRoomId; SnChatRoom? get chatRoom; String get accountId; SnAccount get account; String? get nick; int get role; int get notify; DateTime? get joinedAt; bool get isBot; DateTime? get lastTyped; /// Create a copy of SnChatMember /// with the given fields replaced by the non-null parameter values. @JsonKey(includeFromJson: false, includeToJson: false) @@ -676,16 +676,16 @@ $SnChatMemberCopyWith get copyWith => _$SnChatMemberCopyWithImpl Object.hash(runtimeType,createdAt,updatedAt,deletedAt,id,chatRoomId,chatRoom,accountId,account,nick,role,notify,joinedAt,isBot); +int get hashCode => Object.hash(runtimeType,createdAt,updatedAt,deletedAt,id,chatRoomId,chatRoom,accountId,account,nick,role,notify,joinedAt,isBot,lastTyped); @override String toString() { - return 'SnChatMember(createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, id: $id, chatRoomId: $chatRoomId, chatRoom: $chatRoom, accountId: $accountId, account: $account, nick: $nick, role: $role, notify: $notify, joinedAt: $joinedAt, isBot: $isBot)'; + return 'SnChatMember(createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, id: $id, chatRoomId: $chatRoomId, chatRoom: $chatRoom, accountId: $accountId, account: $account, nick: $nick, role: $role, notify: $notify, joinedAt: $joinedAt, isBot: $isBot, lastTyped: $lastTyped)'; } @@ -696,7 +696,7 @@ abstract mixin class $SnChatMemberCopyWith<$Res> { factory $SnChatMemberCopyWith(SnChatMember value, $Res Function(SnChatMember) _then) = _$SnChatMemberCopyWithImpl; @useResult $Res call({ - DateTime createdAt, DateTime updatedAt, DateTime? deletedAt, String id, String chatRoomId, SnChatRoom? chatRoom, String accountId, SnAccount account, String? nick, int role, int notify, DateTime? joinedAt, bool isBot + DateTime createdAt, DateTime updatedAt, DateTime? deletedAt, String id, String chatRoomId, SnChatRoom? chatRoom, String accountId, SnAccount account, String? nick, int role, int notify, DateTime? joinedAt, bool isBot, DateTime? lastTyped }); @@ -713,7 +713,7 @@ class _$SnChatMemberCopyWithImpl<$Res> /// Create a copy of SnChatMember /// with the given fields replaced by the non-null parameter values. -@pragma('vm:prefer-inline') @override $Res call({Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,Object? id = null,Object? chatRoomId = null,Object? chatRoom = freezed,Object? accountId = null,Object? account = null,Object? nick = freezed,Object? role = null,Object? notify = null,Object? joinedAt = freezed,Object? isBot = null,}) { +@pragma('vm:prefer-inline') @override $Res call({Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,Object? id = null,Object? chatRoomId = null,Object? chatRoom = freezed,Object? accountId = null,Object? account = null,Object? nick = freezed,Object? role = null,Object? notify = null,Object? joinedAt = freezed,Object? isBot = null,Object? lastTyped = freezed,}) { return _then(_self.copyWith( createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable as DateTime,updatedAt: null == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable @@ -728,7 +728,8 @@ as String?,role: null == role ? _self.role : role // ignore: cast_nullable_to_no as int,notify: null == notify ? _self.notify : notify // ignore: cast_nullable_to_non_nullable as int,joinedAt: freezed == joinedAt ? _self.joinedAt : joinedAt // ignore: cast_nullable_to_non_nullable as DateTime?,isBot: null == isBot ? _self.isBot : isBot // ignore: cast_nullable_to_non_nullable -as bool, +as bool,lastTyped: freezed == lastTyped ? _self.lastTyped : lastTyped // ignore: cast_nullable_to_non_nullable +as DateTime?, )); } /// Create a copy of SnChatMember @@ -760,7 +761,7 @@ $SnAccountCopyWith<$Res> get account { @JsonSerializable() class _SnChatMember implements SnChatMember { - const _SnChatMember({required this.createdAt, required this.updatedAt, required this.deletedAt, required this.id, required this.chatRoomId, required this.chatRoom, required this.accountId, required this.account, required this.nick, required this.role, required this.notify, required this.joinedAt, required this.isBot}); + const _SnChatMember({required this.createdAt, required this.updatedAt, required this.deletedAt, required this.id, required this.chatRoomId, required this.chatRoom, required this.accountId, required this.account, required this.nick, required this.role, required this.notify, required this.joinedAt, required this.isBot, this.lastTyped}); factory _SnChatMember.fromJson(Map json) => _$SnChatMemberFromJson(json); @override final DateTime createdAt; @@ -776,6 +777,7 @@ class _SnChatMember implements SnChatMember { @override final int notify; @override final DateTime? joinedAt; @override final bool isBot; +@override final DateTime? lastTyped; /// Create a copy of SnChatMember /// with the given fields replaced by the non-null parameter values. @@ -790,16 +792,16 @@ Map toJson() { @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnChatMember&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)&&(identical(other.id, id) || other.id == id)&&(identical(other.chatRoomId, chatRoomId) || other.chatRoomId == chatRoomId)&&(identical(other.chatRoom, chatRoom) || other.chatRoom == chatRoom)&&(identical(other.accountId, accountId) || other.accountId == accountId)&&(identical(other.account, account) || other.account == account)&&(identical(other.nick, nick) || other.nick == nick)&&(identical(other.role, role) || other.role == role)&&(identical(other.notify, notify) || other.notify == notify)&&(identical(other.joinedAt, joinedAt) || other.joinedAt == joinedAt)&&(identical(other.isBot, isBot) || other.isBot == isBot)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnChatMember&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)&&(identical(other.id, id) || other.id == id)&&(identical(other.chatRoomId, chatRoomId) || other.chatRoomId == chatRoomId)&&(identical(other.chatRoom, chatRoom) || other.chatRoom == chatRoom)&&(identical(other.accountId, accountId) || other.accountId == accountId)&&(identical(other.account, account) || other.account == account)&&(identical(other.nick, nick) || other.nick == nick)&&(identical(other.role, role) || other.role == role)&&(identical(other.notify, notify) || other.notify == notify)&&(identical(other.joinedAt, joinedAt) || other.joinedAt == joinedAt)&&(identical(other.isBot, isBot) || other.isBot == isBot)&&(identical(other.lastTyped, lastTyped) || other.lastTyped == lastTyped)); } @JsonKey(includeFromJson: false, includeToJson: false) @override -int get hashCode => Object.hash(runtimeType,createdAt,updatedAt,deletedAt,id,chatRoomId,chatRoom,accountId,account,nick,role,notify,joinedAt,isBot); +int get hashCode => Object.hash(runtimeType,createdAt,updatedAt,deletedAt,id,chatRoomId,chatRoom,accountId,account,nick,role,notify,joinedAt,isBot,lastTyped); @override String toString() { - return 'SnChatMember(createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, id: $id, chatRoomId: $chatRoomId, chatRoom: $chatRoom, accountId: $accountId, account: $account, nick: $nick, role: $role, notify: $notify, joinedAt: $joinedAt, isBot: $isBot)'; + return 'SnChatMember(createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, id: $id, chatRoomId: $chatRoomId, chatRoom: $chatRoom, accountId: $accountId, account: $account, nick: $nick, role: $role, notify: $notify, joinedAt: $joinedAt, isBot: $isBot, lastTyped: $lastTyped)'; } @@ -810,7 +812,7 @@ abstract mixin class _$SnChatMemberCopyWith<$Res> implements $SnChatMemberCopyWi factory _$SnChatMemberCopyWith(_SnChatMember value, $Res Function(_SnChatMember) _then) = __$SnChatMemberCopyWithImpl; @override @useResult $Res call({ - DateTime createdAt, DateTime updatedAt, DateTime? deletedAt, String id, String chatRoomId, SnChatRoom? chatRoom, String accountId, SnAccount account, String? nick, int role, int notify, DateTime? joinedAt, bool isBot + DateTime createdAt, DateTime updatedAt, DateTime? deletedAt, String id, String chatRoomId, SnChatRoom? chatRoom, String accountId, SnAccount account, String? nick, int role, int notify, DateTime? joinedAt, bool isBot, DateTime? lastTyped }); @@ -827,7 +829,7 @@ class __$SnChatMemberCopyWithImpl<$Res> /// Create a copy of SnChatMember /// with the given fields replaced by the non-null parameter values. -@override @pragma('vm:prefer-inline') $Res call({Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,Object? id = null,Object? chatRoomId = null,Object? chatRoom = freezed,Object? accountId = null,Object? account = null,Object? nick = freezed,Object? role = null,Object? notify = null,Object? joinedAt = freezed,Object? isBot = null,}) { +@override @pragma('vm:prefer-inline') $Res call({Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,Object? id = null,Object? chatRoomId = null,Object? chatRoom = freezed,Object? accountId = null,Object? account = null,Object? nick = freezed,Object? role = null,Object? notify = null,Object? joinedAt = freezed,Object? isBot = null,Object? lastTyped = freezed,}) { return _then(_SnChatMember( createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable as DateTime,updatedAt: null == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable @@ -842,7 +844,8 @@ as String?,role: null == role ? _self.role : role // ignore: cast_nullable_to_no as int,notify: null == notify ? _self.notify : notify // ignore: cast_nullable_to_non_nullable as int,joinedAt: freezed == joinedAt ? _self.joinedAt : joinedAt // ignore: cast_nullable_to_non_nullable as DateTime?,isBot: null == isBot ? _self.isBot : isBot // ignore: cast_nullable_to_non_nullable -as bool, +as bool,lastTyped: freezed == lastTyped ? _self.lastTyped : lastTyped // ignore: cast_nullable_to_non_nullable +as DateTime?, )); } diff --git a/lib/models/chat.g.dart b/lib/models/chat.g.dart index 1f2cc8f..91bafea 100644 --- a/lib/models/chat.g.dart +++ b/lib/models/chat.g.dart @@ -167,6 +167,10 @@ _SnChatMember _$SnChatMemberFromJson(Map json) => ? null : DateTime.parse(json['joined_at'] as String), isBot: json['is_bot'] as bool, + lastTyped: + json['last_typed'] == null + ? null + : DateTime.parse(json['last_typed'] as String), ); Map _$SnChatMemberToJson(_SnChatMember instance) => @@ -184,6 +188,7 @@ Map _$SnChatMemberToJson(_SnChatMember instance) => 'notify': instance.notify, 'joined_at': instance.joinedAt?.toIso8601String(), 'is_bot': instance.isBot, + 'last_typed': instance.lastTyped?.toIso8601String(), }; _SnChatSummary _$SnChatSummaryFromJson(Map json) => diff --git a/lib/route.gr.dart b/lib/route.gr.dart index eaf74e5..b6047bd 100644 --- a/lib/route.gr.dart +++ b/lib/route.gr.dart @@ -329,7 +329,7 @@ class ChatListRouteArgs { /// [_i7.ChatRoomScreen] class ChatRoomRoute extends _i27.PageRouteInfo { ChatRoomRoute({ - _i28.Key? key, + _i29.Key? key, required String id, List<_i27.PageRouteInfo>? children, }) : super( @@ -356,7 +356,7 @@ class ChatRoomRoute extends _i27.PageRouteInfo { class ChatRoomRouteArgs { const ChatRoomRouteArgs({this.key, required this.id}); - final _i28.Key? key; + final _i29.Key? key; final String id; diff --git a/lib/screens/chat/room.dart b/lib/screens/chat/room.dart index 5633d69..aa489e3 100644 --- a/lib/screens/chat/room.dart +++ b/lib/screens/chat/room.dart @@ -321,15 +321,46 @@ class ChatRoomScreen extends HookConsumerWidget { ); } - void sendTypingStatus() async { + // Members who are typing + final typingStatuses = useState>([]); + final typingDebouncer = useState(null); + + void sendTypingStatus() { + // Don't send if we're already in a cooldown period + if (typingDebouncer.value != null) return; + + // Send typing status immediately final wsState = ref.read(websocketStateProvider.notifier); wsState.sendMessage( jsonEncode( WebSocketPacket(type: 'messages.typing', data: {'chat_room_id': id}), ), ); + + typingDebouncer.value = Timer(const Duration(milliseconds: 1000), () { + typingDebouncer.value = null; + }); } + // Add timer to remove typing status after inactivity + useEffect(() { + final removeTypingTimer = Timer.periodic(const Duration(seconds: 5), (_) { + if (typingStatuses.value.isNotEmpty) { + // Remove typing statuses older than 5 seconds + final now = DateTime.now(); + typingStatuses.value = + typingStatuses.value.where((member) { + final lastTyped = + member.lastTyped ?? + DateTime.now().subtract(const Duration(milliseconds: 1350)); + return now.difference(lastTyped).inSeconds < 5; + }).toList(); + } + }); + + return () => removeTypingTimer.cancel(); + }, []); + var isLoading = false; // Add scroll listener for pagination @@ -352,6 +383,28 @@ class ChatRoomScreen extends HookConsumerWidget { void onMessage(WebSocketPacket pkt) { if (!pkt.type.startsWith('messages')) return; if (['messages.read'].contains(pkt.type)) return; + + if (pkt.type == 'messages.typing') { + final sender = SnChatMember.fromJson( + pkt.data!['sender'], + ).copyWith(lastTyped: DateTime.now()); + + // Check if the sender is already in the typing list + final existingIndex = typingStatuses.value.indexWhere( + (member) => member.id == sender.id, + ); + if (existingIndex >= 0) { + // Update the existing entry with new timestamp + final updatedList = [...typingStatuses.value]; + updatedList[existingIndex] = sender; + typingStatuses.value = updatedList; + } else { + // Add new typing status + typingStatuses.value = [...typingStatuses.value, sender]; + } + return; + } + final message = SnChatMessage.fromJson(pkt.data!); if (message.chatRoomId != chatRoom.value?.id) return; switch (pkt.type) { @@ -425,7 +478,17 @@ class ChatRoomScreen extends HookConsumerWidget { } } - useEffect(() {}); + // Add listener to message controller for typing status + useEffect(() { + void onTextChange() { + if (messageController.text.isNotEmpty) { + sendTypingStatus(); + } + } + + messageController.addListener(onTextChange); + return () => messageController.removeListener(onTextChange); + }, [messageController]); final compactHeader = isWideScreen(context); @@ -666,55 +729,133 @@ class ChatRoomScreen extends HookConsumerWidget { ), chatRoom.when( data: - (room) => _ChatInput( - messageController: messageController, - chatRoom: room!, - onSend: sendMessage, - onClear: () { - if (messageEditingTo.value != null) { - attachments.value.clear(); - messageController.clear(); - } - messageEditingTo.value = null; - messageReplyingTo.value = null; - messageForwardingTo.value = null; - }, - messageEditingTo: messageEditingTo.value, - messageReplyingTo: messageReplyingTo.value, - messageForwardingTo: messageForwardingTo.value, - onPickFile: (bool isPhoto) { - if (isPhoto) { - pickPhotoMedia(); - } else { - pickVideoMedia(); - } - }, - attachments: attachments.value, - onUploadAttachment: (_) { - // not going to do anything, only upload when send the message - }, - onDeleteAttachment: (index) async { - final attachment = attachments.value[index]; - if (attachment.isOnCloud) { - final client = ref.watch(apiClientProvider); - await client.delete('/files/${attachment.data.id}'); - } - final clone = List.of(attachments.value); - clone.removeAt(index); - attachments.value = clone; - }, - onMoveAttachment: (idx, delta) { - if (idx + delta < 0 || - idx + delta >= attachments.value.length) { - return; - } - final clone = List.of(attachments.value); - clone.insert(idx + delta, clone.removeAt(idx)); - attachments.value = clone; - }, - onAttachmentsChanged: (newAttachments) { - attachments.value = newAttachments; - }, + (room) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + switchInCurve: Curves.fastEaseInToSlowEaseOut, + switchOutCurve: Curves.fastEaseInToSlowEaseOut, + transitionBuilder: ( + Widget child, + Animation animation, + ) { + return SlideTransition( + position: Tween( + begin: const Offset(0, -0.3), + end: Offset.zero, + ).animate( + CurvedAnimation( + parent: animation, + curve: Curves.easeOutCubic, + ), + ), + child: SizeTransition( + sizeFactor: animation, + axisAlignment: -1.0, + child: FadeTransition( + opacity: animation, + child: child, + ), + ), + ); + }, + child: + typingStatuses.value.isNotEmpty + ? Container( + key: const ValueKey('typing-indicator'), + width: double.infinity, + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 4, + ), + child: Row( + children: [ + const Icon( + Symbols.more_horiz, + size: 16, + ).padding(horizontal: 8), + const Gap(8), + Expanded( + child: Text( + 'typingHint'.plural( + typingStatuses.value.length, + args: [ + typingStatuses.value + .map( + (x) => + x.nick ?? + x.account.nick, + ) + .join(', '), + ], + ), + style: + Theme.of( + context, + ).textTheme.bodySmall, + ), + ), + ], + ), + ) + : const SizedBox.shrink( + key: ValueKey('no_typing'), + ), + ), + _ChatInput( + messageController: messageController, + chatRoom: room!, + onSend: sendMessage, + onClear: () { + if (messageEditingTo.value != null) { + attachments.value.clear(); + messageController.clear(); + } + messageEditingTo.value = null; + messageReplyingTo.value = null; + messageForwardingTo.value = null; + }, + messageEditingTo: messageEditingTo.value, + messageReplyingTo: messageReplyingTo.value, + messageForwardingTo: messageForwardingTo.value, + onPickFile: (bool isPhoto) { + if (isPhoto) { + pickPhotoMedia(); + } else { + pickVideoMedia(); + } + }, + attachments: attachments.value, + onUploadAttachment: (_) { + // not going to do anything, only upload when send the message + }, + onDeleteAttachment: (index) async { + final attachment = attachments.value[index]; + if (attachment.isOnCloud) { + final client = ref.watch(apiClientProvider); + await client.delete( + '/files/${attachment.data.id}', + ); + } + final clone = List.of(attachments.value); + clone.removeAt(index); + attachments.value = clone; + }, + onMoveAttachment: (idx, delta) { + if (idx + delta < 0 || + idx + delta >= attachments.value.length) { + return; + } + final clone = List.of(attachments.value); + clone.insert(idx + delta, clone.removeAt(idx)); + attachments.value = clone; + }, + onAttachmentsChanged: (newAttachments) { + attachments.value = newAttachments; + }, + ), + ], ), error: (_, _) => const SizedBox.shrink(), loading: () => const SizedBox.shrink(), diff --git a/lib/widgets/account/account_session_sheet.g.dart b/lib/widgets/account/account_session_sheet.g.dart index d445f55..3abb919 100644 --- a/lib/widgets/account/account_session_sheet.g.dart +++ b/lib/widgets/account/account_session_sheet.g.dart @@ -6,7 +6,7 @@ part of 'account_session_sheet.dart'; // RiverpodGenerator // ************************************************************************** -String _$authDevicesHash() => r'9b8101167653991314efd37788d8416f414cb9e8'; +String _$authDevicesHash() => r'19807110962206a9637075d03cd372233cae2f49'; /// See also [authDevices]. @ProviderFor(authDevices)