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
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions lib/api/model/events.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.

case 'user_topic': return UserTopicEvent.fromJson(json);
case 'muted_users': return MutedUsersEvent.fromJson(json);
case 'message': return MessageEvent.fromJson(json);
Expand Down Expand Up @@ -708,6 +709,40 @@ class SubscriptionPeerRemoveEvent extends SubscriptionEvent {
Map<String, dynamic> toJson() => _$SubscriptionPeerRemoveEventToJson(this);
}

/// A Zulip event of type `user_status`: https://zulip.com/api/get-events#user_status
@JsonSerializable(fieldRename: FieldRename.snake)
class UserStatusEvent extends Event {
@override
@JsonKey(includeToJson: true)
String get type => 'user_status';

final int userId;

@JsonKey(readValue: _readChange, fromJson: UserStatusChange.fromJson, includeToJson: false)
final UserStatusChange change;

static Object? _readChange(Map<dynamic, dynamic> json, String key) {
assert(json is Map<String, dynamic>); // value came through `fromJson` with this type
json['status_text'] as String?;
json['reaction_type'] as String?;
json['emoji_code'] as String?;
json['emoji_name'] as String?;
return json;
}

UserStatusEvent({
required super.id,
required this.userId,
required this.change,
});

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

@override
Map<String, dynamic> toJson() => _$UserStatusEventToJson(this);
}

/// A Zulip event of type `user_topic`: https://zulip.com/api/get-events#user_topic
@JsonSerializable(fieldRename: FieldRename.snake)
class UserTopicEvent extends Event {
Expand Down
16 changes: 16 additions & 0 deletions lib/api/model/events.g.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 11 additions & 0 deletions lib/api/model/initial_snapshot.dart
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,16 @@ class InitialSnapshot {

final List<ZulipStream> streams;

// 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.
//
// The API expresses each status as a change from the "zero status" (see
// [UserStatus.zero]), with entries omitted for users whose status is the
// zero status.
@JsonKey(name: 'user_status', includeToJson: false)
final Map<int, UserStatusChange> userStatuses;

// Servers pre-5.0 don't have `user_settings`, and instead provide whatever
// user settings they support at toplevel in the initial snapshot. Since we're
// likely to desupport pre-5.0 servers before wide release, we prefer to
Expand Down Expand Up @@ -154,6 +164,7 @@ class InitialSnapshot {
required this.subscriptions,
required this.unreadMsgs,
required this.streams,
required this.userStatuses,
required this.userSettings,
required this.userTopics,
required this.realmWildcardMentionPolicy,
Expand Down
6 changes: 6 additions & 0 deletions lib/api/model/initial_snapshot.g.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

118 changes: 118 additions & 0 deletions lib/api/model/model.dart
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,124 @@ class RealmEmojiItem {
Map<String, dynamic> toJson() => _$RealmEmojiItemToJson(this);
}

/// A user's status, with [text] and [emoji] parts.
///
/// If a part is null, that part is empty/unset.
/// For a [UserStatus] with all parts empty, see [zero].
class UserStatus {
/// The text part (e.g. 'Working remotely'), or null if unset.
///
/// This won't be the empty string.
final String? text;

/// The emoji part, or null if unset.
final StatusEmoji? emoji;

const UserStatus({required this.text, required this.emoji}) : assert(text != '');

static const UserStatus zero = UserStatus(text: null, emoji: null);
}

/// A user's status emoji, as in [UserStatus.emoji].
class StatusEmoji {
final ReactionType reactionType;
final String emojiCode;
final String emojiName;

const StatusEmoji({
required this.reactionType,
required this.emojiCode,
required this.emojiName,
}) : assert(emojiCode != ''), assert(emojiName != '');
}

/// A change to part or all of a user's status.
///
/// The absence of one of these means there is no change.
class UserStatusChange {
// final AwayStatusChange? away; // deprecated in server-6 (FL-148); ignore
final StatusTextChange? text;
final EmojiStatusChange? emoji;

const UserStatusChange({required this.text, required this.emoji});

factory UserStatusChange.fromJson(Map<String, dynamic> json) {
return UserStatusChange(
text: StatusTextChange.fromApiValue(json['status_text'] as String?),
emoji: EmojiStatusChange.fromJson(json),
);
}

static UserStatus apply(UserStatus old, UserStatusChange change) {
return UserStatus(
text: StatusTextChange.apply(old.text, change.text),
emoji: EmojiStatusChange.apply(old.emoji, change.emoji),
);
}
}

/// A change to a user's status text.
///
/// The absence of one of these means there is no change.
class StatusTextChange {
final String? newValue;
const StatusTextChange(this.newValue);

/// A nullable value, from `status_text` in event- or initial-snapshot JSON
/// for a user.
static StatusTextChange? fromApiValue(String? apiValue) =>
switch (apiValue) {
null => null,
'' => StatusTextChange(null),
_ => StatusTextChange(apiValue),
};

static String? apply(String? old, StatusTextChange? change) =>
switch (change) {
null => old,
StatusTextChange(:final newValue) => newValue,
};
}

/// A change to a user's status emoji.
///
/// The absence of one of these means there is no change.
class EmojiStatusChange {
final StatusEmoji? newValue;

const EmojiStatusChange(this.newValue);

/// A nullable value, from event- or initial-snapshot JSON for a user.
///
/// (This takes the whole JSON map because the meaning is contained in multiple
/// fields, unlike [StatusTextChange] which is represented by a single field.)
static EmojiStatusChange? fromJson(Map<String, dynamic> json) {
final reactionType = json['reaction_type'] as String?;
final emojiCode = json['emoji_code'] as String?;
final emojiName = json['emoji_name'] as String?;

if (reactionType == null || emojiCode == null || emojiName == null) {
return null;
} 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 EmojiStatusChange(null);
} else {
return EmojiStatusChange(StatusEmoji(
reactionType: ReactionType.fromApiValue(reactionType),
emojiCode: emojiCode,
emojiName: emojiName));
}
}

static StatusEmoji? apply(StatusEmoji? old, EmojiStatusChange? change) =>
switch (change) {
null => old,
EmojiStatusChange(:final newValue) => newValue,
};
}

/// The name of a user setting that has a property in [UserSettings].
///
/// In Zulip event-handling code (for [UserSettingsUpdateEvent]),
Expand Down
5 changes: 5 additions & 0 deletions lib/api/model/reaction.dart
Original file line number Diff line number Diff line change
Expand Up @@ -175,4 +175,9 @@ enum ReactionType {
zulipExtraEmoji;

String toJson() => _$ReactionTypeEnumMap[this]!;

static ReactionType fromApiValue(String value) => _byApiValue[value]!;

static final _byApiValue = _$ReactionTypeEnumMap
.map((key, value) => MapEntry(value, key));
}
8 changes: 8 additions & 0 deletions lib/model/store.dart
Original file line number Diff line number Diff line change
Expand Up @@ -665,6 +665,9 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor
bool isUserMuted(int userId, {MutedUsersEvent? event}) =>
_users.isUserMuted(userId, event: event);

@override
UserStatus getUserStatus(int userId) => _users.getUserStatus(userId);

final UserStoreImpl _users;

final TypingStatus typingStatus;
Expand Down Expand Up @@ -926,6 +929,11 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor
_channels.handleSubscriptionEvent(event);
notifyListeners();

case UserStatusEvent():
assert(debugLog("server event: user_status"));
_users.handleUserStatusEvent(event);
notifyListeners();

case UserTopicEvent():
assert(debugLog("server event: user_topic"));
_messages.handleUserTopicEvent(event);
Expand Down
17 changes: 16 additions & 1 deletion lib/model/user.dart
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,9 @@ mixin UserStore on PerAccountStoreBase {
/// Looks for [userId] in a private [Set],
/// or in [event.mutedUsers] instead if event is non-null.
bool isUserMuted(int userId, {MutedUsersEvent? event});

/// The status of the user with the given ID, or `null` if no status is set.
UserStatus getUserStatus(int userId);
}

/// The implementation of [UserStore] that does the work.
Expand All @@ -88,7 +91,9 @@ class UserStoreImpl extends PerAccountStoreBase with UserStore {
.followedBy(initialSnapshot.realmNonActiveUsers)
.followedBy(initialSnapshot.crossRealmBots)
.map((user) => MapEntry(user.userId, user))),
_mutedUsers = Set.from(initialSnapshot.mutedUsers.map((item) => item.id));
_mutedUsers = Set.from(initialSnapshot.mutedUsers.map((item) => item.id)),
_userStatuses = initialSnapshot.userStatuses.map((userId, change) =>
MapEntry(userId, UserStatusChange.apply(UserStatus.zero, change)));

final Map<int, User> _users;

Expand All @@ -105,6 +110,11 @@ class UserStoreImpl extends PerAccountStoreBase with UserStore {
return (event?.mutedUsers.map((item) => item.id) ?? _mutedUsers).contains(userId);
}

final Map<int, UserStatus> _userStatuses;

@override
UserStatus getUserStatus(int userId) => _userStatuses[userId] ?? UserStatus.zero;

void handleRealmUserEvent(RealmUserEvent event) {
switch (event) {
case RealmUserAddEvent():
Expand Down Expand Up @@ -144,6 +154,11 @@ class UserStoreImpl extends PerAccountStoreBase with UserStore {
}
}

void handleUserStatusEvent(UserStatusEvent event) {
_userStatuses[event.userId] =
UserStatusChange.apply(getUserStatus(event.userId), event.change);
}

void handleMutedUsersEvent(MutedUsersEvent event) {
_mutedUsers.clear();
_mutedUsers.addAll(event.mutedUsers.map((item) => item.id));
Expand Down
14 changes: 13 additions & 1 deletion lib/widgets/autocomplete.dart
Original file line number Diff line number Diff line change
Expand Up @@ -315,7 +315,19 @@ class _MentionAutocompleteItem extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
labelWidget,
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),
)
],
),
if (sublabelWidget != null) sublabelWidget,
])),
]));
Expand Down
Loading
Loading