Skip to content

Commit 45d9d14

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 d3c8c09 commit 45d9d14

File tree

4 files changed

+152
-5
lines changed

4 files changed

+152
-5
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 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/model/typing_status.dart

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ class TypingStatus extends ChangeNotifier {
2020
Iterable<SendableNarrow> get debugActiveNarrows => _timerMapsByNarrow.keys;
2121

2222
Iterable<int> typistIdsInNarrow(SendableNarrow narrow) =>
23-
_timerMapsByNarrow[narrow]?.keys ?? [];
23+
_timerMapsByNarrow[narrow]?.keys.where((id) => id != selfUserId) ?? [];
2424

2525
// Using SendableNarrow as the key covers the narrows
2626
// where typing notifications are supported (topics and DMs).
@@ -64,7 +64,7 @@ class TypingStatus extends ChangeNotifier {
6464
void handleTypingEvent(TypingEvent event) {
6565
SendableNarrow narrow = switch (event.messageType) {
6666
MessageType.direct => DmNarrow(
67-
allRecipientIds: event.recipientIds!..sort(), selfUserId: selfUserId),
67+
allRecipientIds: event.recipientIds!.toList()..sort(), selfUserId: selfUserId),
6868
MessageType.stream => TopicNarrow(event.streamId!, event.topic!),
6969
};
7070

lib/widgets/message_list.dart

Lines changed: 68 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

@@ -458,6 +462,67 @@ class ScrollToBottomButton extends StatelessWidget {
458462
}
459463
}
460464

465+
class TypingStatusWidget extends StatefulWidget {
466+
const TypingStatusWidget({super.key, required this.narrow});
467+
468+
final Narrow narrow;
469+
470+
@override
471+
State<StatefulWidget> createState() => _TypingStatusWidgetState();
472+
}
473+
474+
class _TypingStatusWidgetState extends State<TypingStatusWidget> with PerAccountStoreAwareStateMixin<TypingStatusWidget> {
475+
TypingStatus? model;
476+
477+
@override
478+
void onNewStore() {
479+
model?.removeListener(_modelChanged);
480+
model = PerAccountStoreWidget.of(context).typingStatus
481+
..addListener(_modelChanged);
482+
}
483+
484+
@override
485+
void dispose() {
486+
model?.removeListener(_modelChanged);
487+
super.dispose();
488+
}
489+
490+
void _modelChanged() {
491+
setState(() {
492+
// The actual state lives in [model].
493+
// This method was called because that just changed.
494+
});
495+
}
496+
497+
@override
498+
Widget build(BuildContext context) {
499+
final store = PerAccountStoreWidget.of(context);
500+
final narrow = widget.narrow;
501+
if (narrow is! SendableNarrow) return const SizedBox();
502+
503+
final localizations = ZulipLocalizations.of(context);
504+
final typistIds = model!.typistIdsInNarrow(narrow).toList();
505+
if (typistIds.isEmpty) return const SizedBox();
506+
final String text = switch (typistIds) {
507+
[final firstTypist] =>
508+
localizations.onePersonTyping(
509+
store.users[firstTypist]?.fullName ?? localizations.unknownUserName),
510+
[final firstTypist, final secondTypist] =>
511+
localizations.twoPeopleTyping(
512+
store.users[firstTypist]?.fullName ?? localizations.unknownUserName,
513+
store.users[secondTypist]?.fullName ?? localizations.unknownUserName,
514+
),
515+
_ => localizations.manyPeopleTyping,
516+
};
517+
518+
return Padding(
519+
padding: const EdgeInsetsDirectional.only(start: 16, top: 2),
520+
child: Text(text,
521+
style: const TextStyle(
522+
color: HslColor(0, 0, 53), fontStyle: FontStyle.italic)));
523+
}
524+
}
525+
461526
class MarkAsReadWidget extends StatelessWidget {
462527
const MarkAsReadWidget({super.key, required this.narrow});
463528

test/widgets/message_list_test.dart

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,69 @@ void main() {
294294
});
295295
});
296296

297+
group('TypingStatusWidget', () {
298+
final users = [eg.selfUser, eg.otherUser, eg.thirdUser, eg.fourthUser];
299+
final finder = find.descendant(
300+
of: find.byType(TypingStatusWidget),
301+
matching: find.byType(Text)
302+
);
303+
304+
Future<void> checkTyping(WidgetTester tester, TypingEvent event, {required String expected}) async {
305+
await store.handleEvent(event);
306+
await tester.pump();
307+
check(tester.widget<Text>(finder)).data.equals(expected);
308+
}
309+
310+
final dmMessage = eg.dmMessage(from: eg.selfUser, to: [eg.otherUser]);
311+
final dmNarrow = DmNarrow.ofMessage(dmMessage, selfUserId: eg.selfUser.userId);
312+
313+
final streamMessage = eg.streamMessage();
314+
final topicNarrow = TopicNarrow.ofMessage(streamMessage);
315+
316+
for (final (description, message, narrow) in [
317+
('typing in dm', dmMessage, dmNarrow),
318+
('typing in topic', streamMessage, topicNarrow),
319+
]) {
320+
testWidgets(description, (tester) async {
321+
await setupMessageListPage(tester,
322+
narrow: narrow, users: users, messages: [message]);
323+
await tester.pump();
324+
check(finder.evaluate()).isEmpty();
325+
await checkTyping(tester,
326+
eg.typingEvent(narrow, TypingOp.start, eg.otherUser.userId),
327+
expected: 'Other User is typing…');
328+
await checkTyping(tester,
329+
eg.typingEvent(narrow, TypingOp.start, eg.selfUser.userId),
330+
expected: 'Other User is typing…');
331+
await checkTyping(tester,
332+
eg.typingEvent(narrow, TypingOp.start, eg.thirdUser.userId),
333+
expected: 'Other User and Third User are typing…');
334+
await checkTyping(tester,
335+
eg.typingEvent(narrow, TypingOp.start, eg.fourthUser.userId),
336+
expected: 'Several people are typing…');
337+
await checkTyping(tester,
338+
eg.typingEvent(narrow, TypingOp.stop, eg.otherUser.userId),
339+
expected: 'Third User and Fourth User are typing…');
340+
// Verify that typing indicators expire after a set duration.
341+
await tester.pump(const Duration(seconds: 15));
342+
check(finder.evaluate()).isEmpty();
343+
});
344+
}
345+
346+
testWidgets('unknown user typing', (tester) async {
347+
final streamMessage = eg.streamMessage();
348+
final narrow = TopicNarrow.ofMessage(streamMessage);
349+
await setupMessageListPage(tester,
350+
narrow: narrow, users: [], messages: [streamMessage]);
351+
await checkTyping(tester,
352+
eg.typingEvent(narrow, TypingOp.start, 1000),
353+
expected: '(unknown user) is typing…',
354+
);
355+
// Wait for the pending timers to end.
356+
await tester.pump(const Duration(seconds: 15));
357+
});
358+
});
359+
297360
group('MarkAsReadWidget', () {
298361
bool isMarkAsReadButtonVisible(WidgetTester tester) {
299362
final zulipLocalizations = GlobalLocalizations.zulipLocalizations;

0 commit comments

Comments
 (0)