From 0fcbc275cdc71e592bd4e930d01ec4ced48616de Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Wed, 21 Oct 2026 15:07:02 -0400 Subject: [PATCH] add typing indicators Fixes #92 --- lib/controllers/client.dart | 5 + lib/controllers/gomuks_listener.dart | 7 ++ lib/controllers/room_typing.dart | 34 +++++++ lib/controllers/settings_sections.dart | 29 ++++++ lib/controllers/typing.dart | 15 +++ lib/models/settings.dart | 2 + lib/models/typing.dart | 14 +++ lib/models/typing_indicators.dart | 1 + lib/widgets/bouncing_dots.dart | 41 ++++++++ lib/widgets/composer/composer.dart | 40 ++++++++ lib/widgets/composer/typing_indicator.dart | 104 +++++++++++++++++++++ lib/widgets/room_chat/chat_timeline.dart | 20 +++- lib/widgets/room_chat/room_chat.dart | 15 +++ 13 files changed, 326 insertions(+), 1 deletion(-) create mode 100644 lib/controllers/room_typing.dart create mode 100644 lib/controllers/typing.dart create mode 100644 lib/models/typing.dart create mode 100644 lib/models/typing_indicators.dart create mode 100644 lib/widgets/bouncing_dots.dart create mode 100644 lib/widgets/composer/typing_indicator.dart diff --git a/lib/controllers/client.dart b/lib/controllers/client.dart index 96db82e..78670ea 100644 --- a/lib/controllers/client.dart +++ b/lib/controllers/client.dart @@ -320,6 +320,11 @@ class ClientController extends AsyncNotifier { Future logout() => _sendCommand("logout"); + Future setTyping(String roomId, Duration timeout) => _sendCommand( + "set_typing", + {"room_id": roomId, "timeout": timeout.inMilliseconds}, + ); + Future markRead(Room room) async { if (room.timeline.isEmpty || room.metadata == null) return; final eventRowId = room.timeline[room.timeline.keys.reduce(max)]; diff --git a/lib/controllers/gomuks_listener.dart b/lib/controllers/gomuks_listener.dart index 9d742fa..a97599b 100644 --- a/lib/controllers/gomuks_listener.dart +++ b/lib/controllers/gomuks_listener.dart @@ -15,6 +15,7 @@ import "package:nexus/controllers/rooms.dart"; import "package:nexus/controllers/space_edges.dart"; import "package:nexus/controllers/sync_status.dart"; import "package:nexus/controllers/top_level_spaces.dart"; +import "package:nexus/controllers/typing.dart"; import "package:nexus/helpers/extensions/gomuks_buffer.dart"; import "package:nexus/main.dart"; import "package:ffi/ffi.dart"; @@ -63,6 +64,10 @@ class GomuksListenerController extends AsyncNotifier { event.roomId: .new(events: .new({event.rowId: event})), }), ); + case "typing": + ref + .read(TypingController.provider.notifier) + .set(.fromJson(decodedMuksEvent)); case "events_decrypted": final decrypted = EventsDecrypted.fromJson(decodedMuksEvent); final metadata = ref @@ -96,6 +101,7 @@ class GomuksListenerController extends AsyncNotifier { ref.invalidate(roomProvider); ref.invalidate(accountDataProvider); ref.invalidate(invitesProvider); + ref.invalidate(TypingController.provider); } ref @@ -153,6 +159,7 @@ class GomuksListenerController extends AsyncNotifier { if (paused) { GomuksPauseSync(handle); } else { + ref.invalidate(TypingController.provider); GomuksResumeSync(handle); } }, fireImmediately: true); diff --git a/lib/controllers/room_typing.dart b/lib/controllers/room_typing.dart new file mode 100644 index 0000000..887a8b2 --- /dev/null +++ b/lib/controllers/room_typing.dart @@ -0,0 +1,34 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/client_state.dart"; +import "package:nexus/controllers/settings.dart"; +import "package:nexus/controllers/typing.dart"; + +/// The other users typing in a room, or none if typing indicators are hidden +/// in settings. +class RoomTypingController(final String roomId) + extends Notifier> { + @override + IList build() { + final shouldShow = ref.watch( + SettingsController.provider.select( + (settings) => settings.value?.typingIndicators != .neither, + ), + ); + if (!shouldShow) return const .empty(); + + final userId = ref.watch( + ClientStateController.provider.select((state) => state?.userId), + ); + + return ref + .watch(TypingController.provider.select((typing) => typing[roomId])) + ?.remove(userId ?? "") ?? + const .empty(); + } + + static final provider = NotifierProvider.family + .autoDispose, String>( + RoomTypingController.new, + ); +} diff --git a/lib/controllers/settings_sections.dart b/lib/controllers/settings_sections.dart index 9a18f01..596fc95 100644 --- a/lib/controllers/settings_sections.dart +++ b/lib/controllers/settings_sections.dart @@ -20,6 +20,7 @@ import "package:nexus/helpers/extensions/distributor_name.dart"; import "package:nexus/models/account_data.dart"; import "package:nexus/models/room_order.dart"; import "package:nexus/models/settings_category.dart"; +import "package:nexus/models/typing_indicators.dart"; import "package:nexus/main.dart"; import "package:nexus/widgets/settings/dialog_list_tile.dart"; import "package:nexus/widgets/reset_encryption_sheet.dart"; @@ -183,6 +184,34 @@ class SettingsSectionsController ), ]), ), + .new( + title: "Privacy", + icon: Icons.privacy_tip, + settings: .new([ + .new( + title: "Typing Indicators", + description: "Show when others are typing, and let others see when you're typing.", + icon: Icons.keyboard, + builder: (title, description, icon) => + DialogListTile( + icon: Icon(icon), + title: title, + subtitle: Text(description), + value: settings.typingIndicators, + options: TypingIndicators.values, + getName: (option) => switch (option) { + .showAndSend => "Show and send", + .show => "Only show", + .neither => "Neither", + }, + onChanged: (value) => ref + .read(SettingsController.provider.notifier) + .set(settings.copyWith(typingIndicators: value)) + .onError(showError), + ), + ), + ]), + ), ]), if (ref.watch(ClientStateController.provider)?.isLoggedIn == true) "Account": .new([ diff --git a/lib/controllers/typing.dart b/lib/controllers/typing.dart new file mode 100644 index 0000000..d1f3d28 --- /dev/null +++ b/lib/controllers/typing.dart @@ -0,0 +1,15 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/models/typing.dart"; + +class TypingController extends Notifier>> { + @override + IMap> build() => .new(); + + void set(Typing typing) => state = state.add(typing.roomId, typing.userIds); + + static final provider = + NotifierProvider>>( + TypingController.new, + ); +} diff --git a/lib/models/settings.dart b/lib/models/settings.dart index f6cc1c5..e1e1bbc 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -1,6 +1,7 @@ import "package:material_ui/material_ui.dart"; import "package:freezed_annotation/freezed_annotation.dart"; import "package:nexus/models/room_order.dart"; +import "package:nexus/models/typing_indicators.dart"; part "settings.freezed.dart"; part "settings.g.dart"; @@ -16,6 +17,7 @@ class const Settings({ final bool swipeToReply = false, final bool hideMembershipEvents = false, final RoomOrder roomOrder = .unreadFirst, + final TypingIndicators typingIndicators = .showAndSend, }) with _$Settings { Map toJson() => _$SettingsToJson(this); diff --git a/lib/models/typing.dart b/lib/models/typing.dart new file mode 100644 index 0000000..bd0198c --- /dev/null +++ b/lib/models/typing.dart @@ -0,0 +1,14 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; + +part "typing.freezed.dart"; +part "typing.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable(createToJson: false) +class const Typing({ + required final String roomId, + final IList userIds = const .empty(), +}) with _$Typing { + factory Typing.fromJson(Map json) => _$TypingFromJson(json); +} diff --git a/lib/models/typing_indicators.dart b/lib/models/typing_indicators.dart new file mode 100644 index 0000000..28df4db --- /dev/null +++ b/lib/models/typing_indicators.dart @@ -0,0 +1 @@ +enum TypingIndicators { showAndSend, show, neither } diff --git a/lib/widgets/bouncing_dots.dart b/lib/widgets/bouncing_dots.dart new file mode 100644 index 0000000..37f655c --- /dev/null +++ b/lib/widgets/bouncing_dots.dart @@ -0,0 +1,41 @@ +import "dart:math"; + +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:material_ui/material_ui.dart"; + +/// Three dots that bounce one after another, in a loop. +class const BouncingDots({required final Color color, super.key}) + extends HookWidget { + @override + Widget build(BuildContext context) { + final controller = useAnimationController( + duration: const .new(milliseconds: 1200), + ); + useEffect(() { + controller.repeat(); + return null; + }, []); + final progress = useAnimation(controller); + + return Row( + mainAxisSize: .min, + spacing: 3, + children: [0, 1, 2].map((index) { + final phase = (progress - index * 0.15) % 1; + final bounce = phase < 0.4 ? sin(phase / 0.4 * pi) : 0.0; + + return Transform.translate( + offset: .new(0, -3 * bounce), + child: Opacity( + opacity: 0.4 + 0.6 * bounce, + child: Container( + width: 6, + height: 6, + decoration: BoxDecoration(color: color, shape: .circle), + ), + ), + ); + }).toList(), + ); + } +} diff --git a/lib/widgets/composer/composer.dart b/lib/widgets/composer/composer.dart index 9b596c6..2648347 100644 --- a/lib/widgets/composer/composer.dart +++ b/lib/widgets/composer/composer.dart @@ -11,6 +11,8 @@ import "package:flutter_hooks/flutter_hooks.dart"; import "package:fluttertagger/fluttertagger.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:nexus/controllers/attachment.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/controllers/settings.dart"; import "package:nexus/controllers/image_picker.dart"; import "package:nexus/helpers/file_dialog_helper.dart"; import "package:image_picker/image_picker.dart"; @@ -94,6 +96,44 @@ class const Composer( return null; }, [relationType, relatedEvent == null]); + final sinceTypingSent = useMemoized(Stopwatch.new); + + useEffect(() { + final client = ref.read(ClientController.provider.notifier); + + void stopTyping() { + if (!sinceTypingSent.isRunning) return; + sinceTypingSent + ..stop() + ..reset(); + client.setTyping(roomId, .zero).ignore(); + } + + void onChanged() { + final shouldSend = + ref.read(SettingsController.provider).value?.typingIndicators == + .showAndSend; + + if (!shouldSend || controller.text.isEmpty) return stopTyping(); + + if (sinceTypingSent.isRunning && + sinceTypingSent.elapsed < const .new(seconds: 5)) { + return; + } + + sinceTypingSent + ..reset() + ..start(); + client.setTyping(roomId, const .new(seconds: 10)).ignore(); + } + + controller.addListener(onChanged); + return () { + controller.removeListener(onChanged); + stopTyping(); + }; + }, [roomId]); + final attachment = ref.watch(AttachmentController.provider(roomId)); Future attach(Future pick) async { diff --git a/lib/widgets/composer/typing_indicator.dart b/lib/widgets/composer/typing_indicator.dart new file mode 100644 index 0000000..fa64f60 --- /dev/null +++ b/lib/widgets/composer/typing_indicator.dart @@ -0,0 +1,104 @@ +import "package:collection/collection.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/controllers/room_typing.dart"; +import "package:nexus/controllers/user.dart"; +import "package:nexus/helpers/extensions/get_localpart.dart"; +import "package:nexus/widgets/bouncing_dots.dart"; + +class const TypingIndicator(final String roomId, {super.key}) + extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final typing = ref.watch(RoomTypingController.provider(roomId)); + + // Past three people, only the first two are named + final named = typing.length > 3 ? 2 : typing.length; + final subjects = [ + ...typing + .take(named) + .map( + (typingUserId) => TextSpan( + text: + ref + .watch( + UserController.provider( + .new(roomId: roomId, userId: typingUserId), + ), + ) + .value + ?.displayName ?? + typingUserId.localpart, + style: TextStyle( + fontWeight: .bold, + color: theme.colorScheme.onSurface, + ), + ), + ), + if (typing.length > named) + TextSpan(text: "${typing.length - named} others"), + ]; + + final spans = [ + ...subjects.expandIndexed( + (index, subject) => [ + if (index > 0) + TextSpan( + text: index < subjects.length - 1 + ? ", " + : subjects.length > 2 + ? ", and " + : " and ", + ), + subject, + ], + ), + TextSpan(text: subjects.length == 1 ? " is typing..." : " are typing..."), + ]; + + return AnimatedSwitcher( + duration: Durations.medium2, + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeInCubic, + transitionBuilder: (child, animation) => FadeTransition( + opacity: animation, + child: ScaleTransition( + scale: animation, + alignment: .bottomLeft, + child: child, + ), + ), + child: typing.isEmpty + ? const SizedBox.shrink() + : Align( + alignment: .bottomLeft, + child: Material( + color: theme.colorScheme.surfaceContainerHigh, + elevation: 1, + shape: const StadiumBorder(), + child: Padding( + padding: .symmetric(horizontal: 12, vertical: 4), + child: Row( + mainAxisSize: .min, + spacing: 8, + children: [ + BouncingDots(color: theme.colorScheme.onSurfaceVariant), + Flexible( + child: Text.rich( + TextSpan(children: spans), + maxLines: 1, + overflow: .ellipsis, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/room_chat/chat_timeline.dart b/lib/widgets/room_chat/chat_timeline.dart index 8a4c071..2de327c 100644 --- a/lib/widgets/room_chat/chat_timeline.dart +++ b/lib/widgets/room_chat/chat_timeline.dart @@ -13,6 +13,9 @@ class const ChatTimeline({ required final IList Function(Event) getEventOptions, required final int? highlightedEvent, required final double composerHeight, + + /// The typing indicator's height, or zero while it's hidden. + required final double typingIndicatorHeight, final void Function(int rowId)? onSwipe, super.key, }) extends StatelessWidget { @@ -40,7 +43,22 @@ class const ChatTimeline({ keyboardDismissBehavior: .onDrag, controller: scroll.scrollController, slivers: [ - SliverToBoxAdapter(child: SizedBox(height: composerHeight)), + SliverToBoxAdapter( + child: Column( + children: [ + // Makes room for the typing indicator, with a small gap above it, + // so it doesn't cover messages + AnimatedContainer( + duration: Durations.medium2, + curve: Curves.easeInOutCubicEmphasized, + height: typingIndicatorHeight == 0 + ? 0 + : typingIndicatorHeight + 4, + ), + SizedBox(height: composerHeight), + ], + ), + ), SuperSliverList.builder( itemCount: scroll.liveRows.length, diff --git a/lib/widgets/room_chat/room_chat.dart b/lib/widgets/room_chat/room_chat.dart index bc077e0..25a4ac2 100644 --- a/lib/widgets/room_chat/room_chat.dart +++ b/lib/widgets/room_chat/room_chat.dart @@ -15,6 +15,7 @@ import "package:nexus/helpers/hooks/chat_scroll.dart"; import "package:nexus/models/event.dart"; import "package:nexus/models/relation_type.dart"; import "package:nexus/widgets/composer/composer.dart"; +import "package:nexus/widgets/composer/typing_indicator.dart"; import "package:nexus/widgets/error_dialog.dart"; import "package:nexus/widgets/loading.dart"; import "package:nexus/widgets/pinned_events_drawer.dart"; @@ -46,6 +47,7 @@ final class const RoomChat({ final highlightedEvent = useState(null); final composerSize = useState(64); + final typingIndicatorSize = useState(0); final userId = ref.watch(ClientStateController.provider)?.userId; final memberListOpened = ref @@ -252,6 +254,8 @@ final class const RoomChat({ : null, highlightedEvent: highlightedEvent.value, composerHeight: composerSize.value, + typingIndicatorHeight: + typingIndicatorSize.value, ), AsyncLoading _ => Loading(), AsyncError( @@ -262,6 +266,17 @@ final class const RoomChat({ }, ), ), + Positioned( + bottom: composerSize.value, + left: 12, + // To give space for jump to bottom button + right: 72, + child: MeasureSize( + onChange: (size) => + typingIndicatorSize.value = size.height, + child: TypingIndicator(roomId), + ), + ), Positioned( right: 16, bottom: composerSize.value,