Skip to content

Commit d3767d4

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 e813313 commit d3767d4

File tree

4 files changed

+146
-4
lines changed

4 files changed

+146
-4
lines changed

assets/l10n/app_en.arb

+19
Original file line numberDiff line numberDiff line change
@@ -479,5 +479,24 @@
479479
"senderFullName": {"type": "String", "example": "Alice"},
480480
"numOthers": {"type": "int", "example": "4"}
481481
}
482+
},
483+
"onePersonTyping": "{typist} is typing...",
484+
"@onePersonTyping": {
485+
"description": "Text to display when there is a user typing.",
486+
"placeholders": {
487+
"typist": {"type": "String", "example": "Alice"}
488+
}
489+
},
490+
"twoPeopleTyping": "{typist} and {otherTypist} are typing...",
491+
"@twoPeopleTyping": {
492+
"description": "Text to display when there are two users typing.",
493+
"placeholders": {
494+
"typist": {"type": "String", "example": "Alice"},
495+
"otherTypist": {"type": "String", "example": "Bob"}
496+
}
497+
},
498+
"manyPeopleTyping": "Several people are typing...",
499+
"@manyPeopleTyping": {
500+
"description": "Text to display when there are multiple users typing."
482501
}
483502
}

lib/api/route/events.dart

+1-1
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

+69-3
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ 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

@@ -11,6 +12,7 @@ import '../api/route/messages.dart';
1112
import '../model/message_list.dart';
1213
import '../model/narrow.dart';
1314
import '../model/store.dart';
15+
import '../model/typing_status.dart';
1416
import 'action_sheet.dart';
1517
import 'compose_box.dart';
1618
import 'content.dart';
@@ -326,17 +328,19 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat
326328
final valueKey = key as ValueKey<int>;
327329
final index = model!.findItemWithMessageId(valueKey.value);
328330
if (index == -1) return null;
329-
return length - 1 - (index - 2);
331+
return length - 1 - (index - 3);
330332
},
331-
childCount: length + 2,
333+
childCount: length + 3,
332334
(context, i) {
333335
// To reinforce that the end of the feed has been reached:
334336
// https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/flutter.3A.20Mark-as-read/near/1680603
335337
if (i == 0) return const SizedBox(height: 36);
336338

337339
if (i == 1) return MarkAsReadWidget(narrow: widget.narrow);
338340

339-
final data = model!.items[length - 1 - (i - 2)];
341+
if (i == 2) return TypingStatusWidget(narrow: widget.narrow);
342+
343+
final data = model!.items[length - 1 - (i - 3)];
340344
return _buildItem(data, i);
341345
}));
342346

@@ -510,6 +514,68 @@ class MarkAsReadWidget extends StatelessWidget {
510514
}
511515
}
512516

517+
class _TypingStatusState extends State<TypingStatusWidget> with PerAccountStoreAwareStateMixin<TypingStatusWidget> {
518+
TypingStatus? model;
519+
520+
@override
521+
void onNewStore() {
522+
model?.removeListener(_modelChanged);
523+
model = PerAccountStoreWidget.of(context).typingStatus
524+
..addListener(_modelChanged);
525+
}
526+
527+
@override
528+
void dispose() {
529+
model?.removeListener(_modelChanged);
530+
super.dispose();
531+
}
532+
533+
void _modelChanged() {
534+
setState(() {
535+
// The actual state lives in [model].
536+
// This method was called because that just changed.
537+
});
538+
}
539+
540+
@override
541+
Widget build(BuildContext context) {
542+
final store = PerAccountStoreWidget.of(context);
543+
final narrow = widget.narrow;
544+
const placeholder = SizedBox(height: 8);
545+
if (narrow is! SendableNarrow) return placeholder;
546+
547+
final typistNames = model!.typistIdsInNarrow(narrow)
548+
.where((id) => id != store.selfUserId)
549+
.map((id) => store.users[id]!.fullName)
550+
.toList();
551+
if (typistNames.isEmpty) return placeholder;
552+
553+
final localization = ZulipLocalizations.of(context);
554+
final String text = switch (typistNames.length) {
555+
1 => localization.onePersonTyping(typistNames[0]),
556+
2 => localization.twoPeopleTyping(typistNames[0], typistNames[1]),
557+
_ => localization.manyPeopleTyping,
558+
};
559+
560+
return Padding(
561+
padding: const EdgeInsets.only(left: 16, top: 2),
562+
child: Text(text,
563+
textAlign: TextAlign.left,
564+
style: const TextStyle(
565+
color: HslColor(0, 0, 53), fontStyle: FontStyle.italic)),
566+
);
567+
}
568+
}
569+
570+
class TypingStatusWidget extends StatefulWidget {
571+
const TypingStatusWidget({super.key, required this.narrow});
572+
573+
final Narrow narrow;
574+
575+
@override
576+
State<StatefulWidget> createState() => _TypingStatusState();
577+
}
578+
513579
class RecipientHeader extends StatelessWidget {
514580
const RecipientHeader({super.key, required this.message, required this.narrow});
515581

test/widgets/message_list_test.dart

+57
Original file line numberDiff line numberDiff line change
@@ -1009,4 +1009,61 @@ void main() {
10091009
});
10101010
});
10111011
});
1012+
1013+
group('TypingStatus', () {
1014+
final users = [eg.selfUser, eg.otherUser, eg.thirdUser, eg.fourthUser];
1015+
final finder = find.descendant(
1016+
of: find.byType(TypingStatusWidget),
1017+
matching: find.byType(Text)
1018+
);
1019+
1020+
checkTyping(WidgetTester tester, TypingEvent event, {required String expected}) async {
1021+
await store.handleEvent(event);
1022+
await tester.pump();
1023+
check(finder.evaluate()).single.has((x) => x.widget, 'widget').isA<Text>()
1024+
.data.equals(expected);
1025+
}
1026+
1027+
testTyping(WidgetTester tester, SendableNarrow narrow) async {
1028+
check(finder.evaluate()).isEmpty();
1029+
await checkTyping(tester,
1030+
eg.typingEvent(narrow, TypingOp.start, eg.otherUser.userId),
1031+
expected: 'Other User is typing...');
1032+
await checkTyping(tester,
1033+
eg.typingEvent(narrow, TypingOp.start, eg.selfUser.userId),
1034+
expected: 'Other User is typing...');
1035+
await checkTyping(tester,
1036+
eg.typingEvent(narrow, TypingOp.start, eg.thirdUser.userId),
1037+
expected: 'Other User and Third User are typing...');
1038+
await checkTyping(tester,
1039+
eg.typingEvent(narrow, TypingOp.start, eg.fourthUser.userId),
1040+
expected: 'Several people are typing...');
1041+
await checkTyping(tester,
1042+
eg.typingEvent(narrow, TypingOp.stop, eg.otherUser.userId),
1043+
expected: 'Third User and Fourth User are typing...');
1044+
// Verify that typing indicators expire after a set duration.
1045+
await tester.pump(const Duration(seconds: 15));
1046+
check(finder.evaluate()).isEmpty();
1047+
}
1048+
1049+
testWidgets('typing in dm', (tester) async {
1050+
final message = eg.dmMessage(from: eg.otherUser, to: [eg.selfUser]);
1051+
final narrow = DmNarrow.withUsers(
1052+
users.map((u) => u.userId).toList(),
1053+
selfUserId: eg.selfUser.userId);
1054+
await setupMessageListPage(tester,
1055+
narrow: narrow, users: users, messages: [message]);
1056+
await tester.pump();
1057+
await testTyping(tester, narrow);
1058+
});
1059+
1060+
testWidgets('typing in topic', (tester) async {
1061+
final message = eg.streamMessage();
1062+
final narrow = TopicNarrow.ofMessage(message);
1063+
await setupMessageListPage(tester,
1064+
narrow: narrow, users: users, messages: [message]);
1065+
await tester.pump();
1066+
await testTyping(tester, narrow);
1067+
});
1068+
});
10121069
}

0 commit comments

Comments
 (0)