diff --git a/lib/api/model/events.dart b/lib/api/model/events.dart index 344be5950f..fd806a4ff1 100644 --- a/lib/api/model/events.dart +++ b/lib/api/model/events.dart @@ -22,6 +22,7 @@ sealed class Event { case 'update': return UserSettingsUpdateEvent.fromJson(json); default: return UnexpectedEvent.fromJson(json); } + case 'custom_profile_fields': return CustomProfileFieldsEvent.fromJson(json); case 'realm_user': switch (json['op'] as String) { case 'add': return RealmUserAddEvent.fromJson(json); @@ -130,6 +131,24 @@ class UserSettingsUpdateEvent extends Event { Map toJson() => _$UserSettingsUpdateEventToJson(this); } +/// A Zulip event of type `custom_profile_fields`: https://zulip.com/api/get-events#custom_profile_fields +@JsonSerializable(fieldRename: FieldRename.snake) +class CustomProfileFieldsEvent extends Event { + @override + @JsonKey(includeToJson: true) + String get type => 'custom_profile_fields'; + + final List fields; + + CustomProfileFieldsEvent({required super.id, required this.fields}); + + factory CustomProfileFieldsEvent.fromJson(Map json) => + _$CustomProfileFieldsEventFromJson(json); + + @override + Map toJson() => _$CustomProfileFieldsEventToJson(this); +} + /// A Zulip event of type `realm_user`. /// /// The corresponding API docs are in several places for @@ -211,7 +230,7 @@ class RealmUserUpdateEvent extends RealmUserEvent { @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, unknownEnumValue: UserRole.unknown) final UserRole? role; @JsonKey(readValue: _readFromPerson) final bool? isBillingAdmin; @JsonKey(readValue: _readFromPerson) final String? deliveryEmail; // TODO handle JSON `null` @JsonKey(readValue: _readFromPerson) final RealmUserUpdateCustomProfileField? customProfileField; diff --git a/lib/api/model/events.g.dart b/lib/api/model/events.g.dart index 53aff1c708..b84f6991db 100644 --- a/lib/api/model/events.g.dart +++ b/lib/api/model/events.g.dart @@ -48,6 +48,23 @@ const _$UserSettingNameEnumMap = { UserSettingName.emojiset: 'emojiset', }; +CustomProfileFieldsEvent _$CustomProfileFieldsEventFromJson( + Map json) => + CustomProfileFieldsEvent( + id: json['id'] as int, + fields: (json['fields'] as List) + .map((e) => CustomProfileField.fromJson(e as Map)) + .toList(), + ); + +Map _$CustomProfileFieldsEventToJson( + CustomProfileFieldsEvent instance) => + { + 'id': instance.id, + 'type': instance.type, + 'fields': instance.fields, + }; + RealmUserAddEvent _$RealmUserAddEventFromJson(Map json) => RealmUserAddEvent( id: json['id'] as int, @@ -92,7 +109,9 @@ RealmUserUpdateEvent _$RealmUserUpdateEventFromJson( RealmUserUpdateEvent._readFromPerson(json, 'timezone') as String?, botOwnerId: RealmUserUpdateEvent._readFromPerson(json, 'bot_owner_id') as int?, - role: RealmUserUpdateEvent._readFromPerson(json, 'role') as int?, + role: $enumDecodeNullable( + _$UserRoleEnumMap, RealmUserUpdateEvent._readFromPerson(json, 'role'), + unknownValue: UserRole.unknown), isBillingAdmin: RealmUserUpdateEvent._readFromPerson(json, 'is_billing_admin') as bool?, @@ -128,6 +147,15 @@ Map _$RealmUserUpdateEventToJson( 'new_email': instance.newEmail, }; +const _$UserRoleEnumMap = { + UserRole.owner: 100, + UserRole.administrator: 200, + UserRole.moderator: 300, + UserRole.member: 400, + UserRole.guest: 600, + UserRole.unknown: null, +}; + StreamCreateEvent _$StreamCreateEventFromJson(Map json) => StreamCreateEvent( id: json['id'] as int, diff --git a/lib/api/model/initial_snapshot.dart b/lib/api/model/initial_snapshot.dart index b7a4db0ccd..30e49c0cee 100644 --- a/lib/api/model/initial_snapshot.dart +++ b/lib/api/model/initial_snapshot.dart @@ -23,8 +23,6 @@ class InitialSnapshot { final List customProfileFields; - // TODO etc., etc. - final List recentPrivateConversations; final List subscriptions; @@ -41,6 +39,8 @@ class InitialSnapshot { // TODO(server-5) remove pre-5.0 comment final UserSettings? userSettings; // TODO(server-5) + final Map realmDefaultExternalAccounts; + final int maxFileUploadSizeMib; @JsonKey(readValue: _readUsersIsActiveFallbackTrue) @@ -84,6 +84,7 @@ class InitialSnapshot { required this.unreadMsgs, required this.streams, required this.userSettings, + required this.realmDefaultExternalAccounts, required this.maxFileUploadSizeMib, required this.realmUsers, required this.realmNonActiveUsers, @@ -96,6 +97,30 @@ class InitialSnapshot { Map toJson() => _$InitialSnapshotToJson(this); } +/// An item in `realm_default_external_accounts`. +/// +/// For docs, search for "realm_default_external_accounts:" +/// in . +@JsonSerializable(fieldRename: FieldRename.snake) +class RealmDefaultExternalAccount { + final String name; + final String text; + final String hint; + final String urlPattern; + + RealmDefaultExternalAccount({ + required this.name, + required this.text, + required this.hint, + required this.urlPattern, + }); + + factory RealmDefaultExternalAccount.fromJson(Map json) => + _$RealmDefaultExternalAccountFromJson(json); + + Map toJson() => _$RealmDefaultExternalAccountToJson(this); +} + /// An item in `recent_private_conversations`. /// /// For docs, search for "recent_private_conversations:" diff --git a/lib/api/model/initial_snapshot.g.dart b/lib/api/model/initial_snapshot.g.dart index 2dfd11d335..66af6d685c 100644 --- a/lib/api/model/initial_snapshot.g.dart +++ b/lib/api/model/initial_snapshot.g.dart @@ -37,6 +37,11 @@ InitialSnapshot _$InitialSnapshotFromJson(Map json) => ? null : UserSettings.fromJson( json['user_settings'] as Map), + realmDefaultExternalAccounts: + (json['realm_default_external_accounts'] as Map).map( + (k, e) => MapEntry( + k, RealmDefaultExternalAccount.fromJson(e as Map)), + ), maxFileUploadSizeMib: json['max_file_upload_size_mib'] as int, realmUsers: (InitialSnapshot._readUsersIsActiveFallbackTrue(json, 'realm_users') @@ -67,12 +72,31 @@ Map _$InitialSnapshotToJson(InitialSnapshot instance) => 'unread_msgs': instance.unreadMsgs, 'streams': instance.streams, 'user_settings': instance.userSettings, + 'realm_default_external_accounts': instance.realmDefaultExternalAccounts, 'max_file_upload_size_mib': instance.maxFileUploadSizeMib, 'realm_users': instance.realmUsers, 'realm_non_active_users': instance.realmNonActiveUsers, 'cross_realm_bots': instance.crossRealmBots, }; +RealmDefaultExternalAccount _$RealmDefaultExternalAccountFromJson( + Map json) => + RealmDefaultExternalAccount( + name: json['name'] as String, + text: json['text'] as String, + hint: json['hint'] as String, + urlPattern: json['url_pattern'] as String, + ); + +Map _$RealmDefaultExternalAccountToJson( + RealmDefaultExternalAccount instance) => + { + 'name': instance.name, + 'text': instance.text, + 'hint': instance.hint, + 'url_pattern': instance.urlPattern, + }; + RecentDmConversation _$RecentDmConversationFromJson( Map json) => RecentDmConversation( diff --git a/lib/api/model/model.dart b/lib/api/model/model.dart index b74a713f6f..12d61ccafa 100644 --- a/lib/api/model/model.dart +++ b/lib/api/model/model.dart @@ -9,7 +9,8 @@ part 'model.g.dart'; @JsonSerializable(fieldRename: FieldRename.snake) class CustomProfileField { final int id; - final int type; // TODO enum; also TODO(server-6) a value added + @JsonKey(unknownEnumValue: CustomProfileFieldType.unknown) + final CustomProfileFieldType type; final int order; final String name; final String hint; @@ -32,17 +33,72 @@ class CustomProfileField { Map toJson() => _$CustomProfileFieldToJson(this); } +/// As in [CustomProfileField.type]. +@JsonEnum(fieldRename: FieldRename.snake, valueField: "apiValue") +enum CustomProfileFieldType { + shortText(apiValue: 1), + longText(apiValue: 2), + choice(apiValue: 3), + date(apiValue: 4), + link(apiValue: 5), + user(apiValue: 6), + externalAccount(apiValue: 7), + pronouns(apiValue: 8), // TODO(server-6) newly added + unknown(apiValue: null); + + const CustomProfileFieldType({ + required this.apiValue + }); + + final int? apiValue; + + int? toJson() => apiValue; +} + +/// An item in the realm-level field data for a "choice" custom profile field. +/// +/// The value of [CustomProfileField.fieldData] decodes to a +/// `List` when +/// the [CustomProfileField.type] is [CustomProfileFieldType.choice]. +/// +/// TODO(server): This isn't really documented. But see chat thread: +/// https://chat.zulip.org/#narrow/stream/378-api-design/topic/custom.20profile.20fields/near/1383005 @JsonSerializable(fieldRename: FieldRename.snake) -class ProfileFieldUserData { - final String value; - final String? renderedValue; +class CustomProfileFieldChoiceDataItem { + final String text; - ProfileFieldUserData({required this.value, this.renderedValue}); + const CustomProfileFieldChoiceDataItem({required this.text}); - factory ProfileFieldUserData.fromJson(Map json) => - _$ProfileFieldUserDataFromJson(json); + factory CustomProfileFieldChoiceDataItem.fromJson(Map json) => + _$CustomProfileFieldChoiceDataItemFromJson(json); - Map toJson() => _$ProfileFieldUserDataToJson(this); + Map toJson() => _$CustomProfileFieldChoiceDataItemToJson(this); + + static Map parseFieldDataChoices(Map json) => + json.map((k, v) => MapEntry(k, CustomProfileFieldChoiceDataItem.fromJson(v))); +} + +/// The realm-level field data for an "external account" custom profile field. +/// +/// This is the decoding of [CustomProfileField.fieldData] when +/// the [CustomProfileField.type] is [CustomProfileFieldType.externalAccount]. +/// +/// TODO(server): This is undocumented. See chat thread: +/// https://chat.zulip.org/#narrow/stream/378-api-design/topic/external.20account.20custom.20profile.20fields/near/1387213 +@JsonSerializable(fieldRename: FieldRename.snake) +class CustomProfileFieldExternalAccountData { + final String subtype; + final String? urlPattern; + + const CustomProfileFieldExternalAccountData({ + required this.subtype, + required this.urlPattern, + }); + + factory CustomProfileFieldExternalAccountData.fromJson(Map json) => + _$CustomProfileFieldExternalAccountDataFromJson(json); + + Map toJson() => _$CustomProfileFieldExternalAccountDataToJson(this); } /// As in [InitialSnapshot.realmUsers], [InitialSnapshot.realmNonActiveUsers], and [InitialSnapshot.crossRealmBots]. @@ -69,7 +125,8 @@ class User { bool isBot; int? botType; // TODO enum int? botOwnerId; - int role; // TODO enum + @JsonKey(unknownEnumValue: UserRole.unknown) + UserRole role; 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; @@ -120,6 +177,39 @@ class User { Map toJson() => _$UserToJson(this); } +/// As in [User.profileData]. +@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 [User.role]. +@JsonEnum(valueField: "apiValue") +enum UserRole{ + owner(apiValue: 100), + administrator(apiValue: 200), + moderator(apiValue: 300), + member(apiValue: 400), + guest(apiValue: 600), + unknown(apiValue: null); + + const UserRole({ + required this.apiValue, + }); + + final int? apiValue; + + int? toJson() => apiValue; +} + /// As in `streams` in the initial snapshot. /// /// Not called `Stream` because dart:async uses that name. diff --git a/lib/api/model/model.g.dart b/lib/api/model/model.g.dart index e8d1e7eb24..0c32fe2a95 100644 --- a/lib/api/model/model.g.dart +++ b/lib/api/model/model.g.dart @@ -11,7 +11,8 @@ part of 'model.dart'; CustomProfileField _$CustomProfileFieldFromJson(Map json) => CustomProfileField( id: json['id'] as int, - type: json['type'] as int, + type: $enumDecode(_$CustomProfileFieldTypeEnumMap, json['type'], + unknownValue: CustomProfileFieldType.unknown), order: json['order'] as int, name: json['name'] as String, hint: json['hint'] as String, @@ -30,18 +31,43 @@ Map _$CustomProfileFieldToJson(CustomProfileField instance) => 'display_in_profile_summary': instance.displayInProfileSummary, }; -ProfileFieldUserData _$ProfileFieldUserDataFromJson( +const _$CustomProfileFieldTypeEnumMap = { + CustomProfileFieldType.shortText: 1, + CustomProfileFieldType.longText: 2, + CustomProfileFieldType.choice: 3, + CustomProfileFieldType.date: 4, + CustomProfileFieldType.link: 5, + CustomProfileFieldType.user: 6, + CustomProfileFieldType.externalAccount: 7, + CustomProfileFieldType.pronouns: 8, + CustomProfileFieldType.unknown: null, +}; + +CustomProfileFieldChoiceDataItem _$CustomProfileFieldChoiceDataItemFromJson( Map json) => - ProfileFieldUserData( - value: json['value'] as String, - renderedValue: json['rendered_value'] as String?, + CustomProfileFieldChoiceDataItem( + text: json['text'] as String, ); -Map _$ProfileFieldUserDataToJson( - ProfileFieldUserData instance) => +Map _$CustomProfileFieldChoiceDataItemToJson( + CustomProfileFieldChoiceDataItem instance) => { - 'value': instance.value, - 'rendered_value': instance.renderedValue, + 'text': instance.text, + }; + +CustomProfileFieldExternalAccountData + _$CustomProfileFieldExternalAccountDataFromJson( + Map json) => + CustomProfileFieldExternalAccountData( + subtype: json['subtype'] as String, + urlPattern: json['url_pattern'] as String?, + ); + +Map _$CustomProfileFieldExternalAccountDataToJson( + CustomProfileFieldExternalAccountData instance) => + { + 'subtype': instance.subtype, + 'url_pattern': instance.urlPattern, }; User _$UserFromJson(Map json) => User( @@ -58,7 +84,8 @@ User _$UserFromJson(Map json) => User( isBot: json['is_bot'] as bool, botType: json['bot_type'] as int?, botOwnerId: json['bot_owner_id'] as int?, - role: json['role'] as int, + role: $enumDecode(_$UserRoleEnumMap, json['role'], + unknownValue: UserRole.unknown), timezone: json['timezone'] as String, avatarUrl: json['avatar_url'] as String?, avatarVersion: json['avatar_version'] as int, @@ -94,6 +121,29 @@ Map _$UserToJson(User instance) => { 'is_system_bot': instance.isSystemBot, }; +const _$UserRoleEnumMap = { + UserRole.owner: 100, + UserRole.administrator: 200, + UserRole.moderator: 300, + UserRole.member: 400, + UserRole.guest: 600, + UserRole.unknown: null, +}; + +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, + }; + ZulipStream _$ZulipStreamFromJson(Map json) => ZulipStream( streamId: json['stream_id'] as int, name: json['name'] as String, diff --git a/lib/model/store.dart b/lib/model/store.dart index 9676402d5a..630b6dce00 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -151,6 +151,8 @@ class PerAccountStore extends ChangeNotifier { required InitialSnapshot initialSnapshot, }) : zulipVersion = initialSnapshot.zulipVersion, maxFileUploadSizeMib = initialSnapshot.maxFileUploadSizeMib, + realmDefaultExternalAccounts = initialSnapshot.realmDefaultExternalAccounts, + customProfileFields = _sortCustomProfileFields(initialSnapshot.customProfileFields), userSettings = initialSnapshot.userSettings, users = Map.fromEntries( initialSnapshot.realmUsers @@ -172,6 +174,8 @@ class PerAccountStore extends ChangeNotifier { // Data attached to the realm or the server. final String zulipVersion; // TODO get from account; update there on initial snapshot final int maxFileUploadSizeMib; // No event for this. + final Map realmDefaultExternalAccounts; + List customProfileFields; // Data attached to the self-account on the realm. final UserSettings? userSettings; // TODO(server-5) @@ -234,6 +238,10 @@ class PerAccountStore extends ChangeNotifier { userSettings?.emojiset = event.value as Emojiset; } notifyListeners(); + } else if (event is CustomProfileFieldsEvent) { + assert(debugLog("server event: custom_profile_fields")); + customProfileFields = _sortCustomProfileFields(event.fields); + notifyListeners(); } else if (event is RealmUserAddEvent) { assert(debugLog("server event: realm_user/add")); users[event.person.userId] = event.person; @@ -267,6 +275,10 @@ class PerAccountStore extends ChangeNotifier { } else { profileData.remove(update.id); } + if (profileData.isEmpty) { + // null is equivalent to `{}` for efficiency; see [User._readProfileData]. + user.profileData = null; + } } autocompleteViewManager.handleRealmUserUpdateEvent(event); notifyListeners(); @@ -315,6 +327,21 @@ class PerAccountStore extends ChangeNotifier { // https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/.23M3881.20Sending.20outbox.20messages.20is.20fraught.20with.20issues/near/1405739 return _apiSendMessage(connection, destination: destination, content: content); } + + static List _sortCustomProfileFields(List initialCustomProfileFields) { + // TODO(server): The realm-wide field objects have an `order` property, + // but the actual API appears to be that the fields should be shown in + // the order they appear in the array (`custom_profile_fields` in the + // API; our `realmFields` array here.) See chat thread: + // https://chat.zulip.org/#narrow/stream/378-api-design/topic/custom.20profile.20fields/near/1382982 + // + // We go on to put at the start of the list any fields that are marked for + // displaying in the "profile summary". (Possibly they should be at the + // start of the list in the first place, but make sure just in case.) + final displayFields = initialCustomProfileFields.where((e) => e.displayInProfileSummary == true); + final nonDisplayFields = initialCustomProfileFields.where((e) => e.displayInProfileSummary != true); + return displayFields.followedBy(nonDisplayFields).toList(); + } } const _apiSendMessage = sendMessage; // Bit ugly; for alternatives, see: https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/flutter.3A.20PerAccountStore.20methods/near/1545809 diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart index 16c791e229..92bc193cb1 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -845,7 +845,7 @@ class AvatarImage extends StatelessWidget { }; return (resolvedUrl == null) ? const SizedBox.shrink() - : RealmContentNetworkImage(resolvedUrl, filterQuality: FilterQuality.medium); + : RealmContentNetworkImage(resolvedUrl, filterQuality: FilterQuality.medium, fit: BoxFit.cover); } } diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index 41df2fece4..f4b4718f7f 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -14,6 +14,7 @@ import 'compose_box.dart'; import 'content.dart'; import 'icons.dart'; import 'page.dart'; +import 'profile.dart'; import 'sticky_header.dart'; import 'store.dart'; @@ -580,14 +581,22 @@ class MessageWithSender extends StatelessWidget { child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.fromLTRB(3, 6, 11, 0), - child: Avatar(userId: message.senderId, size: 35, borderRadius: 4)), + child: GestureDetector( + onTap: () => Navigator.push(context, + ProfilePage.buildRoute(context: context, + userId: message.senderId)), + child: Avatar(userId: message.senderId, size: 35, borderRadius: 4))), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ const SizedBox(height: 3), - Text(message.senderFullName, // TODO get from user data - style: const TextStyle(fontWeight: FontWeight.bold)), + GestureDetector( + onTap: () => Navigator.push(context, + ProfilePage.buildRoute(context: context, + userId: message.senderId)), + child: Text(message.senderFullName, // TODO get from user data + style: const TextStyle(fontWeight: FontWeight.bold))), const SizedBox(height: 4), MessageContent(message: message, content: content), ])), diff --git a/lib/widgets/profile.dart b/lib/widgets/profile.dart new file mode 100644 index 0000000000..bca6a0922d --- /dev/null +++ b/lib/widgets/profile.dart @@ -0,0 +1,261 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; + +import '../api/model/model.dart'; +import '../model/content.dart'; +import '../model/narrow.dart'; +import 'content.dart'; +import 'message_list.dart'; +import 'page.dart'; +import 'store.dart'; + +class _TextStyles { + static const primaryFieldText = TextStyle(fontSize: 20); + static const customProfileFieldLabel = TextStyle(fontSize: 15, fontWeight: FontWeight.bold); + static const customProfileFieldText = TextStyle(fontSize: 15); +} + +class ProfilePage extends StatelessWidget { + const ProfilePage({super.key, required this.userId}); + + final int userId; + + static Route buildRoute({required BuildContext context, required int userId}) { + return MaterialAccountWidgetRoute(context: context, + page: ProfilePage(userId: userId)); + } + + @override + Widget build(BuildContext context) { + final store = PerAccountStoreWidget.of(context); + final user = store.users[userId]; + if (user == null) { + return const _ProfileErrorPage(); + } + + final items = [ + Center( + child: Avatar(userId: userId, size: 200, borderRadius: 200 / 8)), + const SizedBox(height: 16), + Text(user.fullName, + textAlign: TextAlign.center, + style: _TextStyles.primaryFieldText.merge(const TextStyle(fontWeight: FontWeight.bold))), + // TODO(#291) render email field + Text(roleToLabel(user.role), + textAlign: TextAlign.center, + style: _TextStyles.primaryFieldText), + // TODO(#197) render user status + // TODO(#196) render active status + // TODO(#292) render user local time + + _ProfileDataTable(profileData: user.profileData), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: () => Navigator.push(context, + MessageListPage.buildRoute(context: context, + narrow: DmNarrow.withUser(userId, selfUserId: store.account.userId))), + icon: const Icon(Icons.email), + label: const Text('Send direct message')), + ]; + + return Scaffold( + appBar: AppBar(title: Text(user.fullName)), + body: SingleChildScrollView( + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 760), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: items)))))); + } +} + +class _ProfileErrorPage extends StatelessWidget { + const _ProfileErrorPage(); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Error')), + body: const SingleChildScrollView( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 32), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.error), + SizedBox(width: 4), + Text('Could not show user profile.'), + ])))); + } +} + +String roleToLabel(UserRole role) { + return switch (role) { + UserRole.owner => 'Owner', + UserRole.administrator => 'Administrator', + UserRole.moderator => 'Moderator', + UserRole.member => 'Member', + UserRole.guest => 'Guest', + UserRole.unknown => 'Unknown', + }; +} + +class _ProfileDataTable extends StatelessWidget { + const _ProfileDataTable({required this.profileData}); + + final Map? profileData; + + static T? _tryDecode(T Function(U) fromJson, String data) { + try { + return fromJson(jsonDecode(data)); + } on FormatException { + return null; + } on TypeError { + return null; + } + } + + Widget? _buildCustomProfileFieldValue(BuildContext context, String value, CustomProfileField realmField) { + final store = PerAccountStoreWidget.of(context); + + switch (realmField.type) { + case CustomProfileFieldType.link: + return _LinkWidget(url: value, text: value); + + case CustomProfileFieldType.choice: + final choiceFieldData = _tryDecode(CustomProfileFieldChoiceDataItem.parseFieldDataChoices, realmField.fieldData); + if (choiceFieldData == null) return null; + final choiceItem = choiceFieldData[value]; + return (choiceItem == null) ? null : _TextWidget(text: choiceItem.text); + + case CustomProfileFieldType.externalAccount: + final externalAccountFieldData = _tryDecode(CustomProfileFieldExternalAccountData.fromJson, realmField.fieldData); + if (externalAccountFieldData == null) return null; + final urlPattern = externalAccountFieldData.urlPattern ?? + store.realmDefaultExternalAccounts[externalAccountFieldData.subtype]?.urlPattern; + if (urlPattern == null) return null; + final url = urlPattern.replaceFirst('%(username)s', value); + return _LinkWidget(url: url, text: value); + + case CustomProfileFieldType.user: + // TODO(server): This is completely undocumented. The key to + // reverse-engineering it was: + // https://github.com/zulip/zulip/blob/18230fcd9/static/js/settings_account.js#L247 + final userIds = _tryDecode((List json) { + return json.map((e) => e as int).toList(); + }, value); + if (userIds == null) return null; + return Column( + children: userIds.map((userId) => _UserWidget(userId: userId)).toList()); + + case CustomProfileFieldType.date: + // TODO(server): The value's format is undocumented, but empirically + // it's a date in ISO format, like 2000-01-01. + // That's readable as is, but: + // TODO format this date using user's locale. + return _TextWidget(text: value); + + case CustomProfileFieldType.shortText: + case CustomProfileFieldType.longText: + case CustomProfileFieldType.pronouns: + // The web client appears to treat `longText` identically to `shortText`; + // `pronouns` is explicitly meant to display the same as `shortText`. + return _TextWidget(text: value); + + case CustomProfileFieldType.unknown: + return null; + } + } + + @override + Widget build(BuildContext context) { + final store = PerAccountStoreWidget.of(context); + if (profileData == null) return const SizedBox.shrink(); + + List items = []; + + for (final realmField in store.customProfileFields) { + final profileField = profileData![realmField.id]; + if (profileField == null) continue; + final widget = _buildCustomProfileFieldValue(context, profileField.value, realmField); + if (widget == null) continue; // TODO(log) + + items.add(Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + SizedBox(width: 96, + child: Text(realmField.name, style: _TextStyles.customProfileFieldLabel)), + const SizedBox(width: 8), + Flexible(child: widget), + ])); + items.add(const SizedBox(height: 8)); + } + + if (items.isEmpty) return const SizedBox.shrink(); + + return Column(children: [ + const SizedBox(height: 16), + ...items + ]); + } +} + +class _LinkWidget extends StatelessWidget { + const _LinkWidget({required this.url, required this.text}); + + final String url; + final String text; + + @override + Widget build(BuildContext context) { + final linkNode = LinkNode(url: url, nodes: [TextNode(text)]); + final paragraph = Paragraph(node: ParagraphNode(nodes: [linkNode], links: [linkNode])); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: paragraph)); + } +} + +class _TextWidget extends StatelessWidget { + const _TextWidget({required this.text}); + + final String text; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Text(text, style: _TextStyles.customProfileFieldText)); + } +} + +class _UserWidget extends StatelessWidget { + const _UserWidget({required this.userId}); + + final int userId; + + @override + Widget build(BuildContext context) { + final store = PerAccountStoreWidget.of(context); + final user = store.users[userId]; + final fullName = user?.fullName ?? '(unknown user)'; + return InkWell( + onTap: () => Navigator.push(context, + ProfilePage.buildRoute(context: context, + userId: userId)), + child: Padding( + padding: const EdgeInsets.all(8), + child: Row(children: [ + Avatar(userId: userId, size: 32, borderRadius: 32 / 8), + const SizedBox(width: 8), + Expanded(child: Text(fullName, style: _TextStyles.customProfileFieldText)), // TODO(#196) render active status + ]))); + } +} diff --git a/test/api/model/model_test.dart b/test/api/model/model_test.dart index e97293427b..06aaae0c74 100644 --- a/test/api/model/model_test.dart +++ b/test/api/model/model_test.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:checks/checks.dart'; import 'package:test/scaffolding.dart'; import 'package:zulip/api/model/model.dart'; @@ -7,6 +9,20 @@ import '../../stdlib_checks.dart'; import 'model_checks.dart'; void main() { + test('CustomProfileFieldChoiceDataItem', () { + const input = '''{ + "0": {"text": "Option 0", "order": 1}, + "1": {"text": "Option 1", "order": 2}, + "2": {"text": "Option 2", "order": 3} + }'''; + final choices = CustomProfileFieldChoiceDataItem.parseFieldDataChoices(jsonDecode(input)); + check(choices).jsonEquals({ + '0': const CustomProfileFieldChoiceDataItem(text: 'Option 0'), + '1': const CustomProfileFieldChoiceDataItem(text: 'Option 1'), + '2': const CustomProfileFieldChoiceDataItem(text: 'Option 2'), + }); + }); + group('User', () { final Map baseJson = Map.unmodifiable({ 'user_id': 123, diff --git a/test/example_data.dart b/test/example_data.dart index cca991c8bd..bedac38e1a 100644 --- a/test/example_data.dart +++ b/test/example_data.dart @@ -15,6 +15,7 @@ User user({ String? email, String? fullName, String? avatarUrl, + Map? profileData, }) { return User( userId: userId ?? 123, // TODO generate example IDs @@ -28,11 +29,11 @@ User user({ isGuest: false, isBillingAdmin: false, isBot: false, - role: 400, + role: UserRole.member, timezone: 'UTC', avatarUrl: avatarUrl, avatarVersion: 0, - profileData: null, + profileData: profileData, ); } @@ -247,6 +248,7 @@ InitialSnapshot initialSnapshot({ UnreadMessagesSnapshot? unreadMsgs, List? streams, UserSettings? userSettings, + Map? realmDefaultExternalAccounts, int? maxFileUploadSizeMib, List? realmUsers, List? realmNonActiveUsers, @@ -265,6 +267,7 @@ InitialSnapshot initialSnapshot({ unreadMsgs: unreadMsgs ?? _unreadMsgs(), streams: streams ?? [], // TODO add streams to default userSettings: userSettings, // TODO add userSettings to default + realmDefaultExternalAccounts: realmDefaultExternalAccounts ?? {}, maxFileUploadSizeMib: maxFileUploadSizeMib ?? 25, realmUsers: realmUsers ?? [], realmNonActiveUsers: realmNonActiveUsers ?? [], diff --git a/test/widgets/profile_page_checks.dart b/test/widgets/profile_page_checks.dart new file mode 100644 index 0000000000..bc08b43ec1 --- /dev/null +++ b/test/widgets/profile_page_checks.dart @@ -0,0 +1,6 @@ +import 'package:checks/checks.dart'; +import 'package:zulip/widgets/profile.dart'; + +extension ProfilePageChecks on Subject { + Subject get userId => has((x) => x.userId, 'userId'); +} diff --git a/test/widgets/profile_test.dart b/test/widgets/profile_test.dart new file mode 100644 index 0000000000..37e029047c --- /dev/null +++ b/test/widgets/profile_test.dart @@ -0,0 +1,323 @@ +import 'package:checks/checks.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:zulip/api/model/initial_snapshot.dart'; +import 'package:zulip/api/model/model.dart'; +import 'package:zulip/model/narrow.dart'; +import 'package:zulip/widgets/content.dart'; +import 'package:zulip/widgets/message_list.dart'; +import 'package:zulip/widgets/page.dart'; +import 'package:zulip/widgets/profile.dart'; +import 'package:zulip/widgets/store.dart'; + +import '../example_data.dart' as eg; +import '../model/binding.dart'; +import '../model/test_store.dart'; +import '../test_navigation.dart'; +import 'message_list_checks.dart'; +import 'page_checks.dart'; +import 'profile_page_checks.dart'; + +Future setupPage(WidgetTester tester, { + required int pageUserId, + List? users, + List? customProfileFields, + Map? realmDefaultExternalAccounts, + NavigatorObserver? navigatorObserver, +}) async { + addTearDown(testBinding.reset); + + final initialSnapshot = eg.initialSnapshot( + customProfileFields: customProfileFields, + realmDefaultExternalAccounts: realmDefaultExternalAccounts); + await testBinding.globalStore.add(eg.selfAccount, initialSnapshot); + final store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + + store.addUser(eg.selfUser); + if (users != null) { + store.addUsers(users); + } + + await tester.pumpWidget( + GlobalStoreWidget( + child: MaterialApp( + navigatorObservers: navigatorObserver != null ? [navigatorObserver] : [], + home: PerAccountStoreWidget( + accountId: eg.selfAccount.id, + child: ProfilePage(userId: pageUserId))))); + + // global store, per-account store, and page get loaded + await tester.pumpAndSettle(); +} + +CustomProfileField mkCustomProfileField( + int id, + CustomProfileFieldType type, { + int? order, + bool? displayInProfileSummary, + String? fieldData, +}) { + return CustomProfileField( + id: id, + type: type, + order: order ?? id, + name: 'field$id', + hint: 'hint$id', + fieldData: fieldData ?? '', + displayInProfileSummary: displayInProfileSummary ?? true, + ); +} + +void main() { + TestZulipBinding.ensureInitialized(); + + group('ProfilePage', () { + testWidgets('page builds; profile page renders', (WidgetTester tester) async { + final user = eg.user(userId: 1, fullName: 'test user'); + + await setupPage(tester, users: [user], pageUserId: user.userId); + + check(because: 'find user avatar', find.byType(Avatar).evaluate()).length.equals(1); + check(because: 'find user name', find.text('test user').evaluate()).isNotEmpty(); + }); + + testWidgets('page builds; profile page renders with profileData', (WidgetTester tester) async { + await setupPage(tester, + users: [ + eg.user(userId: 1, profileData: { + 0: ProfileFieldUserData(value: 'shortTextValue'), + 1: ProfileFieldUserData(value: 'longTextValue'), + 2: ProfileFieldUserData(value: 'x'), + 3: ProfileFieldUserData(value: 'dateValue'), + 4: ProfileFieldUserData(value: 'http://example/linkValue'), + 5: ProfileFieldUserData(value: '[2]'), + 6: ProfileFieldUserData(value: 'externalValue'), + 7: ProfileFieldUserData(value: 'pronounsValue'), + }), + eg.user(userId: 2, fullName: 'userValue'), + ], + pageUserId: 1, + customProfileFields: [ + mkCustomProfileField(0, CustomProfileFieldType.shortText), + mkCustomProfileField(1, CustomProfileFieldType.longText), + mkCustomProfileField(2, CustomProfileFieldType.choice, + fieldData: '{"x": {"text": "choiceValue", "order": "1"}}'), + mkCustomProfileField(3, CustomProfileFieldType.date), + mkCustomProfileField(4, CustomProfileFieldType.link), + mkCustomProfileField(5, CustomProfileFieldType.user), + mkCustomProfileField(6, CustomProfileFieldType.externalAccount, + fieldData: '{"subtype": "external1"}'), + mkCustomProfileField(7, CustomProfileFieldType.pronouns), + ], realmDefaultExternalAccounts: { + 'external1': RealmDefaultExternalAccount( + name: 'external1', + text: '', + hint: '', + urlPattern: 'https://example/%(username)s')}); + + final testCases = [ + (find.text('field0'), find.text('shortTextValue'), CustomProfileFieldType.shortText), + (find.text('field1'), find.text('longTextValue'), CustomProfileFieldType.longText), + (find.text('field2'), find.text('choiceValue'), CustomProfileFieldType.choice), + (find.text('field3'), find.text('dateValue'), CustomProfileFieldType.date), + (find.text('field4'), find.text('http://example/linkValue'), CustomProfileFieldType.link), + (find.text('field5'), find.text('userValue'), CustomProfileFieldType.user), + (find.text('field6'), find.text('externalValue'), CustomProfileFieldType.externalAccount), + (find.text('field7'), find.text('pronounsValue'), CustomProfileFieldType.pronouns), + ]; + for (final testCase in testCases) { + Finder labelFinder = testCase.$1; + Finder fieldFinder = testCase.$2; + CustomProfileFieldType testCaseType = testCase.$3; + check( + because: 'find label for $testCaseType', + labelFinder.evaluate().length + ).equals(1); + check( + because: 'find field for $testCaseType', + fieldFinder.evaluate().length + ).equals(1); + } + final avatars = tester.widgetList(find.byType(Avatar)); + check(avatars.map((w) => w.userId).toList()) + .deepEquals([1, 2]); + }); + + testWidgets('page builds; error page shows up if data is missing', (WidgetTester tester) async { + await setupPage(tester, pageUserId: eg.selfUser.userId + 1989); + check(because: 'find no user avatar', find.byType(Avatar).evaluate()).isEmpty(); + check(because: 'find error icon', find.byIcon(Icons.error).evaluate()).isNotEmpty(); + }); + + testWidgets('page builds; link type will navigate', (WidgetTester tester) async { + const testUrl = 'http://example/url'; + final user = eg.user(userId: 1, profileData: { + 0: ProfileFieldUserData(value: testUrl), + }); + + await setupPage(tester, + users: [user], + pageUserId: user.userId, + customProfileFields: [mkCustomProfileField(0, CustomProfileFieldType.link)], + ); + + await tester.tap(find.text(testUrl)); + final expectedMode = defaultTargetPlatform == TargetPlatform.android ? + LaunchMode.externalApplication : LaunchMode.platformDefault; + check(testBinding.takeLaunchUrlCalls()) + .single.equals((url: Uri.parse(testUrl), mode: expectedMode)); + }); + + testWidgets('page builds; external link type navigates away', (WidgetTester tester) async { + final user = eg.user(userId: 1, profileData: { + 0: ProfileFieldUserData(value: 'externalValue'), + }); + + await setupPage(tester, + users: [user], + pageUserId: user.userId, + customProfileFields: [ + mkCustomProfileField(0, CustomProfileFieldType.externalAccount, + fieldData: '{"subtype": "external1"}') + ], + realmDefaultExternalAccounts: { + 'external1': RealmDefaultExternalAccount( + name: 'external1', + text: '', + hint: '', + urlPattern: 'http://example/%(username)s')}, + ); + + await tester.tap(find.text('externalValue')); + final expectedMode = defaultTargetPlatform == TargetPlatform.android ? + LaunchMode.externalApplication : LaunchMode.platformDefault; + check(testBinding.takeLaunchUrlCalls()) + .single.equals((url: Uri.parse('http://example/externalValue'), mode: expectedMode)); + }); + + testWidgets('page builds; user links to profile', (WidgetTester tester) async { + final users = [ + eg.user(userId: 1, profileData: { + 0: ProfileFieldUserData(value: '[2]'), + }), + eg.user(userId: 2, fullName: 'test user'), + ]; + final pushedRoutes = >[]; + final testNavObserver = TestNavigatorObserver() + ..onPushed = (route, prevRoute) => pushedRoutes.add(route); + + await setupPage(tester, + users: users, + pageUserId: 1, + customProfileFields: [mkCustomProfileField(0, CustomProfileFieldType.user)], + navigatorObserver: testNavObserver, + ); + + final textFinder = find.text('test user'); + check(textFinder.evaluate()).length.equals(1); + final fieldContainer = find.ancestor(of: textFinder, matching: find.byType(Column)).first; + final targetWidget = find.descendant(of: fieldContainer, matching:find.byType(Avatar)); + await tester.tap(targetWidget, warnIfMissed: false); + check(pushedRoutes).last.isA().page.isA().userId.equals(2); + }); + + testWidgets('page builds; user field with unknown user', (WidgetTester tester) async { + final users = [ + eg.user(userId: 1, profileData: { + 0: ProfileFieldUserData(value: '[2]'), + }), + ]; + await setupPage(tester, + users: users, + pageUserId: 1, + customProfileFields: [mkCustomProfileField(0, CustomProfileFieldType.user)], + ); + + final textFinder = find.text('(unknown user)'); + check(textFinder.evaluate()).length.equals(1); + }); + + testWidgets('page builds; dm links to correct narrow', (WidgetTester tester) async { + final pushedRoutes = >[]; + final testNavObserver = TestNavigatorObserver() + ..onPushed = (route, prevRoute) => pushedRoutes.add(route); + + await setupPage(tester, + users: [eg.user(userId: 1)], + pageUserId: 1, + navigatorObserver: testNavObserver, + ); + + final targetWidget = find.byIcon(Icons.email); + await tester.ensureVisible(targetWidget); + await tester.tap(targetWidget); + check(pushedRoutes).last.isA().page + .isA() + .narrow.equals(DmNarrow.withUser(1, selfUserId: eg.selfUser.userId)); + }); + + testWidgets('page builds; user links render multiple avatars', (WidgetTester tester) async { + final users = [ + eg.user(userId: 1, profileData: { + 0: ProfileFieldUserData(value: '[2,3]'), + }), + eg.user(userId: 2, fullName: 'test user2'), + eg.user(userId: 3, fullName: 'test user3'), + ]; + + await setupPage(tester, + users: users, + pageUserId: 1, + customProfileFields: [mkCustomProfileField(0, CustomProfileFieldType.user)], + ); + + final avatars = tester.widgetList(find.byType(Avatar)); + check(avatars.map((w) => w.userId).toList()) + .deepEquals([1, 2, 3]); + }); + + testWidgets('page builds; ensure long name does not overflow', (WidgetTester tester) async { + final longString = 'X' * 400; + final user = eg.user(userId: 1, fullName: longString); + await setupPage(tester, users: [user], pageUserId: user.userId); + check(find.text(longString).evaluate()).isNotEmpty(); + }); + + testWidgets('page builds; ensure long customProfileFields do not overflow', (WidgetTester tester) async { + final longString = 'X' * 400; + final user = eg.user(userId: 1, fullName: 'fullName', profileData: { + 0: ProfileFieldUserData(value: longString), + 1: ProfileFieldUserData(value: longString), + 2: ProfileFieldUserData(value: 'x'), + 3: ProfileFieldUserData(value: 'http://example/$longString'), + 4: ProfileFieldUserData(value: '[2]'), + 5: ProfileFieldUserData(value: longString), + 6: ProfileFieldUserData(value: longString), + }); + final user2 = eg.user(userId: 2, fullName: longString); + + await setupPage(tester, users: [user, user2], pageUserId: user.userId, + customProfileFields: [ + mkCustomProfileField(0, CustomProfileFieldType.shortText), + mkCustomProfileField(1, CustomProfileFieldType.longText), + mkCustomProfileField(2, CustomProfileFieldType.choice, + fieldData: '{"x": {"text": "$longString", "order": "1"}}'), + // no [CustomProfileFieldType.date] because those can't be made long + mkCustomProfileField(3, CustomProfileFieldType.link), + mkCustomProfileField(4, CustomProfileFieldType.user), + mkCustomProfileField(5, CustomProfileFieldType.externalAccount, + fieldData: '{"subtype": "external1"}'), + mkCustomProfileField(6, CustomProfileFieldType.pronouns), + ], realmDefaultExternalAccounts: { + 'external1': RealmDefaultExternalAccount( + name: 'external1', + text: '', + hint: '', + urlPattern: 'https://example/%(username)s')}); + + check(find.textContaining(longString).evaluate()).length.equals(7); + }); + }); +}