add typing indicators

Fixes #92
This commit is contained in:
Henry Hiles 2026-10-21 15:07:02 -04:00
commit 0fcbc275cd
Signed by: Henry-Hiles
SSH key fingerprint: SHA256:VKQUdS31Q90KvX7EkKMHMBpUspcmItAh86a+v7PGiIs
13 changed files with 326 additions and 1 deletions

View file

@ -320,6 +320,11 @@ class ClientController extends AsyncNotifier<int> {
Future<void> logout() => _sendCommand("logout"); Future<void> logout() => _sendCommand("logout");
Future<void> setTyping(String roomId, Duration timeout) => _sendCommand(
"set_typing",
{"room_id": roomId, "timeout": timeout.inMilliseconds},
);
Future<void> markRead(Room room) async { Future<void> markRead(Room room) async {
if (room.timeline.isEmpty || room.metadata == null) return; if (room.timeline.isEmpty || room.metadata == null) return;
final eventRowId = room.timeline[room.timeline.keys.reduce(max)]; final eventRowId = room.timeline[room.timeline.keys.reduce(max)];

View file

@ -15,6 +15,7 @@ import "package:nexus/controllers/rooms.dart";
import "package:nexus/controllers/space_edges.dart"; import "package:nexus/controllers/space_edges.dart";
import "package:nexus/controllers/sync_status.dart"; import "package:nexus/controllers/sync_status.dart";
import "package:nexus/controllers/top_level_spaces.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/helpers/extensions/gomuks_buffer.dart";
import "package:nexus/main.dart"; import "package:nexus/main.dart";
import "package:ffi/ffi.dart"; import "package:ffi/ffi.dart";
@ -63,6 +64,10 @@ class GomuksListenerController extends AsyncNotifier<void> {
event.roomId: .new(events: .new({event.rowId: event})), event.roomId: .new(events: .new({event.rowId: event})),
}), }),
); );
case "typing":
ref
.read(TypingController.provider.notifier)
.set(.fromJson(decodedMuksEvent));
case "events_decrypted": case "events_decrypted":
final decrypted = EventsDecrypted.fromJson(decodedMuksEvent); final decrypted = EventsDecrypted.fromJson(decodedMuksEvent);
final metadata = ref final metadata = ref
@ -96,6 +101,7 @@ class GomuksListenerController extends AsyncNotifier<void> {
ref.invalidate(roomProvider); ref.invalidate(roomProvider);
ref.invalidate(accountDataProvider); ref.invalidate(accountDataProvider);
ref.invalidate(invitesProvider); ref.invalidate(invitesProvider);
ref.invalidate(TypingController.provider);
} }
ref ref
@ -153,6 +159,7 @@ class GomuksListenerController extends AsyncNotifier<void> {
if (paused) { if (paused) {
GomuksPauseSync(handle); GomuksPauseSync(handle);
} else { } else {
ref.invalidate(TypingController.provider);
GomuksResumeSync(handle); GomuksResumeSync(handle);
} }
}, fireImmediately: true); }, fireImmediately: true);

View file

@ -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<IList<String>> {
@override
IList<String> 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<RoomTypingController, IList<String>, String>(
RoomTypingController.new,
);
}

View file

@ -20,6 +20,7 @@ import "package:nexus/helpers/extensions/distributor_name.dart";
import "package:nexus/models/account_data.dart"; import "package:nexus/models/account_data.dart";
import "package:nexus/models/room_order.dart"; import "package:nexus/models/room_order.dart";
import "package:nexus/models/settings_category.dart"; import "package:nexus/models/settings_category.dart";
import "package:nexus/models/typing_indicators.dart";
import "package:nexus/main.dart"; import "package:nexus/main.dart";
import "package:nexus/widgets/settings/dialog_list_tile.dart"; import "package:nexus/widgets/settings/dialog_list_tile.dart";
import "package:nexus/widgets/reset_encryption_sheet.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<TypingIndicators>(
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) if (ref.watch(ClientStateController.provider)?.isLoggedIn == true)
"Account": .new([ "Account": .new([

View file

@ -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<IMap<String, IList<String>>> {
@override
IMap<String, IList<String>> build() => .new();
void set(Typing typing) => state = state.add(typing.roomId, typing.userIds);
static final provider =
NotifierProvider<TypingController, IMap<String, IList<String>>>(
TypingController.new,
);
}

View file

@ -1,6 +1,7 @@
import "package:material_ui/material_ui.dart"; import "package:material_ui/material_ui.dart";
import "package:freezed_annotation/freezed_annotation.dart"; import "package:freezed_annotation/freezed_annotation.dart";
import "package:nexus/models/room_order.dart"; import "package:nexus/models/room_order.dart";
import "package:nexus/models/typing_indicators.dart";
part "settings.freezed.dart"; part "settings.freezed.dart";
part "settings.g.dart"; part "settings.g.dart";
@ -16,6 +17,7 @@ class const Settings({
final bool swipeToReply = false, final bool swipeToReply = false,
final bool hideMembershipEvents = false, final bool hideMembershipEvents = false,
final RoomOrder roomOrder = .unreadFirst, final RoomOrder roomOrder = .unreadFirst,
final TypingIndicators typingIndicators = .showAndSend,
}) with _$Settings { }) with _$Settings {
Map<String, Object?> toJson() => _$SettingsToJson(this); Map<String, Object?> toJson() => _$SettingsToJson(this);

14
lib/models/typing.dart Normal file
View file

@ -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<String> userIds = const .empty(),
}) with _$Typing {
factory Typing.fromJson(Map<String, Object?> json) => _$TypingFromJson(json);
}

View file

@ -0,0 +1 @@
enum TypingIndicators { showAndSend, show, neither }

View file

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

View file

@ -11,6 +11,8 @@ import "package:flutter_hooks/flutter_hooks.dart";
import "package:fluttertagger/fluttertagger.dart"; import "package:fluttertagger/fluttertagger.dart";
import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/controllers/attachment.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/controllers/image_picker.dart";
import "package:nexus/helpers/file_dialog_helper.dart"; import "package:nexus/helpers/file_dialog_helper.dart";
import "package:image_picker/image_picker.dart"; import "package:image_picker/image_picker.dart";
@ -94,6 +96,44 @@ class const Composer(
return null; return null;
}, [relationType, relatedEvent == 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)); final attachment = ref.watch(AttachmentController.provider(roomId));
Future<void> attach(Future<XFile?> pick) async { Future<void> attach(Future<XFile?> pick) async {

View file

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

View file

@ -13,6 +13,9 @@ class const ChatTimeline({
required final IList<PopupMenuEntry> Function(Event) getEventOptions, required final IList<PopupMenuEntry> Function(Event) getEventOptions,
required final int? highlightedEvent, required final int? highlightedEvent,
required final double composerHeight, 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, final void Function(int rowId)? onSwipe,
super.key, super.key,
}) extends StatelessWidget { }) extends StatelessWidget {
@ -40,7 +43,22 @@ class const ChatTimeline({
keyboardDismissBehavior: .onDrag, keyboardDismissBehavior: .onDrag,
controller: scroll.scrollController, controller: scroll.scrollController,
slivers: [ 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( SuperSliverList.builder(
itemCount: scroll.liveRows.length, itemCount: scroll.liveRows.length,

View file

@ -15,6 +15,7 @@ import "package:nexus/helpers/hooks/chat_scroll.dart";
import "package:nexus/models/event.dart"; import "package:nexus/models/event.dart";
import "package:nexus/models/relation_type.dart"; import "package:nexus/models/relation_type.dart";
import "package:nexus/widgets/composer/composer.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/error_dialog.dart";
import "package:nexus/widgets/loading.dart"; import "package:nexus/widgets/loading.dart";
import "package:nexus/widgets/pinned_events_drawer.dart"; import "package:nexus/widgets/pinned_events_drawer.dart";
@ -46,6 +47,7 @@ final class const RoomChat({
final highlightedEvent = useState<int?>(null); final highlightedEvent = useState<int?>(null);
final composerSize = useState<double>(64); final composerSize = useState<double>(64);
final typingIndicatorSize = useState<double>(0);
final userId = ref.watch(ClientStateController.provider)?.userId; final userId = ref.watch(ClientStateController.provider)?.userId;
final memberListOpened = ref final memberListOpened = ref
@ -252,6 +254,8 @@ final class const RoomChat({
: null, : null,
highlightedEvent: highlightedEvent.value, highlightedEvent: highlightedEvent.value,
composerHeight: composerSize.value, composerHeight: composerSize.value,
typingIndicatorHeight:
typingIndicatorSize.value,
), ),
AsyncLoading _ => Loading(), AsyncLoading _ => Loading(),
AsyncError( 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( Positioned(
right: 16, right: 16,
bottom: composerSize.value, bottom: composerSize.value,