Repository: zulip/zulip-terminal (python)

Issue:
Load, update and show Custom Profile Fields in user information popup
Our current user information popup (`i` on user list) shows a lot of information, but no [custom profile fields](https://docs.zulip.com/help/custom-profile-fields).

This work likely comprises at least 3 steps, each of which should be accompanied by test updates and new tests.

Firstly, the definition of the current fields can be obtained when we first connect to the server; if they are not already in the Model initial_data, then see the API docs for how these can be added by adjusting the data we initially request: https://docs.zulip.com/api/register-queue#response

We also should add an event handler for when these are updated, to ensure they stay up to date in the running application; see the Model for other event handlers, the api_types.py file, and the API docs for the event: https://docs.zulip.com/api/get-events#custom_profile_fields

Once the data is available and updating, we can update the Model method which processes user data into appropriate fields, which is called by the user-information popup to show the user information.

Pull request description:
Load and show custom profile fields in user information popup
<!-- See README for documentation, or ask in #zulip-terminal if unclear -->
### What does this PR do, and why?
This PR adds custom profile fields to the user info popup, which previously only displayed basic user information. This will include information about the user which, currently, has to be accessed by using the webapp. This PR also adds update event handling for the custom profile data.


### Outstanding aspect(s)    <!-- DELETE SECTION IF EMPTY -->
<!-- In what ways is this not fully implemented/functioning? Compared to a discussion/issue? -->
<!-- Do you not understand something? Are you unsure about a certain approach? Want feedback? -->
- [x] Update field values in current session using events.
- [x] Add/update tests.
- [x] Sort fields using 'order'
- [x] Simplify `_clean_custom_profile_data`

### External discussion & connections
<!-- [x] all that apply, specifying topic and adding numbers after # for issues/PRs -->
- [x] Discussed in **#zulip-terminal** in [`Support custom profile fields #T1338`](https://chat.zulip.org/#narrow/stream/206-zulip-terminal/topic/Support.20custom.20profile.20fields.20.23T1338)
- [x] Fully fixes #1338
- [ ] Partially fixes issue 
- [ ] Builds upon previous unmerged work in PR #
- [ ] Is a follow-up to work in PR #
- [ ] Requires merge of PR #
- [ ] Merge will enable work on #

### How did you test this?
<!-- [x] all that apply -->
- [x] Manually - Behavioral changes
- [x] Manually - Visual changes
- [x] Adapting existing automated tests
- [x] Adding automated tests for new behavior (or missing tests)
- [ ] Existing automated tests should already cover this (*only a refactor of tested code*)

### Self-review checklist for each commit
- [x] It is a [minimal coherent idea](https://github.com/zulip/zulip-terminal#structuring-commits---speeding-up-reviews-merging--development)
- [x] It has a commit summary following the [documented style](https://github.com/zulip/zulip-terminal#structuring-commits---speeding-up-reviews-merging--development) (title & body)
- [x] It has a commit summary describing the  motivation and reasoning for the change
- [x] It individually passes linting and tests
- [x] It contains test additions for any new behavior
- [x] It flows clearly from a previous branch commit, and/or prepares for the next commit

### Visual changes    <!-- DELETE SECTION IF NO VISUAL CHANGE -->
<!-- Zulip tips at https://zulip.readthedocs.io/en/latest/tutorials/screenshot-and-gif-software.html -->
<!-- For video, try asciinema; after uploading, embed using
[![yourtitle](https://asciinema.org/a/<id>.png)](https://asciinema.org/a/<id>)
-->
![image](https://user-images.githubusercontent.com/44305101/232261237-703b7a81-f648-41fb-9b02-e1704af4d50c.png)




Patch:
diff --git a/zulipterminal/api_types.py b/zulipterminal/api_types.py
index e9d75e2..23247a8 100644
--- a/zulipterminal/api_types.py
+++ b/zulipterminal/api_types.py
@@ -6,7 +6,7 @@ Types from the Zulip API, translated into python, to improve type checking
 
 from typing import Any, Dict, List, Optional, Union
 
-from typing_extensions import Final, Literal, NotRequired, TypedDict
+from typing_extensions import Final, Literal, NotRequired, TypedDict, final
 
 # These are documented in the zulip package (python-zulip-api repo)
 from zulip import EditPropagateMode  # one/all/later
@@ -237,6 +237,25 @@ class Subscription(TypedDict):
     # in_home_view: bool  # Replaced by is_muted in Zulip 2.1; still present in updates
 
 
+###############################################################################
+# In "custom_profile_fields" response from:
+#   https://zulip.com/api/register-queue
+# Also directly from:
+#   https://zulip.com/api/get-events#custom_profile_fields
+#   NOTE: This data structure is currently used in conftest.py to improve
+#   typing of fixtures, and can be used when initial_data is refactored to have
+#   better typing.
+
+
+class CustomProfileField(TypedDict):
+    id: int
+    name: str
+    type: Literal[1, 2, 3, 4, 5, 6, 7, 8]  # Field types range from 1 to 8.
+    hint: str
+    field_data: str
+    order: int
+
+
 ###############################################################################
 # In "realm_user" response from:
 #   https://zulip.com/api/register-queue
@@ -248,6 +267,11 @@ class Subscription(TypedDict):
 # NOTE: Responses between versions & endpoints vary
 
 
+class CustomFieldValue(TypedDict):
+    value: str
+    rendered_value: NotRequired[str]
+
+
 class RealmUser(TypedDict):
     user_id: int
     full_name: str
@@ -274,8 +298,9 @@ class RealmUser(TypedDict):
     is_admin: bool
     is_guest: bool  # NOTE: added /users/me ZFL 10; other changes before that
 
+    profile_data: Dict[str, CustomFieldValue]
+
     # To support in future:
-    # profile_data: Dict  # NOTE: Only if requested
     # is_active: bool  # NOTE: Dependent upon realm_users vs realm_non_active_users
     # delivery_email: str  # NOTE: Only available if admin, and email visibility limited
 
@@ -373,33 +398,86 @@ class ReactionEvent(TypedDict):
 
 # -----------------------------------------------------------------------------
 # See https://zulip.com/api/get-events#realm_user-add and -remove
-class RealmUserEventPerson(TypedDict):
-    user_id: int
 
+
+class UpdateCustomFieldValue(TypedDict):
+    id: int
+    value: Optional[str]
+    rendered_value: NotRequired[str]
+
+
+@final
+class RealmUserUpdateName(TypedDict):
+    user_id: int
     full_name: str
 
+
+@final
+class RealmUserUpdateAvatar(TypedDict):
+    user_id: int
     avatar_url: str
     avatar_source: str
     avatar_url_medium: str
     avatar_version: int
 
+
+@final
+class RealmUserUpdateTimeZone(TypedDict):
+    user_id: int
     # NOTE: This field will be removed in future as it is redundant with the user_id
     # email: str
     timezone: str
 
+
+@final
+class RealmUserUpdateBotOwner(TypedDict):
+    user_id: int
     bot_owner_id: int
 
+
+@final
+class RealmUserUpdateRole(TypedDict):
+    user_id: int
     role: int
 
+
+@final
+class RealmUserUpdateBillingRole(TypedDict):
+    user_id: int
     is_billing_admin: bool  # New in ZFL 73 (Zulip 5.0)
 
+
+@final
+class RealmUserUpdateDeliveryEmail(TypedDict):
+    user_id: int
     delivery_email: str  # NOTE: Only sent to admins
 
-    # custom_profile_field: Dict  # TODO: Requires checking before implementation
 
+@final
+class RealmUserUpdateCustomProfileField(TypedDict):
+    user_id: int
+    custom_profile_field: UpdateCustomFieldValue
+
+
+@final
+class RealmUserUpdateEmail(TypedDict):
+    user_id: int
     new_email: str
 
 
+RealmUserEventPerson = Union[
+    RealmUserUpdateName,
+    RealmUserUpdateAvatar,
+    RealmUserUpdateTimeZone,
+    RealmUserUpdateBotOwner,
+    RealmUserUpdateRole,
+    RealmUserUpdateBillingRole,
+    RealmUserUpdateDeliveryEmail,
+    RealmUserUpdateCustomProfileField,
+    RealmUserUpdateEmail,
+]
+
+
 class RealmUserEvent(TypedDict):
     type: Literal["realm_user"]
     op: Literal["update"]
diff --git a/zulipterminal/helper.py b/zulipterminal/helper.py
index 0b7f59b..59aa4af 100644
--- a/zulipterminal/helper.py
+++ b/zulipterminal/helper.py
@@ -25,6 +25,7 @@ from typing import (
     Set,
     Tuple,
     TypeVar,
+    Union,
 )
 from urllib.parse import unquote
 
@@ -63,6 +64,13 @@ class EmojiData(TypedDict):
 NamedEmojiData = Dict[str, EmojiData]
 
 
+class CustomProfileData(TypedDict):
+    label: str
+    value: Union[str, List[int]]
+    type: int
+    order: int
+
+
 class TidiedUserInfo(TypedDict):
     full_name: str
     email: str
@@ -70,6 +78,7 @@ class TidiedUserInfo(TypedDict):
     timezone: str
     role: int
     last_active: str
+    custom_profile_data: List[CustomProfileData]
 
     is_bot: bool
     # Below fields are only meaningful if is_bot == True
diff --git a/zulipterminal/model.py b/zulipterminal/model.py
index 906660d..ac4cd1d 100644
--- a/zulipterminal/model.py
+++ b/zulipterminal/model.py
@@ -35,6 +35,7 @@ from zulipterminal.api_types import (
     MAX_STREAM_NAME_LENGTH,
     MAX_TOPIC_NAME_LENGTH,
     Composition,
+    CustomFieldValue,
     DirectTypingNotification,
     EditPropagateMode,
     Event,
@@ -60,6 +61,7 @@ from zulipterminal.config.ui_mappings import (
     StreamAccessType,
 )
 from zulipterminal.helper import (
+    CustomProfileData,
     Message,
     NamedEmojiData,
     StreamData,
@@ -139,6 +141,7 @@ class Model:
             "update_display_settings",
             "user_settings",
             "realm_emoji",
+            "custom_profile_fields",
             # zulip_version and zulip_feature_level are always returned in
             # POST /register from Feature level 3.
             "zulip_version",
@@ -975,6 +978,56 @@ class Model:
                 if sub != self.user_id
             ]
 
+    def _clean_and_order_custom_profile_data(
+        self, custom_profile_data: Dict[str, CustomFieldValue]
+    ) -> List[CustomProfileData]:
+        # Get custom profile fields
+        profile_fields = {
+            str(field["id"]): field
+            for field in self.initial_data["custom_profile_fields"]
+        }
+
+        cleaned_profile_data = []
+        for field_id in custom_profile_data:
+            field = profile_fields[field_id]
+            raw_value = custom_profile_data[field_id]["value"]
+
+            if field["type"] == 3:  # List of options
+                field_options = json.loads(field["field_data"])
+                field_value = field_options[raw_value]["text"]
+
+            elif field["type"] == 6:  # Person picker
+                field_value = list(
+                    map(
+                        int,
+                        raw_value.strip("][").split(","),
+                    )
+                )
+
+            elif field["type"] == 7:  # External Account
+                field_options = json.loads(field["field_data"])
+                field_subtype = field_options["subtype"]
+                if field_subtype == "github":
+                    url_pattern = "https://github.com/%(username)s"
+                elif field_subtype == "twitter":
+                    url_pattern = "https://twitter.com/%(username)s"
+                else:
+                    url_pattern = field_options["url_pattern"]
+                field_value = url_pattern % {"username": raw_value}
+            else:
+                field_value = raw_value
+
+            data: CustomProfileData = {
+                "label": field["name"],
+                "value": field_value,
+                "type": field["type"],
+                "order": field["order"],
+            }
+            cleaned_profile_data.append(data)
+
+        cleaned_profile_data.sort(key=lambda field: field["order"])
+        return cleaned_profile_data
+
     def get_user_info(self, user_id: int) -> Optional[TidiedUserInfo]:
         api_user_data: Optional[RealmUser] = self._all_users_by_id.get(user_id, None)
 
@@ -994,6 +1047,10 @@ class Model:
         else:
             user_role = raw_user_role
 
+        custom_profile_data = api_user_data.get("profile_data", {})
+        cleaned_custom_profile_data = self._clean_and_order_custom_profile_data(
+            custom_profile_data
+        )
         # TODO: Add custom fields later as an enhancement
         user_info: TidiedUserInfo = dict(
             full_name=api_user_data.get("full_name", "(No name)"),
@@ -1002,6 +1059,7 @@ class Model:
             timezone=api_user_data.get("timezone", ""),
             is_bot=api_user_data.get("is_bot", False),
             role=user_role,
+            custom_profile_data=cleaned_custom_profile_data,
             bot_type=api_user_data.get("bot_type", None),
             bot_owner_name="",  # Can be non-empty only if is_bot == True
             last_active="",
@@ -1869,6 +1927,21 @@ class Model:
                     # realm_users has 'email' attribute and not 'new_email'
                     if "new_email" in updated_details:
                         realm_user["email"] = updated_details["new_email"]
+
+                    elif "custom_profile_field" in updated_details:
+                        profile_field_data = updated_details["custom_profile_field"]
+                        profile_field_id = str(profile_field_data["id"])
+
+                        if profile_field_data["value"] is None:
+                            # Ignore if field does not exist
+                            realm_user["profile_data"].pop(profile_field_id, None)
+                        else:
+                            updated_data = {
+                                key: value
+                                for key, value in profile_field_data.items()
+                                if key != "id"
+                            }
+                            realm_user["profile_data"][profile_field_id] = updated_data
                     else:
                         realm_user.update(updated_details)
                     break
diff --git a/zulipterminal/ui_tools/views.py b/zulipterminal/ui_tools/views.py
index 4f63396..04acd52 100644
--- a/zulipterminal/ui_tools/views.py
+++ b/zulipterminal/ui_tools/views.py
@@ -1084,13 +1084,20 @@ class AboutView(PopUpView):
 
 class UserInfoView(PopUpView):
     def __init__(self, controller: Any, user_id: int, title: str, command: str) -> None:
-        display_data = self._fetch_user_data(controller, user_id)
+        display_data, display_custom_profile_data = self._fetch_user_data(
+            controller, user_id
+        )
 
         user_details = [
             (key, value) for key, value in display_data.items() if key != "Name"
         ]
         user_view_content = [(display_data["Name"], user_details)]
 
+        if display_custom_profile_data:
+            user_view_content.extend(
+                [("Additional Details", list(display_custom_profile_data.items()))]
+            )
+
         popup_width, column_widths = self.calculate_table_widths(
             user_view_content, len(title)
         )
@@ -1099,16 +1106,19 @@ class UserInfoView(PopUpView):
         super().__init__(controller, widgets, command, popup_width, title)
 
     @staticmethod
-    def _fetch_user_data(controller: Any, user_id: int) -> Dict[str, Any]:
+    def _fetch_user_data(
+        controller: Any, user_id: int
+    ) -> Tuple[Dict[str, str], Dict[str, str]]:
         # Get user data from model
         data: TidiedUserInfo = controller.model.get_user_info(user_id)
 
+        display_custom_profile_data = {}
         if not data:
             display_data = {
                 "Name": "(Unavailable)",
                 "Error": "User data not found",
             }
-            return display_data
+            return (display_data, display_custom_profile_data)
 
         # Style the data obtained to make it displayable
         display_data = {"Name": data["full_name"]}
@@ -1144,7 +1154,23 @@ class UserInfoView(PopUpView):
             if data["last_active"]:
                 display_data["Last active"] = data["last_active"]
 
-        return display_data
+        # This will be an empty dict in case of bot users
+        custom_profile_data = data["custom_profile_data"]
+
+        if custom_profile_data:
+            for field in custom_profile_data:
+                if field["type"] == 6:  # Person picker
+                    user_names = [
+                        controller.model.user_name_from_id(user)
+                        for user in field["value"]
+                    ]
+                    field["value"] = ", ".join(user_names)
+                # After conversion of field type 6, all values are str
+                assert isinstance(field["value"], str)
+
+                display_custom_profile_data[field["label"]] = field["value"]
+
+        return (display_data, display_custom_profile_data)
 
 
 class HelpView(PopUpView):


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index 3e77d51..5f18bfc 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -6,13 +6,18 @@ import pytest
 from pytest_mock import MockerFixture
 from urwid import Widget
 
-from zulipterminal.api_types import Message, MessageType
+from zulipterminal.api_types import (
+    CustomFieldValue,
+    CustomProfileField,
+    Message,
+    MessageType,
+)
 from zulipterminal.config.keys import (
     ZT_TO_URWID_CMD_MAPPING,
     keys_for_command,
     primary_key_for_command,
 )
-from zulipterminal.helper import Index, TidiedUserInfo
+from zulipterminal.helper import CustomProfileData, Index, TidiedUserInfo
 from zulipterminal.helper import initial_index as helper_initial_index
 from zulipterminal.ui_tools.buttons import StreamButton, TopicButton, UserButton
 from zulipterminal.ui_tools.messages import MessageBox
@@ -120,7 +125,10 @@ def msg_box(
 
 
 @pytest.fixture
-def users_fixture(logged_on_user: Dict[str, Any]) -> List[Dict[str, Any]]:
+def users_fixture(
+    logged_on_user: Dict[str, Any],
+    custom_profile_data_fixture: Dict[str, CustomFieldValue],
+) -> List[Dict[str, Any]]:
     users = [logged_on_user]
     for i in range(1, 3):
         users.append(
@@ -149,11 +157,19 @@ def users_fixture(logged_on_user: Dict[str, Any]) -> List[Dict[str, Any]]:
                 "is_admin": False,
             }
         )
+    # Add custom profile data to user 12
+    for user in users:
+        if user["user_id"] == 12:
+            user["profile_data"] = custom_profile_data_fixture
+        else:
+            user["profile_data"] = {}
     return users
 
 
 @pytest.fixture
-def tidied_user_info_response() -> TidiedUserInfo:
+def tidied_user_info_response(
+    clean_custom_profile_data_fixture: List[CustomProfileData],
+) -> TidiedUserInfo:
     # FIXME: Refactor this to use a more generic user?
     return {
         "full_name": "Human 2",
@@ -165,6 +181,7 @@ def tidied_user_info_response() -> TidiedUserInfo:
         "bot_type": None,
         "bot_owner_name": "",
         "last_active": "",
+        "custom_profile_data": clean_custom_profile_data_fixture,
     }
 
 
@@ -626,12 +643,231 @@ def mentioned_messages_combination(request: Any) -> Tuple[Set[int], Set[int]]:
     return deepcopy(request.param)
 
 
+@pytest.fixture
+def custom_profile_fields_fixture() -> List[CustomProfileField]:
+    return [
+        {
+            # Short text: For one line responses, like "Job title".
+            # Responses are limited to 50 characters.
+            "id": 1,
+            "name": "Phone number",
+            "type": 1,
+            "hint": "",
+            "field_data": "",
+            "order": 1,
+        },
+        {
+            # Long text: For multiline responses, like "Biography".
+            "id": 2,
+            "name": "Biography",
+            "type": 2,
+            "hint": "What are you known for?",
+            "field_data": "",
+            "order": 2,
+        },
+        {
+            # Another example of Short text field.
+            "id": 3,
+            "name": "Favorite food",
+            "type": 1,
+            "hint": "Or drink, if you'd prefer",
+            "field_data": "",
+            "order": 3,
+        },
+        {
+            # List of options: Creates a dropdown with a list of options.
+            "id": 4,
+            "name": "Favorite editor",
+            "type": 3,
+            "hint": "",
+            "field_data": (
+                '{"0":{"text":"Vim","order":"1"},"1":{"text":"Emacs","order":"2"}}'
+            ),
+            "order": 4,
+        },
+        {
+            # Date picker: For dates, like "Birthday".
+            "id": 5,
+            "name": "Birthday",
+            "type": 4,
+            "hint": "",
+            "field_data": "",
+            "order": 5,
+        },
+        {
+            # Link: For links to websites.
+            "id": 6,
+            "name": "Favorite website",
+            "type": 5,
+            "hint": "Or your personal blog's URL",
+            "field_data": "",
+            "order": 6,
+        },
+        {
+            # Person picker: For selecting one or more users,
+            # like "Manager" or "Direct reports".
+            "id": 7,
+            "name": "Manager",
+            "type": 6,
+            "hint": "Only one person",
+            "field_data": "",
+            "order": 7,
+        },
+        {
+            # Another person picker: Use case with multiple users.
+            "id": 8,
+            "name": "Mentor",
+            "type": 6,
+            "hint": "",
+            "field_data": "",
+            "order": 8,
+        },
+        {
+            # External account: For linking to GitHub, Twitter, etc.
+            # GitHub example
+            "id": 9,
+            "name": "GitHub username",
+            "type": 7,
+            "hint": "",
+            "field_data": '{"subtype":"github"}',
+            "order": 9,
+        },
+        {
+            # Twitter example
+            "id": 10,
+            "name": "Twitter username",
+            "type": 7,
+            "hint": "",
+            "field_data": '{"subtype":"twitter"}',
+            "order": 10,
+        },
+        {
+            # Custom example
+            "id": 11,
+            "name": "Reddit username",
+            "type": 7,
+            "hint": "",
+            "field_data": '{"subtype":"custom", "url_pattern":"https://www.reddit.com/u/%(username)s"}',
+            "order": 11,
+        },
+        {
+            # Pronouns: What pronouns should people use to refer to the user?
+            "id": 12,
+            "name": "Pronouns",
+            "type": 8,
+            "hint": "What pronouns should people use to refer to you?",
+            "field_data": "",
+            "order": 12,
+        },
+    ]
+
+
+@pytest.fixture
+def custom_profile_data_fixture() -> Dict[str, CustomFieldValue]:
+    return {
+        "1": {"value": "6352813452", "rendered_value": "<p>6352813452</p>"},
+        "2": {
+            "value": "Simplicity\nThis is a multiline field",
+            "rendered_value": "<p>Simplicity\nThis is a multiline field</p>",
+        },
+        "3": {"value": "cola", "rendered_value": "<p>cola</p>"},
+        "4": {"value": "0"},
+        "5": {"value": "2023-04-22"},
+        "6": {"value": "https://www.google.com"},
+        "7": {"value": "[11]"},
+        "8": {"value": "[11, 13]"},
+        "9": {"value": "gitmaster"},
+        "10": {"value": "twittermaster"},
+        "11": {"value": "redditmaster"},
+        "12": {"value": "he/him"},
+    }
+
+
+@pytest.fixture
+def clean_custom_profile_data_fixture() -> List[CustomProfileData]:
+    return [
+        {
+            "label": "Phone number",
+            "value": "6352813452",
+            "type": 1,
+            "order": 1,
+        },
+        {
+            "label": "Biography",
+            "value": "Simplicity\nThis is a multiline field",
+            "type": 2,
+            "order": 2,
+        },
+        {
+            "label": "Favorite food",
+            "value": "cola",
+            "type": 1,
+            "order": 3,
+        },
+        {
+            "label": "Favorite editor",
+            "value": "Vim",
+            "type": 3,
+            "order": 4,
+        },
+        {
+            "label": "Birthday",
+            "value": "2023-04-22",
+            "type": 4,
+            "order": 5,
+        },
+        {
+            "label": "Favorite website",
+            "value": "https://www.google.com",
+            "type": 5,
+            "order": 6,
+        },
+        {
+            "label": "Manager",
+            "value": [11],
+            "type": 6,
+            "order": 7,
+        },
+        {
+            "label": "Mentor",
+            "value": [11, 13],
+            "type": 6,
+            "order": 8,
+        },
+        {
+            "label": "GitHub username",
+            "value": "https://github.com/gitmaster",
+            "type": 7,
+            "order": 9,
+        },
+        {
+            "label": "Twitter username",
+            "value": "https://twitter.com/twittermaster",
+            "type": 7,
+            "order": 10,
+        },
+        {
+            "label": "Reddit username",
+            "value": "https://www.reddit.com/u/redditmaster",
+            "type": 7,
+            "order": 11,
+        },
+        {
+            "label": "Pronouns",
+            "value": "he/him",
+            "type": 8,
+            "order": 12,
+        },
+    ]
+
+
 @pytest.fixture
 def initial_data(
     logged_on_user: Dict[str, Any],
     users_fixture: List[Dict[str, Any]],
     streams_fixture: List[Dict[str, Any]],
     realm_emojis: Dict[str, Dict[str, Any]],
+    custom_profile_fields_fixture: List[Dict[str, Union[str, int]]],
 ) -> Dict[str, Any]:
     """
     Response from /register API request.
@@ -806,6 +1042,7 @@ def initial_data(
         "zulip_version": MINIMUM_SUPPORTED_SERVER_VERSION[0],
         "zulip_feature_level": MINIMUM_SUPPORTED_SERVER_VERSION[1],
         "starred_messages": [1117554, 1117558, 1117574],
+        "custom_profile_fields": custom_profile_fields_fixture,
     }
 
 
diff --git a/tests/model/test_model.py b/tests/model/test_model.py
index f7bfdc6..aa5c9bf 100644
--- a/tests/model/test_model.py
+++ b/tests/model/test_model.py
@@ -1,3 +1,4 @@
+import copy
 import json
 from collections import OrderedDict
 from copy import deepcopy
@@ -254,6 +255,7 @@ class TestModel:
             "update_display_settings",
             "user_settings",
             "realm_emoji",
+            "custom_profile_fields",
             "zulip_version",
         ]
         model.client.register.assert_called_once_with(
@@ -1570,6 +1572,19 @@ class TestModel:
         }
         assert user_group_names == ["Group 1", "Group 2", "Group 3", "Group 4"]
 
+    def test__clean_and_order_custom_profile_data(
+        self,
+        model,
+        custom_profile_fields_fixture,
+        custom_profile_data_fixture,
+        clean_custom_profile_data_fixture,
+    ):
+        model.initial_data["custom_profile_fields"] = custom_profile_fields_fixture
+        assert (
+            clean_custom_profile_data_fixture
+            == model._clean_and_order_custom_profile_data(custom_profile_data_fixture)
+        )
+
     @pytest.mark.parametrize(
         ["to_vary_in_each_user", "key", "expected_value"],
         [
@@ -1625,6 +1640,26 @@ class TestModel:
                 id="user_bot_has_owner:preZulip_3.0",
             ),
             case({}, "bot_owner_name", "", id="user_bot_has_no_owner"),
+            case(
+                {
+                    "profile_data": {
+                        "2": {
+                            "value": "Simplicity",
+                            "rendered_value": "<p>Simplicity</p>",
+                        },
+                    },
+                },
+                "custom_profile_data",
+                [
+                    {
+                        "label": "Biography",
+                        "value": "Simplicity",
+                        "type": 2,
+                        "order": 2,
+                    },
+                ],
+                id="user_has_custom_profile_data",
+            ),
         ],
     )
     def test_get_user_info(
@@ -3580,7 +3615,7 @@ class TestModel:
             "delivery_email",
         ],
     )
-    def test__handle_realm_user_event(
+    def test__handle_realm_user_event__general(
         self, person, event_field, updated_field_if_different, model, initial_data
     ):
         # id 11 matches initial_data["realm_users"][1] in the initial_data fixture
@@ -3598,6 +3633,165 @@ class TestModel:
             == event["person"][event_field]
         )
 
+    @pytest.mark.parametrize("user_id", [11, 12], ids=["no_custom", "many_custom"])
+    @pytest.mark.parametrize(
+        "update_data, expected_modified_field_id",
+        [
+            case(
+                {
+                    "id": 1,
+                    "value": "7237032732",
+                    "rendered_value": "<p>7237032732</p>",
+                },
+                "1",
+                id="Short Text 1",
+            ),
+            case(
+                {
+                    "id": 2,
+                    "value": "Complexity",
+                    "rendered_value": "<p>Complexity</p>",
+                },
+                "2",
+                id="Long Text",
+            ),
+            case(
+                {
+                    "id": 3,
+                    "value": "pizza",
+                    "rendered_value": "<p>pizza</p>",
+                },
+                "3",
+                id="Short Text 2",
+            ),
+            case(
+                {
+                    "id": 4,
+                    "value": "0",
+                },
+                "4",
+                id="List of Options",
+            ),
+            case(
+                {
+                    "id": 5,
+                    "value": "2023-04-22",
+                },
+                "5",
+                id="Date Picker",
+            ),
+            case(
+                {
+                    "id": 6,
+                    "value": "https://www.google.com",
+                },
+                "6",
+                id="Link",
+            ),
+            case(
+                {
+                    "id": 7,
+                    "value": "[13]",
+                },
+                "7",
+                id="Person Picker",
+            ),
+            case(
+                {
+                    "id": 9,
+                    "value": "githubmaster",
+                },
+                "9",
+                id="External Account",
+            ),
+            case(
+                {
+                    "id": 12,
+                    "value": "she/her",
+                },
+                "12",
+                id="Pronouns",
+            ),
+        ],
+    )
+    def test__handle_realm_user_event__custom_profile_data__update_data(
+        self,
+        user_id,
+        update_data,
+        expected_modified_field_id,
+        model,
+        initial_data,
+    ):
+        REALM_USER_INDEX = user_id - 10
+        user_data = initial_data["realm_users"][REALM_USER_INDEX]
+        # Ensure indices match user id
+        assert user_data["user_id"] == user_id, "unexpected test configuration"
+        # Updated value is expected to vary from existing value
+        assert (
+            user_data["profile_data"].get(update_data["id"], {}).get("value", None)
+            != update_data["value"]
+        ), "unexpected test configuration"
+
+        person = {"custom_profile_field": update_data, "user_id": user_id}
+        event = {"type": "realm_user", "op": "update", "id": 1000, "person": person}
+
+        profile_data_before_update = copy.deepcopy(user_data["profile_data"])
+        expected_profile_data = {
+            key: update_data[key] for key in update_data if key != "id"
+        }
+
+        model._handle_realm_user_event(event)
+
+        assert (
+            user_data["profile_data"][expected_modified_field_id]
+            == expected_profile_data
+        )
+        assert all(
+            user_data["profile_data"][field_id] == profile_data_before_update[field_id]
+            for field_id in profile_data_before_update
+            if field_id != expected_modified_field_id
+        )
+
+    @pytest.mark.parametrize("user_id", [11, 12], ids=["no_custom", "many_custom"])
+    @pytest.mark.parametrize(
+        "update_data, expected_removed_field_id",
+        [
+            (
+                {
+                    "id": 8,
+                    "value": None,
+                },
+                "8",
+            ),
+        ],
+    )
+    def test__handle_realm_user_event__custom_profile_data__remove_data(
+        self,
+        user_id,
+        update_data,
+        expected_removed_field_id,
+        model,
+        initial_data,
+    ):
+        REALM_USER_INDEX = user_id - 10
+        user_data = initial_data["realm_users"][REALM_USER_INDEX]
+        # Ensure indices match user id
+        assert user_data["user_id"] == user_id, "unexpected test configuration"
+
+        person = {"custom_profile_field": update_data, "user_id": user_id}
+        event = {"type": "realm_user", "op": "update", "id": 1000, "person": person}
+
+        profile_data_before_update = copy.deepcopy(user_data["profile_data"])
+
+        model._handle_realm_user_event(event)
+
+        assert expected_removed_field_id not in user_data["profile_data"]
+        assert all(
+            user_data["profile_data"][field_id] == profile_data_before_update[field_id]
+            for field_id in profile_data_before_update
+            if field_id != expected_removed_field_id
+        )
+
     @pytest.mark.parametrize("value", [True, False])
     def test__handle_user_settings_event(self, mocker, model, value):
         setting = "send_private_typing_notifications"
diff --git a/tests/ui_tools/test_popups.py b/tests/ui_tools/test_popups.py
index 194888b..c378ba9 100644
--- a/tests/ui_tools/test_popups.py
+++ b/tests/ui_tools/test_popups.py
@@ -9,7 +9,7 @@ from urwid import Columns, Pile, Text, Widget
 from zulipterminal.api_types import Message
 from zulipterminal.config.keys import is_command_key, keys_for_command
 from zulipterminal.config.ui_mappings import EDIT_MODE_CAPTIONS
-from zulipterminal.helper import TidiedUserInfo
+from zulipterminal.helper import CustomProfileData, TidiedUserInfo
 from zulipterminal.ui_tools.messages import MessageBox
 from zulipterminal.ui_tools.views import (
     AboutView,
@@ -271,6 +271,15 @@ class TestUserInfoView:
             return_value="Tue Mar 13 10:55 AM",
         )
 
+        mocked_user_name_from_id = {
+            11: "Human 1",
+            12: "Human 2",
+            13: "Human 3",
+        }
+        self.controller.model.user_name_from_id = mocker.Mock(
+            side_effect=lambda param: mocked_user_name_from_id.get(param, "(No name)")
+        )
+
         self.user_info_view = UserInfoView(
             self.controller, 10000, "User Info (up/down scrolls)", "USER_INFO"
         )
@@ -334,26 +343,77 @@ class TestUserInfoView:
     )
     def test__fetch_user_data(
         self,
-        mocker: MockerFixture,
         to_vary_in_each_user: Dict[str, Any],
         expected_key: str,
         expected_value: Optional[str],
     ) -> None:
         data = dict(self.user_data, **to_vary_in_each_user)
 
-        mocker.patch.object(self.controller.model, "get_user_info", return_value=data)
+        self.controller.model.get_user_info.return_value = data
 
-        display_data = self.user_info_view._fetch_user_data(self.controller, 1)
+        display_data, custom_profile_data = self.user_info_view._fetch_user_data(
+            self.controller, 1
+        )
 
         assert display_data.get(expected_key, None) == expected_value
 
+    @pytest.mark.parametrize(
+        [
+            "to_vary_in_each_user",
+            "expected_value",
+        ],
+        [
+            case(
+                [],
+                {},
+                id="user_has_no_custom_profile_data",
+            ),
+            case(
+                [
+                    {
+                        "label": "Biography",
+                        "value": "Simplicity",
+                        "type": 2,
+                        "order": 2,
+                    },
+                    {
+                        "label": "Mentor",
+                        "value": [11, 12],
+                        "type": 6,
+                        "order": 7,
+                    },
+                ],
+                {"Biography": "Simplicity", "Mentor": "Human 1, Human 2"},
+                id="user_has_custom_profile_data",
+            ),
+        ],
+    )
+    def test__fetch_user_data__custom_profile_data(
+        self,
+        to_vary_in_each_user: List[CustomProfileData],
+        expected_value: Dict[str, str],
+    ) -> None:
+        data = dict(self.user_data)
+        data["custom_profile_data"] = to_vary_in_each_user
+
+        self.controller.model.get_user_info.return_value = data
+
+        display_data, custom_profile_data = self.user_info_view._fetch_user_data(
+            self.controller, 1
+        )
+
+        assert custom_profile_data == expected_value
+
     def test__fetch_user_data_USER_NOT_FOUND(self, mocker: MockerFixture) -> None:
         mocker.patch.object(self.controller.model, "get_user_info", return_value=dict())
 
-        display_data = self.user_info_view._fetch_user_data(self.controller, 1)
+        display_data, custom_profile_data = self.user_info_view._fetch_user_data(
+            self.controller, 1
+        )
 
         assert display_data["Name"] == "(Unavailable)"
         assert display_data["Error"] == "User data not found"
+        assert custom_profile_data == {}
 
     @pytest.mark.parametrize(
         "key", {*keys_for_command("GO_BACK"), *keys_for_command("USER_INFO")}