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
2 changed files with 326 additions and 1 deletions

View file

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