Skip to content

Commit d10f229

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 57ec07f commit d10f229

File tree

3 files changed

+148
-3
lines changed

3 files changed

+148
-3
lines changed

assets/l10n/app_en.arb

+19
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 one 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/widgets/message_list.dart

+65-3
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';
@@ -496,17 +498,19 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat
496498
final valueKey = key as ValueKey<int>;
497499
final index = model!.findItemWithMessageId(valueKey.value);
498500
if (index == -1) return null;
499-
return length - 1 - (index - 2);
501+
return length - 1 - (index - 3);
500502
},
501-
childCount: length + 2,
503+
childCount: length + 3,
502504
(context, i) {
503505
// To reinforce that the end of the feed has been reached:
504506
// https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/flutter.3A.20Mark-as-read/near/1680603
505507
if (i == 0) return const SizedBox(height: 36);
506508

507509
if (i == 1) return MarkAsReadWidget(narrow: widget.narrow);
508510

509-
final data = model!.items[length - 1 - (i - 2)];
511+
if (i == 2) return TypingStatusWidget(narrow: widget.narrow);
512+
513+
final data = model!.items[length - 1 - (i - 3)];
510514
return _buildItem(data, i);
511515
}));
512516

@@ -609,6 +613,64 @@ class ScrollToBottomButton extends StatelessWidget {
609613
}
610614
}
611615

616+
class TypingStatusWidget extends StatefulWidget {
617+
const TypingStatusWidget({super.key, required this.narrow});
618+
619+
final Narrow narrow;
620+
621+
@override
622+
State<StatefulWidget> createState() => _TypingStatusWidgetState();
623+
}
624+
625+
class _TypingStatusWidgetState extends State<TypingStatusWidget> with PerAccountStoreAwareStateMixin<TypingStatusWidget> {
626+
TypingStatus? model;
627+
628+
@override
629+
void onNewStore() {
630+
model?.removeListener(_modelChanged);
631+
model = PerAccountStoreWidget.of(context).typingStatus
632+
..addListener(_modelChanged);
633+
}
634+
635+
@override
636+
void dispose() {
637+
model?.removeListener(_modelChanged);
638+
super.dispose();
639+
}
640+
641+
void _modelChanged() {
642+
setState(() {
643+
// The actual state lives in [model].
644+
// This method was called because that just changed.
645+
});
646+
}
647+
648+
@override
649+
Widget build(BuildContext context) {
650+
final narrow = widget.narrow;
651+
if (narrow is! SendableNarrow) return const SizedBox();
652+
653+
final store = PerAccountStoreWidget.of(context);
654+
final localizations = ZulipLocalizations.of(context);
655+
final typistIds = model!.typistIdsInNarrow(narrow);
656+
if (typistIds.isEmpty) return const SizedBox();
657+
final text = switch (typistIds.length) {
658+
1 => localizations.onePersonTyping(
659+
store.users[typistIds.first]?.fullName ?? localizations.unknownUserName),
660+
2 => localizations.twoPeopleTyping(
661+
store.users[typistIds.first]?.fullName ?? localizations.unknownUserName,
662+
store.users[typistIds.last]?.fullName ?? localizations.unknownUserName),
663+
_ => localizations.manyPeopleTyping,
664+
};
665+
666+
return Padding(
667+
padding: const EdgeInsetsDirectional.only(start: 16, top: 2),
668+
child: Text(text,
669+
style: const TextStyle(
670+
color: HslColor(0, 0, 53), fontStyle: FontStyle.italic)));
671+
}
672+
}
673+
612674
class MarkAsReadWidget extends StatefulWidget {
613675
const MarkAsReadWidget({super.key, required this.narrow});
614676

test/widgets/message_list_test.dart

+64
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,70 @@ void main() {
320320
});
321321
});
322322

323+
group('TypingStatusWidget', () {
324+
final users = [eg.selfUser, eg.otherUser, eg.thirdUser, eg.fourthUser];
325+
final finder = find.descendant(
326+
of: find.byType(TypingStatusWidget),
327+
matching: find.byType(Text)
328+
);
329+
330+
Future<void> checkTyping(WidgetTester tester, TypingEvent event, {required String expected}) async {
331+
await store.handleEvent(event);
332+
await tester.pump();
333+
check(tester.widget<Text>(finder)).data.equals(expected);
334+
}
335+
336+
final dmMessage = eg.dmMessage(
337+
from: eg.selfUser, to: [eg.otherUser, eg.thirdUser, eg.fourthUser]);
338+
final dmNarrow = DmNarrow.ofMessage(dmMessage, selfUserId: eg.selfUser.userId);
339+
340+
final streamMessage = eg.streamMessage();
341+
final topicNarrow = TopicNarrow.ofMessage(streamMessage);
342+
343+
for (final (description, message, narrow) in [
344+
('typing in dm', dmMessage, dmNarrow),
345+
('typing in topic', streamMessage, topicNarrow),
346+
]) {
347+
testWidgets(description, (tester) async {
348+
await setupMessageListPage(tester,
349+
narrow: narrow, users: users, messages: [message]);
350+
await tester.pump();
351+
check(finder.evaluate()).isEmpty();
352+
await checkTyping(tester,
353+
eg.typingEvent(narrow, TypingOp.start, eg.otherUser.userId),
354+
expected: 'Other User is typing…');
355+
await checkTyping(tester,
356+
eg.typingEvent(narrow, TypingOp.start, eg.selfUser.userId),
357+
expected: 'Other User is typing…');
358+
await checkTyping(tester,
359+
eg.typingEvent(narrow, TypingOp.start, eg.thirdUser.userId),
360+
expected: 'Other User and Third User are typing…');
361+
await checkTyping(tester,
362+
eg.typingEvent(narrow, TypingOp.start, eg.fourthUser.userId),
363+
expected: 'Several people are typing…');
364+
await checkTyping(tester,
365+
eg.typingEvent(narrow, TypingOp.stop, eg.otherUser.userId),
366+
expected: 'Third User and Fourth User are typing…');
367+
// Verify that typing indicators expire after a set duration.
368+
await tester.pump(const Duration(seconds: 15));
369+
check(finder.evaluate()).isEmpty();
370+
});
371+
}
372+
373+
testWidgets('unknown user typing', (tester) async {
374+
final streamMessage = eg.streamMessage();
375+
final narrow = TopicNarrow.ofMessage(streamMessage);
376+
await setupMessageListPage(tester,
377+
narrow: narrow, users: [], messages: [streamMessage]);
378+
await checkTyping(tester,
379+
eg.typingEvent(narrow, TypingOp.start, 1000),
380+
expected: '(unknown user) is typing…',
381+
);
382+
// Wait for the pending timers to end.
383+
await tester.pump(const Duration(seconds: 15));
384+
});
385+
});
386+
323387
group('MarkAsReadWidget', () {
324388
bool isMarkAsReadButtonVisible(WidgetTester tester) {
325389
final zulipLocalizations = GlobalLocalizations.zulipLocalizations;

0 commit comments

Comments
 (0)