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 5, 2024
1 parent dce75e3 commit fc9db66
Show file tree
Hide file tree
Showing 4 changed files with 132 additions and 4 deletions.
9 changes: 9 additions & 0 deletions assets/l10n/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -479,5 +479,14 @@
"senderFullName": {"type": "String", "example": "Alice"},
"numOthers": {"type": "int", "example": "4"}
}
},
"typingIndicator": "{numPeople, plural, =1{{typist} is typing} =2{{typist} and {otherTypist} are typing} other{Several people are typing}}",
"@typingIndicator": {
"description": "Text to display when there are users typing.",
"placeholders": {
"numPeople": {"type": "int", "example": "5"},
"typist": {"type": "String", "example": "Alice"},
"otherTypist": {"type": "String", "example": "Bob"}
}
}
}
2 changes: 1 addition & 1 deletion lib/api/route/events.dart
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ Future<InitialSnapshot> registerQueue(ApiConnection connection) {
'notification_settings_null': true,
'bulk_message_deletion': true,
'user_avatar_url_field_optional': false, // TODO(#254): turn on
'stream_typing_notifications': false, // TODO implement
'stream_typing_notifications': true,
'user_settings_object': true,
},
});
Expand Down
65 changes: 62 additions & 3 deletions lib/widgets/message_list.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import '../api/route/messages.dart';
import '../model/message_list.dart';
import '../model/narrow.dart';
import '../model/store.dart';
import '../model/typing_status.dart';
import 'action_sheet.dart';
import 'compose_box.dart';
import 'content.dart';
Expand Down Expand Up @@ -326,17 +327,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 @@ -510,6 +513,62 @@ class MarkAsReadWidget extends StatelessWidget {
}
}

class _TypingStatusState 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 store = PerAccountStoreWidget.of(context);
final narrow = widget.narrow;
const placeholder = SizedBox(height: 8);
if (narrow is! SendableNarrow) return placeholder;

final typistNames = model!.getTypistIdsInNarrow(narrow)
.where((id) => id != store.selfUserId)
.map((id) => store.users[id]!.fullName)
.toList();
if (typistNames.isEmpty) return placeholder;

final localization = ZulipLocalizations.of(context);
final secondTypist = (typistNames.length > 1) ? typistNames[1] : "";
final String text = localization.typingIndicator(typistNames.length, typistNames[0], secondTypist);

return Padding(
padding: const EdgeInsets.only(left: 16, top: 2),
child: Text(text, textAlign: TextAlign.left),
);
}
}

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

final Narrow narrow;

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

class RecipientHeader extends StatelessWidget {
const RecipientHeader({super.key, required this.message, required this.narrow});

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

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

checkTyping(WidgetTester tester, TypingEvent event, {required List<User> expected}) async {
await store.handleEvent(event);
await tester.pump();
final otherTypist = (expected.length > 1) ? expected[1] : null;
check(finder.evaluate()).single.has((x) => x.widget, 'widget').isA<Text>()
.data.equals(zulipLocalizations.typingIndicator(
expected.length, expected[0].fullName, otherTypist?.fullName ?? ''));
}

testTyping(WidgetTester tester, SendableNarrow narrow) async {
check(finder.evaluate()).isEmpty();
await checkTyping(tester,
eg.typingEvent(narrow, TypingOp.start, eg.otherUser.userId),
expected: [eg.otherUser]);
await checkTyping(tester,
eg.typingEvent(narrow, TypingOp.start, eg.selfUser.userId),
expected: [eg.otherUser]);
await checkTyping(tester,
eg.typingEvent(narrow, TypingOp.start, eg.thirdUser.userId),
expected: [eg.otherUser, eg.thirdUser]);
await checkTyping(tester,
eg.typingEvent(narrow, TypingOp.start, eg.fourthUser.userId),
expected: [eg.otherUser, eg.thirdUser, eg.fourthUser]);
await checkTyping(tester,
eg.typingEvent(narrow, TypingOp.stop, eg.otherUser.userId),
expected: [eg.thirdUser, eg.fourthUser]);
// Verify that typing indicators expire after a set duration.
await tester.pump(const Duration(seconds: 15));
check(finder.evaluate()).isEmpty();
}

testWidgets('typing in dm', (tester) async {
final message = eg.dmMessage(from: eg.otherUser, to: [eg.selfUser]);
final narrow = DmNarrow.withUsers(
users.map((u) => u.userId).toList(),
selfUserId: eg.selfUser.userId);
await setupMessageListPage(tester,
narrow: narrow, users: users, messages: [message]);
await tester.pump();
await testTyping(tester, narrow);
});

testWidgets('typing in topic', (tester) async {
final message = eg.streamMessage();
final narrow = TopicNarrow.ofMessage(message);
await setupMessageListPage(tester,
narrow: narrow, users: users, messages: [message]);
await tester.pump();
await testTyping(tester, narrow);
});
});
}

0 comments on commit fc9db66

Please sign in to comment.