Skip to content

store: Add PerAccountStore.users data structure, as Map<int, User> #84

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

Merged
merged 3 commits into from
May 5, 2023
Merged
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
118 changes: 118 additions & 0 deletions lib/api/model/events.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -61,6 +69,116 @@ class AlertWordsEvent extends Event {
Map<String, dynamic> 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<String, dynamic> json) =>
_$RealmUserAddEventFromJson(json);

@override
Map<String, dynamic> 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<String, dynamic> json) {
return RealmUserRemoveEvent(
id: json['id'] as int,
userId: (json['person'] as Map<String, dynamic>)['user_id'] as int);
}

@override
Map<String, dynamic> 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<String, dynamic> json) =>
_$RealmUserUpdateCustomProfileFieldFromJson(json);

Map<String, dynamic> 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<String, dynamic>)[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<String, dynamic> json) =>
_$RealmUserUpdateEventFromJson(json);

// TODO make round-trip (see _readFromPerson)
@override
Map<String, dynamic> 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].
Expand Down
80 changes: 80 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.

28 changes: 28 additions & 0 deletions lib/api/model/initial_snapshot.dart
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,33 @@ class InitialSnapshot {

final int maxFileUploadSizeMib;

@JsonKey(readValue: _readUsersIsActiveFallbackTrue)
final List<User> realmUsers;
@JsonKey(readValue: _readUsersIsActiveFallbackFalse)
final List<User> realmNonActiveUsers;
@JsonKey(readValue: _readUsersIsActiveFallbackTrue)
final List<User> 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<dynamic>);
for (final Map<String, dynamic> user in list) {
user.putIfAbsent('is_active', () => true);
}
return list;
}
static Object? _readUsersIsActiveFallbackFalse(Map json, String key) {
final list = (json[key] as List<dynamic>);
for (final Map<String, dynamic> user in list) {
user.putIfAbsent('is_active', () => false);
}
return list;
}

InitialSnapshot({
this.queueId,
required this.lastEventId,
Expand All @@ -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<String, dynamic> json) =>
Expand Down
16 changes: 16 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.

86 changes: 86 additions & 0 deletions lib/api/model/model.dart
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,92 @@ class CustomProfileField {
Map<String, dynamic> 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<String, dynamic> json) =>
_$ProfileFieldUserDataFromJson(json);

Map<String, dynamic> 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<int, ProfileFieldUserData>? profileData;
@JsonKey(readValue: _readIsSystemBot)
bool? isSystemBot; // TODO(server-5)

static Map<String, dynamic>? _readProfileData(Map json, String key) {
final value = (json[key] as Map<String, dynamic>?);
// 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<String, dynamic> json) => _$UserFromJson(json);

Map<String, dynamic> toJson() => _$UserToJson(this);
}

/// As in `subscriptions` in the initial snapshot.
@JsonSerializable(fieldRename: FieldRename.snake)
class Subscription {
Expand Down
Loading