Skip to content

Commit 3bf08e1

Browse files
committed
msglist: Display typing indicators on typing.
Because we don't have a Figma design yet, this revision supports a basic design similar to the web app when there are people typing. Fixes #665. Signed-off-by: Zixuan James Li <[email protected]>
1 parent dbb2dcf commit 3bf08e1

File tree

4 files changed

+155
-4
lines changed

4 files changed

+155
-4
lines changed

assets/l10n/app_en.arb

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -483,5 +483,24 @@
483483
"notifSelfUser": "You",
484484
"@notifSelfUser": {
485485
"description": "Display name for the user themself, to show after replying in an Android notification"
486+
},
487+
"onePersonTyping": "{typist} is typing…",
488+
"@onePersonTyping": {
489+
"description": "Text to display when there is a user typing.",
490+
"placeholders": {
491+
"typist": {"type": "String", "example": "Alice"}
492+
}
493+
},
494+
"twoPeopleTyping": "{typist} and {otherTypist} are typing…",
495+
"@twoPeopleTyping": {
496+
"description": "Text to display when there are two users typing.",
497+
"placeholders": {
498+
"typist": {"type": "String", "example": "Alice"},
499+
"otherTypist": {"type": "String", "example": "Bob"}
500+
}
501+
},
502+
"manyPeopleTyping": "Several people are typing…",
503+
"@manyPeopleTyping": {
504+
"description": "Text to display when there are multiple users typing."
486505
}
487506
}

lib/api/route/events.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ Future<InitialSnapshot> registerQueue(ApiConnection connection) {
1616
'notification_settings_null': true,
1717
'bulk_message_deletion': true,
1818
'user_avatar_url_field_optional': false, // TODO(#254): turn on
19-
'stream_typing_notifications': false, // TODO implement
19+
'stream_typing_notifications': true,
2020
'user_settings_object': true,
2121
},
2222
});

lib/widgets/message_list.dart

Lines changed: 69 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,15 @@ import 'dart:math';
22

33
import 'package:collection/collection.dart';
44
import 'package:flutter/material.dart';
5+
import 'package:flutter_color_models/flutter_color_models.dart';
56
import 'package:flutter_gen/gen_l10n/zulip_localizations.dart';
67
import 'package:intl/intl.dart';
78

89
import '../api/model/model.dart';
910
import '../model/message_list.dart';
1011
import '../model/narrow.dart';
1112
import '../model/store.dart';
13+
import '../model/typing_status.dart';
1214
import 'action_sheet.dart';
1315
import 'actions.dart';
1416
import 'compose_box.dart';
@@ -346,17 +348,19 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat
346348
final valueKey = key as ValueKey<int>;
347349
final index = model!.findItemWithMessageId(valueKey.value);
348350
if (index == -1) return null;
349-
return length - 1 - (index - 2);
351+
return length - 1 - (index - 3);
350352
},
351-
childCount: length + 2,
353+
childCount: length + 3,
352354
(context, i) {
353355
// To reinforce that the end of the feed has been reached:
354356
// https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/flutter.3A.20Mark-as-read/near/1680603
355357
if (i == 0) return const SizedBox(height: 36);
356358

357359
if (i == 1) return MarkAsReadWidget(narrow: widget.narrow);
358360

359-
final data = model!.items[length - 1 - (i - 2)];
361+
if (i == 2) return TypingStatusWidget(narrow: widget.narrow);
362+
363+
final data = model!.items[length - 1 - (i - 3)];
360364
return _buildItem(data, i);
361365
}));
362366

@@ -530,6 +534,68 @@ class MarkAsReadWidget extends StatelessWidget {
530534
}
531535
}
532536

537+
class _TypingStatusState extends State<TypingStatusWidget> with PerAccountStoreAwareStateMixin<TypingStatusWidget> {
538+
TypingStatus? model;
539+
540+
@override
541+
void onNewStore() {
542+
model?.removeListener(_modelChanged);
543+
model = PerAccountStoreWidget.of(context).typingStatus
544+
..addListener(_modelChanged);
545+
}
546+
547+
@override
548+
void dispose() {
549+
model?.removeListener(_modelChanged);
550+
super.dispose();
551+
}
552+
553+
void _modelChanged() {
554+
setState(() {
555+
// The actual state lives in [model].
556+
// This method was called because that just changed.
557+
});
558+
}
559+
560+
@override
561+
Widget build(BuildContext context) {
562+
final store = PerAccountStoreWidget.of(context);
563+
final narrow = widget.narrow;
564+
const placeholder = SizedBox(height: 8);
565+
if (narrow is! SendableNarrow) return placeholder;
566+
567+
final localization = ZulipLocalizations.of(context);
568+
final typistNames = model!.typistIdsInNarrow(narrow)
569+
.where((id) => id != store.selfUserId)
570+
.map((id) => store.users[id]?.fullName ?? localization.unknownUserName)
571+
.toList();
572+
if (typistNames.isEmpty) return placeholder;
573+
574+
final String text = switch (typistNames.length) {
575+
1 => localization.onePersonTyping(typistNames[0]),
576+
2 => localization.twoPeopleTyping(typistNames[0], typistNames[1]),
577+
_ => localization.manyPeopleTyping,
578+
};
579+
580+
return Padding(
581+
padding: const EdgeInsetsDirectional.only(start: 16, top: 2),
582+
child: Text(text,
583+
textAlign: TextAlign.start,
584+
style: const TextStyle(
585+
color: HslColor(0, 0, 53), fontStyle: FontStyle.italic)),
586+
);
587+
}
588+
}
589+
590+
class TypingStatusWidget extends StatefulWidget {
591+
const TypingStatusWidget({super.key, required this.narrow});
592+
593+
final Narrow narrow;
594+
595+
@override
596+
State<StatefulWidget> createState() => _TypingStatusState();
597+
}
598+
533599
class RecipientHeader extends StatelessWidget {
534600
const RecipientHeader({super.key, required this.message, required this.narrow});
535601

test/widgets/message_list_test.dart

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -981,4 +981,70 @@ void main() {
981981
});
982982
});
983983
});
984+
985+
group('TypingStatus', () {
986+
final users = [eg.selfUser, eg.otherUser, eg.thirdUser, eg.fourthUser];
987+
final finder = find.descendant(
988+
of: find.byType(TypingStatusWidget),
989+
matching: find.byType(Text)
990+
);
991+
992+
checkTyping(WidgetTester tester, TypingEvent event, {required String expected}) async {
993+
await store.handleEvent(event);
994+
await tester.pump();
995+
check(finder.evaluate()).single.has((x) => x.widget, 'widget').isA<Text>()
996+
.data.equals(expected);
997+
}
998+
999+
final dmMessage = eg.dmMessage(from: eg.otherUser, to: [eg.selfUser]);
1000+
final dmNarrow = DmNarrow.withUsers(
1001+
users.map((u) => u.userId).toList(),
1002+
selfUserId: eg.selfUser.userId);
1003+
1004+
final streamMessage = eg.streamMessage();
1005+
final topicNarrow = TopicNarrow.ofMessage(streamMessage);
1006+
1007+
for (final (description, message, narrow) in [
1008+
('typing in dm', dmMessage, dmNarrow),
1009+
('typing in topic', streamMessage, topicNarrow),
1010+
]) {
1011+
testWidgets(description, (tester) async {
1012+
await setupMessageListPage(tester,
1013+
narrow: narrow, users: users, messages: [message]);
1014+
await tester.pump();
1015+
check(finder.evaluate()).isEmpty();
1016+
await checkTyping(tester,
1017+
eg.typingEvent(narrow, TypingOp.start, eg.otherUser.userId),
1018+
expected: 'Other User is typing…');
1019+
await checkTyping(tester,
1020+
eg.typingEvent(narrow, TypingOp.start, eg.selfUser.userId),
1021+
expected: 'Other User is typing…');
1022+
await checkTyping(tester,
1023+
eg.typingEvent(narrow, TypingOp.start, eg.thirdUser.userId),
1024+
expected: 'Other User and Third User are typing…');
1025+
await checkTyping(tester,
1026+
eg.typingEvent(narrow, TypingOp.start, eg.fourthUser.userId),
1027+
expected: 'Several people are typing…');
1028+
await checkTyping(tester,
1029+
eg.typingEvent(narrow, TypingOp.stop, eg.otherUser.userId),
1030+
expected: 'Third User and Fourth User are typing…');
1031+
// Verify that typing indicators expire after a set duration.
1032+
await tester.pump(const Duration(seconds: 15));
1033+
check(finder.evaluate()).isEmpty();
1034+
});
1035+
}
1036+
1037+
testWidgets('unknown user typing', (tester) async {
1038+
final streamMessage = eg.streamMessage();
1039+
final narrow = TopicNarrow.ofMessage(streamMessage);
1040+
await setupMessageListPage(tester,
1041+
narrow: narrow, users: [], messages: [streamMessage]);
1042+
await checkTyping(tester,
1043+
eg.typingEvent(narrow, TypingOp.start, 1000),
1044+
expected: '(unknown user) is typing…',
1045+
);
1046+
// Wait for the pending timers to end.
1047+
await tester.pump(const Duration(seconds: 15));
1048+
});
1049+
});
9841050
}

0 commit comments

Comments
 (0)