diff --git a/commet/lib/client/components/component_registry.dart b/commet/lib/client/components/component_registry.dart index 43cff177..a15da655 100644 --- a/commet/lib/client/components/component_registry.dart +++ b/commet/lib/client/components/component_registry.dart @@ -11,6 +11,7 @@ import 'package:commet/client/matrix/components/invitation/matrix_invitation_com import 'package:commet/client/matrix/components/push_notifications/matrix_push_notification_component.dart'; import 'package:commet/client/matrix/components/read_receipts/matrix_read_receipt_component.dart'; import 'package:commet/client/matrix/components/threads/matrix_threads_component.dart'; +import 'package:commet/client/matrix/components/typing_indicators/matrix_typing_indicators_component.dart'; import 'package:commet/client/matrix/components/url_preview/matrix_url_preview_component.dart'; import 'package:commet/client/matrix/matrix_client.dart'; import 'package:commet/client/matrix/matrix_room.dart'; @@ -35,7 +36,8 @@ class ComponentRegistry { return [ MatrixRoomEmoticonComponent(client, room), MatrixGifComponent(client, room), - MatrixReadReceiptComponent(client, room) + MatrixReadReceiptComponent(client, room), + MatrixTypingIndicatorsComponent(client, room), ]; } diff --git a/commet/lib/client/components/typing_indicators/typing_indicator_component.dart b/commet/lib/client/components/typing_indicators/typing_indicator_component.dart new file mode 100644 index 00000000..4a8a7f35 --- /dev/null +++ b/commet/lib/client/components/typing_indicators/typing_indicator_component.dart @@ -0,0 +1,12 @@ +import 'package:commet/client/client.dart'; +import 'package:commet/client/components/room_component.dart'; +import 'package:commet/client/member.dart'; + +abstract class TypingIndicatorComponent + implements RoomComponent { + Stream get onTypingUsersUpdated; + + List get typingUsers; + + Future setTypingStatus(bool status); +} diff --git a/commet/lib/client/matrix/components/typing_indicators/matrix_typing_indicators_component.dart b/commet/lib/client/matrix/components/typing_indicators/matrix_typing_indicators_component.dart new file mode 100644 index 00000000..a20becfe --- /dev/null +++ b/commet/lib/client/matrix/components/typing_indicators/matrix_typing_indicators_component.dart @@ -0,0 +1,50 @@ +import 'dart:async'; + +import 'package:commet/client/components/typing_indicators/typing_indicator_component.dart'; +import 'package:commet/client/matrix/components/matrix_sync_listener.dart'; +import 'package:commet/client/matrix/matrix_client.dart'; +import 'package:commet/client/matrix/matrix_member.dart'; +import 'package:commet/client/matrix/matrix_room.dart'; +import 'package:commet/client/member.dart'; +import 'package:matrix/matrix_api_lite/model/sync_update.dart'; + +class MatrixTypingIndicatorsComponent + implements + TypingIndicatorComponent, + MatrixRoomSyncListener { + @override + MatrixClient client; + @override + MatrixRoom room; + + MatrixTypingIndicatorsComponent(this.client, this.room); + + final StreamController _controller = StreamController.broadcast(); + + @override + onSync(JoinedRoomUpdate update) { + final ephemeral = update.ephemeral; + + if (ephemeral == null) { + return; + } + + if (ephemeral.any((e) => e.type == "m.typing")) { + _controller.add(null); + } + } + + @override + Stream get onTypingUsersUpdated => _controller.stream; + + @override + List get typingUsers => room.matrixRoom.typingUsers + .where((element) => client.self?.identifier != element.id) + .map((e) => MatrixMember(room.matrixRoom.client, e)) + .toList(); + + @override + Future setTypingStatus(bool status) { + return room.matrixRoom.setTyping(status, timeout: 2000); + } +} diff --git a/commet/lib/client/matrix/matrix_room.dart b/commet/lib/client/matrix/matrix_room.dart index 28e4fa36..afd14edf 100644 --- a/commet/lib/client/matrix/matrix_room.dart +++ b/commet/lib/client/matrix/matrix_room.dart @@ -102,12 +102,6 @@ class MatrixRoom extends Room { @override Iterable get memberIds => _memberIds; - @override - List get typingPeers => _matrixRoom.typingUsers - .where((element) => client.self?.identifier != element.id) - .map((e) => MatrixMember(_matrixRoom.client, e)) - .toList(); - @override String get developerInfo => const JsonEncoder.withIndent(' ').convert(_matrixRoom.states); @@ -425,11 +419,6 @@ class MatrixRoom extends Room { await _matrixRoom.setName(newName); } - @override - Future setTypingStatus(bool typing) async { - await _matrixRoom.setTyping(typing, timeout: 2000); - } - @override Color getColorOfUser(String userId) { return MatrixPeer.hashColor(userId); diff --git a/commet/lib/client/room.dart b/commet/lib/client/room.dart index 24abbc7d..f65a18e2 100644 --- a/commet/lib/client/room.dart +++ b/commet/lib/client/room.dart @@ -58,9 +58,6 @@ abstract class Room { /// Gets the time of the last known event DateTime get lastEventTimestamp; - /// Set of peers who are currently typing a message in this room - List get typingPeers; - /// Debug info for developers String get developerInfo; @@ -116,9 +113,6 @@ abstract class Room { /// Set a notification push rule Future setPushRule(PushRule rule); - /// Set the typing status of the current user - Future setTypingStatus(bool typing); - /// Gets the color of a user based on their ID Color getColorOfUser(String userId); diff --git a/commet/lib/ui/molecules/message_input.dart b/commet/lib/ui/molecules/message_input.dart index 6de9bea4..5fcd8ef2 100644 --- a/commet/lib/ui/molecules/message_input.dart +++ b/commet/lib/ui/molecules/message_input.dart @@ -16,7 +16,6 @@ import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:implicitly_animated_list/implicitly_animated_list.dart'; -import 'package:intl/intl.dart'; import 'package:just_the_tooltip/just_the_tooltip.dart'; import 'package:pasteboard/pasteboard.dart'; import 'package:tiamat/tiamat.dart' as tiamat; @@ -47,7 +46,7 @@ class MessageInput extends StatefulWidget { this.addAttachment, this.onTextUpdated, this.removeAttachment, - this.typingUsernames, + this.typingIndicatorWidget, this.availibleEmoticons, this.availibleStickers, this.gifComponent, @@ -74,7 +73,7 @@ class MessageInput extends StatefulWidget { final Stream? setInputText; final bool isProcessing; final bool enabled; - final List? typingUsernames; + final Widget? typingIndicatorWidget; final List? availibleEmoticons; final List? availibleStickers; final GifComponent? gifComponent; @@ -108,16 +107,6 @@ class MessageInputState extends State { (int, int)? autoFillRange; ScrollController autofillScrollController = ScrollController(); - String typingUsers(int howMany, String user1, String user2, String user3) => - Intl.plural(howMany, - one: "$user1 is typing...", - two: "$user1 and $user2 are typing...", - few: "$user1, $user2, and $user3 are typing...", - other: "Several people are typing...", - desc: "Text to display which users are currently typing", - name: "typingUsers", - args: [howMany, user1, user2, user3]); - void unfocus() { textFocus.unfocus(); } @@ -379,7 +368,8 @@ class MessageInputState extends State { child: Column( mainAxisSize: MainAxisSize.min, children: [ - typingUsersWidget(), + if (widget.typingIndicatorWidget != null) + widget.typingIndicatorWidget!, if (widget.interactionType != null) interactionText(), if (widget.attachments != null && widget.attachments!.isNotEmpty) displayAttachments(), @@ -799,26 +789,4 @@ class MessageInputState extends State { onShare: null, ); } - - Widget typingUsersWidget() { - String text = getTypingText(); - - return Align( - alignment: Alignment.centerLeft, - child: Padding( - padding: const EdgeInsets.fromLTRB(0, 0, 0, 4), - child: tiamat.Text.labelLow(text), - )); - } - - String getTypingText() { - if (widget.typingUsernames?.isEmpty == true) return ""; - - String user1 = widget.typingUsernames![0]; - String user2 = - widget.typingUsernames!.length >= 2 ? widget.typingUsernames![1] : ""; - String user3 = - widget.typingUsernames!.length >= 3 ? widget.typingUsernames![2] : ""; - return typingUsers(widget.typingUsernames!.length, user1, user2, user3); - } } diff --git a/commet/lib/ui/molecules/typing_indicators_widget.dart b/commet/lib/ui/molecules/typing_indicators_widget.dart new file mode 100644 index 00000000..e924393b --- /dev/null +++ b/commet/lib/ui/molecules/typing_indicators_widget.dart @@ -0,0 +1,197 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:commet/client/components/typing_indicators/typing_indicator_component.dart'; +import 'package:commet/client/member.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +import 'package:tiamat/tiamat.dart' as tiamat; + +class TypingIndicatorsWidget extends StatefulWidget { + const TypingIndicatorsWidget({required this.component, super.key}); + final TypingIndicatorComponent component; + + @override + State createState() => _TypingIndicatorsWidgetState(); +} + +class _TypingIndicatorsWidgetState extends State { + StreamSubscription? sub; + + late List typingMembers; + + late List blobKeys = [ + GlobalKey(), + GlobalKey(), + GlobalKey(), + ]; + + Timer? timer; + + late String currentText = ""; + + String typingUsers(int howMany, String user1, String user2, String user3) => + Intl.plural(howMany, + one: "$user1 is typing...", + two: "$user1 and $user2 are typing...", + few: "$user1, $user2, and $user3 are typing...", + other: "Several people are typing...", + desc: "Text to display which users are currently typing", + name: "typingUsers", + args: [howMany, user1, user2, user3]); + + @override + void initState() { + sub = widget.component.onTypingUsersUpdated.listen(onTypingUsersUpdated); + typingMembers = widget.component.typingUsers; + if (typingMembers.isNotEmpty) { + currentText = getTypingText(); + startTimer(); + } + super.initState(); + } + + @override + void dispose() { + timer?.cancel(); + super.dispose(); + } + + int prevIndex = 0; + + void onTimer(Timer timer) { + var r = Random().nextInt(3); + + if (r == prevIndex) { + r += 1; + r = r % blobKeys.length; + } + + prevIndex = r; + + var key = blobKeys[r]; + + if (key.currentState == null) { + return; + } + + var state = key.currentState! as __SingleTypingIndicatorBlobState; + state.controller.forward(from: 0); + } + + void onTypingUsersUpdated(void event) { + setState(() { + typingMembers = widget.component.typingUsers; + if (typingMembers.isNotEmpty) { + currentText = getTypingText(); + if (timer == null) { + startTimer(); + } + } else { + timer?.cancel(); + timer = null; + } + }); + } + + void startTimer() { + timer = Timer.periodic(const Duration(milliseconds: 250), onTimer); + } + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 23, + child: ClipRect( + child: AnimatedSlide( + duration: Durations.medium3, + curve: Curves.easeInOutExpo, + offset: typingMembers.isEmpty ? const Offset(0, 1) : Offset.zero, + child: Row(children: [ + Padding( + padding: const EdgeInsets.fromLTRB(4, 0, 4, 0), + child: Row( + children: [ + _SingleTypingIndicatorBlob( + key: blobKeys[0], + ), + _SingleTypingIndicatorBlob( + key: blobKeys[1], + ), + _SingleTypingIndicatorBlob( + key: blobKeys[2], + ), + ], + ), + ), + tiamat.Text.labelLow(currentText) + ])), + )); + } + + String getTypingText() { + String user1 = typingMembers[0].displayName; + String user2 = + typingMembers.length >= 2 ? typingMembers[1].displayName : ""; + String user3 = + typingMembers.length >= 3 ? typingMembers[2].displayName : ""; + return typingUsers(typingMembers.length, user1, user2, user3); + } +} + +class _SingleTypingIndicatorBlob extends StatefulWidget { + const _SingleTypingIndicatorBlob({super.key}); + + @override + State<_SingleTypingIndicatorBlob> createState() => + __SingleTypingIndicatorBlobState(); +} + +class __SingleTypingIndicatorBlobState extends State<_SingleTypingIndicatorBlob> + with TickerProviderStateMixin { + late AnimationController controller = AnimationController( + duration: const Duration(milliseconds: 200), vsync: this, value: 1); + + @override + void initState() { + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(1), + child: AnimatedBuilder( + animation: controller, + builder: (context, child) { + var alpha = 1.0; + alpha -= sin(controller.value * 3.1415926) * 0.4; + + var translation = sin(controller.value * 3.1415926); + + return SizedBox( + height: 7, + width: 7, + child: Align( + alignment: Alignment.center, + heightFactor: controller.value, + child: Transform( + transform: Matrix4.translationValues(0, translation * 4, 0), + child: ClipRRect( + borderRadius: BorderRadius.circular(4), + child: Align( + heightFactor: alpha, + child: Container( + color: Theme.of(context).colorScheme.secondary, + ), + ), + ), + ), + ), + ); + }, + ), + ); + } +} diff --git a/commet/lib/ui/organisms/chat/chat.dart b/commet/lib/ui/organisms/chat/chat.dart index 1a1c15fc..16121152 100644 --- a/commet/lib/ui/organisms/chat/chat.dart +++ b/commet/lib/ui/organisms/chat/chat.dart @@ -9,6 +9,7 @@ import 'package:commet/client/components/gif/gif_component.dart'; import 'package:commet/client/components/gif/gif_search_result.dart'; import 'package:commet/client/components/read_receipts/read_receipt_component.dart'; import 'package:commet/client/components/threads/thread_component.dart'; +import 'package:commet/client/components/typing_indicators/typing_indicator_component.dart'; import 'package:commet/client/room.dart'; import 'package:commet/client/timeline.dart'; import 'package:commet/debug/log.dart'; @@ -71,6 +72,7 @@ class ChatState extends State { GifComponent? gifs; RoomEmoticonComponent? emoticons; ReadReceiptComponent? receipts; + TypingIndicatorComponent? typingIndicators; Debouncer typingStatusDebouncer = Debouncer(delay: const Duration(seconds: 5)); @@ -92,6 +94,7 @@ class ChatState extends State { emoticons = room.getComponent(); threadsComponent = room.client.getComponent(); receipts = room.getComponent(); + typingIndicators = room.getComponent(); if (widget.threadId != null && threadsComponent != null) { loadThreadTimeline(); @@ -204,7 +207,7 @@ class ChatState extends State { processedAttachments: processedAttachments); } - room.setTypingStatus(false); + typingIndicators?.setTypingStatus(false); setInteractingEvent(null); clearAttachments(); @@ -287,7 +290,7 @@ class ChatState extends State { lastSetTyping = DateTime.fromMicrosecondsSinceEpoch(0); } else { if ((DateTime.now().difference(lastSetTyping)).inSeconds > 3) { - room.setTypingStatus(true); + typingIndicators?.setTypingStatus(true); lastSetTyping = DateTime.now(); } typingStatusDebouncer.run(stopTyping); @@ -295,7 +298,7 @@ class ChatState extends State { } void stopTyping() { - room.setTypingStatus(false); + typingIndicators?.setTypingStatus(false); } void onFileDropped(DropDoneDetails event) async { diff --git a/commet/lib/ui/organisms/chat/chat_view.dart b/commet/lib/ui/organisms/chat/chat_view.dart index 49f64367..f687b444 100644 --- a/commet/lib/ui/organisms/chat/chat_view.dart +++ b/commet/lib/ui/organisms/chat/chat_view.dart @@ -5,6 +5,7 @@ import 'package:commet/config/layout_config.dart'; import 'package:commet/ui/molecules/message_input.dart'; import 'package:commet/ui/molecules/read_indicator.dart'; import 'package:commet/ui/molecules/room_timeline_widget/room_timeline_widget.dart'; +import 'package:commet/ui/molecules/typing_indicators_widget.dart'; import 'package:commet/ui/organisms/chat/chat.dart'; import 'package:commet/utils/autofill_utils.dart'; import 'package:flutter/material.dart'; @@ -100,8 +101,6 @@ class ChatView extends StatelessWidget { iconScale: Layout.mobile ? 0.6 : 0.5, isProcessing: state.processing, enabled: state.room.permissions.canSendMessage, - typingUsernames: - state.room.typingPeers.map((e) => e.displayName).toList(), relatedEventBody: state.interactingEvent?.body, relatedEventSenderName: relatedEventSenderName, relatedEventSenderColor: relatedEventSenderColor, @@ -119,6 +118,13 @@ class ChatView extends StatelessWidget { cancelReply: () { state.setInteractingEvent(null); }, + typingIndicatorWidget: state.typingIndicators != null + ? TypingIndicatorsWidget( + component: state.typingIndicators!, + key: ValueKey( + "room_typing_indicators_key_${state.room.identifier}"), + ) + : null, readIndicator: state.receipts != null ? ReadIndicator( key: ValueKey(