Skip to content

Commit ba78d03

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 dce75e3 commit ba78d03

File tree

4 files changed

+129
-4
lines changed

4 files changed

+129
-4
lines changed

assets/l10n/app_en.arb

+9
Original file line numberDiff line numberDiff line change
@@ -479,5 +479,14 @@
479479
"senderFullName": {"type": "String", "example": "Alice"},
480480
"numOthers": {"type": "int", "example": "4"}
481481
}
482+
},
483+
"typingIndicator": "{numPeople, plural, =1{{typist} is typing} =2{{typist} and {otherTypist} are typing} other{Several people are typing}}",
484+
"@typingIndicator": {
485+
"description": "Text to display when there are users typing.",
486+
"placeholders": {
487+
"numPeople": {"type": "int", "example": "5"},
488+
"typist": {"type": "String", "example": "Alice"},
489+
"otherTypist": {"type": "String", "example": "Bob"}
490+
}
482491
}
483492
}

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

+62-3
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import '../api/route/messages.dart';
1111
import '../model/message_list.dart';
1212
import '../model/narrow.dart';
1313
import '../model/store.dart';
14+
import '../model/typing_status.dart';
1415
import 'action_sheet.dart';
1516
import 'compose_box.dart';
1617
import 'content.dart';
@@ -326,17 +327,19 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat
326327
final valueKey = key as ValueKey<int>;
327328
final index = model!.findItemWithMessageId(valueKey.value);
328329
if (index == -1) return null;
329-
return length - 1 - (index - 2);
330+
return length - 1 - (index - 3);
330331
},
331-
childCount: length + 2,
332+
childCount: length + 3,
332333
(context, i) {
333334
// To reinforce that the end of the feed has been reached:
334335
// https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/flutter.3A.20Mark-as-read/near/1680603
335336
if (i == 0) return const SizedBox(height: 36);
336337

337338
if (i == 1) return MarkAsReadWidget(narrow: widget.narrow);
338339

339-
final data = model!.items[length - 1 - (i - 2)];
340+
if (i == 2) return TypingStatusWidget(narrow: widget.narrow);
341+
342+
final data = model!.items[length - 1 - (i - 3)];
340343
return _buildItem(data, i);
341344
}));
342345

@@ -510,6 +513,62 @@ class MarkAsReadWidget extends StatelessWidget {
510513
}
511514
}
512515

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

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)