Skip to content

Track user status #1629

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 13 commits into
base: main
Choose a base branch
from
Open

Conversation

sm-sayedi
Copy link
Collaborator

@sm-sayedi sm-sayedi commented Jun 24, 2025

Support for tracking the user status.

Message List:

Normal display name Long display name

Recent DMs Page:

Normal display name Long display name

New DM Sheet

Normal display name Long display name

User Autocomplete:

Normal display name Long display name

Profile Page:

Normal display name Normal display name - Dark Long display name

Fixes: #197

@sm-sayedi sm-sayedi added the maintainer review PR ready for review by Zulip maintainers label Jun 24, 2025
@chrisbobbe chrisbobbe self-requested a review June 24, 2025 21:44
@chrisbobbe chrisbobbe self-assigned this Jun 24, 2025
@chrisbobbe
Copy link
Collaborator

Thanks!! CI is failing; could you take a look?

Copy link
Collaborator

@chrisbobbe chrisbobbe left a comment

Choose a reason for hiding this comment

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

Thanks, this is exciting! Comments below on the first 4 commits:

5f8cf0e33 api: Add InitialSnapshot.userStatus
1202dbd14 api [nfc]: Add ReactionType.fromApiValue
99b96b76a api: Add user_status event
f333f8935 store: Add UserStore.getUserStatus, with event updates

This is an area where we're reasonably happy with the zulip-mobile implementation, so I recommend reading it. It handles the nuance I mention below, and it discusses the concept of a "zero value" (or perhaps links to discussion; I don't remember 🙂). Could you take a look and see what might be helpful there? For example I think the model code has a name like "user statuses" (plural); it seems like an API wart that the initial snapshot uses the singular user_status for a map about many users.

@@ -158,6 +158,31 @@ class RealmEmojiItem {
Map<String, dynamic> toJson() => _$RealmEmojiItemToJson(this);
}

/// An value in [InitialSnapshot.userStatus].
Copy link
Collaborator

Choose a reason for hiding this comment

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

nit: "A value"

Comment on lines 735 to 731
static String? _stringFromJson(Object? json) {
final value = json as String;
return value.isNotEmpty ? value : null;
}

static ReactionType? _reactionTypeFromJson(Object? json) {
final value = json as String;
return value.isNotEmpty ? ReactionType.fromApiValue(value) : null;
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

There's a nuance that's not really clear in the API doc, but that we figured out and implemented in zulip-mobile:

The meaning of user_status is really an update to a user's status; it's not a complete representation of what their status is.

In particular, the event's status_text, emoji_name, emoji_code, reaction_type may be null, and null means that that part of the status didn't change. So for example, if your current status is a house emoji and the text "Working remotely", and we handle an event with status_text: null and non-null emoji fields, then your status should then be "Working remotely" with the different (not-house) emoji. The fields could be the empty string, which is a "zero value": if the event has status_text: "", then that means the status text was cleared (set to "zero"), which is a kind of change.

It's tricky to see this behavior, because I think the current web app always includes all the fields when it makes update-status requests. But some clients do send partial updates (maybe zulip-mobile?), and we should avoid misinterpreting e.g. status_text: null as clearing out the status text, when in fact it means the status didn't change.

So concretely, for this code, we should allow the fields to be null (so no json as String), and preserve the distinction between "" and null (so no conversion of "" to null).

Copy link
Collaborator

Choose a reason for hiding this comment

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

Relatedly, we found that user_status in the initial snapshot isn't a full snapshot of everyone's status either. Each entry represents a change from the baseline of the "zero value" status—

{
  away: false, // (which we're ignoring; it's deprecated)
  status_text: '',
  emoji_name: '',
  reaction_type: '',
  emoji_code: ''
}

—which explains why a user with that "zero value" status doesn't appear in the initial snapshot.

Copy link
Collaborator

Choose a reason for hiding this comment

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

@sm-sayedi sm-sayedi force-pushed the 197-track-user-status branch from a2a9b53 to ae0b51f Compare June 26, 2025 05:57
Copy link
Collaborator

@chrisbobbe chrisbobbe 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! Looks like some existing tests are failing, and build_runner at one of the first few commits; do you know why that's happening?

@@ -61,6 +61,7 @@ sealed class Event {
default: return UnexpectedEvent.fromJson(json);
}
// case 'muted_topics': … // TODO(#422) we ignore this feature on older servers
case 'user_status': return UserStatusEvent.fromJson(json);
Copy link
Collaborator

Choose a reason for hiding this comment

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

api: Add user_status event

tools/check build_runner is failing at this commit; I think that can be fixed with tools/check build_runner --fix.

@alya
Copy link
Collaborator

alya commented Jun 26, 2025

The screenshots look great to me!

@sm-sayedi sm-sayedi force-pushed the 197-track-user-status branch from ae0b51f to d51cc98 Compare June 26, 2025 22:53
@sm-sayedi
Copy link
Collaborator Author

Thank you @chrisbobbe for the previous review. It's good to know about the intricate detail in the API you mentioned. A new revision is pushed with tests included too, PTAL.

The CI errors are also solved; thanks for mentioning the suggested fix.

@sm-sayedi sm-sayedi requested a review from chrisbobbe June 26, 2025 23:09
Copy link
Collaborator

@chrisbobbe chrisbobbe left a comment

Choose a reason for hiding this comment

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

Thanks! This looks mostly good except I'd like to tighten up a few things:

  • I'd like to align a bit more with the model code in zulip-mobile. I think it'll be easier for me to show than to write an explanation, so I'll push a WIP commit at the end. If it makes sense, please incorporate it into the earlier commits.
  • In this PR, the status emoji always appears after the user's name, which is a piece of text. Can we use asWidgetSpan everywhere, and leave the plain UserStatusEmoji to be used later, just for #198?
    • With this pattern, it seems like it would be easier to make sure the appearance stays consistent as new callers are added in the future (and among current callers).
    • I think it might mean the status emoji gets cut off sometimes, when a super long name is truncated with "…". Depending on the call site, I'd say either:
      • That's OK; the emoji isn't more important than the part of the name we've already decided to cut off. Or,
      • The name actually shouldn't be getting cut off (or should be getting cut off later), and let's add a TODO to fix that. (The sender row in the message list, I think; possibly other places?)

If centralizing on asWidgetSpan makes sense, then the tests will need a way to find the emoji widget inside rich text. Here's a stab at a helper function for that, inspired by similar code for finding UserMentions in WidgetSpans in message content:

      /// Find a [UserStatusEmoji] widget, if present, that is shown
      /// in a [WidgetSpan] along with a user's name.
      ///
      /// Pass either [fullName] or [fullNameFinder] (not both)
      /// to find the [RenderParagraph] that contains the user's name and may
      /// contain the [UserStatusEmoji].
      ///
      /// If [fullName] is passed, the following Finder is used:
      ///   find.textContaining(fullName, findRichText: true)
      UserStatusEmoji? findStatusEmojiOnName({String? fullName, Finder? fullNameFinder}) {
        assert((fullName == null) != (fullNameFinder == null),
          'pass either fullName or fullNameFinder but not both');
        fullNameFinder ??= find.textContaining(fullName!, findRichText: true);

        final nameRootSpan = tester.renderObject<RenderParagraph>(fullNameFinder).text;

        UserStatusEmoji? result;
        nameRootSpan.visitChildren((span) {
          if (span is! WidgetSpan) return true; // keep looking

          result = tester.widgetList<UserStatusEmoji>(
            find.descendant(
              of: find.byWidget(span.child),
              matching: find.byWidgetPredicate((widget) => widget is UserStatusEmoji),
              matchRoot: true,
            )).singleOrNull;

          if (result == null) {
            return true; // keep looking
          } else {
            return false; // found; stop looking
          }
        });
        return result;
      }

Comment on lines 69 to 71
@JsonKey(name: 'user_status')
// In register-queue, the name of this field is the singular "user_status",
// even though it actually contains user status information for all the users
// that the self-user has access to. Therefore, we prefer to use the plural form.
Copy link
Collaborator

Choose a reason for hiding this comment

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

nit: put the comment above the line that makes it necessary, instead of below

Comment on lines 161 to 273
/// A value in [InitialSnapshot.userStatuses].
///
/// For docs, search for "user_status:"
/// in <https://zulip.com/api/register-queue>.
@JsonSerializable(fieldRename: FieldRename.snake)
class UserStatus {
// final bool? away; // deprecated in server-6 (FL-148); ignore
final String? statusText;
final String? emojiName;
final String? emojiCode;
final ReactionType? reactionType;

UserStatus({
required this.statusText,
required this.emojiName,
required this.emojiCode,
required this.reactionType,
});

factory UserStatus.fromJson(Map<String, dynamic> json) =>
_$UserStatusFromJson(json);

Map<String, dynamic> toJson() => _$UserStatusToJson(this);
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

The emoji-status information is contained in multiple fields, and you've already spotted a surprise accident where the fields can be inconsistent with each other. Thanks for that!

From that API design thread, we know how to handle that inconsistency, but ideally we'd have some code that handles it explicitly at the edge / the "crunchy shell", making it possible to write the "soft center" (PerAccountStore and its consumers) error-free without even needing to be aware of it. I plan to push a draft commit that does that, along with some other suggested changes in how we model the data.

Comment on lines 156 to 175
void handleUserStatusEvent(UserStatusEvent event) {
final UserStatusEvent(
:userId, :statusText, :emojiName, :emojiCode, :reactionType) = event;

final oldStatus = _userStatuses[userId];
// Here's what the different values of a property in the event mean and how
// they affect the corresponding values in the resulting new status:
// - Value is `null` -> property is not changed -> value in the new status
// is the same as in the old status, or `null` if status wasn't set before.
// - Value is empty -> property is cleared -> value in the new status is `null`.
// - Value is not empty -> property is set - value in the new status is the
// same as the value from the event.
final newStatus = UserStatus(
statusText: statusText == null
? oldStatus?.statusText
: statusText.isEmpty
? null
: statusText,
emojiName: emojiName == null
? oldStatus?.emojiName
: emojiName.isEmpty
? null
: emojiName,
emojiCode: emojiCode == null
? oldStatus?.emojiCode
: emojiCode.isEmpty
? null
: emojiCode,
reactionType: reactionType == null
? oldStatus?.reactionType
: reactionType == UserStatusEventReactionType.empty
// Currently, if a status emoji is not selected, the server sends
// "reaction_type: unicode_emoji", so to keep things clean,
// we treat it as `null`.
// See discussion:
// https://chat.zulip.org/#narrow/channel/378-api-design/topic/user.20status/near/2203928
|| (emojiName!.isEmpty)
? null
: ReactionType.fromApiValue(reactionType.toJson()),
);

_userStatuses[event.userId] = newStatus;
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

This can become much shorter with a change in how we model the data; I plan to push a draft commit to demonstrate, as mentioned :)

(And the "keep things clean" logic will be out at the "crunchy shell", as I hinted in a previous comment.)

Comment on lines 109 to 112
/// Whether to disable the animation for animated emojis.
///
/// By default, animation is enabled.
final bool noAnimation;
Copy link
Collaborator

Choose a reason for hiding this comment

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

Let's improve this by making it clear that it's an override, that it's not making the caller responsible for checking the accessibility settings, power-saving settings, etc.

How about something like:

  /// Whether to show an animated emoji in its "still" variant only,
  /// even if device settings permit animation.
  ///
  /// Defaults to false.
  final bool neverAnimate;

Comment on lines 1954 to 2003
return switch (emojiDisplay) {
UnicodeEmojiDisplay() => UnicodeEmojiWidget(
size: size, notoColorEmojiTextSize: notoColorEmojiTextSize,
emojiDisplay: emojiDisplay),
ImageEmojiDisplay() => ImageEmojiWidget(
size: size, emojiDisplay: emojiDisplay, noAnimation: noAnimation),
// If the status emoji has to be displayed as text because it failed to
// load, we ignore it because it would look weird for emojis with longer
// text, in certain places, for example in message list.
TextEmojiDisplay() => SizedBox.shrink(),
};
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think this handles the case where an image emoji's URL doesn't parse, but it doesn't yet handle the case where the network request for the image fails. How about:

    // If an image emoji's URL string doesn't parse or the image fails to load,
    // show nothing. The user-status feature doesn't support a :text_emoji:-
    // style display.
    final placeholder = SizedBox.shrink();

    return switch (emojiDisplay) {
      UnicodeEmojiDisplay() => UnicodeEmojiWidget(
        size: size, notoColorEmojiTextSize: notoColorEmojiTextSize,
        emojiDisplay: emojiDisplay),
      ImageEmojiDisplay() => ImageEmojiWidget(
        size: size,
        emojiDisplay: emojiDisplay,
        neverAnimate: noAnimation,
        errorBuilder: (_, _, _) => placeholder,
      ),
      TextEmojiDisplay() => placeholder,
    };

@@ -182,6 +186,22 @@ void main() {
}
}

void checkStatusEmoji({required bool isVisible, int? count}) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

recent-dms: Show user status emoji in recent DMs page

It looks like count is never passed; can we simplify that away?

Comment on lines 1921 to 1969
static InlineSpan asWidgetSpan({
required int userId,
required double size,
required double notoColorEmojiTextSize,
bool noAnimation = true,
}) {
return WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: Padding(
padding: const EdgeInsetsDirectional.only(start: 4.0),
child: UserStatusEmoji(userId: userId, size: size,
notoColorEmojiTextSize: notoColorEmojiTextSize, noAnimation: noAnimation)));
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

Could you add a dartdoc with some guidance helping callers decide whether to use asWidgetSpan vs. just the UserStatusEmoji constructor? As long as we're putting the status emoji directly after text, it seems like asWidgetSpan is a possible choice, but I don't know whether it's always or only sometimes the right choice. Having the sizing, spacing, and vertical alignment always look right is a goal 🙂

We'll probably need the not-WidgetSpan variant for at least the choose-emoji-status picker, #198, later, but I'm not sure about the sites in this PR.

Comment on lines +283 to +285
final statusEmojiFinder = find.ancestor(of: find.byType(UnicodeEmojiWidget),
matching: find.byType(UserStatusEmoji));
Copy link
Collaborator

Choose a reason for hiding this comment

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

(same comment that sometimes the status emoji won't be a Unicode emoji; here and one more below)

Comment on lines 316 to 319
check(findUserChip(user1)).findsNothing();
check(findUserChip(user2)).findsNothing();
checkChipStatusEmoji(user1, isPresent: false);
checkChipStatusEmoji(user2, isPresent: false);
Copy link
Collaborator

Choose a reason for hiding this comment

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

These checkChipStatusEmoji calls are redundant with the findUserChip(…).findsNothing()s.

Comment on lines +165 to +169
final statusEmojiFinder = find.ancestor(of: find.byType(UnicodeEmojiWidget),
matching: find.byType(UserStatusEmoji));
Copy link
Collaborator

Choose a reason for hiding this comment

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

(same comment that some status emoji won't be Unicode emoji)

/// representing an empty string value in [UserStatusEvent] where no status
/// emoji is selected.
@JsonEnum(fieldRename: FieldRename.snake)
enum UserStatusEventReactionType {
Copy link
Collaborator

Choose a reason for hiding this comment

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

This made sense to add, before I thought of the changes in my WIP commit 🙂 but I think the WIP commit gives a nice way to make this unnecessary, in addition to aligning more with the intended API semantics.

Comment on lines 257 to 223
if (reactionType == null || emojiCode == null || emojiName == null) {
return null;
} else if (reactionType == '' || emojiCode == '' || emojiName == '') {
Copy link
Member

Choose a reason for hiding this comment

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

Can the null case actually happen? That seems at odds with the API docs:
https://zulip.com/api/get-events#user_status
which say these fields are always present, though sometimes the empty string.

Copy link
Collaborator

@chrisbobbe chrisbobbe Jul 1, 2025

Choose a reason for hiding this comment

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

Hmm. We intentionally wrote zulip-mobile to handle the fields being sometimes absent; see type UserStatusUpdate and linked discussion in zulip/zulip-mobile@2106381e4

Copy link
Member

Choose a reason for hiding this comment

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

those should eventually make it into the server API doc

Hmm I see :-)

Comment on lines 226 to 230
static StatusTextChange? fromApiValue(String? apiValue) =>
switch (apiValue) {
null => null,
'' => StatusTextChange(null),
_ => StatusTextChange(apiValue),
Copy link
Member

Choose a reason for hiding this comment

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

Similarly, can this null case actually happen?

Comment on lines 219 to 222
/// The absence of one of these means there is no change.
class StatusTextChange {
final String? newValue;
const StatusTextChange(this.newValue);
Copy link
Member

@gnprice gnprice Jul 1, 2025

Choose a reason for hiding this comment

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

These two classes, and their apply methods, feel fairly boilerplatey to me. I think what's going on can be made clearer by using a generic "option"/"maybe" type. I've just pushed some additional commits as a demo of that approach.

Other than the commit adding Option, those commits are to be squashed into the "WIP add/use UserStatusChange" commit, and/or into the preceding commits that that one would get squashed into.

@gnprice gnprice force-pushed the 197-track-user-status branch from e4dbf32 to 11ba820 Compare July 2, 2025 00:23
@gnprice
Copy link
Member

gnprice commented Jul 2, 2025

OK, let's go with the UserStatusChange approach mentioned at #1629 (review) . I've just pushed a revision which

  • reworks my commit adding an Option class to something more complete;
  • squashes my other changes into Chris's "add/use UserStatusChange" commit.

The Option commit should get reordered to earlier in the branch, and the other commit incorporated as Chris mentioned above.

@gnprice
Copy link
Member

gnprice commented Jul 2, 2025

About asWidgetSpan: I think the current approach, where some call sites use that and others use the widget directly, is fine.

@sm-sayedi sm-sayedi force-pushed the 197-track-user-status branch from 11ba820 to 89b10fa Compare July 2, 2025 21:20
@sm-sayedi
Copy link
Collaborator Author

sm-sayedi commented Jul 2, 2025

Thanks @chrisbobbe and @gnprice for the reviews. I have pushed the new revision, PTAL.

I haven't investigated the suggested solution in #1629 comment yet. I will do that after I get some sleep now.

CI is also failing for 9 tests in test/model/test_store.dart. I looked at it to find the reason, but unfortunately, I couldn't. It fails for 83680b1 "api: Add InitialSnapshot.userStatuses", where we just added the API bindings.

@sm-sayedi sm-sayedi requested a review from chrisbobbe July 2, 2025 22:08
@sm-sayedi sm-sayedi force-pushed the 197-track-user-status branch from 89b10fa to 42dec2d Compare July 3, 2025 06:15
@sm-sayedi sm-sayedi force-pushed the 197-track-user-status branch from 42dec2d to 8d30ccd Compare July 3, 2025 08:06
@sm-sayedi
Copy link
Collaborator Author

sm-sayedi commented Jul 3, 2025

Ok, so the CI failure is solved. The following change had to be made:

class InitialSnapshot {
...
- @JsonKey(name: 'user_status', includeToJson: false)
+ @JsonKey(name: 'user_status')
  final Map<int, UserStatusChange> userStatuses;
...
}

In addition to adding UserStatusChange.toJson.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
maintainer review PR ready for review by Zulip maintainers
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Track user status
4 participants