diff --git a/lib/api/model/events.dart b/lib/api/model/events.dart index a8c191e317..d166757d74 100644 --- a/lib/api/model/events.dart +++ b/lib/api/model/events.dart @@ -17,6 +17,14 @@ abstract class Event { final type = json['type'] as String; switch (type) { case 'alert_words': return AlertWordsEvent.fromJson(json); + case 'realm_user': + final op = json['op'] as String; + switch (op) { + case 'add': return RealmUserAddEvent.fromJson(json); + case 'remove': return RealmUserRemoveEvent.fromJson(json); + case 'update': return RealmUserUpdateEvent.fromJson(json); + default: return UnexpectedEvent.fromJson(json); + } case 'message': return MessageEvent.fromJson(json); case 'heartbeat': return HeartbeatEvent.fromJson(json); // TODO add many more event types @@ -61,6 +69,116 @@ class AlertWordsEvent extends Event { Map toJson() => _$AlertWordsEventToJson(this); } +/// A Zulip event of type `realm_user`. +abstract class RealmUserEvent extends Event { + @override + @JsonKey(includeToJson: true) + String get type => 'realm_user'; + + String get op; + + RealmUserEvent({required super.id}); +} + +/// A [RealmUserEvent] with op `add`: https://zulip.com/api/get-events#realm_user-add +@JsonSerializable(fieldRename: FieldRename.snake) +class RealmUserAddEvent extends RealmUserEvent { + @override + String get op => 'add'; + + final User person; + + RealmUserAddEvent({required super.id, required this.person}); + + factory RealmUserAddEvent.fromJson(Map json) => + _$RealmUserAddEventFromJson(json); + + @override + Map toJson() => _$RealmUserAddEventToJson(this); +} + +/// A [RealmUserEvent] with op `remove`: https://zulip.com/api/get-events#realm_user-remove +class RealmUserRemoveEvent extends RealmUserEvent { + @override + String get op => 'remove'; + + final int userId; + + RealmUserRemoveEvent({required super.id, required this.userId}); + + factory RealmUserRemoveEvent.fromJson(Map json) { + return RealmUserRemoveEvent( + id: json['id'] as int, + userId: (json['person'] as Map)['user_id'] as int); + } + + @override + Map toJson() { + return {'id': id, 'type': type, 'op': op, 'person': {'user_id': userId}}; + } +} + +@JsonSerializable(fieldRename: FieldRename.snake) +class RealmUserUpdateCustomProfileField { + final int id; + final String? value; + final String? renderedValue; + + RealmUserUpdateCustomProfileField({required this.id, required this.value, required this.renderedValue}); + + factory RealmUserUpdateCustomProfileField.fromJson(Map json) => + _$RealmUserUpdateCustomProfileFieldFromJson(json); + + Map toJson() => _$RealmUserUpdateCustomProfileFieldToJson(this); +} + +/// A [RealmUserEvent] with op `update`: https://zulip.com/api/get-events#realm_user-update +@JsonSerializable(fieldRename: FieldRename.snake) +class RealmUserUpdateEvent extends RealmUserEvent { + @override + String get op => 'update'; + + @JsonKey(readValue: _readFromPerson) final int userId; + @JsonKey(readValue: _readFromPerson) final String? fullName; + @JsonKey(readValue: _readFromPerson) final String? avatarUrl; + // @JsonKey(readValue: _readFromPerson) final String? avatarSource; // TODO obsolete? + // @JsonKey(readValue: _readFromPerson) final String? avatarUrlMedium; // TODO obsolete? + @JsonKey(readValue: _readFromPerson) final int? avatarVersion; + @JsonKey(readValue: _readFromPerson) final String? timezone; + @JsonKey(readValue: _readFromPerson) final int? botOwnerId; + @JsonKey(readValue: _readFromPerson) final int? role; // TODO enum + @JsonKey(readValue: _readFromPerson) final bool? isBillingAdmin; + @JsonKey(readValue: _readFromPerson) final String? deliveryEmail; // TODO handle JSON `null` + @JsonKey(readValue: _readFromPerson) final RealmUserUpdateCustomProfileField? customProfileField; + @JsonKey(readValue: _readFromPerson) final String? newEmail; + + static Object? _readFromPerson(Map json, String key) { + return (json['person'] as Map)[key]; + } + + RealmUserUpdateEvent({ + required super.id, + required this.userId, + this.fullName, + this.avatarUrl, + this.avatarVersion, + this.timezone, + this.botOwnerId, + this.role, + this.isBillingAdmin, + this.deliveryEmail, + this.customProfileField, + this.newEmail, + }); + + factory RealmUserUpdateEvent.fromJson(Map json) => + _$RealmUserUpdateEventFromJson(json); + + // TODO make round-trip (see _readFromPerson) + @override + Map toJson() => _$RealmUserUpdateEventToJson(this); +} + /// A Zulip event of type `message`. // TODO use [JsonSerializable] here too, using its customization features, // in order to skip the boilerplate in [fromJson] and [toJson]. diff --git a/lib/api/model/events.g.dart b/lib/api/model/events.g.dart index 16a5ba4474..a5427da747 100644 --- a/lib/api/model/events.g.dart +++ b/lib/api/model/events.g.dart @@ -23,6 +23,86 @@ Map _$AlertWordsEventToJson(AlertWordsEvent instance) => 'alert_words': instance.alertWords, }; +RealmUserAddEvent _$RealmUserAddEventFromJson(Map json) => + RealmUserAddEvent( + id: json['id'] as int, + person: User.fromJson(json['person'] as Map), + ); + +Map _$RealmUserAddEventToJson(RealmUserAddEvent instance) => + { + 'id': instance.id, + 'type': instance.type, + 'person': instance.person, + }; + +RealmUserUpdateCustomProfileField _$RealmUserUpdateCustomProfileFieldFromJson( + Map json) => + RealmUserUpdateCustomProfileField( + id: json['id'] as int, + value: json['value'] as String?, + renderedValue: json['rendered_value'] as String?, + ); + +Map _$RealmUserUpdateCustomProfileFieldToJson( + RealmUserUpdateCustomProfileField instance) => + { + 'id': instance.id, + 'value': instance.value, + 'rendered_value': instance.renderedValue, + }; + +RealmUserUpdateEvent _$RealmUserUpdateEventFromJson( + Map json) => + RealmUserUpdateEvent( + id: json['id'] as int, + userId: RealmUserUpdateEvent._readFromPerson(json, 'user_id') as int, + fullName: + RealmUserUpdateEvent._readFromPerson(json, 'full_name') as String?, + avatarUrl: + RealmUserUpdateEvent._readFromPerson(json, 'avatar_url') as String?, + avatarVersion: + RealmUserUpdateEvent._readFromPerson(json, 'avatar_version') as int?, + timezone: + RealmUserUpdateEvent._readFromPerson(json, 'timezone') as String?, + botOwnerId: + RealmUserUpdateEvent._readFromPerson(json, 'bot_owner_id') as int?, + role: RealmUserUpdateEvent._readFromPerson(json, 'role') as int?, + isBillingAdmin: + RealmUserUpdateEvent._readFromPerson(json, 'is_billing_admin') + as bool?, + deliveryEmail: + RealmUserUpdateEvent._readFromPerson(json, 'delivery_email') + as String?, + customProfileField: RealmUserUpdateEvent._readFromPerson( + json, 'custom_profile_field') == + null + ? null + : RealmUserUpdateCustomProfileField.fromJson( + RealmUserUpdateEvent._readFromPerson(json, 'custom_profile_field') + as Map), + newEmail: + RealmUserUpdateEvent._readFromPerson(json, 'new_email') as String?, + ); + +Map _$RealmUserUpdateEventToJson( + RealmUserUpdateEvent instance) => + { + 'id': instance.id, + 'type': instance.type, + 'user_id': instance.userId, + 'full_name': instance.fullName, + 'avatar_url': instance.avatarUrl, + 'avatar_version': instance.avatarVersion, + 'timezone': instance.timezone, + 'bot_owner_id': instance.botOwnerId, + 'role': instance.role, + 'is_billing_admin': instance.isBillingAdmin, + 'delivery_email': instance.deliveryEmail, + 'custom_profile_field': instance.customProfileField, + 'new_email': instance.newEmail, + }; + HeartbeatEvent _$HeartbeatEventFromJson(Map json) => HeartbeatEvent( id: json['id'] as int, diff --git a/lib/api/model/initial_snapshot.dart b/lib/api/model/initial_snapshot.dart index 4feaff093e..3b620a2b67 100644 --- a/lib/api/model/initial_snapshot.dart +++ b/lib/api/model/initial_snapshot.dart @@ -23,8 +23,33 @@ class InitialSnapshot { final int maxFileUploadSizeMib; + @JsonKey(readValue: _readUsersIsActiveFallbackTrue) + final List realmUsers; + @JsonKey(readValue: _readUsersIsActiveFallbackFalse) + final List realmNonActiveUsers; + @JsonKey(readValue: _readUsersIsActiveFallbackTrue) + final List crossRealmBots; + // TODO etc., etc. + // `is_active` is sometimes absent: + // https://chat.zulip.org/#narrow/stream/412-api-documentation/topic/.60is_active.60.20in.20.60.2Fregister.60.20response/near/1371603 + // But for our model it's convenient to always have it; so, fill it in. + static Object? _readUsersIsActiveFallbackTrue(Map json, String key) { + final list = (json[key] as List); + for (final Map user in list) { + user.putIfAbsent('is_active', () => true); + } + return list; + } + static Object? _readUsersIsActiveFallbackFalse(Map json, String key) { + final list = (json[key] as List); + for (final Map user in list) { + user.putIfAbsent('is_active', () => false); + } + return list; + } + InitialSnapshot({ this.queueId, required this.lastEventId, @@ -35,6 +60,9 @@ class InitialSnapshot { required this.customProfileFields, required this.subscriptions, required this.maxFileUploadSizeMib, + required this.realmUsers, + required this.realmNonActiveUsers, + required this.crossRealmBots, }); factory InitialSnapshot.fromJson(Map json) => diff --git a/lib/api/model/initial_snapshot.g.dart b/lib/api/model/initial_snapshot.g.dart index 312439b0d2..6db0420180 100644 --- a/lib/api/model/initial_snapshot.g.dart +++ b/lib/api/model/initial_snapshot.g.dart @@ -25,6 +25,19 @@ InitialSnapshot _$InitialSnapshotFromJson(Map json) => .map((e) => Subscription.fromJson(e as Map)) .toList(), maxFileUploadSizeMib: json['max_file_upload_size_mib'] as int, + realmUsers: + (InitialSnapshot._readUsersIsActiveFallbackTrue(json, 'realm_users') + as List) + .map((e) => User.fromJson(e as Map)) + .toList(), + realmNonActiveUsers: (InitialSnapshot._readUsersIsActiveFallbackFalse( + json, 'realm_non_active_users') as List) + .map((e) => User.fromJson(e as Map)) + .toList(), + crossRealmBots: (InitialSnapshot._readUsersIsActiveFallbackTrue( + json, 'cross_realm_bots') as List) + .map((e) => User.fromJson(e as Map)) + .toList(), ); Map _$InitialSnapshotToJson(InitialSnapshot instance) => @@ -38,4 +51,7 @@ Map _$InitialSnapshotToJson(InitialSnapshot instance) => 'custom_profile_fields': instance.customProfileFields, 'subscriptions': instance.subscriptions, 'max_file_upload_size_mib': instance.maxFileUploadSizeMib, + 'realm_users': instance.realmUsers, + 'realm_non_active_users': instance.realmNonActiveUsers, + 'cross_realm_bots': instance.crossRealmBots, }; diff --git a/lib/api/model/model.dart b/lib/api/model/model.dart index 49e0274946..e3ecd2a2d9 100644 --- a/lib/api/model/model.dart +++ b/lib/api/model/model.dart @@ -31,6 +31,92 @@ class CustomProfileField { Map toJson() => _$CustomProfileFieldToJson(this); } +@JsonSerializable(fieldRename: FieldRename.snake) +class ProfileFieldUserData { + final String value; + final String? renderedValue; + + ProfileFieldUserData({required this.value, this.renderedValue}); + + factory ProfileFieldUserData.fromJson(Map json) => + _$ProfileFieldUserDataFromJson(json); + + Map toJson() => _$ProfileFieldUserDataToJson(this); +} + +/// As in [InitialSnapshot.realmUsers], [InitialSnapshot.realmNonActiveUsers], and [InitialSnapshot.crossRealmBots]. +/// +/// In the Zulip API, the items in realm_users, realm_non_active_users, and +/// cross_realm_bots are all extremely similar. They differ only in that +/// cross_realm_bots has is_system_bot. +/// +/// https://zulip.com/api/register-queue#response +@JsonSerializable(fieldRename: FieldRename.snake) +class User { + final int userId; + @JsonKey(name: 'delivery_email') + String? deliveryEmailStaleDoNotUse; // TODO see [RealmUserUpdateEvent.deliveryEmail] + String email; + String fullName; + String dateJoined; + bool isActive; // Really sometimes absent in /register, but we normalize that away; see [InitialSnapshot.realmUsers]. + bool isOwner; + bool isAdmin; + bool isGuest; + bool? isBillingAdmin; // TODO(server-5) + bool isBot; + int? botType; // TODO enum + int? botOwnerId; + int role; // TODO enum + String timezone; + String? avatarUrl; // TODO distinguish null from missing https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/flutter.3A.20omitted.20vs.2E.20null.20in.20JSON/near/1551759 + int avatarVersion; + // null for bots, which don't have custom profile fields. + // If null for a non-bot, equivalent to `{}` (null just written for efficiency.) + @JsonKey(readValue: _readProfileData) + Map? profileData; + @JsonKey(readValue: _readIsSystemBot) + bool? isSystemBot; // TODO(server-5) + + static Map? _readProfileData(Map json, String key) { + final value = (json[key] as Map?); + // Represent `{}` as `null`, to avoid allocating a huge number + // of LinkedHashMap data structures that we can do without. + // A hash table is inevitably going to involve some overhead + // (several words, at minimum), even when nothing's stored in it yet. + return (value != null && value.isNotEmpty) ? value : null; + } + + static bool? _readIsSystemBot(Map json, String key) => + json[key] ?? json['is_cross_realm_bot']; + + User({ + required this.userId, + required this.deliveryEmailStaleDoNotUse, + required this.email, + required this.fullName, + required this.dateJoined, + required this.isActive, + required this.isOwner, + required this.isAdmin, + required this.isGuest, + required this.isBillingAdmin, + required this.isBot, + this.botType, + this.botOwnerId, + required this.role, + required this.timezone, + required this.avatarUrl, + required this.avatarVersion, + this.profileData, + this.isSystemBot, + }); + + factory User.fromJson(Map json) => _$UserFromJson(json); + + Map toJson() => _$UserToJson(this); +} + /// As in `subscriptions` in the initial snapshot. @JsonSerializable(fieldRename: FieldRename.snake) class Subscription { diff --git a/lib/api/model/model.g.dart b/lib/api/model/model.g.dart index 864b708012..00d522edb3 100644 --- a/lib/api/model/model.g.dart +++ b/lib/api/model/model.g.dart @@ -30,6 +30,70 @@ Map _$CustomProfileFieldToJson(CustomProfileField instance) => 'display_in_profile_summary': instance.displayInProfileSummary, }; +ProfileFieldUserData _$ProfileFieldUserDataFromJson( + Map json) => + ProfileFieldUserData( + value: json['value'] as String, + renderedValue: json['rendered_value'] as String?, + ); + +Map _$ProfileFieldUserDataToJson( + ProfileFieldUserData instance) => + { + 'value': instance.value, + 'rendered_value': instance.renderedValue, + }; + +User _$UserFromJson(Map json) => User( + userId: json['user_id'] as int, + deliveryEmailStaleDoNotUse: json['delivery_email'] as String?, + email: json['email'] as String, + fullName: json['full_name'] as String, + dateJoined: json['date_joined'] as String, + isActive: json['is_active'] as bool, + isOwner: json['is_owner'] as bool, + isAdmin: json['is_admin'] as bool, + isGuest: json['is_guest'] as bool, + isBillingAdmin: json['is_billing_admin'] as bool?, + isBot: json['is_bot'] as bool, + botType: json['bot_type'] as int?, + botOwnerId: json['bot_owner_id'] as int?, + role: json['role'] as int, + timezone: json['timezone'] as String, + avatarUrl: json['avatar_url'] as String?, + avatarVersion: json['avatar_version'] as int, + profileData: + (User._readProfileData(json, 'profile_data') as Map?) + ?.map( + (k, e) => MapEntry(int.parse(k), + ProfileFieldUserData.fromJson(e as Map)), + ), + isSystemBot: User._readIsSystemBot(json, 'is_system_bot') as bool?, + ); + +Map _$UserToJson(User instance) => { + 'user_id': instance.userId, + 'delivery_email': instance.deliveryEmailStaleDoNotUse, + 'email': instance.email, + 'full_name': instance.fullName, + 'date_joined': instance.dateJoined, + 'is_active': instance.isActive, + 'is_owner': instance.isOwner, + 'is_admin': instance.isAdmin, + 'is_guest': instance.isGuest, + 'is_billing_admin': instance.isBillingAdmin, + 'is_bot': instance.isBot, + 'bot_type': instance.botType, + 'bot_owner_id': instance.botOwnerId, + 'role': instance.role, + 'timezone': instance.timezone, + 'avatar_url': instance.avatarUrl, + 'avatar_version': instance.avatarVersion, + 'profile_data': + instance.profileData?.map((k, e) => MapEntry(k.toString(), e)), + 'is_system_bot': instance.isSystemBot, + }; + Subscription _$SubscriptionFromJson(Map json) => Subscription( streamId: json['stream_id'] as int, name: json['name'] as String, diff --git a/lib/model/store.dart b/lib/model/store.dart index fe86f414f5..2673234e8c 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -147,6 +147,10 @@ class PerAccountStore extends ChangeNotifier { required this.connection, required InitialSnapshot initialSnapshot, }) : zulipVersion = initialSnapshot.zulipVersion, + users = Map.fromEntries(initialSnapshot.realmUsers + .followedBy(initialSnapshot.realmNonActiveUsers) + .followedBy(initialSnapshot.crossRealmBots) + .map((user) => MapEntry(user.userId, user))), subscriptions = Map.fromEntries(initialSnapshot.subscriptions.map( (subscription) => MapEntry(subscription.streamId, subscription))), maxFileUploadSizeMib = initialSnapshot.maxFileUploadSizeMib; @@ -155,6 +159,7 @@ class PerAccountStore extends ChangeNotifier { final ApiConnection connection; final String zulipVersion; + final Map users; final Map subscriptions; final int maxFileUploadSizeMib; // No event for this. @@ -188,6 +193,39 @@ class PerAccountStore extends ChangeNotifier { } else if (event is AlertWordsEvent) { debugPrint("server event: alert_words"); // We don't yet store this data, so there's nothing to update. + } else if (event is RealmUserAddEvent) { + debugPrint("server event: realm_user/add"); + users[event.person.userId] = event.person; + notifyListeners(); + } else if (event is RealmUserRemoveEvent) { + debugPrint("server event: realm_user/remove"); + users.remove(event.userId); + notifyListeners(); + } else if (event is RealmUserUpdateEvent) { + debugPrint("server event: realm_user/update"); + final user = users[event.userId]; + if (user == null) { + return; // TODO log + } + if (event.fullName != null) user.fullName = event.fullName!; + if (event.avatarUrl != null) user.avatarUrl = event.avatarUrl!; + if (event.avatarVersion != null) user.avatarVersion = event.avatarVersion!; + if (event.timezone != null) user.timezone = event.timezone!; + if (event.botOwnerId != null) user.botOwnerId = event.botOwnerId!; + if (event.role != null) user.role = event.role!; + if (event.isBillingAdmin != null) user.isBillingAdmin = event.isBillingAdmin!; + if (event.deliveryEmail != null) user.deliveryEmailStaleDoNotUse = event.deliveryEmail!; + if (event.newEmail != null) user.email = event.newEmail!; + if (event.customProfileField != null) { + final profileData = (user.profileData ??= {}); + final update = event.customProfileField!; + if (update.value != null) { + profileData[update.id] = ProfileFieldUserData(value: update.value!, renderedValue: update.renderedValue); + } else { + profileData.remove(update.id); + } + } + notifyListeners(); } else if (event is MessageEvent) { debugPrint("server event: message ${jsonEncode(event.message.toJson())}"); for (final view in _messageListViews) { diff --git a/test/api/model/model_test.dart b/test/api/model/model_test.dart new file mode 100644 index 0000000000..1ac0b511b0 --- /dev/null +++ b/test/api/model/model_test.dart @@ -0,0 +1,47 @@ +import 'package:checks/checks.dart'; +import 'package:test/scaffolding.dart'; +import 'package:zulip/api/model/model.dart'; + +void main() { + group('User', () { + final Map baseJson = Map.unmodifiable({ + 'user_id': 123, + 'delivery_email': 'name@example.com', + 'email': 'name@example.com', + 'full_name': 'A User', + 'date_joined': '2023-04-28', + 'is_active': true, + 'is_owner': false, + 'is_admin': false, + 'is_guest': false, + 'is_billing_admin': false, + 'is_bot': false, + 'role': 400, + 'timezone': 'UTC', + 'avatar_version': 0, + 'profile_data': {}, + }); + + User mkUser (Map specialJson) { + return User.fromJson({ ...baseJson, ...specialJson }); + } + + test('delivery_email', () { + check(mkUser({'delivery_email': 'name@email.com'}).deliveryEmailStaleDoNotUse) + .equals('name@email.com'); + }); + + test('profile_data', () { + check(mkUser({'profile_data': {}}).profileData).isNull(); + check(mkUser({'profile_data': null}).profileData).isNull(); + check(mkUser({'profile_data': {'1': {'value': 'foo'}}}).profileData) + .isNotNull().deepEquals({1: it()}); + }); + + test('is_system_bot', () { + check(mkUser({}).isSystemBot).isNull(); + check(mkUser({'is_cross_realm_bot': true}).isSystemBot).equals(true); + check(mkUser({'is_system_bot': true}).isSystemBot).equals(true); + }); + }); +} diff --git a/test/example_data.dart b/test/example_data.dart index 1e6365d696..c9d314bb38 100644 --- a/test/example_data.dart +++ b/test/example_data.dart @@ -83,4 +83,7 @@ final InitialSnapshot initialSnapshot = InitialSnapshot( customProfileFields: [], subscriptions: [], // TODO add subscriptions to example initial snapshot maxFileUploadSizeMib: 25, + realmUsers: [], + realmNonActiveUsers: [], + crossRealmBots: [], );