-
Notifications
You must be signed in to change notification settings - Fork 321
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
Track user status #1629
Conversation
Thanks!! CI is failing; could you take a look? |
There was a problem hiding this 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.
lib/api/model/model.dart
Outdated
@@ -158,6 +158,31 @@ class RealmEmojiItem { | |||
Map<String, dynamic> toJson() => _$RealmEmojiItemToJson(this); | |||
} | |||
|
|||
/// An value in [InitialSnapshot.userStatus]. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: "A value"
lib/api/model/events.dart
Outdated
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; | ||
} |
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
a2a9b53
to
ae0b51f
Compare
There was a problem hiding this 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); |
There was a problem hiding this comment.
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
.
The screenshots look great to me! |
ae0b51f
to
d51cc98
Compare
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. |
There was a problem hiding this 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 plainUserStatusEmoji
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 UserMention
s in WidgetSpan
s 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;
}
lib/api/model/initial_snapshot.dart
Outdated
@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. |
There was a problem hiding this comment.
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
lib/api/model/model.dart
Outdated
/// 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); | ||
} |
There was a problem hiding this comment.
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.
lib/model/user.dart
Outdated
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; | ||
} |
There was a problem hiding this comment.
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.)
lib/widgets/emoji.dart
Outdated
/// Whether to disable the animation for animated emojis. | ||
/// | ||
/// By default, animation is enabled. | ||
final bool noAnimation; |
There was a problem hiding this comment.
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;
lib/widgets/content.dart
Outdated
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(), | ||
}; |
There was a problem hiding this comment.
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}) { |
There was a problem hiding this comment.
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?
lib/widgets/content.dart
Outdated
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))); | ||
} |
There was a problem hiding this comment.
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.
test/widgets/new_dm_sheet_test.dart
Outdated
final statusEmojiFinder = find.ancestor(of: find.byType(UnicodeEmojiWidget), | ||
matching: find.byType(UserStatusEmoji)); |
There was a problem hiding this comment.
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)
test/widgets/new_dm_sheet_test.dart
Outdated
check(findUserChip(user1)).findsNothing(); | ||
check(findUserChip(user2)).findsNothing(); | ||
checkChipStatusEmoji(user1, isPresent: false); | ||
checkChipStatusEmoji(user2, isPresent: false); |
There was a problem hiding this comment.
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.
test/widgets/autocomplete_test.dart
Outdated
final statusEmojiFinder = find.ancestor(of: find.byType(UnicodeEmojiWidget), | ||
matching: find.byType(UserStatusEmoji)); |
There was a problem hiding this comment.
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)
lib/api/model/events.dart
Outdated
/// representing an empty string value in [UserStatusEvent] where no status | ||
/// emoji is selected. | ||
@JsonEnum(fieldRename: FieldRename.snake) | ||
enum UserStatusEventReactionType { |
There was a problem hiding this comment.
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.
lib/api/model/model.dart
Outdated
if (reactionType == null || emojiCode == null || emojiName == null) { | ||
return null; | ||
} else if (reactionType == '' || emojiCode == '' || emojiName == '') { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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 :-)
lib/api/model/model.dart
Outdated
static StatusTextChange? fromApiValue(String? apiValue) => | ||
switch (apiValue) { | ||
null => null, | ||
'' => StatusTextChange(null), | ||
_ => StatusTextChange(apiValue), |
There was a problem hiding this comment.
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?
lib/api/model/model.dart
Outdated
/// The absence of one of these means there is no change. | ||
class StatusTextChange { | ||
final String? newValue; | ||
const StatusTextChange(this.newValue); |
There was a problem hiding this comment.
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.
e4dbf32
to
11ba820
Compare
OK, let's go with the UserStatusChange approach mentioned at #1629 (review) . I've just pushed a revision which
The Option commit should get reordered to earlier in the branch, and the other commit incorporated as Chris mentioned above. |
About |
11ba820
to
89b10fa
Compare
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. Edit: The result of the investigation is shared in #1629 (comment).
|
42dec2d
to
8d30ccd
Compare
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 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks! I think this is close; comments below, and I'll also ask @gnprice to take a look too in case it's almost ready for our release today (or in case he has thoughts on the emoji-size stuff, and lib/api/model tests).
lib/api/model/model.dart
Outdated
} else if (reactionType == '' || emojiCode == '' || emojiName == '') { | ||
// Sometimes `reaction_type` is 'unicode_emoji' when the emoji is cleared. | ||
// This is an accident, to be handled by looking at `emoji_code` instead: | ||
// https://chat.zulip.org/#narrow/channel/378-api-design/topic/user.20status/near/2203132 | ||
return OptionSome(null); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
api: Add InitialSnapshot.userStatuses
Ideally we'd have a test that this server glitch is tolerated, and for the rest of the nontrivial logic in this commit and the commit that adds the event type. Perhaps this can be a followup; @gnprice, what do you think? 🙂
As discussed in a previous review, it's great that we're handling the glitch just once, here at the edge/"crunchy shell". Since we don't let the glitch affect anything deeper in the app code, we don't have to write tests against this glitch in all the places we consume the data 🙂
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Tests are added for the logic in this commit, but for the commit that adds the event type, I think the tests added in 8e7b47a
- "store: Add UserStore.getUserStatus, with event updates" cover them. 🙂
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For the event, I think actually the nontrivial logic that's there doesn't need to be there: #1629 (comment)
(And for the same reason, I think it's not possible to write a reasonable test for it — with or without that bit of logic, an invalid JSON input would produce an exception, so the test would end up having to specify details like what the stack trace of the exception should look like. And those seem like details we don't actually care about specifying.)
test/model/user_test.dart
Outdated
await store.handleEvent(userStatusEvent((5, | ||
'Working remotely', '', '', ''))); | ||
checkUserStatus(store.getUserStatus(5), | ||
('Working remotely', null, null, null)); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's add another stanza like this, checking what happens when the event says both the text and emoji were cleared.
lib/widgets/message_list.dart
Outdated
Padding( | ||
padding: const EdgeInsetsDirectional.only(start: 5.0), | ||
child: UserStatusEmoji(userId: sender.userId, | ||
size: 18, notoColorEmojiTextSize: 15), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for investigating! (#1629 (comment))
What was your experiment that showed "it worked perfectly fine"? 🙂 I'm imagining something like this:
- (Make sure you're testing on Android, with Unicode emoji, since
notoColorEmojiTextSize
has no effect on iOS or with image emoji.) - Choose a
size
. - Multiply that
size
by(17.0 / 14.5)
and try the result fornotoColorEmojiTextSize
. - Open the dev tools to "select widget mode" and confirm that the emoji in fact occupies a
size
-sized square.
If that's successful for several size
values and a particular line-height setting, then it sounds like the widget can compute notoColorEmojiTextSize
for itself (using size * (17.0 / 14.5)
) as long as it always uses that same line-height setting. From reading the dartdoc it does sound like the actual resulting size can be different for different line-height settings, so we should either:
- have the implementation use a constant line height (explicitly overriding a value that might be in an ancestor
DefaultTextStyle
etc.), or - satisfy ourselves that the dartdoc is wrong and fix it. 🙂
- (Or I guess give the widget a line-height param and adjust from that, if we can learn exactly how the line height affects the size.)
See the doc on line heights: https://api.flutter.dev/flutter/painting/TextStyle/height.html
What was the effective line in your testing on the message-list page? I think you can find it in the dev tools:
(Things like Material defaults from Typography
and our own DefaultTextStyle
s might be controlling it, I don't remember all the details offhand.)
lib/widgets/message_list.dart
Outdated
Padding( | ||
padding: const EdgeInsetsDirectional.only(start: 5.0), | ||
child: UserStatusEmoji(userId: sender.userId, | ||
size: 18, notoColorEmojiTextSize: 15), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's OK to leave that refactor for later if it's complicated. I wouldn't want to merge something that accidentally makes us forget how to show emojis at a size from our design specs. 🙂
But the PR should at least maintain the invariant that each emoji's actual size matches size
. In the current world before the refactor, that'll involve opening the devtools to check the size against size
for all the new callsites in the PR, unless you're sure that a given callsite uses the same size
and line height as one you've already confirmed experimentally.
test/widgets/message_list_test.dart
Outdated
final senderRowFinder = find.ancestor( | ||
of: nameFinder, | ||
matching: find.ancestor( | ||
of: statusEmojiFinder, | ||
matching: find.byType(Row), | ||
), | ||
); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: can make this more compact, putting parens on the same line
lib/widgets/new_dm_sheet.dart
Outdated
child: Text.rich( | ||
TextSpan( | ||
children: [ | ||
TextSpan(text: store.userDisplayName(userId)), | ||
UserStatusEmoji.asWidgetSpan(userId: userId, size: 17, | ||
notoColorEmojiTextSize: 14), | ||
] | ||
), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: can be more compact with parens/newlines
lib/widgets/autocomplete.dart
Outdated
Row( | ||
children: [ | ||
Flexible(child: labelWidget), | ||
if (option case UserMentionAutocompleteResult(:var userId)) | ||
Padding( | ||
padding: const EdgeInsetsDirectional.only(start: 5.0), | ||
child: UserStatusEmoji( | ||
userId: userId, | ||
size: 18, | ||
notoColorEmojiTextSize: 15), | ||
) | ||
], | ||
), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: make more compact
I'm just about to prepare today's release, so this is just in time for me to include it as a not-yet-merged PR 🙂. I skimmed the code and also played a bit with the UI, and it looks ready for that. Then that'll give time to discuss the questions above, and otherwise review a bit more, before merging early next week. |
f6d3bf0
to
c8bc5dd
Compare
Thanks @chrisbobbe for the review. New revision pushed with tests added for #1629 (comment). |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks @sm-sayedi for building this, and thanks @chrisbobbe for the previous reviews!
Comments below. I've read through the first 9 commits:
0d9e97a basic: Add a generic Option class
5aa1092 api [nfc]: Add ReactionType.fromApiValue
ea32ee4 api: Add InitialSnapshot.userStatuses
8268c0c api: Add user_status event
a7d9a1d store: Add UserStore.getUserStatus, with event updates
74baf27 emoji [nfc]: Remove UnicodeEmojiWidget.notoColorEmojiTextSize
d27a334 emoji [nfc]: Add ImageEmojiWidget.neverAnimate
b5503f1 content: Add UserStatusEmoji widget
5485c1b test: Add changeUserStatus(es)
to PerAccountStoreTestExtension
and all but the tests in the 10th:
8f00a1b msglist: Show user status emoji
That leaves those tests, plus 4 more commits, for a future round:
454b37b recent-dms: Show user status emoji in recent DMs page
de0d257 new-dm: Show user status emoji
d4b7646 autocomplete: Show user status emoji in user-mention autocomplete
43112fd profile: Show user status
lib/api/model/model.dart
Outdated
Map<String, dynamic> _textToJson(Option<String?> text) { | ||
return { | ||
'status_text': switch (text) { | ||
OptionNone<String?>() => null, | ||
OptionSome<String?>(:var value) => value ?? '', |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This isn't quite accurate — this will lead to 'status_text': null
in the JSON, which (according to the API docs, anyway) never occurs:
status_text: string
If present, the text content of the user's status message.
What does occur is for status_text
to be absent entirely, or to be a string.
Do we need toJson
on this type? On the bulk of the types representing data we get from the server, a toJson
is only ever useful in tests, and for most such types we don't end up using it there either.
Since it's a bit complicated to get right for this type, let's just leave it out. That way if we find ourselves wanting it in the future, we can add it then and spend the effort required to make it accurate. (If we do use it in a test, we'll want it to accurately simulate what the server might send us.)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, I think we need it as UserStatusChange.toJson
is needed by JsonSerializable
for InitialSnapshot.userStatuses
map, when dart run build_runner build --delete-conflicting-outputs
is run. If we don’t include InitialSnapshot.userStatuses
in toJson
by using @JsonKey(includeToJson: false)
, then some tests in test/model/store_test.dart
fail.
Please have a look at the new revision. I think it now handles the absence case.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah I see, in places like here:
test('GlobalStore.perAccount loading succeeds; InitialSnapshot has ancient server version', () => awaitFakeAsync((async) async {
final globalStore = UpdateMachineTestGlobalStore(accounts: [eg.selfAccount]);
final json = eg.initialSnapshot(zulipFeatureLevel: eg.ancientZulipFeatureLevel).toJson();
globalStore.prepareRegisterQueueResponse = (connection) {
connection.prepare(json: json);
};
I think that would still work if this toJson method just threw UnimplementedError, because the minimal boring initial snapshot being used in those tests doesn't contain any user statuses.
But the new implementation looks right, so that's a good solution 🙂
test/api/model/model_test.dart
Outdated
typedef RawStatusChange = (String? statusText, String? emojiName, | ||
String? emojiCode, String? reactionType); | ||
|
||
typedef RipeStatusChange = (Option<String?> text, Option<StatusEmoji?> emoji); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
"ripe" doesn't feel like the right metaphor here — "processed"? "cooked"?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could also inline both these typedefs, and inline mkStatusChange so that the first type isn't needed in two places. I think that mkStatusChange helper is pretty closely tied to the thing doCheck is doing, where it takes its arguments in a very compact form.
test/api/model/model_test.dart
Outdated
incoming: ('', '', '', 'unicode_emoji'), | ||
expected: (OptionSome(null), OptionSome(null))); | ||
|
||
// Hardly to happen from the API standpoint, but we handle it anyways. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
// Hardly to happen from the API standpoint, but we handle it anyways. | |
// Hardly likely to happen from the API standpoint, but we handle it anyways. |
?
lib/api/model/events.dart
Outdated
_$UserStatusEventFromJson(json); | ||
|
||
@override | ||
Map<String, dynamic> toJson() => _$UserStatusEventToJson(this); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm and I guess this isn't accurate either 🙂
Can this throw UnimplementedError? (It can't just be left out, since the base class calls for it. But I suspect that again nothing really uses it.)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
With the new change mentioned in #1629 (comment), and the current revision of UserStatusEvent.toJson
, I think this is accurate now. IMO, if we can make it accurate, it will be better to be there than to replace it with throwing an UnimplementedError. 🙂
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Agreed :-)
lib/model/user.dart
Outdated
|
||
/// The status of the user with the given ID. | ||
/// | ||
/// If no status is set for a user, returns [UserStatus.zero]. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
/// If no status is set for a user, returns [UserStatus.zero]. | |
/// If no status is set for the user, returns [UserStatus.zero]. |
(since it's about this particular user, the one identified by the argument to the method)
lib/widgets/emoji.dart
Outdated
fontSize: notoColorEmojiTextSize, forceStrutHeight: true), | ||
fontSize: notoColorEmojiTextSize, | ||
// Responsible for keeping the line height constant, even | ||
// with ambient DefaultTextStyles. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit:
// with ambient DefaultTextStyles. | |
// with ambient DefaultTextStyle. |
(If you mean the plural, writing it that way is ambiguous because it looks like it means an identifier DefaultTextStyles
. When the plural of some identifier is necessary, can disambiguate with backticks around the identifier part. But usually it works to leave it singular instead — here it ends up meaning "with an ambient DefaultTextStyle", which has effectively the same meaning and in fact is a bit more precisely what we mean anyway.)
lib/widgets/emoji.dart
Outdated
@@ -105,13 +115,20 @@ class ImageEmojiWidget extends StatelessWidget { | |||
|
|||
final ImageErrorWidgetBuilder? errorBuilder; | |||
|
|||
/// Whether to show an animated emoji in its "still (non-animated)" variant |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What's this quoting from?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It’s not quoting from a specific place, but I thought it may avoid confusion for the word “still” as it also means “yet”. But I think it causes another confusion, so removed it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah, I see.
I think putting quotes just around the word "still" would also work:
in its "still" (non-animated) variant
But this revision, with no quote marks, works fine too.
lib/widgets/content.dart
Outdated
alignment: PlaceholderAlignment.middle, | ||
child: Padding( | ||
padding: EdgeInsetsDirectional.only(start: paddingStart, end: paddingEnd), | ||
child: UserStatusEmoji(userId: userId, size: size, neverAnimate: noAnimation))); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: seems like these parameters have the same meaning; use the same name?
child: UserStatusEmoji(userId: userId, size: size, neverAnimate: noAnimation))); | |
child: UserStatusEmoji(userId: userId, size: size, neverAnimate: neverAnimate))); |
lib/widgets/content.dart
Outdated
// If image emoji fails to load, show nothing. | ||
errorBuilder: (_, _, _) => placeholder, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It seems like our other call sites that don't have anything in particular to show in this case just leave this parameter out. What happens if we do that?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If errorBuilder
is not provided, an exception will be reported.
Quoting the related part of documentation for Image.errorBuilder
:
If this builder is not provided, any exceptions will be reported to
[FlutterError.onError]. If it is provided, the caller should either handle
the exception by providing a replacement widget, or rethrow the exception.
In debug mode, it causes the Simulator to hang, unless hot restarted. In release mode, nothing happens, the image is shown empty.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Huh. I wonder how it is that we get away with that in the other call sites, then.
I guess maybe if you're in debug mode and manage to hit an error there, then those other call sites have the exact same problem. And we just haven't noticed because in debug mode one is usually at one's desk with a good connection, and in release mode it's quiet.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, it happens in other places as well. For example, if we navigate to a narrow with a compose box, turn off the internet connection, and then start typing "@Someone" to mention a user, the same problem occurs because there is no errorBuilder
provided in the implementation of AvatarImage
.
lib/widgets/message_list.dart
Outdated
if (sender != null) | ||
Padding( | ||
padding: const EdgeInsetsDirectional.only(start: 5.0), | ||
child: UserStatusEmoji(userId: sender.userId, size: 18)), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We don't actually need the User object here, right? So we can express the user ID the same way as in the neighboring code:
if (sender != null) | |
Padding( | |
padding: const EdgeInsetsDirectional.only(start: 5.0), | |
child: UserStatusEmoji(userId: sender.userId, size: 18)), | |
Padding( | |
padding: const EdgeInsetsDirectional.only(start: 5.0), | |
child: UserStatusEmoji(userId: message.senderId, size: 18)), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
One awkward thing that points at, though, is that we're including the padding even when there's no status emoji to show.
The consequence of that is pretty subtle here. One, it means if there isn't room horizontally for the name, it'll wrap or truncate a bit sooner. Two, if the sender is a bot, there's this extra spacing before the bot icon.
Seems like this might come up in other contexts too, though. Perhaps push this padding inside UserStatusEmoji? The caller can perhaps pass the padding
argument, exactly like it would to Padding
; but then if there's no emoji to show, it doesn't get padded.
Instead of passing this property each time the widget is used, we now calculate it internally. See discussion: zulip#1629 (comment)
43112fd
to
11a8aec
Compare
Thanks @gnprice for the review. Revision pushed, please have a look. |
Instead of passing this property each time the widget is used, we now calculate it internally. See discussion: zulip#1629 (comment)
11a8aec
to
f4e566a
Compare
There was a problem hiding this 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! Those changes all look good, except a few small comments below.
Still ahead for me to read are those last few commits as mentioned at #1629 (review) . I'll try to get to those tomorrow; probably not before today's release.
This has some conflicts with main, I think from #1631 which I just merged. I may try to rebase and merge the finished portion of this PR before the release, and then review on the rest can continue in a follow-up PR (which I'll include in the release unmerged, just like I did with a previous revision of this PR in last week's release).
/// Use [padding] to control the padding of status emoji from neighboring | ||
/// widgets. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
/// Use [padding] to control the padding of status emoji from neighboring | |
/// widgets. | |
/// Use [padding] to control the padding of status emoji from neighboring | |
/// widgets. | |
/// When there is no status emoji to be shown, the padding will be omitted too. |
lib/widgets/content.dart
Outdated
// If image emoji fails to load, show nothing. | ||
errorBuilder: (_, _, _) => placeholder, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Huh. I wonder how it is that we get away with that in the other call sites, then.
I guess maybe if you're in debug mode and manage to hit an error there, then those other call sites have the exact same problem. And we just haven't noticed because in debug mode one is usually at one's desk with a good connection, and in release mode it's quiet.
lib/widgets/content.dart
Outdated
child: UnicodeEmojiWidget( | ||
size: size, emojiDisplay: emojiDisplay), | ||
), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit:
child: UnicodeEmojiWidget( | |
size: size, emojiDisplay: emojiDisplay), | |
), | |
child: UnicodeEmojiWidget( | |
size: size, emojiDisplay: emojiDisplay)), |
lib/widgets/content.dart
Outdated
errorBuilder: (_, _, _) => placeholder, | ||
), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit:
errorBuilder: (_, _, _) => placeholder, | |
), | |
errorBuilder: (_, _, _) => placeholder), |
lib/api/model/events.dart
Outdated
_$UserStatusEventFromJson(json); | ||
|
||
@override | ||
Map<String, dynamic> toJson() => _$UserStatusEventToJson(this); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Agreed :-)
Or @sm-sayedi if you're online, it'd be quite helpful if you can do that rebase :-) |
Sure, doing now. |
Co-authored-by: Greg Price <[email protected]> Co-authored-by: Chris Bobbe <[email protected]>
Co-authored-by: Chris Bobbe <[email protected]>
Co-authored-by: Chris Bobbe <[email protected]>
Instead of passing this property each time the widget is used, we now calculate it internally. See discussion: zulip#1629 (comment)
Co-authored-by: Chris Bobbe <[email protected]>
Co-authored-by: Chris Bobbe <[email protected]>
f4e566a
to
057502d
Compare
Done rebasing, with previous review addressed too. |
Thanks! Those edits look good. I'm going to shorten this PR branch to just the portion that's completed review, and then merge: Please go ahead and send the remaining commits as a follow-up PR: I'll also include them in today's release. |
057502d
to
4b75025
Compare
Support for tracking the user status.
Message List:
Recent DMs Page:
New DM Sheet
User Autocomplete:
Profile Page:
Fixes: #197