Skip to content

Commit

Permalink
msglist: Display typing indicators on typing.
Browse files Browse the repository at this point in the history
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 zulip#665.

Signed-off-by: Zixuan James Li <zixuan@zulip.com>
  • Loading branch information
PIG208 committed Jul 30, 2024
1 parent fee1558 commit 71ef85a
Show file tree
Hide file tree
Showing 3 changed files with 148 additions and 3 deletions.
19 changes: 19 additions & 0 deletions assets/l10n/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -483,5 +483,24 @@
"notifSelfUser": "You",
"@notifSelfUser": {
"description": "Display name for the user themself, to show after replying in an Android notification"
},
"onePersonTyping": "{typist} is typing…",
"@onePersonTyping": {
"description": "Text to display when there is one user typing.",
"placeholders": {
"typist": {"type": "String", "example": "Alice"}
}
},
"twoPeopleTyping": "{typist} and {otherTypist} are typing…",
"@twoPeopleTyping": {
"description": "Text to display when there are two users typing.",
"placeholders": {
"typist": {"type": "String", "example": "Alice"},
"otherTypist": {"type": "String", "example": "Bob"}
}
},
"manyPeopleTyping": "Several people are typing…",
"@manyPeopleTyping": {
"description": "Text to display when there are multiple users typing."
}
}
68 changes: 65 additions & 3 deletions lib/widgets/message_list.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@ import 'dart:math';

import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter_color_models/flutter_color_models.dart';
import 'package:flutter_gen/gen_l10n/zulip_localizations.dart';
import 'package:intl/intl.dart';

import '../api/model/model.dart';
import '../model/message_list.dart';
import '../model/narrow.dart';
import '../model/store.dart';
import '../model/typing_status.dart';
import 'action_sheet.dart';
import 'actions.dart';
import 'compose_box.dart';
Expand Down Expand Up @@ -496,17 +498,19 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat
final valueKey = key as ValueKey<int>;
final index = model!.findItemWithMessageId(valueKey.value);
if (index == -1) return null;
return length - 1 - (index - 2);
return length - 1 - (index - 3);
},
childCount: length + 2,
childCount: length + 3,
(context, i) {
// To reinforce that the end of the feed has been reached:
// https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/flutter.3A.20Mark-as-read/near/1680603
if (i == 0) return const SizedBox(height: 36);

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

final data = model!.items[length - 1 - (i - 2)];
if (i == 2) return TypingStatusWidget(narrow: widget.narrow);

final data = model!.items[length - 1 - (i - 3)];
return _buildItem(data, i);
}));

Expand Down Expand Up @@ -609,6 +613,64 @@ class ScrollToBottomButton extends StatelessWidget {
}
}

class TypingStatusWidget extends StatefulWidget {
const TypingStatusWidget({super.key, required this.narrow});

final Narrow narrow;

@override
State<StatefulWidget> createState() => _TypingStatusWidgetState();
}

class _TypingStatusWidgetState extends State<TypingStatusWidget> with PerAccountStoreAwareStateMixin<TypingStatusWidget> {
TypingStatus? model;

@override
void onNewStore() {
model?.removeListener(_modelChanged);
model = PerAccountStoreWidget.of(context).typingStatus
..addListener(_modelChanged);
}

@override
void dispose() {
model?.removeListener(_modelChanged);
super.dispose();
}

void _modelChanged() {
setState(() {
// The actual state lives in [model].
// This method was called because that just changed.
});
}

@override
Widget build(BuildContext context) {
final narrow = widget.narrow;
if (narrow is! SendableNarrow) return const SizedBox();

final store = PerAccountStoreWidget.of(context);
final localizations = ZulipLocalizations.of(context);
final typistIds = model!.typistIdsInNarrow(narrow);
if (typistIds.isEmpty) return const SizedBox();
final text = switch (typistIds.length) {
1 => localizations.onePersonTyping(
store.users[typistIds.first]?.fullName ?? localizations.unknownUserName),
2 => localizations.twoPeopleTyping(
store.users[typistIds.first]?.fullName ?? localizations.unknownUserName,
store.users[typistIds.last]?.fullName ?? localizations.unknownUserName),
_ => localizations.manyPeopleTyping,
};

return Padding(
padding: const EdgeInsetsDirectional.only(start: 16, top: 2),
child: Text(text,
style: const TextStyle(
color: HslColor(0, 0, 53), fontStyle: FontStyle.italic)));
}
}

class MarkAsReadWidget extends StatefulWidget {
const MarkAsReadWidget({super.key, required this.narrow});

Expand Down
64 changes: 64 additions & 0 deletions test/widgets/message_list_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,70 @@ void main() {
});
});

group('TypingStatusWidget', () {
final users = [eg.selfUser, eg.otherUser, eg.thirdUser, eg.fourthUser];
final finder = find.descendant(
of: find.byType(TypingStatusWidget),
matching: find.byType(Text)
);

Future<void> checkTyping(WidgetTester tester, TypingEvent event, {required String expected}) async {
await store.handleEvent(event);
await tester.pump();
check(tester.widget<Text>(finder)).data.equals(expected);
}

final dmMessage = eg.dmMessage(
from: eg.selfUser, to: [eg.otherUser, eg.thirdUser, eg.fourthUser]);
final dmNarrow = DmNarrow.ofMessage(dmMessage, selfUserId: eg.selfUser.userId);

final streamMessage = eg.streamMessage();
final topicNarrow = TopicNarrow.ofMessage(streamMessage);

for (final (description, message, narrow) in [
('typing in dm', dmMessage, dmNarrow),
('typing in topic', streamMessage, topicNarrow),
]) {
testWidgets(description, (tester) async {
await setupMessageListPage(tester,
narrow: narrow, users: users, messages: [message]);
await tester.pump();
check(finder.evaluate()).isEmpty();
await checkTyping(tester,
eg.typingEvent(narrow, TypingOp.start, eg.otherUser.userId),
expected: 'Other User is typing…');
await checkTyping(tester,
eg.typingEvent(narrow, TypingOp.start, eg.selfUser.userId),
expected: 'Other User is typing…');
await checkTyping(tester,
eg.typingEvent(narrow, TypingOp.start, eg.thirdUser.userId),
expected: 'Other User and Third User are typing…');
await checkTyping(tester,
eg.typingEvent(narrow, TypingOp.start, eg.fourthUser.userId),
expected: 'Several people are typing…');
await checkTyping(tester,
eg.typingEvent(narrow, TypingOp.stop, eg.otherUser.userId),
expected: 'Third User and Fourth User are typing…');
// Verify that typing indicators expire after a set duration.
await tester.pump(const Duration(seconds: 15));
check(finder.evaluate()).isEmpty();
});
}

testWidgets('unknown user typing', (tester) async {
final streamMessage = eg.streamMessage();
final narrow = TopicNarrow.ofMessage(streamMessage);
await setupMessageListPage(tester,
narrow: narrow, users: [], messages: [streamMessage]);
await checkTyping(tester,
eg.typingEvent(narrow, TypingOp.start, 1000),
expected: '(unknown user) is typing…',
);
// Wait for the pending timers to end.
await tester.pump(const Duration(seconds: 15));
});
});

group('MarkAsReadWidget', () {
bool isMarkAsReadButtonVisible(WidgetTester tester) {
final zulipLocalizations = GlobalLocalizations.zulipLocalizations;
Expand Down

0 comments on commit 71ef85a

Please sign in to comment.