Skip to content

Show user status in UI #1702

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
Open

Show user status in UI #1702

wants to merge 7 commits into from

Conversation

sm-sayedi
Copy link
Collaborator

The remaining half of #1629. For related images, please see #1629 description.

Fixes: #197

This was referenced Jul 10, 2025
@gnprice gnprice changed the title Track user status - Part 2 Show user status in UI Jul 10, 2025
@gnprice gnprice mentioned this pull request Jul 10, 2025
@gnprice gnprice added the integration review Added by maintainers when PR may be ready for integration label Jul 10, 2025
@gnprice
Copy link
Member

gnprice commented Jul 10, 2025

Copying the review status from #1629 (per #1629 (review)): I've read the first commit except for its tests:
463b938 msglist: Show user status emoji

and the comments I had there have been resolved. Remaining to review are those tests, and the other 4 commits:
14093b2 recent-dms: Show user status emoji in recent DMs page
646f9d9 new-dm: Show user status emoji
846261a autocomplete: Show user status emoji in user-mention autocomplete
e9f682e profile: Show user status

Copy link
Member

@gnprice gnprice left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks again for building this! Comments below.

I've read through the whole of the first commit:
463b938 msglist: Show user status emoji

and the non-test changes in the other commits:
14093b2 recent-dms: Show user status emoji in recent DMs page
646f9d9 new-dm: Show user status emoji
846261a autocomplete: Show user status emoji in user-mention autocomplete
e9f682e profile: Show user status

For this round, I skipped the tests in the later commits because I think a number of my comments on the first commit's tests will apply to those too. So please go ahead and revise the later tests too where applicable.

Comment on lines 55 to 63
/// Finder for [UserStatusEmoji] widget.
///
/// Use [type] to specify the exact emoji child widget. It can be either
/// [UnicodeEmojiWidget] or [ImageEmojiWidget].
Finder findStatusEmoji(Type type) {
assert(type == UnicodeEmojiWidget || type == ImageEmojiWidget);
return find.ancestor(
of: find.byType(type),
matching: find.byType(UserStatusEmoji));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems like it's clearer if just inlined at its call sites.

The implementation (minus the assert, which becomes unnecessary) isn't much longer than the call; and it's more transparent about what it's doing. I also don't feel like this is particularly encapsulating any knowledge of the details of how the UserStatusEmoji widget works.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, after doing that: how about just find.byType(UserStatusEmoji)? It's not clear to me what's gained by adding the condition that the widget have a UnicodeEmojiWidget descendant, or an ImageEmojiWidget descendant.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, after doing that: how about just find.byType(UserStatusEmoji)?

Copying #1629 (comment) here: 🙂

As we discussed in this week’s check-in call, UserStatusEmoji for many cases can contain only SizedBox.shrink(), including when there’s no status set for a user. So only using find.byType(UserStatusEmoji) will pass the tests even when we don’t pass status for a user.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(continued in #1702 (comment) below)

Comment on lines 66 to 68
void checkUserStatusEmoji(Finder emojiFinder, {required bool isAnimated}) {
check((emojiFinder
.evaluate().first.widget as UserStatusEmoji).neverAnimate).equals(!isAnimated);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This also feels like it'd be clearer inlined. The actual check is all about neverAnimate, but the name doesn't sound like that — it sounds like it's going to be doing some other, more general, check.

Comment on lines 67 to 68
check((emojiFinder
.evaluate().first.widget as UserStatusEmoji).neverAnimate).equals(!isAnimated);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of finder.evaluate(), use tester explicitly:

Suggested change
check((emojiFinder
.evaluate().first.widget as UserStatusEmoji).neverAnimate).equals(!isAnimated);
check(tester.firstWidget<UserStatusEmoji>(emojiFinder).neverAnimate)
.equals(!isAnimated);

That way it's conceptually clearer what's going on. (The implementation of finder.evaluate will end up using the same WidgetTester instance anyway, getting hold of it behind the scenes via a singleton.)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The main motivation for not using tester was to minimize the number of params passed to the helper methods (checkStatusEmoji and others in the next commits). Now that it helps in making the tests clearer, so going to use that in the new revision.🙂

@@ -91,6 +110,7 @@ void main() {
if (mutedUserIds != null) {
await store.setMutedUsers(mutedUserIds);
}
await store.changeUserStatuses(userStatuses ?? []);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about having the individual test cases do this? That way we avoid adding yet another feature to this shared helper. (It probably should have fewer features already ­— that'd help make fewer things for the reader of each test case to potentially have to think about.)

Comment on lines 1805 to 1809
userStatuses: [
(
user1.userId,
UserStatusChange(
text: OptionSome('Busy'),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, these tuples are a bit of a pain to read.

How about making changeUserStatuses instead take a Map? That seems semantically appropriate, as there's no reason to have the same user ID more than once in the same call. Then:

Suggested change
userStatuses: [
(
user1.userId,
UserStatusChange(
text: OptionSome('Busy'),
userStatuses: {
user1.userId: UserStatusChange(
text: OptionSome('Busy'),

Flexible(child: labelWidget),
if (option case UserMentionAutocompleteResult(:var userId))
UserStatusEmoji(userId: userId, size: 18,
padding: const EdgeInsetsDirectional.only(start: 5.0))]),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: similarly:

Suggested change
padding: const EdgeInsetsDirectional.only(start: 5.0))]),
padding: const EdgeInsetsDirectional.only(start: 5.0)),
]),

Comment on lines 316 to 323
children: [
labelWidget,
Row(children: [
Flexible(child: labelWidget),
if (option case UserMentionAutocompleteResult(:var userId))
UserStatusEmoji(userId: userId, size: 18,
padding: const EdgeInsetsDirectional.only(start: 5.0))]),
if (sublabelWidget != null) sublabelWidget,
])),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this new logic is best included as part of labelWidget. Conceptually it's closely tied to the user's name, which is part of the label.

Probably in fact the cleanest home for this is in the switch (option) above, which is already the place where we inspect the details of option.

@@ -73,17 +75,28 @@ class ProfilePage extends StatelessWidget {
),
// TODO write a test where the user is muted; check this and avatar
TextSpan(text: store.userDisplayName(userId, replaceIfMuted: false)),
UserStatusEmoji.asWidgetSpan(
userId: userId,
fontSize: 20,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: this says fontSize: 20 but the presence circle above says fontSize: nameStyle.fontSize!; should say the same thing both places, since the intent is for them to be the same

@@ -47,6 +48,7 @@ class ProfilePage extends StatelessWidget {
if (user == null) {
return const _ProfileErrorPage();
}
final userStatus = store.getUserStatus(userId);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: put this next to displayEmail; conceptually they're here for the very same reason

Comment on lines 91 to 92
color: DesignVariables.of(context).userStatusText)),

if (displayEmail != null)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this blank line intended?

The items just above here feel to me closely related to the items just below, and in particular just as closely related as other items that are grouped next to each other.

@sm-sayedi sm-sayedi force-pushed the 197-P2 branch 3 times, most recently from eccaa7a to 686130f Compare July 17, 2025 18:22
@sm-sayedi
Copy link
Collaborator Author

Thanks @gnprice for the detailed review. Revision pushed, PTAL.

Note: CI is failing with the following issue:

Run flutter pub get
Resolving dependencies...
The current Flutter SDK version is 0.0.0-unknown.

Because zulip requires Flutter SDK version >=3.33.0-1.0.pre.832, version solving failed.


You can try the following suggestion to make the pubspec resolve:
* Try using the Flutter SDK version: 3.35.0-0.0.pre. 
Failed to update packages.
Error: Process completed with exit code 1.

I tried pushing several times, but it didn't solve the problem.

@gnprice
Copy link
Member

gnprice commented Jul 17, 2025

Yeah, that CI failure is unrelated. Filed as #1710, and I'll fix it.

Copy link
Member

@gnprice gnprice left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the revision! Just a handful of comments this time. I've read the same portions of the branch as last round, plus the two small new commits.

Comment on lines 1784 to 1786
await store.changeUserStatuses({
user.userId: UserStatusChange(
text: OptionSome('Busy'),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: can be a bit simpler and more compact by calling changeUserStatus directly:

Suggested change
await store.changeUserStatuses({
user.userId: UserStatusChange(
text: OptionSome('Busy'),
await store.changeUserStatus(user.userId, UserStatusChange(
text: OptionSome('Busy'),

Also nice is that that corresponds more directly to what happens in the real server API: each user's status comes in a separate UserStatusEvent.

Comment on lines 1780 to 1783
await setupMessageListPage(tester,
users: [user],
messages: [eg.streamMessage(sender: user)],
);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: can compact a bit vertically:

Suggested change
await setupMessageListPage(tester,
users: [user],
messages: [eg.streamMessage(sender: user)],
);
await setupMessageListPage(tester,
users: [user], messages: [eg.streamMessage(sender: user)]);

Making each test case more compact is helpful for the reader scanning through a given test case to fully understand what it's doing, and especially for scanning through several neighboring test cases to compare them and to think about what situations are covered and what situations there might be that aren't yet.

});
await tester.pump();

checkStatusEmoji(tester, type: ImageEmojiWidget, isPresent: true);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's kind of puzzling to me that this isn't giving an error, actually: if there's an image emoji here, that should be a RealmContentNetworkImage, which should mean the widget tries to load an image from the network. And I'd expect that to throw an error, given that this test case hasn't set debugNetworkImageHttpClientProvider (e.g. by calling prepareBoringImageHttpClient).

Would you try to get to the bottom of that? I wonder if perhaps this widget tree isn't getting fully built in one frame, and another tester.pump is needed in order to fully exercise the code.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The first problem is that it needs another tester.pump, but even with that, it will not throw an error because we have ImageEmojiWidget.errorBuilder set. Nevertheless, added prepareBoringImageHttpClient.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, I see.

How did you determine that it needs another tester.pump — what's missing after the first pump?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Examining the code again, if ImageEmojiWidget.errorBuilder is not provided, there is no need for the second pump. Only with the first pump will the code throw the error.
The last time that I concluded that it needs two pumps is because I used a print statement inside ImageEmojiWidget.errorBuilder with the assumption that if there is an error, then errorBuilder is called. But it is triggered after a setState, which requires the second pump in the test code to see if it is called.

});
await tester.pump();

checkStatusEmoji(tester, type: UnicodeEmojiWidget, isPresent: true);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of find.byType(UnicodeEmojiWidget), how about something like find.text("\u{1f6e0}")? (Continuing from #1702 (comment) .)

That's more nicely end-to-end — it expresses what we're looking for in terms of what the user sees, rather than the way we've organized our code. In this case it makes it a bit more specific; it also means that if we refactor how those widgets work, these tests wouldn't need to change. (Except insofar as we changed how the UI actually looks to the user in ways this is checking for, which is a good reason to need to change it.)

Then I think that opens up a further refactor that would make these a bit more transparent:

Suggested change
checkStatusEmoji(tester, type: UnicodeEmojiWidget, isPresent: true);
checkFindsStatusEmoji(tester, find.text('\u{1f6e0}'));

The isPresent: false call sites could become like:

        check(find.text('\u{1f6e0}')).findsNothing();

…. But actually, looking at them, the test data in those test cases doesn't supply any emoji in the first place. I think it's fine to not have any such check in that case, then — instead we can rely on an assumption that our code isn't going to invent out of whole cloth an emoji to display as the user's status emoji.

Comment on lines 1789 to 1793
final senderRowFinder = find.ancestor(of: nameFinder,
matching: find.ancestor(of: statusEmojiFinder,
matching: find.byType(Row)));
isPresent
? check(senderRowFinder).findsAny()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, that works.

It does introduce some degree of dependence on the internals of how we've organized our widgets (the existence of a SenderRow widget class, and the fact that it should contain the status emoji). But I think the concept of a "sender row" is a pretty stable aspect of our UI, so that the only way that assumption is likely to break is if we're changing the real user-visible UI in a way that alters the user-facing fact that the sender's status emoji belongs on a row together with their name etc.

Comment on lines 112 to 113
title = TextSpan(children: [
TextSpan(text: store.selfUser.fullName),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: can simplify these a bit by using both text and children:

Suggested change
title = TextSpan(children: [
TextSpan(text: store.selfUser.fullName),
title = TextSpan(text: store.selfUser.fullName, children: [

(See the docs for those two TextSpan fields.)

Looking at the TextSpan implementation, I think that saves a small amount of work by eliminating that indirection. There's potentially a lot of these on the screen, so that savings is potentially useful too.

Comment on lines 285 to 292
label = store.userDisplayName(userId);
emoji = UserStatusEmoji(userId: userId, size: 18,
padding: const EdgeInsetsDirectional.only(start: 5.0));
sublabel = store.userDisplayEmail(userId);
case WildcardMentionAutocompleteResult(:var wildcardOption):
avatar = SizedBox.square(dimension: 36,
child: const Icon(ZulipIcons.three_person, size: 24));
emoji = null;
label = wildcardOption.canonicalString;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: order label vs. emoji consistently

(probably label then emoji, to correspond to how they're displayed)

@sm-sayedi sm-sayedi force-pushed the 197-P2 branch 2 times, most recently from b14ee65 to cd4a1a6 Compare July 18, 2025 18:09
@sm-sayedi
Copy link
Collaborator Author

Thanks for the review. New revision pushed.

emojiCode: '1f6e0', reactionType: ReactionType.unicodeEmoji))));
await tester.pump();

checkFindsStatusEmoji(tester, find.text('\u{1f6e0}'));
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For this commit and the following ones, should we make find.text('\u{1f6e0}') an implementation detail of checkFindsStatusEmoji as it stays the same?

@sm-sayedi sm-sayedi requested a review from gnprice July 18, 2025 18:16
Copy link
Member

@gnprice gnprice left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! Comments below on the changes.

Comment on lines 421 to 422
TextSpan(children: [
TextSpan(text: store.userDisplayName(userId)),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same as #1702 (comment)

Comment on lines 1785 to 1787
checkFindsStatusEmoji(tester, find.text('\u{1f6e0}'));
check(find.descendant(of: find.byType(SenderRow),
matching: find.text('Busy'))).findsNothing();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test is a bit stronger if the "finds nothing" check isn't limited by SenderRow:

Suggested change
checkFindsStatusEmoji(tester, find.text('\u{1f6e0}'));
check(find.descendant(of: find.byType(SenderRow),
matching: find.text('Busy'))).findsNothing();
checkFindsStatusEmoji(tester, find.text('\u{1f6e0}'));
check(find.text('Busy')).findsNothing();

With the SenderRow restriction, the reader has to wonder if perhaps the status text is getting shown after all, and just doesn't happen to be within a widget called SenderRow. (Perhaps something changed in the organization of the UI code.)

This is a bit different from the situation at #1702 (comment) (where introducing SenderRow was OK), because the check has the opposite polarity. In that test, adding a SenderRow condition makes the check more demanding — so if the code gets reorganized in a way that breaks the test's implicit assumptions about how it's organized, then the test will fail and we'll know we need to update it. But here, adding SenderRow makes it less demanding — so in that situation the check wouldn't fail, but instead would become vacuous, less able to fail when it should.

text: OptionSome('Coding'),
emoji: OptionSome(StatusEmoji(emojiName: 'zulip',
emojiCode: 'zulip', reactionType: ReactionType.zulipExtraEmoji))));
await tester.pumpAndSettle();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Best to avoid pumpAndSettle: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#avoid-using-pumpandsettle

Can this be two pumps? Better to be explicit that way.

});
await tester.pump();

checkStatusEmoji(tester, type: ImageEmojiWidget, isPresent: true);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, I see.

How did you determine that it needs another tester.pump — what's missing after the first pump?

Comment on lines 1770 to 1772
final senderRowFinder = find.ancestor(of: statusEmojiFinder,
matching: find.byType(SenderRow));
check(senderRowFinder).findsOne();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I think this would be just as clear if inlined, and a bit shorter. It's saying the status emoji has a SenderRow as an ancestor.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Then also can drop the blank line above, and join this function's whole body in one stanza.

Comment on lines 1766 to 1767
check(statusEmojiFinder).findsOne();
check(tester.firstWidget<UserStatusEmoji>(statusEmojiFinder)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: since we're expecting this to find just one widget anyway, can simplify firstWidget to widget:

Suggested change
check(statusEmojiFinder).findsOne();
check(tester.firstWidget<UserStatusEmoji>(statusEmojiFinder)
check(statusEmojiFinder).findsOne();
check(tester.widget<UserStatusEmoji>(statusEmojiFinder)

Copy link
Collaborator Author

@sm-sayedi sm-sayedi left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank @gnprice for the review. New changes pushed.

await tester.pump();

checkFindsStatusEmoji(tester, find.text('\u{1f6e0}'));
check(find.textContaining('Busy')).findsNothing();
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In places where status emoji is used as a span, I used find.textContaining instead of find.text, so if status text is shown in a text span, these tests can detect it.

Comment on lines +331 to +337
checkFindsTileStatusEmoji(tester, user, find.text('\u{1f6e0}'));
check(find.descendant(of: findUserTile(user),
matching: find.textContaining('Busy'))).findsNothing();
check(findUserChip(user)).findsOne();
checkFindsChipStatusEmoji(tester, user, find.text('\u{1f6e0}'));
check(find.descendant(of: findUserChip(user),
matching: find.text('Busy'))).findsNothing();
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I still kept this check for the status texts the same as before (didn't remove find.descendant), as there could be status texts in two places on the same page (one for the user tile, another for the user chip).

@sm-sayedi sm-sayedi requested a review from gnprice July 19, 2025 10:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
integration review Added by maintainers when PR may be ready for integration
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Track user status
2 participants