Repository: zulip/zulip-terminal
Pull request #1296 (2023-02-13 06:53:29)

Extract MessageBox into new file (messages)
<!-- Please see https://github.com/zulip/zulip-terminal#contributor-guidelines ! -->

**What does this PR do?**  <!-- Overall description goes here -->

This migrates the main message-display class and test code that have been in unrelated files for some time, into dedicated related locations:
- `ui_tools/boxes.py` has search boxes and the compose box, but over half of that file is currently the object for rendering each message (`MessageBox`)
- tests for two of the classes in `boxes.py` are in a mirrored test file `ui_tools/test_boxes.py`, but the `MessageBox` tests are in a general tests file for ui code, `ui/test_ui_tools.py` - again taking up over half of that file.

This PR would place the code into `ui_tools/messages.py` and the tests into `ui_tools/test_messages.py`, in the mirrored style we currently use.

Moving the tests have been discussed and slightly progressed in the past from 2020 onwards, eg. #811 to migrate popups tests into their own file, following writing new tests in dedicated files to ease such a transition - see [#zulip-terminal > Splitting UI tests](https://chat.zulip.org/#narrow/stream/206-zulip-terminal/topic/Splitting.20UI.20tests/near/988827)

This has been held off in the past, to avoid introducing conflicts with outstanding PRs, but previous changes have been positively received and improve the logical structure of where to find code and tests. These splits also introduce smaller files with a tighter focus; the changes in this PR shrink two files which were previously over 2000 and 3000 lines in length each.

This change has also been somewhat simplified by the docstring-linting/sync tool #1208 (via #921), which provides motivation for keeping each file focused via the descriptions.

As noted in the above czo topic, @Rohitth007 addressed some of these ideas in #982 in 2021, though this did not get followed up to address moving this code and related tests in tandem, or into their own dedicated files, which is the focus of this PR.

<!-- If fixing a filed bug or new feature, add 'Fixes #<issue>' or 'Partial fix for #<issue>' -->

<!-- Add a link to a discussion on chat.zulip.org, if relevant -->

**Tested?** <!-- Fine to leave some of these unchecked if this is a draft/work-in-progress -->
- [ ] Manually
- [x] Existing tests (adapted, if necessary)
- [ ] New tests added (for any new behavior)
- [x] Passed linting & tests (each commit)
<!-- Code must pass CI (GitHub Actions) before merging - look for the green tick! -->

<!-- See https://github.com/zulip/zulip-terminal#commit-style -->
**Commit flow** <!-- if more than one commit; add/delete/fill-in as appropriate -->
This migrates the code and tests into the new files as above, which can be done independently, with minimal changes to imports and test references. Along with the split, the associated docstrings are updated and the overview file regenerated.

In addition, a `Message` prefix is added to the `SearchBox`, to distinguish it from the `PanelSearchBox`. This is more feasible to add to the docstring now that the boxes.py file is shorter and contains one fewer class to list.

Before migrating tests, a small commit moves a common fixture across into conftest.py, to avoid duplication, as in #982. Afterwards, another commit tidies some naming now that test_messages content is dedicated to a single module, as opposed to in test_ui_tools.py.

**Notes & Questions** <!-- if any; add/delete/fill-in as appropriate -->

Potential followup work:
- Examine the other commit in #982 and determine if files can simply be renamed/moved, or would benefit from other analysis first
  - it would be great to move utils.py functions into other files and generally improve their behavior
- Movement of popup code across into it's own file, to mirror test_popups
- keypress code in the messagebox suggests we don't handle all narrows as contextually as I thought?

zulipbot: Hello @zulip/server-refactoring members, this pull request was labeled with the "area: refactoring" label, so you may want to check it out!

<!-- areaLabelAddition -->


Patch:
diff --git a/docs/developer-file-overview.md b/docs/developer-file-overview.md
index 9ee48dc..e73e550 100644
--- a/docs/developer-file-overview.md
+++ b/docs/developer-file-overview.md
@@ -29,8 +29,9 @@ Zulip Terminal uses [Zulip's API](https://zulip.com/api/) to store and retrieve
 |                        | ui_mappings.py      | Relationships between state/API data and presentation in the UI                                        |
 |                        | ui_sizes.py         | Fixed sizes of UI elements                                                                             |
 |                        |                     |                                                                                                        |
-| zulipterminal/ui_tools | boxes.py            | UI boxes for displaying messages and entering text, such as `MessageBox`, `SearchBox`, `WriteBox`, etc.|
+| zulipterminal/ui_tools | boxes.py            | UI boxes for entering text: WriteBox, MessageSearchBox, PanelSearchBox                                 |
 |                        | buttons.py          | UI buttons for 'narrowing' & showing unread counts, eg. All, Stream, Private message, Topic, Starred   |
+|                        | messages.py         | UI to render a Zulip message for display, and respond contextually to actions                          |
 |                        | tables.py           | Helper functions which render tables in the UI                                                         |
 |                        | utils.py            | The `MessageBox` for every message displayed is created here                                           |
 |                        | views.py            | UI views for larger elements such as Streams, Messages, Topics, Help, etc                              |
diff --git a/tools/run-mypy b/tools/run-mypy
index 231f1a5..a35180d 100755
--- a/tools/run-mypy
+++ b/tools/run-mypy
@@ -19,6 +19,7 @@ EXCLUDE_FILES = [
     # Remaining test files to have types applied:
     "tests/model/test_model.py",
     "tests/ui/test_ui_tools.py",
+    "tests/ui_tools/test_messages.py",
 ]
 
 python_project_folders = ["zulipterminal", "tests", "tools"]
diff --git a/zulipterminal/ui.py b/zulipterminal/ui.py
index 03195b6..fa14063 100644
--- a/zulipterminal/ui.py
+++ b/zulipterminal/ui.py
@@ -19,7 +19,7 @@ from zulipterminal.config.symbols import (
 from zulipterminal.config.ui_sizes import LEFT_WIDTH, RIGHT_WIDTH, TAB_WIDTH
 from zulipterminal.helper import asynch
 from zulipterminal.platform_code import MOUSE_SELECTION_KEY, PLATFORM
-from zulipterminal.ui_tools.boxes import SearchBox, WriteBox
+from zulipterminal.ui_tools.boxes import MessageSearchBox, WriteBox
 from zulipterminal.ui_tools.views import (
     LeftColumnView,
     MiddleColumnView,
@@ -42,7 +42,7 @@ class View(urwid.WidgetWrap):
         self.pinned_streams = self.model.pinned_streams
         self.unpinned_streams = self.model.unpinned_streams
         self.write_box = WriteBox(self)
-        self.search_box = SearchBox(self.controller)
+        self.search_box = MessageSearchBox(self.controller)
 
         self.message_view: Any = None
         self.displaying_selection_hint = False
diff --git a/zulipterminal/ui_tools/boxes.py b/zulipterminal/ui_tools/boxes.py
index fdae30c..00fc024 100644
--- a/zulipterminal/ui_tools/boxes.py
+++ b/zulipterminal/ui_tools/boxes.py
@@ -1,22 +1,16 @@
 """
-UI boxes for displaying messages and entering text, such as `MessageBox`, `SearchBox`, `WriteBox`, etc.
+UI boxes for entering text: WriteBox, MessageSearchBox, PanelSearchBox
 """
 
 import re
-import typing
 import unicodedata
-from collections import Counter, OrderedDict, defaultdict
-from datetime import date, datetime, timedelta
-from time import sleep, time
-from typing import Any, Callable, Dict, List, NamedTuple, Optional, Tuple, Union
-from urllib.parse import urljoin, urlparse
+from collections import Counter, OrderedDict
+from datetime import datetime, timedelta
+from time import sleep
+from typing import Any, Callable, Dict, List, NamedTuple, Optional, Tuple
 
-import dateutil.parser
 import urwid
-from bs4 import BeautifulSoup
-from bs4.element import NavigableString, Tag
 from typing_extensions import Literal
-from tzlocal import get_localzone
 from urwid_readline import ReadlineEdit
 
 from zulipterminal.api_types import Composition, PrivateComposition, StreamComposition
@@ -32,20 +26,11 @@ from zulipterminal.config.regexes import (
     REGEX_STREAM_AND_TOPIC_FENCED_HALF,
     REGEX_STREAM_AND_TOPIC_UNFENCED,
 )
-from zulipterminal.config.symbols import (
-    INVALID_MARKER,
-    MESSAGE_CONTENT_MARKER,
-    MESSAGE_HEADER_DIVIDER,
-    QUOTED_TEXT_MARKER,
-    STREAM_TOPIC_SEPARATOR,
-    TIME_MENTION_MARKER,
-)
-from zulipterminal.config.ui_mappings import STATE_ICON, STREAM_ACCESS_TYPE
+from zulipterminal.config.symbols import INVALID_MARKER, STREAM_TOPIC_SEPARATOR
+from zulipterminal.config.ui_mappings import STREAM_ACCESS_TYPE
 from zulipterminal.helper import (
-    Message,
     asynch,
     format_string,
-    get_unused_fence,
     match_emoji,
     match_group,
     match_stream,
@@ -53,14 +38,8 @@ from zulipterminal.helper import (
     match_user,
     match_user_name_and_email,
 )
-from zulipterminal.server_url import near_message_url
 from zulipterminal.ui_tools.buttons import EditModeButton
-from zulipterminal.ui_tools.tables import render_table
-from zulipterminal.urwid_types import urwid_MarkupTuple, urwid_Size
-
-
-if typing.TYPE_CHECKING:
-    from zulipterminal.model import Model
+from zulipterminal.urwid_types import urwid_Size
 
 
 class _MessageEditState(NamedTuple):
@@ -937,1078 +916,11 @@ class WriteBox(urwid.Pile):
         return key
 
 
-class MessageBox(urwid.Pile):
-    # type of last_message is Optional[Message], but needs refactoring
-    def __init__(self, message: Message, model: "Model", last_message: Any) -> None:
-        self.model = model
-        self.message = message
-        self.header: List[Any] = []
-        self.content: urwid.Text = urwid.Text("")
-        self.footer: List[Any] = []
-        self.stream_name = ""
-        self.stream_id: Optional[int] = None
-        self.topic_name = ""
-        self.email = ""  # FIXME: Can we remove this?
-        self.user_id: Optional[int] = None
-        self.message_links: "OrderedDict[str, Tuple[str, int, bool]]" = OrderedDict()
-        self.topic_links: "OrderedDict[str, Tuple[str, int, bool]]" = OrderedDict()
-        self.time_mentions: List[Tuple[str, str]] = list()
-        self.last_message = last_message
-        # if this is the first message
-        if self.last_message is None:
-            self.last_message = defaultdict(dict)
-
-        if self.message["type"] == "stream":
-            # Set `topic_links` if present
-            for link in self.message.get("topic_links", []):
-                # Modernized response
-                self.topic_links[link["url"]] = (
-                    link["text"],
-                    len(self.topic_links) + 1,
-                    True,
-                )
-
-            self.stream_name = self.message["display_recipient"]
-            self.stream_id = self.message["stream_id"]
-            self.topic_name = self.message["subject"]
-        elif self.message["type"] == "private":
-            self.email = self.message["sender_email"]
-            self.user_id = self.message["sender_id"]
-        else:
-            raise RuntimeError("Invalid message type")
-
-        if self.message["type"] == "private":
-            if self._is_private_message_to_self():
-                recipient = self.message["display_recipient"][0]
-                self.recipients_names = recipient["full_name"]
-                self.recipient_emails = [self.model.user_email]
-                self.recipient_ids = [self.model.user_id]
-            else:
-                self.recipients_names = ", ".join(
-                    list(
-                        recipient["full_name"]
-                        for recipient in self.message["display_recipient"]
-                        if recipient["email"] != self.model.user_email
-                    )
-                )
-                self.recipient_emails = [
-                    recipient["email"]
-                    for recipient in self.message["display_recipient"]
-                    if recipient["email"] != self.model.user_email
-                ]
-                self.recipient_ids = [
-                    recipient["id"]
-                    for recipient in self.message["display_recipient"]
-                    if recipient["id"] != self.model.user_id
-                ]
-
-        super().__init__(self.main_view())
-
-    def need_recipient_header(self) -> bool:
-        # Prevent redundant information in recipient bar
-        if len(self.model.narrow) == 1 and self.model.narrow[0][0] == "pm_with":
-            return False
-        if len(self.model.narrow) == 2 and self.model.narrow[1][0] == "topic":
-            return False
-
-        last_msg = self.last_message
-        if self.message["type"] == "stream":
-            return not (
-                last_msg["type"] == "stream"
-                and self.topic_name == last_msg["subject"]
-                and self.stream_name == last_msg["display_recipient"]
-            )
-        elif self.message["type"] == "private":
-            recipient_ids = [
-                {
-                    recipient["id"]
-                    for recipient in message["display_recipient"]
-                    if "id" in recipient
-                }
-                for message in (self.message, last_msg)
-                if "display_recipient" in message
-            ]
-            return not (
-                len(recipient_ids) == 2
-                and recipient_ids[0] == recipient_ids[1]
-                and last_msg["type"] == "private"
-            )
-        else:
-            raise RuntimeError("Invalid message type")
-
-    def _is_private_message_to_self(self) -> bool:
-        recipient_list = self.message["display_recipient"]
-        return (
-            len(recipient_list) == 1
-            and recipient_list[0]["email"] == self.model.user_email
-        )
-
-    def stream_header(self) -> Any:
-        assert self.stream_id is not None
-        color = self.model.stream_dict[self.stream_id]["color"]
-        bar_color = f"s{color}"
-        stream_title_markup = (
-            "bar",
-            [
-                (bar_color, f"{self.stream_name} {STREAM_TOPIC_SEPARATOR} "),
-                ("title", f" {self.topic_name}"),
-            ],
-        )
-        stream_title = urwid.Text(stream_title_markup)
-        header = urwid.Columns(
-            [
-                ("pack", stream_title),
-                (1, urwid.Text((color, " "))),
-                urwid.AttrWrap(urwid.Divider(MESSAGE_HEADER_DIVIDER), color),
-            ]
-        )
-        header.markup = stream_title_markup
-        return header
-
-    def private_header(self) -> Any:
-        title_markup = (
-            "header",
-            [("general_narrow", "You and "), ("general_narrow", self.recipients_names)],
-        )
-        title = urwid.Text(title_markup)
-        header = urwid.Columns(
-            [
-                ("pack", title),
-                (1, urwid.Text(("general_bar", " "))),
-                urwid.AttrWrap(urwid.Divider(MESSAGE_HEADER_DIVIDER), "general_bar"),
-            ]
-        )
-        header.markup = title_markup
-        return header
-
-    def top_header_bar(self, message_view: Any) -> Any:
-        if self.message["type"] == "stream":
-            return message_view.stream_header()
-        else:
-            return message_view.private_header()
-
-    def top_search_bar(self) -> Any:
-        curr_narrow = self.model.narrow
-        is_search_narrow = self.model.is_search_narrow()
-        if is_search_narrow:
-            curr_narrow = [
-                sub_narrow for sub_narrow in curr_narrow if sub_narrow[0] != "search"
-            ]
-        else:
-            self.model.controller.view.search_box.text_box.set_edit_text("")
-        text_to_fill: Union[str, Tuple[str, List[Tuple[str, str]]]]
-        if curr_narrow == []:
-            text_to_fill = "All messages"
-        elif len(curr_narrow) == 1 and curr_narrow[0][1] == "private":
-            text_to_fill = "All private messages"
-        elif len(curr_narrow) == 1 and curr_narrow[0][1] == "starred":
-            text_to_fill = "Starred messages"
-        elif len(curr_narrow) == 1 and curr_narrow[0][1] == "mentioned":
-            text_to_fill = "Mentions"
-        elif self.message["type"] == "stream":
-            assert self.stream_id is not None
-            bar_color = self.model.stream_dict[self.stream_id]["color"]
-            bar_color = f"s{bar_color}"
-            if len(curr_narrow) == 2 and curr_narrow[1][0] == "topic":
-                text_to_fill = (
-                    "bar",
-                    [
-                        (bar_color, self.stream_name),
-                        (bar_color, ": topic narrow"),
-                    ],
-                )
-            else:
-                text_to_fill = ("bar", [(bar_color, self.stream_name)])
-        elif len(curr_narrow) == 1 and len(curr_narrow[0][1].split(",")) > 1:
-            text_to_fill = "Group private conversation"
-        else:
-            text_to_fill = "Private conversation"
-
-        if is_search_narrow:
-            title_markup = (
-                "header",
-                [
-                    ("general_narrow", text_to_fill),
-                    (None, " "),
-                    ("filter_results", "Search Results"),
-                ],
-            )
-        else:
-            title_markup = ("header", [("general_narrow", text_to_fill)])
-        title = urwid.Text(title_markup)
-        header = urwid.AttrWrap(title, "bar")
-        header.text_to_fill = text_to_fill
-        header.markup = title_markup
-        return header
-
-    def reactions_view(self, reactions: List[Dict[str, Any]]) -> Any:
-        if not reactions:
-            return ""
-        try:
-            MAXIMUM_USERNAMES_VISIBLE = 3
-            my_user_id = self.model.user_id
-            reaction_stats = defaultdict(list)
-            for reaction in reactions:
-                user_id = int(reaction["user"].get("id", -1))
-                if user_id == -1:
-                    user_id = int(reaction["user"]["user_id"])
-                user_name = reaction["user"]["full_name"]
-                if user_id == my_user_id:
-                    user_name = "You"
-                reaction_stats[reaction["emoji_name"]].append((user_id, user_name))
-
-            for reaction, ids in reaction_stats.items():
-                if (my_user_id, "You") in ids:
-                    ids.remove((my_user_id, "You"))
-                    ids.append((my_user_id, "You"))
-
-            reaction_texts = [
-                (
-                    "reaction_mine"
-                    if my_user_id in [id[0] for id in ids]
-                    else "reaction",
-                    f" :{reaction}: {len(ids)} "
-                    if len(reactions) > MAXIMUM_USERNAMES_VISIBLE
-                    else f" :{reaction}: {', '.join([id[1] for id in ids])} ",
-                )
-                for reaction, ids in reaction_stats.items()
-            ]
-
-            spaced_reaction_texts = [
-                entry
-                for pair in zip(reaction_texts, " " * len(reaction_texts))
-                for entry in pair
-            ]
-            return urwid.Padding(
-                urwid.Text(spaced_reaction_texts),
-                align="left",
-                width=("relative", 90),
-                left=25,
-                min_width=50,
-            )
-        except Exception:
-            return ""
-
-    # Use quotes as a workaround for OrderedDict typing issue.
-    # See https://github.com/python/mypy/issues/6904.
-    @staticmethod
-    def footlinks_view(
-        message_links: "OrderedDict[str, Tuple[str, int, bool]]",
-        *,
-        maximum_footlinks: int,
-        padded: bool,
-        wrap: str,
-    ) -> Tuple[Any, int]:
-        """
-        Returns a Tuple that consists footlinks view (widget) and its required
-        width.
-        """
-        # Return if footlinks are disabled by the user.
-        if maximum_footlinks == 0:
-            return None, 0
-
-        footlinks = []
-        counter = 0
-        footlinks_width = 0
-        for link, (text, index, show_footlink) in message_links.items():
-            if counter == maximum_footlinks:
-                break
-            if not show_footlink:
-                continue
-
-            counter += 1
-            styled_footlink = [
-                ("msg_link_index", f"{index}:"),
-                (None, " "),
-                ("msg_link", link),
-            ]
-            footlinks_width = max(
-                footlinks_width, sum([len(text) for style, text in styled_footlink])
-            )
-            footlinks.extend([*styled_footlink, "\n"])
-
-        if not footlinks:
-            return None, 0
-
-        footlinks[-1] = footlinks[-1][:-1]  # Remove the last newline.
-
-        text_widget = urwid.Text(footlinks, wrap=wrap)
-        if padded:
-            return (
-                urwid.Padding(
-                    text_widget,
-                    align="left",
-                    left=8,
-                    width=("relative", 100),
-                    min_width=10,
-                    right=2,
-                ),
-                footlinks_width,
-            )
-        else:
-            return text_widget, footlinks_width
-
-    @classmethod
-    def soup2markup(
-        cls, soup: Any, metadata: Dict[str, Any], **state: Any
-    ) -> Tuple[
-        List[Any], "OrderedDict[str, Tuple[str, int, bool]]", List[Tuple[str, str]]
-    ]:
-        # Ensure a string is provided, in case the soup finds none
-        # This could occur if eg. an image is removed or not shown
-        markup: List[Union[str, Tuple[Optional[str], Any]]] = [""]
-        if soup is None:  # This is not iterable, so return promptly
-            return markup, metadata["message_links"], metadata["time_mentions"]
-        unrendered_tags = {  # In pairs of 'tag_name': 'text'
-            # TODO: Some of these could be implemented
-            "br": "",  # No indicator of absence
-            "hr": "RULER",
-            "img": "IMAGE",
-        }
-        unrendered_div_classes = {  # In pairs of 'div_class': 'text'
-            # TODO: Support embedded content & twitter preview?
-            "message_embed": "EMBEDDED CONTENT",
-            "inline-preview-twitter": "TWITTER PREVIEW",
-            "message_inline_ref": "",  # Duplicate of other content
-            "message_inline_image": "",  # Duplicate of other content
-        }
-        unrendered_template = "[{} NOT RENDERED]"
-        for element in soup:
-            if isinstance(element, Tag):
-                # Caching element variables for use in the
-                # if/elif/else chain below for improving legibility.
-                tag = element.name
-                tag_attrs = element.attrs
-                tag_classes = tag_attrs.get("class", [])
-                tag_text = element.text
-
-            if isinstance(element, NavigableString):
-                # NORMAL STRINGS
-                if element == "\n" and metadata.get("bq_len", 0) > 0:
-                    metadata["bq_len"] -= 1
-                    continue
-                markup.append(element)
-            elif tag == "div" and (set(tag_classes) & set(unrendered_div_classes)):
-                # UNRENDERED DIV CLASSES
-                # NOTE: Though `matches` is generalized for multiple
-                # matches it is very unlikely that there would be any.
-                matches = set(unrendered_div_classes) & set(tag_classes)
-                text = unrendered_div_classes[matches.pop()]
-                if text:
-                    markup.append(unrendered_template.format(text))
-            elif tag == "img" and tag_classes == ["emoji"]:
-                # CUSTOM EMOJIS AND ZULIP_EXTRA_EMOJI
-                emoji_name = tag_attrs.get("title", [])
-                markup.append(("msg_emoji", f":{emoji_name}:"))
-            elif tag in unrendered_tags:
-                # UNRENDERED SIMPLE TAGS
-                text = unrendered_tags[tag]
-                if text:
-                    markup.append(unrendered_template.format(text))
-            elif tag in ("h1", "h2", "h3", "h4", "h5", "h6"):
-                # HEADING STYLE (h1 to h6)
-                markup.append(("msg_heading", tag_text))
-            elif tag in ("p", "del"):
-                # PARAGRAPH, STRIKE-THROUGH
-                markup.extend(cls.soup2markup(element, metadata)[0])
-            elif tag == "span" and "emoji" in tag_classes:
-                # EMOJI
-                markup.append(("msg_emoji", tag_text))
-            elif tag == "span" and ({"katex-display", "katex"} & set(tag_classes)):
-                # MATH TEXT
-                # FIXME: Add html -> urwid client-side logic for rendering KaTex text.
-                # Avoid displaying multiple markups, and show only the source
-                # as of now.
-                if element.find("annotation"):
-                    tag_text = element.find("annotation").text
-
-                markup.append(("msg_math", tag_text))
-            elif tag == "span" and (
-                {"user-group-mention", "user-mention"} & set(tag_classes)
-            ):
-                # USER MENTIONS & USER-GROUP MENTIONS
-                markup.append(("msg_mention", tag_text))
-            elif tag == "a":
-                # LINKS
-                # Use rstrip to avoid anomalies and edge cases like
-                # https://google.com vs https://google.com/.
-                link = tag_attrs["href"].rstrip("/")
-                text = element.img["src"] if element.img else tag_text
-                text = text.rstrip("/")
-
-                parsed_link = urlparse(link)
-                if not parsed_link.scheme:  # => relative link
-                    # Prepend org url to convert it to an absolute link
-                    link = urljoin(metadata["server_url"], link)
-
-                text = text if text else link
-
-                show_footlink = True
-                # Only use the last segment if the text is redundant.
-                # NOTE: The 'without scheme' excerpt is to deal with the case
-                # where a user puts a link without any scheme and the server
-                # uses http as the default scheme but keeps the text as-is.
-                # For instance, see how example.com/some/path becomes
-                # <a href="http://example.com">example.com/some/path</a>.
-                link_without_scheme, text_without_scheme = [
-                    data.split("://")[1] if "://" in data else data
-                    for data in [link, text]
-                ]  # Split on '://' is for cases where text == link.
-                if link_without_scheme == text_without_scheme:
-                    last_segment = text.split("/")[-1]
-                    if "." in last_segment:
-                        new_text = last_segment  # Filename.
-                    elif text.startswith(metadata["server_url"]):
-                        # Relative URL.
-                        new_text = text.split(metadata["server_url"])[-1]
-                    else:
-                        new_text = (
-                            parsed_link.netloc
-                            if parsed_link.netloc
-                            else text.split("/")[0]
-                        )  # Domain name.
-                    if new_text != text_without_scheme:
-                        text = new_text
-                    else:
-                        # Do not show as a footlink as the text is sufficient
-                        # to represent the link.
-                        show_footlink = False
-
-                # Detect duplicate links to save screen real estate.
-                if link not in metadata["message_links"]:
-                    metadata["message_links"][link] = (
-                        text,
-                        len(metadata["message_links"]) + 1,
-                        show_footlink,
-                    )
-                else:
-                    # Append the text if its link already exist with a
-                    # different text.
-                    saved_text, saved_link_index, saved_footlink_status = metadata[
-                        "message_links"
-                    ][link]
-                    if saved_text != text:
-                        metadata["message_links"][link] = (
-                            f"{saved_text}, {text}",
-                            saved_link_index,
-                            show_footlink or saved_footlink_status,
-                        )
-
-                markup.extend(
-                    [
-                        ("msg_link", text),
-                        " ",
-                        ("msg_link_index", f"[{metadata['message_links'][link][1]}]"),
-                    ]
-                )
-            elif tag == "blockquote":
-                # BLOCKQUOTE TEXT
-                markup.append(("msg_quote", cls.soup2markup(element, metadata)[0]))
-            elif tag == "code":
-                # CODE (INLINE?)
-                markup.append(("msg_code", tag_text))
-            elif tag == "div" and "codehilite" in tag_classes:
-                """
-                CODE BLOCK
-                -------------
-                Structure:   # Language is optional
-                    <div class="codehilite" data-code-language="python">
-                      <pre>
-                        <span></span>
-                        <code>
-                          Code HTML
-                          Made of <span>'s and NavigableStrings
-                        </code>
-                      </pre>
-                    </div>
-                """
-                code_soup = element.pre.code
-                # NOTE: Old messages don't have the additional `code` tag.
-                # Ref: https://github.com/Python-Markdown/markdown/pull/862
-                if code_soup is None:
-                    code_soup = element.pre
-
-                for code_element in code_soup.contents:
-                    code_text = (
-                        code_element.text
-                        if isinstance(code_element, Tag)
-                        else code_element.string
-                    )
-
-                    if code_element.name == "span":
-                        if len(code_text) == 0:
-                            continue
-                        css_style = code_element.attrs.get("class", ["w"])
-                        markup.append((f"pygments:{css_style[0]}", code_text))
-                    else:
-                        markup.append(("pygments:w", code_text))
-            elif tag in ("strong", "em"):
-                # BOLD & ITALIC
-                markup.append(("msg_bold", tag_text))
-            elif tag in ("ul", "ol"):
-                # LISTS (UL & OL)
-                for part in element.contents:
-                    if part == "\n":
-                        part.replace_with("")
-
-                if "indent_level" not in state:
-                    state["indent_level"] = 1
-                    state["list_start"] = True
-                else:
-                    state["indent_level"] += 1
-                    state["list_start"] = False
-                if tag == "ol":
-                    start_number = int(tag_attrs.get("start", 1))
-                    state["list_index"] = start_number
-                    markup.extend(cls.soup2markup(element, metadata, **state)[0])
-                    del state["list_index"]  # reset at end of this list
-                else:
-                    if "list_index" in state:
-                        del state["list_index"]  # this is unordered
-                    markup.extend(cls.soup2markup(element, metadata, **state)[0])
-                del state["indent_level"]  # reset indents after any list
-            elif tag == "li":
-                # LIST ITEMS (LI)
-                for part in element.contents:
-                    if part == "\n":
-                        part.replace_with("")
-                if not state.get("list_start", False):
-                    markup.append("\n")
-
-                indent = state.get("indent_level", 1)
-                if "list_index" in state:
-                    markup.append(f"{'  ' * indent}{state['list_index']}. ")
-                    state["list_index"] += 1
-                else:
-                    chars = [
-                        "\N{BULLET}",
-                        "\N{RING OPERATOR}",  # small hollow
-                        "\N{HYPHEN}",
-                    ]
-                    markup.append(f"{'  ' * indent}{chars[(indent - 1) % 3]} ")
-                state["list_start"] = False
-                markup.extend(cls.soup2markup(element, metadata, **state)[0])
-            elif tag == "table":
-                markup.extend(render_table(element))
-            elif tag == "time":
-                # New in feature level 16, server version 3.0.
-                # Render time in current user's local time zone.
-                timestamp = element.get("datetime")
-
-                # This should not happen. Regardless, we are interested in
-                # debugging and reporting it to zulip/zulip if it does.
-                assert timestamp is not None, "Could not find datetime attr"
-
-                utc_time = dateutil.parser.parse(timestamp)
-                local_time = utc_time.astimezone(get_localzone())
-                # TODO: Address 12-hour format support with application-wide
-                # support for different formats.
-                time_string = local_time.strftime("%a, %b %-d %Y, %-H:%M (%Z)")
-                markup.append(("msg_time", f" {TIME_MENTION_MARKER} {time_string} "))
-
-                source_text = f"Original text was {tag_text.strip()}"
-                metadata["time_mentions"].append((time_string, source_text))
-            else:
-                markup.extend(cls.soup2markup(element, metadata)[0])
-        return markup, metadata["message_links"], metadata["time_mentions"]
-
-    def main_view(self) -> List[Any]:
-        # Recipient Header
-        if self.need_recipient_header():
-            if self.message["type"] == "stream":
-                recipient_header = self.stream_header()
-            else:
-                recipient_header = self.private_header()
-        else:
-            recipient_header = None
-
-        # Content Header
-        message = {
-            key: {
-                "is_starred": "starred" in msg["flags"],
-                "author": (
-                    msg["sender_full_name"] if "sender_full_name" in msg else None
-                ),
-                "time": (
-                    self.model.formatted_local_time(
-                        msg["timestamp"], show_seconds=False
-                    )
-                    if "timestamp" in msg
-                    else None
-                ),
-                "datetime": (
-                    datetime.fromtimestamp(msg["timestamp"])
-                    if "timestamp" in msg
-                    else None
-                ),
-            }
-            for key, msg in dict(this=self.message, last=self.last_message).items()
-        }
-        different = {  # How this message differs from the previous one
-            "recipients": recipient_header is not None,
-            "author": message["this"]["author"] != message["last"]["author"],
-            "24h": (
-                message["last"]["datetime"] is not None
-                and ((message["this"]["datetime"] - message["last"]["datetime"]).days)
-            ),
-            "timestamp": (
-                message["last"]["time"] is not None
-                and message["this"]["time"] != message["last"]["time"]
-            ),
-            "star_status": (
-                message["this"]["is_starred"] != message["last"]["is_starred"]
-            ),
-        }
-        any_differences = any(different.values())
-
-        if any_differences:  # Construct content_header, if needed
-            TextType = Dict[str, urwid_MarkupTuple]
-            text_keys = ("author", "star", "time", "status")
-            text: TextType = {key: (None, " ") for key in text_keys}
-
-            if any(different[key] for key in ("recipients", "author", "24h")):
-                text["author"] = ("msg_sender", message["this"]["author"])
-
-                # TODO: Refactor to use user ids for look up instead of emails.
-                email = self.message.get("sender_email", "")
-                user = self.model.user_dict.get(email, None)
-                # TODO: Currently status of bots are shown as `inactive`.
-                # Render bot users' status with bot marker as a follow-up
-                status = user.get("status", "inactive") if user else "inactive"
-
-                # The default text['status'] value is (None, ' ')
-                if status in STATE_ICON:
-                    text["status"] = (f"user_{status}", STATE_ICON[status])
-
-            if message["this"]["is_starred"]:
-                text["star"] = ("starred", "*")
-            if any(different[key] for key in ("recipients", "author", "timestamp")):
-                this_year = date.today().year
-                msg_year = message["this"]["datetime"].year
-                if this_year != msg_year:
-                    text["time"] = ("time", f"{msg_year} - {message['this']['time']}")
-                else:
-                    text["time"] = ("time", message["this"]["time"])
-
-            content_header = urwid.Columns(
-                [
-                    ("pack", urwid.Text(text["status"])),
-                    ("weight", 10, urwid.Text(text["author"])),
-                    (26, urwid.Text(text["time"], align="right")),
-                    (1, urwid.Text(text["star"], align="right")),
-                ],
-                dividechars=1,
-            )
-        else:
-            content_header = None
-
-        # If the message contains '/me' emote then replace it with
-        # sender's full name and show it in bold.
-        if self.message["is_me_message"]:
-            self.message["content"] = self.message["content"].replace(
-                "/me", f"<strong>{self.message['sender_full_name']}</strong>", 1
-            )
-
-        # Transform raw message content into markup (As needed by urwid.Text)
-        content, self.message_links, self.time_mentions = self.transform_content(
-            self.message["content"], self.model.server_url
-        )
-        self.content.set_text(content)
-
-        if self.message["id"] in self.model.index["edited_messages"]:
-            edited_label_size = 7
-            left_padding = 1
-        else:
-            edited_label_size = 0
-            left_padding = 8
-
-        wrapped_content = urwid.Padding(
-            urwid.Columns(
-                [
-                    (edited_label_size, urwid.Text("EDITED")),
-                    urwid.LineBox(
-                        urwid.Columns(
-                            [
-                                (1, urwid.Text("")),
-                                self.content,
-                            ]
-                        ),
-                        tline="",
-                        bline="",
-                        rline="",
-                        lline=MESSAGE_CONTENT_MARKER,
-                    ),
-                ]
-            ),
-            align="left",
-            left=left_padding,
-            width=("relative", 100),
-            min_width=10,
-            right=5,
-        )
-
-        # Reactions
-        reactions = self.reactions_view(self.message["reactions"])
-
-        # Footlinks.
-        footlinks, _ = self.footlinks_view(
-            self.message_links,
-            maximum_footlinks=self.model.controller.maximum_footlinks,
-            padded=True,
-            wrap="ellipsis",
-        )
-
-        # Build parts together and return
-        parts = [
-            (recipient_header, recipient_header is not None),
-            (content_header, any_differences),
-            (wrapped_content, True),
-            (footlinks, footlinks is not None),
-            (reactions, reactions != ""),
-        ]
-
-        self.header = [part for part, condition in parts[:2] if condition]
-        self.footer = [part for part, condition in parts[3:] if condition]
-
-        return [part for part, condition in parts if condition]
-
-    def update_message_author_status(self) -> bool:
-        """
-        Update the author status by resetting the entire message box
-        if author field is present.
-        """
-        author_is_present = False
-        author_column = 1  # Index of author field in content header
-
-        if len(self.header) > 0:
-            # -1 represents that content header is the last row of header field
-            author_field = self.header[-1][author_column]
-            author_is_present = author_field.text != " "
-
-        if author_is_present:
-            # Re initialize the message if update is required.
-            # FIXME: Render specific element (here author field) instead?
-            super().__init__(self.main_view())
-
-        return author_is_present
-
-    @classmethod
-    def transform_content(
-        cls, content: Any, server_url: str
-    ) -> Tuple[
-        Tuple[None, Any],
-        "OrderedDict[str, Tuple[str, int, bool]]",
-        List[Tuple[str, str]],
-    ]:
-        soup = BeautifulSoup(content, "lxml")
-        body = soup.find(name="body")
-
-        metadata = dict(
-            server_url=server_url,
-            message_links=OrderedDict(),
-            time_mentions=list(),
-        )  # type: Dict[str, Any]
-
-        if body and body.find(name="blockquote"):
-            metadata["bq_len"] = cls.indent_quoted_content(soup, QUOTED_TEXT_MARKER)
-
-        markup, message_links, time_mentions = cls.soup2markup(body, metadata)
-        return (None, markup), message_links, time_mentions
-
-    @staticmethod
-    def indent_quoted_content(soup: Any, padding_char: str) -> int:
-        """
-        We indent quoted text by padding them.
-        The extent of indentation depends on their level of quoting.
-        For example:
-        [Before Padding]               [After Padding]
-
-        <blockquote>                    <blockquote>
-        <blockquote>                    <blockquote>
-        <p>Foo</p>                      <p>▒ ▒ </p><p>Foo</p>
-        </blockquote>       --->        </blockquote>
-        <p>Boo</p>                      <p>▒ </p><p>Boo</p>
-        </blockquote>                   </blockquote>
-        """
-        pad_count = 1
-        blockquote_list = soup.find_all("blockquote")
-        bq_len = len(blockquote_list)
-        for tag in blockquote_list:
-            child_list = tag.findChildren(recursive=False)
-            child_block = tag.find_all("blockquote")
-            actual_padding = f"{padding_char} " * pad_count
-            if len(child_list) == 1:
-                pad_count -= 1
-                child_iterator = child_list
-            else:
-                if len(child_block) == 0:
-                    child_iterator = child_list
-                else:
-                    # If there is some text at the begining of a
-                    # quote, we pad it seperately.
-                    if child_list[0].name == "p":
-                        new_tag = soup.new_tag("p")
-                        new_tag.string = f"\n{actual_padding}"
-                        child_list[0].insert_before(new_tag)
-                    child_iterator = child_list[1:]
-            for child in child_iterator:
-                new_tag = soup.new_tag("p")
-                new_tag.string = actual_padding
-                # If the quoted message is multi-line message
-                # we deconstruct it and pad it at break-points (<br/>)
-                for br in child.findAll("br"):
-                    next_s = br.nextSibling
-                    text = str(next_s.string).strip()
-                    if text:
-                        insert_tag = soup.new_tag("p")
-                        insert_tag.string = f"\n{padding_char} {text}"
-                        next_s.replace_with(insert_tag)
-                child.insert_before(new_tag)
-            pad_count += 1
-        return bq_len
-
-    def selectable(self) -> bool:
-        # Returning True, indicates that this widget
-        # is designed to take focus.
-        return True
-
-    def mouse_event(
-        self, size: urwid_Size, event: str, button: int, col: int, row: int, focus: bool
-    ) -> bool:
-        if event == "mouse press":
-            if button == 1:
-                if self.model.controller.is_in_editor_mode():
-                    return True
-                self.keypress(size, primary_key_for_command("ENTER"))
-                return True
-
-        return super().mouse_event(size, event, button, col, row, focus)
-
-    def keypress(self, size: urwid_Size, key: str) -> Optional[str]:
-        if is_command_key("REPLY_MESSAGE", key):
-            if self.message["type"] == "private":
-                self.model.controller.view.write_box.private_box_view(
-                    recipient_user_ids=self.recipient_ids,
-                )
-            elif self.message["type"] == "stream":
-                self.model.controller.view.write_box.stream_box_view(
-                    caption=self.message["display_recipient"],
-                    title=self.message["subject"],
-                    stream_id=self.stream_id,
-                )
-        elif is_command_key("STREAM_MESSAGE", key):
-            if len(self.model.narrow) != 0 and self.model.narrow[0][0] == "stream":
-                self.model.controller.view.write_box.stream_box_view(
-                    caption=self.message["display_recipient"],
-                    stream_id=self.stream_id,
-                )
-            else:
-                self.model.controller.view.write_box.stream_box_view(0)
-        elif is_command_key("STREAM_NARROW", key):
-            if self.message["type"] == "private":
-                self.model.controller.narrow_to_user(
-                    recipient_emails=self.recipient_emails,
-                    contextual_message_id=self.message["id"],
-                )
-            elif self.message["type"] == "stream":
-                self.model.controller.narrow_to_stream(
-                    stream_name=self.stream_name,
-                    contextual_message_id=self.message["id"],
-                )
-        elif is_command_key("TOGGLE_NARROW", key):
-            self.model.unset_search_narrow()
-            if self.message["type"] == "private":
-                if len(self.model.narrow) == 1 and self.model.narrow[0][0] == "pm_with":
-                    self.model.controller.narrow_to_all_pm(
-                        contextual_message_id=self.message["id"],
-                    )
-                else:
-                    self.model.controller.narrow_to_user(
-                        recipient_emails=self.recipient_emails,
-                        contextual_message_id=self.message["id"],
-                    )
-            elif self.message["type"] == "stream":
-                if len(self.model.narrow) > 1:  # in a topic
-                    self.model.controller.narrow_to_stream(
-                        stream_name=self.stream_name,
-                        contextual_message_id=self.message["id"],
-                    )
-                else:
-                    self.model.controller.narrow_to_topic(
-                        stream_name=self.stream_name,
-                        topic_name=self.topic_name,
-                        contextual_message_id=self.message["id"],
-                    )
-        elif is_command_key("TOPIC_NARROW", key):
-            if self.message["type"] == "private":
-                self.model.controller.narrow_to_user(
-                    recipient_emails=self.recipient_emails,
-                    contextual_message_id=self.message["id"],
-                )
-            elif self.message["type"] == "stream":
-                self.model.controller.narrow_to_topic(
-                    stream_name=self.stream_name,
-                    topic_name=self.topic_name,
-                    contextual_message_id=self.message["id"],
-                )
-        elif is_command_key("ALL_MESSAGES", key):
-            self.model.controller.narrow_to_all_messages(
-                contextual_message_id=self.message["id"]
-            )
-        elif is_command_key("REPLY_AUTHOR", key):
-            # All subscribers from recipient_ids are not needed here.
-            self.model.controller.view.write_box.private_box_view(
-                recipient_user_ids=[self.message["sender_id"]],
-            )
-        elif is_command_key("MENTION_REPLY", key):
-            self.keypress(size, primary_key_for_command("REPLY_MESSAGE"))
-            mention = f"@**{self.message['sender_full_name']}** "
-            self.model.controller.view.write_box.msg_write_box.set_edit_text(mention)
-            self.model.controller.view.write_box.msg_write_box.set_edit_pos(
-                len(mention)
-            )
-            self.model.controller.view.middle_column.set_focus("footer")
-        elif is_command_key("QUOTE_REPLY", key):
-            self.keypress(size, primary_key_for_command("REPLY_MESSAGE"))
-
-            # To correctly quote a message that contains quote/code-blocks,
-            # we need to fence quoted message containing ``` with ````,
-            # ```` with ````` and so on.
-            response = self.model.fetch_raw_message_content(self.message["id"])
-            message_raw_content = response if response is not None else ""
-            fence = get_unused_fence(message_raw_content)
-
-            absolute_url = near_message_url(self.model.server_url[:-1], self.message)
-
-            # Compose box should look something like this:
-            #   @_**Zeeshan|514** [said](link to message):
-            #   ```quote
-            #   message_content
-            #   ```
-            quote = "@_**{0}|{1}** [said]({2}):\n{3}quote\n{4}\n{3}\n".format(
-                self.message["sender_full_name"],
-                self.message["sender_id"],
-                absolute_url,
-                fence,
-                message_raw_content,
-            )
-
-            self.model.controller.view.write_box.msg_write_box.set_edit_text(quote)
-            self.model.controller.view.write_box.msg_write_box.set_edit_pos(len(quote))
-            self.model.controller.view.middle_column.set_focus("footer")
-        elif is_command_key("EDIT_MESSAGE", key):
-            # User can't edit messages of others that already have a subject
-            # For private messages, subject = "" (empty string)
-            # This also handles the realm_message_content_edit_limit_seconds == 0 case
-            if (
-                self.message["sender_id"] != self.model.user_id
-                and self.message["subject"] != "(no topic)"
-            ):
-                if self.message["type"] == "stream":
-                    self.model.controller.report_error(
-                        [
-                            " You can't edit messages sent by other users that"
-                            " already have a topic."
-                        ]
-                    )
-                else:
-                    self.model.controller.report_error(
-                        [" You can't edit private messages sent by other users."]
-                    )
-                return key
-            # Check if editing is allowed in the realm
-            elif not self.model.initial_data["realm_allow_message_editing"]:
-                self.model.controller.report_error(
-                    [" Editing sent message is disabled."]
-                )
-                return key
-            # Check if message is still editable, i.e. within
-            # the time limit. A limit of 0 signifies no limit
-            # on message body editing.
-            msg_body_edit_enabled = True
-            if self.model.initial_data["realm_message_content_edit_limit_seconds"] > 0:
-                if self.message["sender_id"] == self.model.user_id:
-                    time_since_msg_sent = time() - self.message["timestamp"]
-                    edit_time_limit = self.model.initial_data[
-                        "realm_message_content_edit_limit_seconds"
-                    ]
-                    # Don't allow editing message body if time-limit exceeded.
-                    if time_since_msg_sent >= edit_time_limit:
-                        if self.message["type"] == "private":
-                            self.model.controller.report_error(
-                                [
-                                    " Time Limit for editing the message has been exceeded."
-                                ]
-                            )
-                            return key
-                        elif self.message["type"] == "stream":
-                            self.model.controller.report_warning(
-                                [
-                                    " Only topic editing allowed."
-                                    " Time Limit for editing the message body"
-                                    " has been exceeded."
-                                ]
-                            )
-                            msg_body_edit_enabled = False
-                elif self.message["type"] == "stream":
-                    # Allow editing topic if the message has "(no topic)" subject
-                    if self.message["subject"] == "(no topic)":
-                        self.model.controller.report_warning(
-                            [
-                                " Only topic editing is allowed."
-                                " This is someone else's message but with (no topic)."
-                            ]
-                        )
-                        msg_body_edit_enabled = False
-                    else:
-                        self.model.controller.report_error(
-                            [
-                                " You can't edit messages sent by other users that"
-                                " already have a topic."
-                            ]
-                        )
-                        return key
-                else:
-                    # The remaining case is of a private message not belonging to user.
-                    # Which should be already handled by the topmost if block
-                    raise RuntimeError(
-                        "Reached unexpected block. This should be handled at the top."
-                    )
-
-            if self.message["type"] == "private":
-                self.keypress(size, primary_key_for_command("REPLY_MESSAGE"))
-            elif self.message["type"] == "stream":
-                self.model.controller.view.write_box.stream_box_edit_view(
-                    stream_id=self.stream_id,
-                    caption=self.message["display_recipient"],
-                    title=self.message["subject"],
-                )
-            msg_id = self.message["id"]
-            response = self.model.fetch_raw_message_content(msg_id)
-            msg = response if response is not None else ""
-            write_box = self.model.controller.view.write_box
-            write_box.msg_edit_state = _MessageEditState(
-                message_id=msg_id, old_topic=self.message["subject"]
-            )
-            write_box.msg_write_box.set_edit_text(msg)
-            write_box.msg_write_box.set_edit_pos(len(msg))
-            write_box.msg_body_edit_enabled = msg_body_edit_enabled
-            # Set focus to topic box if message body editing is disabled.
-            if not msg_body_edit_enabled:
-                write_box.focus_position = write_box.FOCUS_CONTAINER_HEADER
-                write_box.header_write_box.focus_col = write_box.FOCUS_HEADER_BOX_TOPIC
-
-            self.model.controller.view.middle_column.set_focus("footer")
-        elif is_command_key("MSG_INFO", key):
-            self.model.controller.show_msg_info(
-                self.message, self.topic_links, self.message_links, self.time_mentions
-            )
-        elif is_command_key("ADD_REACTION", key):
-            self.model.controller.show_emoji_picker(self.message)
-        return key
-
+class MessageSearchBox(urwid.Pile):
+    """
+    Search Box to search/control main list of messages
+    """
 
-class SearchBox(urwid.Pile):
     def __init__(self, controller: Any) -> None:
         self.controller = controller
         super().__init__(self.main_view())
diff --git a/zulipterminal/ui_tools/messages.py b/zulipterminal/ui_tools/messages.py
new file mode 100644
index 0000000..c13f38c
--- /dev/null
+++ b/zulipterminal/ui_tools/messages.py
@@ -0,0 +1,1109 @@
+"""
+UI to render a Zulip message for display, and respond contextually to actions
+"""
+
+import typing
+from collections import OrderedDict, defaultdict
+from datetime import date, datetime
+from time import time
+from typing import Any, Dict, List, NamedTuple, Optional, Tuple, Union
+from urllib.parse import urljoin, urlparse
+
+import dateutil.parser
+import urwid
+from bs4 import BeautifulSoup
+from bs4.element import NavigableString, Tag
+from tzlocal import get_localzone
+
+from zulipterminal.config.keys import is_command_key, primary_key_for_command
+from zulipterminal.config.symbols import (
+    MESSAGE_CONTENT_MARKER,
+    MESSAGE_HEADER_DIVIDER,
+    QUOTED_TEXT_MARKER,
+    STREAM_TOPIC_SEPARATOR,
+    TIME_MENTION_MARKER,
+)
+from zulipterminal.config.ui_mappings import STATE_ICON
+from zulipterminal.helper import Message, get_unused_fence
+from zulipterminal.server_url import near_message_url
+from zulipterminal.ui_tools.tables import render_table
+from zulipterminal.urwid_types import urwid_MarkupTuple, urwid_Size
+
+
+if typing.TYPE_CHECKING:
+    from zulipterminal.model import Model
+
+
+class _MessageEditState(NamedTuple):
+    message_id: int
+    old_topic: str
+
+
+class MessageBox(urwid.Pile):
+    # type of last_message is Optional[Message], but needs refactoring
+    def __init__(self, message: Message, model: "Model", last_message: Any) -> None:
+        self.model = model
+        self.message = message
+        self.header: List[Any] = []
+        self.content: urwid.Text = urwid.Text("")
+        self.footer: List[Any] = []
+        self.stream_name = ""
+        self.stream_id: Optional[int] = None
+        self.topic_name = ""
+        self.email = ""  # FIXME: Can we remove this?
+        self.user_id: Optional[int] = None
+        self.message_links: "OrderedDict[str, Tuple[str, int, bool]]" = OrderedDict()
+        self.topic_links: "OrderedDict[str, Tuple[str, int, bool]]" = OrderedDict()
+        self.time_mentions: List[Tuple[str, str]] = list()
+        self.last_message = last_message
+        # if this is the first message
+        if self.last_message is None:
+            self.last_message = defaultdict(dict)
+
+        if self.message["type"] == "stream":
+            # Set `topic_links` if present
+            for link in self.message.get("topic_links", []):
+                # Modernized response
+                self.topic_links[link["url"]] = (
+                    link["text"],
+                    len(self.topic_links) + 1,
+                    True,
+                )
+
+            self.stream_name = self.message["display_recipient"]
+            self.stream_id = self.message["stream_id"]
+            self.topic_name = self.message["subject"]
+        elif self.message["type"] == "private":
+            self.email = self.message["sender_email"]
+            self.user_id = self.message["sender_id"]
+        else:
+            raise RuntimeError("Invalid message type")
+
+        if self.message["type"] == "private":
+            if self._is_private_message_to_self():
+                recipient = self.message["display_recipient"][0]
+                self.recipients_names = recipient["full_name"]
+                self.recipient_emails = [self.model.user_email]
+                self.recipient_ids = [self.model.user_id]
+            else:
+                self.recipients_names = ", ".join(
+                    list(
+                        recipient["full_name"]
+                        for recipient in self.message["display_recipient"]
+                        if recipient["email"] != self.model.user_email
+                    )
+                )
+                self.recipient_emails = [
+                    recipient["email"]
+                    for recipient in self.message["display_recipient"]
+                    if recipient["email"] != self.model.user_email
+                ]
+                self.recipient_ids = [
+                    recipient["id"]
+                    for recipient in self.message["display_recipient"]
+                    if recipient["id"] != self.model.user_id
+                ]
+
+        super().__init__(self.main_view())
+
+    def need_recipient_header(self) -> bool:
+        # Prevent redundant information in recipient bar
+        if len(self.model.narrow) == 1 and self.model.narrow[0][0] == "pm_with":
+            return False
+        if len(self.model.narrow) == 2 and self.model.narrow[1][0] == "topic":
+            return False
+
+        last_msg = self.last_message
+        if self.message["type"] == "stream":
+            return not (
+                last_msg["type"] == "stream"
+                and self.topic_name == last_msg["subject"]
+                and self.stream_name == last_msg["display_recipient"]
+            )
+        elif self.message["type"] == "private":
+            recipient_ids = [
+                {
+                    recipient["id"]
+                    for recipient in message["display_recipient"]
+                    if "id" in recipient
+                }
+                for message in (self.message, last_msg)
+                if "display_recipient" in message
+            ]
+            return not (
+                len(recipient_ids) == 2
+                and recipient_ids[0] == recipient_ids[1]
+                and last_msg["type"] == "private"
+            )
+        else:
+            raise RuntimeError("Invalid message type")
+
+    def _is_private_message_to_self(self) -> bool:
+        recipient_list = self.message["display_recipient"]
+        return (
+            len(recipient_list) == 1
+            and recipient_list[0]["email"] == self.model.user_email
+        )
+
+    def stream_header(self) -> Any:
+        assert self.stream_id is not None
+        color = self.model.stream_dict[self.stream_id]["color"]
+        bar_color = f"s{color}"
+        stream_title_markup = (
+            "bar",
+            [
+                (bar_color, f"{self.stream_name} {STREAM_TOPIC_SEPARATOR} "),
+                ("title", f" {self.topic_name}"),
+            ],
+        )
+        stream_title = urwid.Text(stream_title_markup)
+        header = urwid.Columns(
+            [
+                ("pack", stream_title),
+                (1, urwid.Text((color, " "))),
+                urwid.AttrWrap(urwid.Divider(MESSAGE_HEADER_DIVIDER), color),
+            ]
+        )
+        header.markup = stream_title_markup
+        return header
+
+    def private_header(self) -> Any:
+        title_markup = (
+            "header",
+            [("general_narrow", "You and "), ("general_narrow", self.recipients_names)],
+        )
+        title = urwid.Text(title_markup)
+        header = urwid.Columns(
+            [
+                ("pack", title),
+                (1, urwid.Text(("general_bar", " "))),
+                urwid.AttrWrap(urwid.Divider(MESSAGE_HEADER_DIVIDER), "general_bar"),
+            ]
+        )
+        header.markup = title_markup
+        return header
+
+    def top_header_bar(self, message_view: Any) -> Any:
+        if self.message["type"] == "stream":
+            return message_view.stream_header()
+        else:
+            return message_view.private_header()
+
+    def top_search_bar(self) -> Any:
+        curr_narrow = self.model.narrow
+        is_search_narrow = self.model.is_search_narrow()
+        if is_search_narrow:
+            curr_narrow = [
+                sub_narrow for sub_narrow in curr_narrow if sub_narrow[0] != "search"
+            ]
+        else:
+            self.model.controller.view.search_box.text_box.set_edit_text("")
+        if curr_narrow == []:
+            text_to_fill = "All messages"
+        elif len(curr_narrow) == 1 and curr_narrow[0][1] == "private":
+            text_to_fill = "All private messages"
+        elif len(curr_narrow) == 1 and curr_narrow[0][1] == "starred":
+            text_to_fill = "Starred messages"
+        elif len(curr_narrow) == 1 and curr_narrow[0][1] == "mentioned":
+            text_to_fill = "Mentions"
+        elif self.message["type"] == "stream":
+            assert self.stream_id is not None
+            bar_color = self.model.stream_dict[self.stream_id]["color"]
+            bar_color = f"s{bar_color}"
+            if len(curr_narrow) == 2 and curr_narrow[1][0] == "topic":
+                text_to_fill = (
+                    "bar",  # type: ignore
+                    [
+                        (bar_color, self.stream_name),
+                        (bar_color, ": topic narrow"),
+                    ],
+                )
+            else:
+                text_to_fill = ("bar", [(bar_color, self.stream_name)])  # type: ignore
+        elif len(curr_narrow) == 1 and len(curr_narrow[0][1].split(",")) > 1:
+            text_to_fill = "Group private conversation"
+        else:
+            text_to_fill = "Private conversation"
+
+        if is_search_narrow:
+            title_markup = (
+                "header",
+                [
+                    ("general_narrow", text_to_fill),
+                    (None, " "),
+                    ("filter_results", "Search Results"),
+                ],
+            )
+        else:
+            title_markup = ("header", [("general_narrow", text_to_fill)])
+        title = urwid.Text(title_markup)
+        header = urwid.AttrWrap(title, "bar")
+        header.text_to_fill = text_to_fill
+        header.markup = title_markup
+        return header
+
+    def reactions_view(self, reactions: List[Dict[str, Any]]) -> Any:
+        if not reactions:
+            return ""
+        try:
+            MAXIMUM_USERNAMES_VISIBLE = 3
+            my_user_id = self.model.user_id
+            reaction_stats = defaultdict(list)
+            for reaction in reactions:
+                user_id = int(reaction["user"].get("id", -1))
+                if user_id == -1:
+                    user_id = int(reaction["user"]["user_id"])
+                user_name = reaction["user"]["full_name"]
+                if user_id == my_user_id:
+                    user_name = "You"
+                reaction_stats[reaction["emoji_name"]].append((user_id, user_name))
+
+            for reaction, ids in reaction_stats.items():
+                if (my_user_id, "You") in ids:
+                    ids.remove((my_user_id, "You"))
+                    ids.append((my_user_id, "You"))
+
+            reaction_texts = [
+                (
+                    "reaction_mine"
+                    if my_user_id in [id[0] for id in ids]
+                    else "reaction",
+                    f" :{reaction}: {len(ids)} "
+                    if len(reactions) > MAXIMUM_USERNAMES_VISIBLE
+                    else f" :{reaction}: {', '.join([id[1] for id in ids])} ",
+                )
+                for reaction, ids in reaction_stats.items()
+            ]
+
+            spaced_reaction_texts = [
+                entry
+                for pair in zip(reaction_texts, " " * len(reaction_texts))
+                for entry in pair
+            ]
+            return urwid.Padding(
+                urwid.Text(spaced_reaction_texts),
+                align="left",
+                width=("relative", 90),
+                left=25,
+                min_width=50,
+            )
+        except Exception:
+            return ""
+
+    # Use quotes as a workaround for OrderedDict typing issue.
+    # See https://github.com/python/mypy/issues/6904.
+    @staticmethod
+    def footlinks_view(
+        message_links: "OrderedDict[str, Tuple[str, int, bool]]",
+        *,
+        maximum_footlinks: int,
+        padded: bool,
+        wrap: str,
+    ) -> Tuple[Any, int]:
+        """
+        Returns a Tuple that consists footlinks view (widget) and its required
+        width.
+        """
+        # Return if footlinks are disabled by the user.
+        if maximum_footlinks == 0:
+            return None, 0
+
+        footlinks = []
+        counter = 0
+        footlinks_width = 0
+        for link, (text, index, show_footlink) in message_links.items():
+            if counter == maximum_footlinks:
+                break
+            if not show_footlink:
+                continue
+
+            counter += 1
+            styled_footlink = [
+                ("msg_link_index", f"{index}:"),
+                (None, " "),
+                ("msg_link", link),
+            ]
+            footlinks_width = max(
+                footlinks_width, sum([len(text) for style, text in styled_footlink])
+            )
+            footlinks.extend([*styled_footlink, "\n"])
+
+        if not footlinks:
+            return None, 0
+
+        footlinks[-1] = footlinks[-1][:-1]  # Remove the last newline.
+
+        text_widget = urwid.Text(footlinks, wrap=wrap)
+        if padded:
+            return (
+                urwid.Padding(
+                    text_widget,
+                    align="left",
+                    left=8,
+                    width=("relative", 100),
+                    min_width=10,
+                    right=2,
+                ),
+                footlinks_width,
+            )
+        else:
+            return text_widget, footlinks_width
+
+    @classmethod
+    def soup2markup(
+        cls, soup: Any, metadata: Dict[str, Any], **state: Any
+    ) -> Tuple[
+        List[Any], "OrderedDict[str, Tuple[str, int, bool]]", List[Tuple[str, str]]
+    ]:
+        # Ensure a string is provided, in case the soup finds none
+        # This could occur if eg. an image is removed or not shown
+        markup: List[Union[str, Tuple[Optional[str], Any]]] = [""]
+        if soup is None:  # This is not iterable, so return promptly
+            return markup, metadata["message_links"], metadata["time_mentions"]
+        unrendered_tags = {  # In pairs of 'tag_name': 'text'
+            # TODO: Some of these could be implemented
+            "br": "",  # No indicator of absence
+            "hr": "RULER",
+            "img": "IMAGE",
+        }
+        unrendered_div_classes = {  # In pairs of 'div_class': 'text'
+            # TODO: Support embedded content & twitter preview?
+            "message_embed": "EMBEDDED CONTENT",
+            "inline-preview-twitter": "TWITTER PREVIEW",
+            "message_inline_ref": "",  # Duplicate of other content
+            "message_inline_image": "",  # Duplicate of other content
+        }
+        unrendered_template = "[{} NOT RENDERED]"
+        for element in soup:
+            if isinstance(element, Tag):
+                # Caching element variables for use in the
+                # if/elif/else chain below for improving legibility.
+                tag = element.name
+                tag_attrs = element.attrs
+                tag_classes = tag_attrs.get("class", [])
+                tag_text = element.text
+
+            if isinstance(element, NavigableString):
+                # NORMAL STRINGS
+                if element == "\n" and metadata.get("bq_len", 0) > 0:
+                    metadata["bq_len"] -= 1
+                    continue
+                markup.append(element)
+            elif tag == "div" and (set(tag_classes) & set(unrendered_div_classes)):
+                # UNRENDERED DIV CLASSES
+                # NOTE: Though `matches` is generalized for multiple
+                # matches it is very unlikely that there would be any.
+                matches = set(unrendered_div_classes) & set(tag_classes)
+                text = unrendered_div_classes[matches.pop()]
+                if text:
+                    markup.append(unrendered_template.format(text))
+            elif tag == "img" and tag_classes == ["emoji"]:
+                # CUSTOM EMOJIS AND ZULIP_EXTRA_EMOJI
+                emoji_name = tag_attrs.get("title", [])
+                markup.append(("msg_emoji", f":{emoji_name}:"))
+            elif tag in unrendered_tags:
+                # UNRENDERED SIMPLE TAGS
+                text = unrendered_tags[tag]
+                if text:
+                    markup.append(unrendered_template.format(text))
+            elif tag in ("h1", "h2", "h3", "h4", "h5", "h6"):
+                # HEADING STYLE (h1 to h6)
+                markup.append(("msg_heading", tag_text))
+            elif tag in ("p", "del"):
+                # PARAGRAPH, STRIKE-THROUGH
+                markup.extend(cls.soup2markup(element, metadata)[0])
+            elif tag == "span" and "emoji" in tag_classes:
+                # EMOJI
+                markup.append(("msg_emoji", tag_text))
+            elif tag == "span" and ({"katex-display", "katex"} & set(tag_classes)):
+                # MATH TEXT
+                # FIXME: Add html -> urwid client-side logic for rendering KaTex text.
+                # Avoid displaying multiple markups, and show only the source
+                # as of now.
+                if element.find("annotation"):
+                    tag_text = element.find("annotation").text
+
+                markup.append(("msg_math", tag_text))
+            elif tag == "span" and (
+                {"user-group-mention", "user-mention"} & set(tag_classes)
+            ):
+                # USER MENTIONS & USER-GROUP MENTIONS
+                markup.append(("msg_mention", tag_text))
+            elif tag == "a":
+                # LINKS
+                # Use rstrip to avoid anomalies and edge cases like
+                # https://google.com vs https://google.com/.
+                link = tag_attrs["href"].rstrip("/")
+                text = element.img["src"] if element.img else tag_text
+                text = text.rstrip("/")
+
+                parsed_link = urlparse(link)
+                if not parsed_link.scheme:  # => relative link
+                    # Prepend org url to convert it to an absolute link
+                    link = urljoin(metadata["server_url"], link)
+
+                text = text if text else link
+
+                show_footlink = True
+                # Only use the last segment if the text is redundant.
+                # NOTE: The 'without scheme' excerpt is to deal with the case
+                # where a user puts a link without any scheme and the server
+                # uses http as the default scheme but keeps the text as-is.
+                # For instance, see how example.com/some/path becomes
+                # <a href="http://example.com">example.com/some/path</a>.
+                link_without_scheme, text_without_scheme = [
+                    data.split("://")[1] if "://" in data else data
+                    for data in [link, text]
+                ]  # Split on '://' is for cases where text == link.
+                if link_without_scheme == text_without_scheme:
+                    last_segment = text.split("/")[-1]
+                    if "." in last_segment:
+                        new_text = last_segment  # Filename.
+                    elif text.startswith(metadata["server_url"]):
+                        # Relative URL.
+                        new_text = text.split(metadata["server_url"])[-1]
+                    else:
+                        new_text = (
+                            parsed_link.netloc
+                            if parsed_link.netloc
+                            else text.split("/")[0]
+                        )  # Domain name.
+                    if new_text != text_without_scheme:
+                        text = new_text
+                    else:
+                        # Do not show as a footlink as the text is sufficient
+                        # to represent the link.
+                        show_footlink = False
+
+                # Detect duplicate links to save screen real estate.
+                if link not in metadata["message_links"]:
+                    metadata["message_links"][link] = (
+                        text,
+                        len(metadata["message_links"]) + 1,
+                        show_footlink,
+                    )
+                else:
+                    # Append the text if its link already exist with a
+                    # different text.
+                    saved_text, saved_link_index, saved_footlink_status = metadata[
+                        "message_links"
+                    ][link]
+                    if saved_text != text:
+                        metadata["message_links"][link] = (
+                            f"{saved_text}, {text}",
+                            saved_link_index,
+                            show_footlink or saved_footlink_status,
+                        )
+
+                markup.extend(
+                    [
+                        ("msg_link", text),
+                        " ",
+                        ("msg_link_index", f"[{metadata['message_links'][link][1]}]"),
+                    ]
+                )
+            elif tag == "blockquote":
+                # BLOCKQUOTE TEXT
+                markup.append(("msg_quote", cls.soup2markup(element, metadata)[0]))
+            elif tag == "code":
+                # CODE (INLINE?)
+                markup.append(("msg_code", tag_text))
+            elif tag == "div" and "codehilite" in tag_classes:
+                """
+                CODE BLOCK
+                -------------
+                Structure:   # Language is optional
+                    <div class="codehilite" data-code-language="python">
+                      <pre>
+                        <span></span>
+                        <code>
+                          Code HTML
+                          Made of <span>'s and NavigableStrings
+                        </code>
+                      </pre>
+                    </div>
+                """
+                code_soup = element.pre.code
+                # NOTE: Old messages don't have the additional `code` tag.
+                # Ref: https://github.com/Python-Markdown/markdown/pull/862
+                if code_soup is None:
+                    code_soup = element.pre
+
+                for code_element in code_soup.contents:
+                    code_text = (
+                        code_element.text
+                        if isinstance(code_element, Tag)
+                        else code_element.string
+                    )
+
+                    if code_element.name == "span":
+                        if len(code_text) == 0:
+                            continue
+                        css_style = code_element.attrs.get("class", ["w"])
+                        markup.append((f"pygments:{css_style[0]}", code_text))
+                    else:
+                        markup.append(("pygments:w", code_text))
+            elif tag in ("strong", "em"):
+                # BOLD & ITALIC
+                markup.append(("msg_bold", tag_text))
+            elif tag in ("ul", "ol"):
+                # LISTS (UL & OL)
+                for part in element.contents:
+                    if part == "\n":
+                        part.replace_with("")
+
+                if "indent_level" not in state:
+                    state["indent_level"] = 1
+                    state["list_start"] = True
+                else:
+                    state["indent_level"] += 1
+                    state["list_start"] = False
+                if tag == "ol":
+                    start_number = int(tag_attrs.get("start", 1))
+                    state["list_index"] = start_number
+                    markup.extend(cls.soup2markup(element, metadata, **state)[0])
+                    del state["list_index"]  # reset at end of this list
+                else:
+                    if "list_index" in state:
+                        del state["list_index"]  # this is unordered
+                    markup.extend(cls.soup2markup(element, metadata, **state)[0])
+                del state["indent_level"]  # reset indents after any list
+            elif tag == "li":
+                # LIST ITEMS (LI)
+                for part in element.contents:
+                    if part == "\n":
+                        part.replace_with("")
+                if not state.get("list_start", False):
+                    markup.append("\n")
+
+                indent = state.get("indent_level", 1)
+                if "list_index" in state:
+                    markup.append(f"{'  ' * indent}{state['list_index']}. ")
+                    state["list_index"] += 1
+                else:
+                    chars = [
+                        "\N{BULLET}",
+                        "\N{RING OPERATOR}",  # small hollow
+                        "\N{HYPHEN}",
+                    ]
+                    markup.append(f"{'  ' * indent}{chars[(indent - 1) % 3]} ")
+                state["list_start"] = False
+                markup.extend(cls.soup2markup(element, metadata, **state)[0])
+            elif tag == "table":
+                markup.extend(render_table(element))
+            elif tag == "time":
+                # New in feature level 16, server version 3.0.
+                # Render time in current user's local time zone.
+                timestamp = element.get("datetime")
+
+                # This should not happen. Regardless, we are interested in
+                # debugging and reporting it to zulip/zulip if it does.
+                assert timestamp is not None, "Could not find datetime attr"
+
+                utc_time = dateutil.parser.parse(timestamp)
+                local_time = utc_time.astimezone(get_localzone())
+                # TODO: Address 12-hour format support with application-wide
+                # support for different formats.
+                time_string = local_time.strftime("%a, %b %-d %Y, %-H:%M (%Z)")
+                markup.append(("msg_time", f" {TIME_MENTION_MARKER} {time_string} "))
+
+                source_text = f"Original text was {tag_text.strip()}"
+                metadata["time_mentions"].append((time_string, source_text))
+            else:
+                markup.extend(cls.soup2markup(element, metadata)[0])
+        return markup, metadata["message_links"], metadata["time_mentions"]
+
+    def main_view(self) -> List[Any]:
+        # Recipient Header
+        if self.need_recipient_header():
+            if self.message["type"] == "stream":
+                recipient_header = self.stream_header()
+            else:
+                recipient_header = self.private_header()
+        else:
+            recipient_header = None
+
+        # Content Header
+        message = {
+            key: {
+                "is_starred": "starred" in msg["flags"],
+                "author": (
+                    msg["sender_full_name"] if "sender_full_name" in msg else None
+                ),
+                "time": (
+                    self.model.formatted_local_time(
+                        msg["timestamp"], show_seconds=False
+                    )
+                    if "timestamp" in msg
+                    else None
+                ),
+                "datetime": (
+                    datetime.fromtimestamp(msg["timestamp"])
+                    if "timestamp" in msg
+                    else None
+                ),
+            }
+            for key, msg in dict(this=self.message, last=self.last_message).items()
+        }
+        different = {  # How this message differs from the previous one
+            "recipients": recipient_header is not None,
+            "author": message["this"]["author"] != message["last"]["author"],
+            "24h": (
+                message["last"]["datetime"] is not None
+                and ((message["this"]["datetime"] - message["last"]["datetime"]).days)
+            ),
+            "timestamp": (
+                message["last"]["time"] is not None
+                and message["this"]["time"] != message["last"]["time"]
+            ),
+            "star_status": (
+                message["this"]["is_starred"] != message["last"]["is_starred"]
+            ),
+        }
+        any_differences = any(different.values())
+
+        if any_differences:  # Construct content_header, if needed
+            TextType = Dict[str, urwid_MarkupTuple]
+            text_keys = ("author", "star", "time", "status")
+            text: TextType = {key: (None, " ") for key in text_keys}
+
+            if any(different[key] for key in ("recipients", "author", "24h")):
+                text["author"] = ("msg_sender", message["this"]["author"])
+
+                # TODO: Refactor to use user ids for look up instead of emails.
+                email = self.message.get("sender_email", "")
+                user = self.model.user_dict.get(email, None)
+                # TODO: Currently status of bots are shown as `inactive`.
+                # Render bot users' status with bot marker as a follow-up
+                status = user.get("status", "inactive") if user else "inactive"
+
+                # The default text['status'] value is (None, ' ')
+                if status in STATE_ICON:
+                    text["status"] = (f"user_{status}", STATE_ICON[status])
+
+            if message["this"]["is_starred"]:
+                text["star"] = ("starred", "*")
+            if any(different[key] for key in ("recipients", "author", "timestamp")):
+                this_year = date.today().year
+                msg_year = message["this"]["datetime"].year
+                if this_year != msg_year:
+                    text["time"] = ("time", f"{msg_year} - {message['this']['time']}")
+                else:
+                    text["time"] = ("time", message["this"]["time"])
+
+            content_header = urwid.Columns(
+                [
+                    ("pack", urwid.Text(text["status"])),
+                    ("weight", 10, urwid.Text(text["author"])),
+                    (26, urwid.Text(text["time"], align="right")),
+                    (1, urwid.Text(text["star"], align="right")),
+                ],
+                dividechars=1,
+            )
+        else:
+            content_header = None
+
+        # If the message contains '/me' emote then replace it with
+        # sender's full name and show it in bold.
+        if self.message["is_me_message"]:
+            self.message["content"] = self.message["content"].replace(
+                "/me", f"<strong>{self.message['sender_full_name']}</strong>", 1
+            )
+
+        # Transform raw message content into markup (As needed by urwid.Text)
+        content, self.message_links, self.time_mentions = self.transform_content(
+            self.message["content"], self.model.server_url
+        )
+        self.content.set_text(content)
+
+        if self.message["id"] in self.model.index["edited_messages"]:
+            edited_label_size = 7
+            left_padding = 1
+        else:
+            edited_label_size = 0
+            left_padding = 8
+
+        wrapped_content = urwid.Padding(
+            urwid.Columns(
+                [
+                    (edited_label_size, urwid.Text("EDITED")),
+                    urwid.LineBox(
+                        urwid.Columns(
+                            [
+                                (1, urwid.Text("")),
+                                self.content,
+                            ]
+                        ),
+                        tline="",
+                        bline="",
+                        rline="",
+                        lline=MESSAGE_CONTENT_MARKER,
+                    ),
+                ]
+            ),
+            align="left",
+            left=left_padding,
+            width=("relative", 100),
+            min_width=10,
+            right=5,
+        )
+
+        # Reactions
+        reactions = self.reactions_view(self.message["reactions"])
+
+        # Footlinks.
+        footlinks, _ = self.footlinks_view(
+            self.message_links,
+            maximum_footlinks=self.model.controller.maximum_footlinks,
+            padded=True,
+            wrap="ellipsis",
+        )
+
+        # Build parts together and return
+        parts = [
+            (recipient_header, recipient_header is not None),
+            (content_header, any_differences),
+            (wrapped_content, True),
+            (footlinks, footlinks is not None),
+            (reactions, reactions != ""),
+        ]
+
+        self.header = [part for part, condition in parts[:2] if condition]
+        self.footer = [part for part, condition in parts[3:] if condition]
+
+        return [part for part, condition in parts if condition]
+
+    def update_message_author_status(self) -> bool:
+        """
+        Update the author status by resetting the entire message box
+        if author field is present.
+        """
+        author_is_present = False
+        author_column = 1  # Index of author field in content header
+
+        if len(self.header) > 0:
+            # -1 represents that content header is the last row of header field
+            author_field = self.header[-1][author_column]
+            author_is_present = author_field.text != " "
+
+        if author_is_present:
+            # Re initialize the message if update is required.
+            # FIXME: Render specific element (here author field) instead?
+            super().__init__(self.main_view())
+
+        return author_is_present
+
+    @classmethod
+    def transform_content(
+        cls, content: Any, server_url: str
+    ) -> Tuple[
+        Tuple[None, Any],
+        "OrderedDict[str, Tuple[str, int, bool]]",
+        List[Tuple[str, str]],
+    ]:
+        soup = BeautifulSoup(content, "lxml")
+        body = soup.find(name="body")
+
+        metadata = dict(
+            server_url=server_url,
+            message_links=OrderedDict(),
+            time_mentions=list(),
+        )  # type: Dict[str, Any]
+
+        if body and body.find(name="blockquote"):
+            metadata["bq_len"] = cls.indent_quoted_content(soup, QUOTED_TEXT_MARKER)
+
+        markup, message_links, time_mentions = cls.soup2markup(body, metadata)
+        return (None, markup), message_links, time_mentions
+
+    @staticmethod
+    def indent_quoted_content(soup: Any, padding_char: str) -> int:
+        """
+        We indent quoted text by padding them.
+        The extent of indentation depends on their level of quoting.
+        For example:
+        [Before Padding]               [After Padding]
+
+        <blockquote>                    <blockquote>
+        <blockquote>                    <blockquote>
+        <p>Foo</p>                      <p>▒ ▒ </p><p>Foo</p>
+        </blockquote>       --->        </blockquote>
+        <p>Boo</p>                      <p>▒ </p><p>Boo</p>
+        </blockquote>                   </blockquote>
+        """
+        pad_count = 1
+        blockquote_list = soup.find_all("blockquote")
+        bq_len = len(blockquote_list)
+        for tag in blockquote_list:
+            child_list = tag.findChildren(recursive=False)
+            child_block = tag.find_all("blockquote")
+            actual_padding = f"{padding_char} " * pad_count
+            if len(child_list) == 1:
+                pad_count -= 1
+                child_iterator = child_list
+            else:
+                if len(child_block) == 0:
+                    child_iterator = child_list
+                else:
+                    # If there is some text at the begining of a
+                    # quote, we pad it seperately.
+                    if child_list[0].name == "p":
+                        new_tag = soup.new_tag("p")
+                        new_tag.string = f"\n{actual_padding}"
+                        child_list[0].insert_before(new_tag)
+                    child_iterator = child_list[1:]
+            for child in child_iterator:
+                new_tag = soup.new_tag("p")
+                new_tag.string = actual_padding
+                # If the quoted message is multi-line message
+                # we deconstruct it and pad it at break-points (<br/>)
+                for br in child.findAll("br"):
+                    next_s = br.nextSibling
+                    text = str(next_s.string).strip()
+                    if text:
+                        insert_tag = soup.new_tag("p")
+                        insert_tag.string = f"\n{padding_char} {text}"
+                        next_s.replace_with(insert_tag)
+                child.insert_before(new_tag)
+            pad_count += 1
+        return bq_len
+
+    def selectable(self) -> bool:
+        # Returning True, indicates that this widget
+        # is designed to take focus.
+        return True
+
+    def mouse_event(
+        self, size: urwid_Size, event: str, button: int, col: int, row: int, focus: bool
+    ) -> bool:
+        if event == "mouse press":
+            if button == 1:
+                if self.model.controller.is_in_editor_mode():
+                    return True
+                self.keypress(size, primary_key_for_command("ENTER"))
+                return True
+
+        return super().mouse_event(size, event, button, col, row, focus)
+
+    def keypress(self, size: urwid_Size, key: str) -> Optional[str]:
+        if is_command_key("REPLY_MESSAGE", key):
+            if self.message["type"] == "private":
+                self.model.controller.view.write_box.private_box_view(
+                    recipient_user_ids=self.recipient_ids,
+                )
+            elif self.message["type"] == "stream":
+                self.model.controller.view.write_box.stream_box_view(
+                    caption=self.message["display_recipient"],
+                    title=self.message["subject"],
+                    stream_id=self.stream_id,
+                )
+        elif is_command_key("STREAM_MESSAGE", key):
+            if len(self.model.narrow) != 0 and self.model.narrow[0][0] == "stream":
+                self.model.controller.view.write_box.stream_box_view(
+                    caption=self.message["display_recipient"],
+                    stream_id=self.stream_id,
+                )
+            else:
+                self.model.controller.view.write_box.stream_box_view(0)
+        elif is_command_key("STREAM_NARROW", key):
+            if self.message["type"] == "private":
+                self.model.controller.narrow_to_user(
+                    recipient_emails=self.recipient_emails,
+                    contextual_message_id=self.message["id"],
+                )
+            elif self.message["type"] == "stream":
+                self.model.controller.narrow_to_stream(
+                    stream_name=self.stream_name,
+                    contextual_message_id=self.message["id"],
+                )
+        elif is_command_key("TOGGLE_NARROW", key):
+            self.model.unset_search_narrow()
+            if self.message["type"] == "private":
+                if len(self.model.narrow) == 1 and self.model.narrow[0][0] == "pm_with":
+                    self.model.controller.narrow_to_all_pm(
+                        contextual_message_id=self.message["id"],
+                    )
+                else:
+                    self.model.controller.narrow_to_user(
+                        recipient_emails=self.recipient_emails,
+                        contextual_message_id=self.message["id"],
+                    )
+            elif self.message["type"] == "stream":
+                if len(self.model.narrow) > 1:  # in a topic
+                    self.model.controller.narrow_to_stream(
+                        stream_name=self.stream_name,
+                        contextual_message_id=self.message["id"],
+                    )
+                else:
+                    self.model.controller.narrow_to_topic(
+                        stream_name=self.stream_name,
+                        topic_name=self.topic_name,
+                        contextual_message_id=self.message["id"],
+                    )
+        elif is_command_key("TOPIC_NARROW", key):
+            if self.message["type"] == "private":
+                self.model.controller.narrow_to_user(
+                    recipient_emails=self.recipient_emails,
+                    contextual_message_id=self.message["id"],
+                )
+            elif self.message["type"] == "stream":
+                self.model.controller.narrow_to_topic(
+                    stream_name=self.stream_name,
+                    topic_name=self.topic_name,
+                    contextual_message_id=self.message["id"],
+                )
+        elif is_command_key("ALL_MESSAGES", key):
+            self.model.controller.narrow_to_all_messages(
+                contextual_message_id=self.message["id"]
+            )
+        elif is_command_key("REPLY_AUTHOR", key):
+            # All subscribers from recipient_ids are not needed here.
+            self.model.controller.view.write_box.private_box_view(
+                recipient_user_ids=[self.message["sender_id"]],
+            )
+        elif is_command_key("MENTION_REPLY", key):
+            self.keypress(size, primary_key_for_command("REPLY_MESSAGE"))
+            mention = f"@**{self.message['sender_full_name']}** "
+            self.model.controller.view.write_box.msg_write_box.set_edit_text(mention)
+            self.model.controller.view.write_box.msg_write_box.set_edit_pos(
+                len(mention)
+            )
+            self.model.controller.view.middle_column.set_focus("footer")
+        elif is_command_key("QUOTE_REPLY", key):
+            self.keypress(size, primary_key_for_command("REPLY_MESSAGE"))
+
+            # To correctly quote a message that contains quote/code-blocks,
+            # we need to fence quoted message containing ``` with ````,
+            # ```` with ````` and so on.
+            response = self.model.fetch_raw_message_content(self.message["id"])
+            message_raw_content = response if response is not None else ""
+            fence = get_unused_fence(message_raw_content)
+
+            absolute_url = near_message_url(self.model.server_url[:-1], self.message)
+
+            # Compose box should look something like this:
+            #   @_**Zeeshan|514** [said](link to message):
+            #   ```quote
+            #   message_content
+            #   ```
+            quote = "@_**{0}|{1}** [said]({2}):\n{3}quote\n{4}\n{3}\n".format(
+                self.message["sender_full_name"],
+                self.message["sender_id"],
+                absolute_url,
+                fence,
+                message_raw_content,
+            )
+
+            self.model.controller.view.write_box.msg_write_box.set_edit_text(quote)
+            self.model.controller.view.write_box.msg_write_box.set_edit_pos(len(quote))
+            self.model.controller.view.middle_column.set_focus("footer")
+        elif is_command_key("EDIT_MESSAGE", key):
+            # User can't edit messages of others that already have a subject
+            # For private messages, subject = "" (empty string)
+            # This also handles the realm_message_content_edit_limit_seconds == 0 case
+            if (
+                self.message["sender_id"] != self.model.user_id
+                and self.message["subject"] != "(no topic)"
+            ):
+                if self.message["type"] == "stream":
+                    self.model.controller.report_error(
+                        [
+                            " You can't edit messages sent by other users that"
+                            " already have a topic."
+                        ]
+                    )
+                else:
+                    self.model.controller.report_error(
+                        [" You can't edit private messages sent by other users."]
+                    )
+                return key
+            # Check if editing is allowed in the realm
+            elif not self.model.initial_data["realm_allow_message_editing"]:
+                self.model.controller.report_error(
+                    [" Editing sent message is disabled."]
+                )
+                return key
+            # Check if message is still editable, i.e. within
+            # the time limit. A limit of 0 signifies no limit
+            # on message body editing.
+            msg_body_edit_enabled = True
+            if self.model.initial_data["realm_message_content_edit_limit_seconds"] > 0:
+                if self.message["sender_id"] == self.model.user_id:
+                    time_since_msg_sent = time() - self.message["timestamp"]
+                    edit_time_limit = self.model.initial_data[
+                        "realm_message_content_edit_limit_seconds"
+                    ]
+                    # Don't allow editing message body if time-limit exceeded.
+                    if time_since_msg_sent >= edit_time_limit:
+                        if self.message["type"] == "private":
+                            self.model.controller.report_error(
+                                [
+                                    " Time Limit for editing the message has been exceeded."
+                                ]
+                            )
+                            return key
+                        elif self.message["type"] == "stream":
+                            self.model.controller.report_warning(
+                                [
+                                    " Only topic editing allowed."
+                                    " Time Limit for editing the message body"
+                                    " has been exceeded."
+                                ]
+                            )
+                            msg_body_edit_enabled = False
+                elif self.message["type"] == "stream":
+                    # Allow editing topic if the message has "(no topic)" subject
+                    if self.message["subject"] == "(no topic)":
+                        self.model.controller.report_warning(
+                            [
+                                " Only topic editing is allowed."
+                                " This is someone else's message but with (no topic)."
+                            ]
+                        )
+                        msg_body_edit_enabled = False
+                    else:
+                        self.model.controller.report_error(
+                            [
+                                " You can't edit messages sent by other users that"
+                                " already have a topic."
+                            ]
+                        )
+                        return key
+                else:
+                    # The remaining case is of a private message not belonging to user.
+                    # Which should be already handled by the topmost if block
+                    raise RuntimeError(
+                        "Reached unexpected block. This should be handled at the top."
+                    )
+
+            if self.message["type"] == "private":
+                self.keypress(size, primary_key_for_command("REPLY_MESSAGE"))
+            elif self.message["type"] == "stream":
+                self.model.controller.view.write_box.stream_box_edit_view(
+                    stream_id=self.stream_id,
+                    caption=self.message["display_recipient"],
+                    title=self.message["subject"],
+                )
+            msg_id = self.message["id"]
+            response = self.model.fetch_raw_message_content(msg_id)
+            msg = response if response is not None else ""
+            write_box = self.model.controller.view.write_box
+            write_box.msg_edit_state = _MessageEditState(
+                message_id=msg_id, old_topic=self.message["subject"]
+            )
+            write_box.msg_write_box.set_edit_text(msg)
+            write_box.msg_write_box.set_edit_pos(len(msg))
+            write_box.msg_body_edit_enabled = msg_body_edit_enabled
+            # Set focus to topic box if message body editing is disabled.
+            if not msg_body_edit_enabled:
+                write_box.focus_position = write_box.FOCUS_CONTAINER_HEADER
+                write_box.header_write_box.focus_col = write_box.FOCUS_HEADER_BOX_TOPIC
+
+            self.model.controller.view.middle_column.set_focus("footer")
+        elif is_command_key("MSG_INFO", key):
+            self.model.controller.show_msg_info(
+                self.message, self.topic_links, self.message_links, self.time_mentions
+            )
+        elif is_command_key("ADD_REACTION", key):
+            self.model.controller.show_emoji_picker(self.message)
+        return key
diff --git a/zulipterminal/ui_tools/utils.py b/zulipterminal/ui_tools/utils.py
index 857fb9f..4c939e7 100644
--- a/zulipterminal/ui_tools/utils.py
+++ b/zulipterminal/ui_tools/utils.py
@@ -7,7 +7,7 @@ from typing import Any, Iterable, List, Optional
 import urwid
 
 from zulipterminal.api_types import Message
-from zulipterminal.ui_tools.boxes import MessageBox
+from zulipterminal.ui_tools.messages import MessageBox
 
 
 def create_msg_box_list(
diff --git a/zulipterminal/ui_tools/views.py b/zulipterminal/ui_tools/views.py
index eb9f3a2..4e3dd68 100644
--- a/zulipterminal/ui_tools/views.py
+++ b/zulipterminal/ui_tools/views.py
@@ -43,7 +43,7 @@ from zulipterminal.helper import (
     match_user,
 )
 from zulipterminal.server_url import near_message_url
-from zulipterminal.ui_tools.boxes import MessageBox, PanelSearchBox
+from zulipterminal.ui_tools.boxes import PanelSearchBox
 from zulipterminal.ui_tools.buttons import (
     EmojiButton,
     HomeButton,
@@ -55,6 +55,7 @@ from zulipterminal.ui_tools.buttons import (
     TopicButton,
     UserButton,
 )
+from zulipterminal.ui_tools.messages import MessageBox
 from zulipterminal.ui_tools.utils import create_msg_box_list
 from zulipterminal.urwid_types import urwid_Size
 


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index 8943b5f..c4dc1c6 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -14,8 +14,8 @@ from zulipterminal.config.keys import (
 )
 from zulipterminal.helper import Index, TidiedUserInfo
 from zulipterminal.helper import initial_index as helper_initial_index
-from zulipterminal.ui_tools.boxes import MessageBox
 from zulipterminal.ui_tools.buttons import StreamButton, TopicButton, UserButton
+from zulipterminal.ui_tools.messages import MessageBox
 from zulipterminal.urwid_types import urwid_Size
 from zulipterminal.version import (
     MINIMUM_SUPPORTED_SERVER_VERSION,
@@ -23,6 +23,9 @@ from zulipterminal.version import (
 )
 
 
+# --------------- Autouse Fixtures -----------------------------------------
+
+
 @pytest.fixture(autouse=True)
 def no_requests(monkeypatch: pytest.MonkeyPatch) -> None:
     """
@@ -1234,6 +1237,11 @@ def classified_unread_counts() -> Dict[str, Any]:
 # --------------- UI Fixtures -----------------------------------------
 
 
+@pytest.fixture(params=[True, False], ids=["ignore_mouse_click", "handle_mouse_click"])
+def compose_box_is_open(request: Any) -> bool:
+    return request.param
+
+
 @pytest.fixture(
     params=[
         ("mouse press", 4, primary_key_for_command("GO_UP")),
diff --git a/tests/core/test_core.py b/tests/core/test_core.py
index beac0d1..a083ae7 100644
--- a/tests/core/test_core.py
+++ b/tests/core/test_core.py
@@ -25,7 +25,7 @@ SERVER_URL = "https://chat.zulip.zulip"
 class TestController:
     @pytest.fixture(autouse=True)
     def mock_external_classes(self, mocker: MockerFixture) -> None:
-        mocker.patch("zulipterminal.ui_tools.boxes.MessageBox.main_view")
+        mocker.patch("zulipterminal.ui_tools.messages.MessageBox.main_view")
         self.client = mocker.patch("zulip.Client")
         # Patch init only, in general, allowing specific patching elsewhere
         self.model = mocker.patch(MODEL + ".__init__", return_value=None)
diff --git a/tests/ui/test_ui.py b/tests/ui/test_ui.py
index 80ce5ff..949a1d0 100644
--- a/tests/ui/test_ui.py
+++ b/tests/ui/test_ui.py
@@ -21,7 +21,7 @@ class TestView:
         self.controller = mocker.patch(CONTROLLER, return_value=None)
         self.model = mocker.patch(CONTROLLER + ".model")
         self.write_box = mocker.patch(MODULE + ".WriteBox")
-        self.search_box = mocker.patch(MODULE + ".SearchBox")
+        self.search_box = mocker.patch(MODULE + ".MessageSearchBox")
         mocker.patch(MODULE + ".TabView")
         mocker.patch(MODULE + ".LeftColumnView")
         mocker.patch("zulipterminal.ui_tools.views.urwid.Frame")
diff --git a/tests/ui/test_ui_tools.py b/tests/ui/test_ui_tools.py
index a527dd0..fb9f3b0 100644
--- a/tests/ui/test_ui_tools.py
+++ b/tests/ui/test_ui_tools.py
@@ -1,22 +1,11 @@
-from collections import OrderedDict, defaultdict
-from datetime import date
+from collections import OrderedDict
 
 import pytest
-import pytz
-from bs4 import BeautifulSoup
-from pytest import param as case
-from urwid import Columns, Divider, Padding, Text
+from urwid import Divider
 
 from zulipterminal.config.keys import keys_for_command, primary_key_for_command
-from zulipterminal.config.symbols import (
-    QUOTED_TEXT_MARKER,
-    STATUS_ACTIVE,
-    STATUS_INACTIVE,
-    STREAM_TOPIC_SEPARATOR,
-    TIME_MENTION_MARKER,
-)
+from zulipterminal.config.symbols import STATUS_ACTIVE
 from zulipterminal.helper import powerset
-from zulipterminal.ui_tools.boxes import MessageBox
 from zulipterminal.ui_tools.views import (
     SIDE_PANELS_MOUSE_SCROLL_LINES,
     LeftColumnView,
@@ -33,20 +22,11 @@ from zulipterminal.ui_tools.views import (
 
 
 SUBDIR = "zulipterminal.ui_tools"
-BOXES = SUBDIR + ".boxes"
 VIEWS = SUBDIR + ".views"
 MESSAGEVIEW = VIEWS + ".MessageView"
 MIDCOLVIEW = VIEWS + ".MiddleColumnView"
 
 
-SERVER_URL = "https://chat.zulip.zulip"
-
-
-@pytest.fixture(params=[True, False], ids=["ignore_mouse_click", "handle_mouse_click"])
-def compose_box_is_open(request):
-    return request.param
-
-
 class TestModListWalker:
     @pytest.fixture
     def mod_walker(self):
@@ -1249,1857 +1229,3 @@ class TestTabView:
     def test_tab_render(self, tab_view, TAB_WIDTH, TAB_HEIGHT, expected_output):
         render_output = tab_view._w.render((TAB_WIDTH, TAB_HEIGHT)).text
         assert render_output == expected_output
-
-
-class TestMessageBox:
-    @pytest.fixture(autouse=True)
-    def mock_external_classes(self, mocker, initial_index):
-        self.model = mocker.MagicMock()
-        self.model.index = initial_index
-
-    @pytest.mark.parametrize(
-        "message_type, set_fields",
-        [
-            ("stream", [("stream_name", ""), ("stream_id", None), ("topic_name", "")]),
-            ("private", [("email", ""), ("user_id", None)]),
-        ],
-    )
-    def test_init(self, mocker, message_type, set_fields):
-        mocker.patch.object(MessageBox, "main_view")
-        message = dict(
-            display_recipient=[
-                {"id": 7, "email": "boo@zulip.com", "full_name": "Boo is awesome"}
-            ],
-            stream_id=5,
-            subject="hi",
-            sender_email="foo@zulip.com",
-            sender_id=4209,
-            type=message_type,
-        )
-
-        msg_box = MessageBox(message, self.model, None)
-
-        assert msg_box.last_message == defaultdict(dict)
-        for field, invalid_default in set_fields:
-            assert getattr(msg_box, field) != invalid_default
-        if message_type == "stream":
-            assert msg_box.topic_links == OrderedDict()
-        assert msg_box.message_links == OrderedDict()
-        assert msg_box.time_mentions == list()
-
-    def test_init_fails_with_bad_message_type(self):
-        message = dict(type="BLAH")
-
-        with pytest.raises(RuntimeError):
-            msg_box = MessageBox(message, self.model, None)
-
-    def test_private_message_to_self(self, mocker):
-        message = dict(
-            type="private",
-            display_recipient=[
-                {"full_name": "Foo Foo", "email": "foo@zulip.com", "id": None}
-            ],
-            sender_id=9,
-            content="<p> self message. </p>",
-            sender_full_name="Foo Foo",
-            sender_email="foo@zulip.com",
-            timestamp=150989984,
-        )
-        self.model.user_email = "foo@zulip.com"
-        mocker.patch(
-            BOXES + ".MessageBox._is_private_message_to_self", return_value=True
-        )
-        mocker.patch.object(MessageBox, "main_view")
-        msg_box = MessageBox(message, self.model, None)
-
-        assert msg_box.recipient_emails == ["foo@zulip.com"]
-        msg_box._is_private_message_to_self.assert_called_once_with()
-
-    @pytest.mark.parametrize(
-        "content, expected_markup",
-        [
-            case("", [], id="empty"),
-            case("<p>hi</p>", ["", "hi"], id="p"),
-            case(
-                '<span class="user-mention">@Bob Smith',
-                [("msg_mention", "@Bob Smith")],
-                id="user-mention",
-            ),
-            case("<h1>heading1</h1>", [("msg_heading", "heading1")], id="h1"),
-            case("<h2>heading2</h2>", [("msg_heading", "heading2")], id="h2"),
-            case("<h3>heading3</h3>", [("msg_heading", "heading3")], id="h3"),
-            case("<h4>heading4</h4>", [("msg_heading", "heading4")], id="h4"),
-            case("<h5>heading5</h5>", [("msg_heading", "heading5")], id="h5"),
-            case("<h6>heading6</h6>", [("msg_heading", "heading6")], id="h6"),
-            case(
-                '<span class="user-group-mention">@A Group',
-                [("msg_mention", "@A Group")],
-                id="group-mention",
-            ),
-            case("<code>some code", [("msg_code", "some code")], id="code"),
-            case(
-                '<div class="codehilite" data-code-language="python">'
-                "<pre><span></span>"
-                "<code><span>def</span> <span>func</span><span>():</span>\n"
-                '    <span class="pg">print</span><span>()</span><span></span>\n'
-                "\n"
-                "<span>class</span> <span>New</span><span>:</span>\n"
-                '    <span>name</span> <span>=</span> <span>"name"</span>\n'
-                "</code></pre></div>",
-                [
-                    ("pygments:w", "def"),
-                    ("pygments:w", " "),
-                    ("pygments:w", "func"),
-                    ("pygments:w", "():"),
-                    ("pygments:w", "\n" "    "),
-                    ("pygments:pg", "print"),
-                    ("pygments:w", "()"),
-                    ("pygments:w", "\n" "\n"),
-                    ("pygments:w", "class"),
-                    ("pygments:w", " "),
-                    ("pygments:w", "New"),
-                    ("pygments:w", ":"),
-                    ("pygments:w", "\n" "    "),
-                    ("pygments:w", "name"),
-                    ("pygments:w", " "),
-                    ("pygments:w", "="),
-                    ("pygments:w", " "),
-                    ("pygments:w", '"name"'),
-                    ("pygments:w", "\n"),
-                ],
-                id="codehilite-code",
-            ),
-            case(
-                '<div class="codehilite" data-code-language="python">'
-                "<pre><span></span>"
-                "<span>def</span> <span>func</span><span>():</span>\n"
-                '    <span class="pg">print</span><span>()</span>\n'
-                "\n"
-                "<span>class</span> <span>New</span><span>:</span>\n"
-                '    <span>name</span> <span>=</span> <span>"name"</span>\n'
-                "</pre></div>",
-                [
-                    ("pygments:w", "def"),
-                    ("pygments:w", " "),
-                    ("pygments:w", "func"),
-                    ("pygments:w", "():"),
-                    ("pygments:w", "\n" "    "),
-                    ("pygments:pg", "print"),
-                    ("pygments:w", "()"),
-                    ("pygments:w", "\n" "\n"),
-                    ("pygments:w", "class"),
-                    ("pygments:w", " "),
-                    ("pygments:w", "New"),
-                    ("pygments:w", ":"),
-                    ("pygments:w", "\n" "    "),
-                    ("pygments:w", "name"),
-                    ("pygments:w", " "),
-                    ("pygments:w", "="),
-                    ("pygments:w", " "),
-                    ("pygments:w", '"name"'),
-                    ("pygments:w", "\n"),
-                ],
-                id="codehilite-code-old",
-            ),
-            case(
-                '<div class="codehilite">'
-                "<pre><span></span>"
-                "<code>This is a\n"
-                "    Plain\n"
-                "\n"
-                "    Codeblock\n"
-                "</code></pre></div>",
-                [
-                    ("pygments:w", "This is a\n    Plain\n\n    Codeblock\n"),
-                ],
-                id="codehilite-plain-text-codeblock",
-            ),
-            case(
-                '<div class="codehilite">'
-                "<pre><span></span>"
-                "This is a\n"
-                "    Plain\n"
-                "\n"
-                "    Codeblock\n"
-                "</pre></div>",
-                [
-                    ("pygments:w", "This is a\n    Plain\n\n    Codeblock\n"),
-                ],
-                id="codehilite-plain-text-codeblock-old",
-            ),
-            case("<strong>Something", [("msg_bold", "Something")], id="strong"),
-            case("<em>Something", [("msg_bold", "Something")], id="em"),
-            case("<blockquote>stuff", [("msg_quote", ["", "stuff"])], id="blockquote"),
-            # FIXME Unsupported:
-            case(
-                '<div class="message_embed">',
-                ["[EMBEDDED CONTENT NOT RENDERED]"],
-                id="embedded_content",
-            ),
-            # TODO: Generate test cases to work with both soup2markup and
-            # footlinks_view.
-            case(
-                '<a href="http://foo">Foo</a><a href="https://bar.org">Bar</a>',
-                [
-                    ("msg_link", "Foo"),
-                    " ",
-                    ("msg_link_index", "[1]"),
-                    ("msg_link", "Bar"),
-                    " ",
-                    ("msg_link_index", "[2]"),
-                ],
-                id="link_two",
-            ),
-            case(
-                '<a href="http://foo">Foo</a><a href="http://foo">Another foo</a>',
-                [
-                    ("msg_link", "Foo"),
-                    " ",
-                    ("msg_link_index", "[1]"),
-                    ("msg_link", "Another foo"),
-                    " ",
-                    ("msg_link_index", "[1]"),
-                ],
-                id="link_samelinkdifferentname",
-            ),
-            case(
-                '<a href="http://foo">Foo</a><a href="https://bar.org">Bar</a>'
-                '<a href="http://foo">Foo</a><a href="https://bar.org">Bar</a>',
-                [
-                    ("msg_link", "Foo"),
-                    " ",
-                    ("msg_link_index", "[1]"),
-                    ("msg_link", "Bar"),
-                    " ",
-                    ("msg_link_index", "[2]"),
-                    ("msg_link", "Foo"),
-                    " ",
-                    ("msg_link_index", "[1]"),
-                    ("msg_link", "Bar"),
-                    " ",
-                    ("msg_link_index", "[2]"),
-                ],
-                id="link_duplicatelink",
-            ),
-            case(
-                '<a href="http://baz.com/">http://baz.com/</a>',
-                [("msg_link", "http://baz.com"), " ", ("msg_link_index", "[1]")],
-                id="link_trailingslash",
-            ),
-            case(
-                '<a href="http://foo.com/">Foo</a><a href="http://foo.com">Foo</a>',
-                [
-                    ("msg_link", "Foo"),
-                    " ",
-                    ("msg_link_index", "[1]"),
-                    ("msg_link", "Foo"),
-                    " ",
-                    ("msg_link_index", "[1]"),
-                ],
-                id="link_trailingslashduplicatelink",
-            ),
-            case(
-                '<a href="http://foo">http://foo</a>',
-                [("msg_link", "http://foo"), " ", ("msg_link_index", "[1]")],
-                id="link_sametext",
-            ),
-            case(
-                '<a href="http://foo/bar.png">http://foo/bar.png</a>',
-                [("msg_link", "bar.png"), " ", ("msg_link_index", "[1]")],
-                id="link_sameimage",
-            ),
-            case(
-                '<a href="http://foo">bar</a>',
-                [("msg_link", "bar"), " ", ("msg_link_index", "[1]")],
-                id="link_differenttext",
-            ),
-            case(
-                '<a href="/user_uploads/blah.gif"',
-                [("msg_link", "blah.gif"), " ", ("msg_link_index", "[1]")],
-                id="link_userupload",
-            ),
-            case(
-                '<a href="/api"',
-                [("msg_link", "/api"), " ", ("msg_link_index", "[1]")],
-                id="link_api",
-            ),
-            case(
-                f'<a href="some/relative_url">{SERVER_URL}/some/relative_url</a>',
-                [("msg_link", "/some/relative_url"), " ", ("msg_link_index", "[1]")],
-                id="link_serverrelative_same",
-            ),
-            case(
-                '<a href="http://foo.com/bar">foo.com/bar</a>',
-                [("msg_link", "foo.com"), " ", ("msg_link_index", "[1]")],
-                id="link_textwithoutscheme",
-            ),
-            case(
-                '<a href="http://foo.com">foo.com</a>'
-                '<a href="http://foo.com">http://foo.com</a>'
-                '<a href="https://foo.com">https://foo.com</a>'
-                '<a href="http://foo.com">Text</a>',
-                [
-                    ("msg_link", "foo.com"),
-                    " ",
-                    ("msg_link_index", "[1]"),
-                    ("msg_link", "http://foo.com"),
-                    " ",
-                    ("msg_link_index", "[1]"),
-                    ("msg_link", "https://foo.com"),
-                    " ",
-                    ("msg_link_index", "[2]"),
-                    ("msg_link", "Text"),
-                    " ",
-                    ("msg_link_index", "[1]"),
-                ],
-                id="link_differentscheme",
-            ),
-            case("<li>Something", ["\n", "  \N{BULLET} ", "", "Something"], id="li"),
-            case("<li></li>", ["\n", "  \N{BULLET} ", ""], id="empty_li"),
-            case(
-                "<li>\n<p>Something",
-                ["\n", "  \N{BULLET} ", "", "", "", "Something"],
-                id="li_with_li_p_newline",
-            ),
-            case(
-                "<li>Something<li>else",
-                [
-                    "\n",
-                    "  \N{BULLET} ",
-                    "",
-                    "Something",
-                    "\n",
-                    "  \N{BULLET} ",
-                    "",
-                    "else",
-                ],
-                id="two_li",
-            ),
-            case(
-                "<li>\n<p>Something</p>\n</li><li>else",
-                [
-                    "\n",
-                    "  \N{BULLET} ",
-                    "",
-                    "",
-                    "",
-                    "Something",
-                    "",
-                    "\n",
-                    "  \N{BULLET} ",
-                    "",
-                    "else",
-                ],
-                id="two_li_with_li_p_newlines",
-            ),
-            case(
-                "<ul><li>Something<ul><li>nested",
-                [
-                    "",
-                    "  \N{BULLET} ",
-                    "",
-                    "Something",
-                    "",
-                    "\n",
-                    "    \N{RING OPERATOR} ",
-                    "",
-                    "nested",
-                ],
-                id="li_nested",
-            ),
-            case(
-                "<ul><li>Something<ul><li>nested<ul><li>a<ul><li>lot",
-                [
-                    "",
-                    "  \N{BULLET} ",
-                    "",
-                    "Something",
-                    "",
-                    "\n",
-                    "    \N{RING OPERATOR} ",
-                    "",
-                    "nested",
-                    "",
-                    "\n",
-                    "      \N{HYPHEN} ",
-                    "",
-                    "a",
-                    "",
-                    "\n",
-                    "        \N{BULLET} ",
-                    "",
-                    "lot",
-                ],
-                id="li_heavily_nested",
-            ),
-            case("<br>", [], id="br"),
-            case("<br/>", [], id="br2"),
-            case("<hr>", ["[RULER NOT RENDERED]"], id="hr"),
-            case("<hr/>", ["[RULER NOT RENDERED]"], id="hr2"),
-            case("<img>", ["[IMAGE NOT RENDERED]"], id="img"),
-            case("<img/>", ["[IMAGE NOT RENDERED]"], id="img2"),
-            case(
-                "<table><thead><tr><th>Firstname</th><th>Lastname</th></tr></thead>"
-                "<tbody><tr><td>John</td><td>Doe</td></tr><tr><td>Mary</td><td>Moe"
-                "</td></tr></tbody></table>",
-                [
-                    "┌─",
-                    "─────────",
-                    "─┬─",
-                    "────────",
-                    "─┐\n",
-                    "│ ",
-                    ("table_head", "Firstname"),
-                    " │ ",
-                    ("table_head", "Lastname"),
-                    " │\n",
-                    "├─",
-                    "─────────",
-                    "─┼─",
-                    "────────",
-                    "─┤\n",
-                    "│ ",
-                    (None, "John     "),
-                    " │ ",
-                    (None, "Doe     "),
-                    " │\n",
-                    "│ ",
-                    (None, "Mary     "),
-                    " │ ",
-                    (None, "Moe     "),
-                    " │\n",
-                    "└─",
-                    "─────────",
-                    "─┴─",
-                    "────────",
-                    "─┘",
-                ],
-                id="table_default",
-            ),
-            case(
-                '<table><thead><tr><th align="left">Name</th><th align="right">Id'
-                '</th></tr></thead><tbody><tr><td align="left">Robert</td>'
-                '<td align="right">1</td></tr><tr><td align="left">Mary</td>'
-                '<td align="right">100</td></tr></tbody></table>',
-                [
-                    "┌─",
-                    "──────",
-                    "─┬─",
-                    "───",
-                    "─┐\n",
-                    "│ ",
-                    ("table_head", "Name  "),
-                    " │ ",
-                    ("table_head", " Id"),
-                    " │\n",
-                    "├─",
-                    "──────",
-                    "─┼─",
-                    "───",
-                    "─┤\n",
-                    "│ ",
-                    (None, "Robert"),
-                    " │ ",
-                    (None, "  1"),
-                    " │\n",
-                    "│ ",
-                    (None, "Mary  "),
-                    " │ ",
-                    (None, "100"),
-                    " │\n",
-                    "└─",
-                    "──────",
-                    "─┴─",
-                    "───",
-                    "─┘",
-                ],
-                id="table_with_left_and_right_alignments",
-            ),
-            case(
-                '<table><thead><tr><th align="center">Name</th><th align="right">Id'
-                '</th></tr></thead><tbody><tr><td align="center">Robert</td>'
-                '<td align="right">1</td></tr><tr><td align="center">Mary</td>'
-                '<td align="right">100</td></tr></tbody></table>',
-                [
-                    "┌─",
-                    "──────",
-                    "─┬─",
-                    "───",
-                    "─┐\n",
-                    "│ ",
-                    ("table_head", " Name "),
-                    " │ ",
-                    ("table_head", " Id"),
-                    " │\n",
-                    "├─",
-                    "──────",
-                    "─┼─",
-                    "───",
-                    "─┤\n",
-                    "│ ",
-                    (None, "Robert"),
-                    " │ ",
-                    (None, "  1"),
-                    " │\n",
-                    "│ ",
-                    (None, " Mary "),
-                    " │ ",
-                    (None, "100"),
-                    " │\n",
-                    "└─",
-                    "──────",
-                    "─┴─",
-                    "───",
-                    "─┘",
-                ],
-                id="table_with_center_and_right_alignments",
-            ),
-            case(
-                "<table><thead><tr><th>Name</th></tr></thead><tbody><tr><td>Foo</td>"
-                "</tr><tr><td>Bar</td></tr><tr><td>Baz</td></tr></tbody></table>",
-                [
-                    "┌─",
-                    "────",
-                    "─┐\n",
-                    "│ ",
-                    ("table_head", "Name"),
-                    " │\n",
-                    "├─",
-                    "────",
-                    "─┤\n",
-                    "│ ",
-                    (None, "Foo "),
-                    " │\n",
-                    "│ ",
-                    (None, "Bar "),
-                    " │\n",
-                    "│ ",
-                    (None, "Baz "),
-                    " │\n",
-                    "└─",
-                    "────",
-                    "─┘",
-                ],
-                id="table_with_single_column",
-            ),
-            case(
-                "<table><thead><tr><th>Column1</th></tr></thead><tbody><tr><td></td>"
-                "</tr></tbody></table>",
-                [
-                    "┌─",
-                    "───────",
-                    "─┐\n",
-                    "│ ",
-                    ("table_head", "Column1"),
-                    " │\n",
-                    "├─",
-                    "───────",
-                    "─┤\n",
-                    "│ ",
-                    (None, "       "),
-                    " │\n",
-                    "└─",
-                    "───────",
-                    "─┘",
-                ],
-                id="table_with_the_bare_minimum",
-            ),
-            case(
-                '<time datetime="2020-08-07T04:30:00Z"> Fri, Aug 7 2020, 10:00AM IST'
-                "</time>",
-                [("msg_time", f" {TIME_MENTION_MARKER} Fri, Aug 7 2020, 10:00 (IST) ")],
-                id="time_human_readable_input",
-            ),
-            case(
-                '<time datetime="2020-08-11T16:32:58Z"> 1597163578</time>',
-                [
-                    (
-                        "msg_time",
-                        f" {TIME_MENTION_MARKER} Tue, Aug 11 2020, 22:02 (IST) ",
-                    )
-                ],
-                id="time_UNIX_timestamp_input",
-            ),
-            case(
-                # Markdown:
-                # ```math
-                # some-math
-                # ```
-                '<span class="katex-display"><span class="katex"><semantics>'
-                "<annotation>some-math</annotation></semantics></span></span>",
-                [("msg_math", "some-math")],
-                id="katex_HTML_response_math_fenced_markdown",
-            ),
-            case(
-                # Markdown:
-                # $$ some-math $$
-                '<span class="katex"><semantics><annotation>some-math</annotation>'
-                "</semantics></span>",
-                [("msg_math", "some-math")],
-                id="katex_HTML_response_double_$_fenced_markdown",
-            ),
-            case("<ul><li>text</li></ul>", ["", "  \N{BULLET} ", "", "text"], id="ul"),
-            case(
-                "<ul>\n<li>text</li>\n</ul>",
-                ["", "", "  \N{BULLET} ", "", "text", ""],
-                id="ul_with_ul_li_newlines",
-            ),
-            case("<ol><li>text</li></ol>", ["", "  1. ", "", "text"], id="ol"),
-            case(
-                "<ol>\n<li>text</li>\n</ol>",
-                ["", "", "  1. ", "", "text", ""],
-                id="ol_with_ol_li_newlines",
-            ),
-            case(
-                '<ol start="5"><li>text</li></ol>',
-                ["", "  5. ", "", "text"],
-                id="ol_starting_at_5",
-            ),
-            # FIXME Strikethrough
-            case("<del>text</del>", ["", "text"], id="strikethrough_del"),
-            # FIXME inline image?
-            case(
-                '<div class="message_inline_image">'
-                '<a href="x"><img src="x"></a></div>',
-                [],
-                id="inline_image",
-            ),
-            # FIXME inline ref?
-            case('<div class="message_inline_ref">blah</div>', [], id="inline_ref"),
-            case(
-                '<span class="emoji">:smile:</span>',
-                [("msg_emoji", ":smile:")],
-                id="emoji",
-            ),
-            case(
-                '<div class="inline-preview-twitter"',
-                ["[TWITTER PREVIEW NOT RENDERED]"],
-                id="preview-twitter",
-            ),
-            case(
-                '<img class="emoji" title="zulip"/>',
-                [("msg_emoji", ":zulip:")],
-                id="zulip_extra_emoji",
-            ),
-            case(
-                '<img class="emoji" title="github"/>',
-                [("msg_emoji", ":github:")],
-                id="custom_emoji",
-            ),
-        ],
-    )
-    def test_soup2markup(self, content, expected_markup, mocker):
-        mocker.patch(
-            BOXES + ".get_localzone", return_value=pytz.timezone("Asia/Kolkata")
-        )
-        soup = BeautifulSoup(content, "lxml").find(name="body")
-        metadata = dict(
-            server_url=SERVER_URL,
-            message_links=OrderedDict(),
-            time_mentions=list(),
-            bq_len=0,
-        )
-
-        markup, *_ = MessageBox.soup2markup(soup, metadata)
-
-        assert markup == [""] + expected_markup
-
-    @pytest.mark.parametrize(
-        "message, last_message",
-        [
-            (
-                {
-                    "sender_id": 1,
-                    "display_recipient": "Verona",
-                    "sender_full_name": "aaron",
-                    "submessages": [],
-                    "stream_id": 5,
-                    "subject": "Verona2",
-                    "id": 37,
-                    "subject_links": [],
-                    "content": (
-                        "<p>It's nice and it feels more modern, but I think"
-                        " this will take some time to get used to</p>"
-                    ),
-                    "timestamp": 1531716583,
-                    "sender_realm_str": "zulip",
-                    "client": "populate_db",
-                    "content_type": "text/html",
-                    "reactions": [],
-                    "type": "stream",
-                    "is_me_message": False,
-                    "flags": ["read"],
-                    "sender_email": "AARON@zulip.com",
-                },
-                None,
-            ),
-            (
-                {
-                    "sender_id": 5,
-                    "display_recipient": [
-                        {
-                            "is_mirror_dummy": False,
-                            "email": "AARON@zulip.com",
-                            "id": 1,
-                            "full_name": "aaron",
-                        },
-                        {
-                            "is_mirror_dummy": False,
-                            "email": "iago@zulip.com",
-                            "id": 5,
-                            "full_name": "Iago",
-                        },
-                    ],
-                    "sender_full_name": "Iago",
-                    "submessages": [],
-                    "subject": "",
-                    "id": 107,
-                    "subject_links": [],
-                    "content": "<p>what are you planning to do this week</p>",
-                    "timestamp": 1532103879,
-                    "sender_realm_str": "zulip",
-                    "client": "ZulipTerminal",
-                    "content_type": "text/html",
-                    "reactions": [],
-                    "type": "private",
-                    "is_me_message": False,
-                    "flags": ["read"],
-                    "sender_email": "iago@zulip.com",
-                },
-                None,
-            ),
-        ],
-    )
-    def test_main_view(self, mocker, message, last_message):
-        self.model.stream_dict = {
-            5: {
-                "color": "#bd6",
-            },
-        }
-        msg_box = MessageBox(message, self.model, last_message)
-
-    @pytest.mark.parametrize(
-        "message",
-        [
-            {
-                "id": 4,
-                "type": "stream",
-                "display_recipient": "Verona",
-                "stream_id": 5,
-                "subject": "Test topic",
-                "is_me_message": True,  # will be overridden by test function.
-                "flags": [],
-                "content": "",  # will be overridden by test function.
-                "reactions": [],
-                "sender_full_name": "Alice",
-                "timestamp": 1532103879,
-            }
-        ],
-    )
-    @pytest.mark.parametrize(
-        "content, is_me_message",
-        [
-            ("<p>/me is excited!</p>", True),
-            ("<p>/me is excited! /me is not excited.</p>", True),
-            ("<p>This is /me not.</p>", False),
-            ("<p>/me is excited!</p>", False),
-        ],
-    )
-    def test_main_view_renders_slash_me(self, mocker, message, content, is_me_message):
-        mocker.patch(BOXES + ".urwid.Text")
-        message["content"] = content
-        message["is_me_message"] = is_me_message
-        msg_box = MessageBox(message, self.model, message)
-        msg_box.main_view()
-        name_index = 11 if is_me_message else -1  # 11 = len(<str><strong>)
-        assert (
-            msg_box.message["content"].find(message["sender_full_name"]) == name_index
-        )
-
-    @pytest.mark.parametrize(
-        "message",
-        [
-            {
-                "id": 4,
-                "type": "stream",
-                "display_recipient": "Verona",
-                "stream_id": 5,
-                "subject": "Test topic",
-                "flags": [],
-                "is_me_message": False,
-                "content": "<p>what are you planning to do this week</p>",
-                "reactions": [],
-                "sender_full_name": "Alice",
-                "timestamp": 1532103879,
-            }
-        ],
-    )
-    @pytest.mark.parametrize(
-        "to_vary_in_last_message",
-        [
-            {"display_recipient": "Verona offtopic"},
-            {"subject": "Test topic (previous)"},
-            {"type": "private"},
-        ],
-        ids=[
-            "different_stream_before",
-            "different_topic_before",
-            "PM_before",
-        ],
-    )
-    def test_main_view_generates_stream_header(
-        self, mocker, message, to_vary_in_last_message
-    ):
-        self.model.stream_dict = {
-            5: {
-                "color": "#bd6",
-            },
-        }
-        last_message = dict(message, **to_vary_in_last_message)
-        msg_box = MessageBox(message, self.model, last_message)
-        view_components = msg_box.main_view()
-        assert len(view_components) == 3
-
-        assert isinstance(view_components[0], Columns)
-
-        assert isinstance(view_components[0][0], Text)
-        assert isinstance(view_components[0][1], Text)
-        assert isinstance(view_components[0][2], Divider)
-
-    @pytest.mark.parametrize(
-        "message",
-        [
-            {
-                "id": 4,
-                "type": "private",
-                "sender_email": "iago@zulip.com",
-                "sender_id": 5,
-                "display_recipient": [
-                    {"email": "AARON@zulip.com", "id": 1, "full_name": "aaron"},
-                    {"email": "iago@zulip.com", "id": 5, "full_name": "Iago"},
-                ],
-                "flags": [],
-                "is_me_message": False,
-                "content": "<p>what are you planning to do this week</p>",
-                "reactions": [],
-                "sender_full_name": "Alice",
-                "timestamp": 1532103879,
-            },
-        ],
-    )
-    @pytest.mark.parametrize(
-        "to_vary_in_last_message",
-        [
-            {
-                "display_recipient": [
-                    {"email": "AARON@zulip.com", "id": 1, "full_name": "aaron"},
-                    {"email": "iago@zulip.com", "id": 5, "full_name": "Iago"},
-                    {"email": "SE@zulip.com", "id": 6, "full_name": "Someone Else"},
-                ],
-            },
-            {"type": "stream"},
-        ],
-        ids=[
-            "larger_pm_group",
-            "stream_before",
-        ],
-    )
-    def test_main_view_generates_PM_header(
-        self, mocker, message, to_vary_in_last_message
-    ):
-        last_message = dict(message, **to_vary_in_last_message)
-        msg_box = MessageBox(message, self.model, last_message)
-        view_components = msg_box.main_view()
-        assert len(view_components) == 3
-
-        assert isinstance(view_components[0], Columns)
-
-        assert isinstance(view_components[0][0], Text)
-        assert isinstance(view_components[0][1], Text)
-        assert isinstance(view_components[0][2], Divider)
-
-    @pytest.mark.parametrize(
-        "msg_narrow, msg_type, assert_header_bar, assert_search_bar",
-        [
-            ([], 0, f"PTEST {STREAM_TOPIC_SEPARATOR} ", "All messages"),
-            ([], 1, "You and ", "All messages"),
-            ([], 2, "You and ", "All messages"),
-            (
-                [["stream", "PTEST"]],
-                0,
-                f"PTEST {STREAM_TOPIC_SEPARATOR} ",
-                ("bar", [("s#bd6", "PTEST")]),
-            ),
-            (
-                [["stream", "PTEST"], ["topic", "b"]],
-                0,
-                f"PTEST {STREAM_TOPIC_SEPARATOR}",
-                ("bar", [("s#bd6", "PTEST"), ("s#bd6", ": topic narrow")]),
-            ),
-            ([["is", "private"]], 1, "You and ", "All private messages"),
-            ([["is", "private"]], 2, "You and ", "All private messages"),
-            ([["pm_with", "boo@zulip.com"]], 1, "You and ", "Private conversation"),
-            (
-                [["pm_with", "boo@zulip.com, bar@zulip.com"]],
-                2,
-                "You and ",
-                "Group private conversation",
-            ),
-            (
-                [["is", "starred"]],
-                0,
-                f"PTEST {STREAM_TOPIC_SEPARATOR} ",
-                "Starred messages",
-            ),
-            ([["is", "starred"]], 1, "You and ", "Starred messages"),
-            ([["is", "starred"]], 2, "You and ", "Starred messages"),
-            ([["is", "starred"], ["search", "FOO"]], 1, "You and ", "Starred messages"),
-            (
-                [["search", "FOO"]],
-                0,
-                f"PTEST {STREAM_TOPIC_SEPARATOR} ",
-                "All messages",
-            ),
-            ([["is", "mentioned"]], 0, f"PTEST {STREAM_TOPIC_SEPARATOR} ", "Mentions"),
-            ([["is", "mentioned"]], 1, "You and ", "Mentions"),
-            ([["is", "mentioned"]], 2, "You and ", "Mentions"),
-            ([["is", "mentioned"], ["search", "FOO"]], 1, "You and ", "Mentions"),
-        ],
-    )
-    def test_msg_generates_search_and_header_bar(
-        self,
-        mocker,
-        messages_successful_response,
-        msg_type,
-        msg_narrow,
-        assert_header_bar,
-        assert_search_bar,
-    ):
-        self.model.stream_dict = {
-            205: {
-                "color": "#bd6",
-            },
-        }
-        self.model.narrow = msg_narrow
-        messages = messages_successful_response["messages"]
-        current_message = messages[msg_type]
-        msg_box = MessageBox(current_message, self.model, messages[0])
-        search_bar = msg_box.top_search_bar()
-        header_bar = msg_box.top_header_bar(msg_box)
-
-        assert header_bar[0].text.startswith(assert_header_bar)
-        assert search_bar.text_to_fill == assert_search_bar
-
-    # Assume recipient (PM/stream/topic) header is unchanged below
-    @pytest.mark.parametrize(
-        "message",
-        [
-            {
-                "id": 4,
-                "type": "stream",
-                "display_recipient": "Verona",
-                "stream_id": 5,
-                "subject": "Test topic",
-                "flags": [],
-                "is_me_message": False,
-                "content": "<p>what are you planning to do this week</p>",
-                "reactions": [],
-                "sender_full_name": "alice",
-                "timestamp": 1532103879,
-            }
-        ],
-    )
-    @pytest.mark.parametrize(
-        "current_year", [2018, 2019, 2050], ids=["now_2018", "now_2019", "now_2050"]
-    )
-    @pytest.mark.parametrize(
-        "starred_msg",
-        ["this", "last", "neither"],
-        ids=["this_starred", "last_starred", "no_stars"],
-    )
-    @pytest.mark.parametrize(
-        "expected_header, to_vary_in_last_message",
-        [
-            (
-                [STATUS_INACTIVE, "alice", " ", "DAYDATETIME"],
-                {"sender_full_name": "bob"},
-            ),
-            ([" ", " ", " ", "DAYDATETIME"], {"timestamp": 1532103779}),
-            ([STATUS_INACTIVE, "alice", " ", "DAYDATETIME"], {"timestamp": 0}),
-        ],
-        ids=[
-            "show_author_as_authors_different",
-            "merge_messages_as_only_slightly_earlier_message",
-            "dont_merge_messages_as_much_earlier_message",
-        ],
-    )
-    def test_main_view_content_header_without_header(
-        self,
-        mocker,
-        message,
-        expected_header,
-        current_year,
-        starred_msg,
-        to_vary_in_last_message,
-    ):
-        mocked_date = mocker.patch(BOXES + ".date")
-        mocked_date.today.return_value = date(current_year, 1, 1)
-        mocked_date.side_effect = lambda *args, **kw: date(*args, **kw)
-
-        output_date_time = "Fri Jul 20 21:54"  # corresponding to timestamp
-
-        self.model.formatted_local_time.side_effect = [  # for this- and last-message
-            output_date_time,
-            " ",
-        ] * 2  # called once in __init__ and then in main_view explicitly
-
-        # The empty dict is responsible for INACTIVE status of test user.
-        self.model.user_dict = {}  # called once in main_view explicitly
-
-        stars = {
-            msg: ({"flags": ["starred"]} if msg == starred_msg else {})
-            for msg in ("this", "last")
-        }
-        this_msg = dict(message, **stars["this"])
-        all_to_vary = dict(to_vary_in_last_message, **stars["last"])
-        last_msg = dict(message, **all_to_vary)
-
-        msg_box = MessageBox(this_msg, self.model, last_msg)
-
-        expected_header[2] = output_date_time
-        if current_year > 2018:
-            expected_header[2] = "2018 - " + expected_header[2]
-        expected_header[3] = "*" if starred_msg == "this" else " "
-
-        view_components = msg_box.main_view()
-
-        assert len(view_components) == 2
-        assert isinstance(view_components[0], Columns)
-        assert [w.text for w in view_components[0].widget_list] == expected_header
-        assert isinstance(view_components[1], Padding)
-
-    @pytest.mark.parametrize(
-        "to_vary_in_each_message",
-        [
-            {"sender_full_name": "bob"},
-            {"timestamp": 1532103779},
-            {"timestamp": 0},
-            {},
-            {"flags": ["starred"]},
-        ],
-        ids=[
-            "common_author",
-            "common_timestamp",
-            "common_early_timestamp",
-            "common_unchanged_message",
-            "both_starred",
-        ],
-    )
-    def test_main_view_compact_output(
-        self, mocker, message_fixture, to_vary_in_each_message
-    ):
-        message_fixture.update({"id": 4})
-        varied_message = dict(message_fixture, **to_vary_in_each_message)
-        msg_box = MessageBox(varied_message, self.model, varied_message)
-        view_components = msg_box.main_view()
-        assert len(view_components) == 1
-        assert isinstance(view_components[0], Padding)
-
-    def test_main_view_generates_EDITED_label(
-        self, mocker, messages_successful_response
-    ):
-        messages = messages_successful_response["messages"]
-        for message in messages:
-            self.model.index["edited_messages"].add(message["id"])
-            msg_box = MessageBox(message, self.model, message)
-            view_components = msg_box.main_view()
-
-            label = view_components[0].original_widget.contents[0]
-            assert label[0].text == "EDITED"
-            assert label[1][1] == 7
-
-    @pytest.mark.parametrize(
-        "to_vary_in_last_message, update_required",
-        [
-            ({"sender_full_name": "Unique name (won't be in next message)"}, True),
-            ({}, False),
-        ],
-        ids=[
-            "author_field_present",
-            "author_field_not_present",
-        ],
-    )
-    def test_update_message_author_status(
-        self,
-        message_fixture,
-        update_required,
-        to_vary_in_last_message,
-    ):
-        message = message_fixture
-        last_msg = dict(message, **to_vary_in_last_message)
-
-        msg_box = MessageBox(message, self.model, last_msg)
-
-        assert msg_box.update_message_author_status() == update_required
-
-    @pytest.mark.parametrize("key", keys_for_command("STREAM_MESSAGE"))
-    @pytest.mark.parametrize(
-        "narrow, expect_to_prefill",
-        [
-            ([], False),
-            ([["stream", "general"]], True),
-            ([["stream", "general"], ["topic", "Test"]], True),
-            ([["is", "starred"]], False),
-            ([["is", "mentioned"]], False),
-            ([["is", "private"]], False),
-            ([["pm_with", "notification-bot@zulip.com"]], False),
-        ],
-        ids=[
-            "all_messages_narrow",
-            "stream_narrow",
-            "topic_narrow",
-            "private_conversation_narrow",
-            "starred_messages_narrow",
-            "mentions_narrow",
-            "private_messages_narrow",
-        ],
-    )
-    def test_keypress_STREAM_MESSAGE(
-        self, mocker, msg_box, widget_size, narrow, expect_to_prefill, key
-    ):
-        write_box = msg_box.model.controller.view.write_box
-        msg_box.model.narrow = narrow
-        size = widget_size(msg_box)
-
-        msg_box.keypress(size, key)
-
-        if expect_to_prefill:
-            write_box.stream_box_view.assert_called_once_with(
-                caption="PTEST",
-                stream_id=205,
-            )
-        else:
-            write_box.stream_box_view.assert_called_once_with(0)
-
-    @pytest.mark.parametrize("key", keys_for_command("EDIT_MESSAGE"))
-    @pytest.mark.parametrize(
-        [
-            "to_vary_in_each_message",
-            "realm_editing_allowed",
-            "msg_body_edit_limit",
-            "expect_msg_body_edit_enabled",
-            "expect_editing_to_succeed",
-            "expect_footer_text",
-        ],
-        [
-            case(
-                {"sender_id": 2, "timestamp": 45, "subject": "test"},
-                True,
-                60,
-                {"stream": False, "private": False},
-                {"stream": False, "private": False},
-                {
-                    "stream": " You can't edit messages sent by other users that already have a topic.",
-                    "private": " You can't edit private messages sent by other users.",
-                },
-                id="msg_sent_by_other_user_with_topic",
-            ),
-            case(
-                {"sender_id": 1, "timestamp": 1, "subject": "test"},
-                True,
-                60,
-                {"stream": False, "private": False},
-                {"stream": True, "private": False},
-                {
-                    "stream": " Only topic editing allowed."
-                    " Time Limit for editing the message body has been exceeded.",
-                    "private": " Time Limit for editing the message has been exceeded.",
-                },
-                id="topic_edit_only_after_time_limit",
-            ),
-            case(
-                {"sender_id": 1, "timestamp": 45, "subject": "test"},
-                False,
-                60,
-                {"stream": False, "private": False},
-                {"stream": False, "private": False},
-                {
-                    "stream": " Editing sent message is disabled.",
-                    "private": " Editing sent message is disabled.",
-                },
-                id="realm_editing_not_allowed",
-            ),
-            case(
-                {"sender_id": 1, "timestamp": 45, "subject": "test"},
-                True,
-                60,
-                {"stream": True, "private": True},
-                {"stream": True, "private": True},
-                {"stream": None, "private": None},
-                id="realm_editing_allowed_and_within_time_limit",
-            ),
-            case(
-                {"sender_id": 1, "timestamp": 1, "subject": "test"},
-                True,
-                0,
-                {"stream": True, "private": True},
-                {"stream": True, "private": True},
-                {"stream": None, "private": None},
-                id="no_msg_body_edit_limit",
-            ),
-            case(
-                {"sender_id": 1, "timestamp": 1, "subject": "(no topic)"},
-                True,
-                60,
-                {"stream": False, "private": False},
-                {"stream": True, "private": False},
-                {
-                    "stream": " Only topic editing allowed."
-                    " Time Limit for editing the message body has been exceeded.",
-                    "private": " Time Limit for editing the message has been exceeded.",
-                },
-                id="msg_sent_by_me_with_no_topic",
-            ),
-            case(
-                {"sender_id": 2, "timestamp": 1, "subject": "(no topic)"},
-                True,
-                60,
-                {"stream": False, "private": False},
-                {"stream": True, "private": False},
-                {
-                    "stream": " Only topic editing is allowed."
-                    " This is someone else's message but with (no topic).",
-                    "private": " You can't edit private messages sent by other users.",
-                },
-                id="msg_sent_by_other_with_no_topic",
-            ),
-            case(
-                {"sender_id": 1, "timestamp": 1, "subject": "(no topic)"},
-                False,
-                60,
-                {"stream": False, "private": False},
-                {"stream": False, "private": False},
-                {
-                    "stream": " Editing sent message is disabled.",
-                    "private": " Editing sent message is disabled.",
-                },
-                id="realm_editing_not_allowed_for_no_topic",
-            ),
-            case(
-                {"sender_id": 1, "timestamp": 45, "subject": "(no topic)"},
-                True,
-                0,
-                {"stream": True, "private": True},
-                {"stream": True, "private": True},
-                {"stream": None, "private": None},
-                id="no_msg_body_edit_limit_with_no_topic",
-            ),
-        ],
-    )
-    def test_keypress_EDIT_MESSAGE(
-        self,
-        mocker,
-        message_fixture,
-        widget_size,
-        to_vary_in_each_message,
-        realm_editing_allowed,
-        msg_body_edit_limit,
-        expect_msg_body_edit_enabled,
-        expect_editing_to_succeed,
-        expect_footer_text,
-        key,
-    ):
-        if message_fixture["type"] == "private":
-            to_vary_in_each_message["subject"] = ""
-        varied_message = dict(message_fixture, **to_vary_in_each_message)
-        message_type = varied_message["type"]
-        msg_box = MessageBox(varied_message, self.model, message_fixture)
-        size = widget_size(msg_box)
-        msg_box.model.user_id = 1
-        msg_box.model.initial_data = {
-            "realm_allow_message_editing": realm_editing_allowed,
-            "realm_message_content_edit_limit_seconds": msg_body_edit_limit,
-        }
-        msg_box.model.fetch_raw_message_content.return_value = "Edit this message"
-        write_box = msg_box.model.controller.view.write_box
-        write_box.msg_edit_state = None
-        write_box.msg_body_edit_enabled = None
-        report_error = msg_box.model.controller.report_error
-        report_warning = msg_box.model.controller.report_warning
-        mocker.patch(BOXES + ".time", return_value=100)
-
-        msg_box.keypress(size, key)
-
-        if expect_editing_to_succeed[message_type]:
-            assert write_box.msg_edit_state.message_id == varied_message["id"]
-            assert write_box.msg_edit_state.old_topic == varied_message["subject"]
-            write_box.msg_write_box.set_edit_text.assert_called_once_with(
-                "Edit this message"
-            )
-            assert (
-                write_box.msg_body_edit_enabled
-                == expect_msg_body_edit_enabled[message_type]
-            )
-        else:
-            assert write_box.msg_edit_state is None
-            write_box.msg_write_box.set_edit_text.assert_not_called()
-        if expect_footer_text[message_type]:
-            if expect_editing_to_succeed[message_type]:
-                report_warning.assert_called_once_with(
-                    [expect_footer_text[message_type]]
-                )
-            else:
-                report_error.assert_called_once_with([expect_footer_text[message_type]])
-
-    @pytest.mark.parametrize(
-        "raw_html, expected_content",
-        [
-            # Avoid reformatting to preserve quote result readability
-            # fmt: off
-            case("""<blockquote>
-                        <p>A</p>
-                    </blockquote>
-                    <p>B</p>""",
-                 ("{} A\n\n"
-                  "B"),
-                 id="quoted level 1"),
-            case("""<blockquote>
-                        <blockquote>
-                            <p>A</p>
-                        </blockquote>
-                        <p>B</p>
-                    </blockquote>
-                    <p>C</p>""",
-                 ("{} {} A\n\n"
-                  "{} B\n\n"
-                  "C"),
-                 id="quoted level 2"),
-            case("""<blockquote>
-                        <blockquote>
-                            <blockquote>
-                                <p>A</p>
-                            </blockquote>
-                            <p>B</p>
-                        </blockquote>
-                        <p>C</p>
-                    </blockquote>
-                    <p>D</p>""",
-                 ("{} {} {} A\n\n"
-                  "{} {} B\n\n"
-                  "{} C\n\n"
-                  "D"),
-                 id="quoted level 3"),
-            case("""<blockquote>
-                        <p>A<br>
-                        B</p>
-                    </blockquote>
-                    <p>C</p>""",
-                 ("{} A\n"
-                  "{} B\n\n"
-                  "C"),
-                 id="multi-line quoting"),
-            case("""<blockquote>
-                        <p><a href='https://chat.zulip.org/'>czo</a></p>
-                    </blockquote>""",
-                 ("{} czo [1]\n"),
-                 id="quoting with links"),
-            case("""<blockquote>
-                        <blockquote>
-                            <p>A<br>
-                            B</p>
-                        </blockquote>
-                    </blockquote>""",
-                 ("{} {} A\n"
-                  "{} {} B\n\n"),
-                 id="multi-line level 2"),
-            case("""<blockquote>
-                        <blockquote>
-                            <p>A</p>
-                        </blockquote>
-                        <p>B</p>
-                        <blockquote>
-                            <p>C</p>
-                        </blockquote>
-                    </blockquote>""",
-                 ("{} {} A\n"
-                  "{} B\n"
-                  "{} \n"
-                  "{} {} C\n\n"),
-                 id="quoted level 2-1-2"),
-            case("""<p><a href='https://chat.zulip.org/1'>czo</a></p>
-                    <blockquote>
-                        <p><a href='https://chat.zulip.org/2'>czo</a></p>
-                        <blockquote>
-                            <p>A<br>
-                            B</p>
-                        </blockquote>
-                        <p>C</p>
-                    </blockquote>
-                    <p>D</p>""",
-                 ("czo [1]\n"
-                  "{} czo [2]\n"
-                  "{} \n"
-                  "{} {} A\n"
-                  "{} {} B\n\n"
-                  "{} C\n\n"
-                  "D"),
-                 id="quoted with links level 2"),
-            case("""<blockquote>
-                        <blockquote>
-                            <blockquote>
-                                <p>A</p>
-                            </blockquote>
-                            <p>B</p>
-                            <blockquote>
-                                <p>C</p>
-                            </blockquote>
-                            <p>D</p>
-                        </blockquote>
-                        <p>E</p>
-                    </blockquote>
-                    <p>F</p>""",
-                 ("{} {} {} A\n"
-                  "{} {} B\n"
-                  "{} {} \n"
-                  "{} {} {} C\n\n"
-                  "{} {} D\n\n"
-                  "{} E\n\n"
-                  "F"),
-                 id="quoted level 3-2-3"),
-            case("""<blockquote>
-                        <p>A</p>
-                        <blockquote>
-                            <blockquote>
-                                <blockquote>
-                                    <p>B<br>
-                                    C</p>
-                                </blockquote>
-                            </blockquote>
-                        </blockquote>
-                    </blockquote>""",
-                 ("{} A\n"
-                  "{} {} {} B\n"
-                  "{} {} {} C\n"),
-                 id="quoted level 1-3",
-                 marks=pytest.mark.xfail(reason="rendered_bug")),
-            case("""<blockquote>
-                        <p><a href="https://chat.zulip.org/1">czo</a></p>
-                        <blockquote>
-                            <p><a href="https://chat.zulip.org/2">czo</a></p>
-                            <blockquote>
-                                <p>A<br>
-                                B</p>
-                            </blockquote>
-                            <p>C</p>
-                        </blockquote>
-                        <p>D<br>
-                        E</p>
-                    </blockquote>""",
-                 ("{} czo [1]\n"
-                  "{} {} czo [2]\n"
-                  "{} {} {} A\n"
-                  "{} {} {} B\n"
-                  "{} {} C\n"
-                  "{} D\n"
-                  "{} E\n"),
-                 id="quoted with links level 1-3-1",
-                 marks=pytest.mark.xfail(reason="rendered_bug")),
-            # fmt: on
-        ],
-    )
-    def test_transform_content(self, mocker, raw_html, expected_content):
-        expected_content = expected_content.replace("{}", QUOTED_TEXT_MARKER)
-
-        content, *_ = MessageBox.transform_content(raw_html, SERVER_URL)
-
-        rendered_text = Text(content)
-        assert rendered_text.text == expected_content
-
-    # FIXME This is the same parametrize as MsgInfoView:test_height_reactions
-    @pytest.mark.parametrize(
-        "to_vary_in_each_message, expected_text, expected_attributes",
-        [
-            case(
-                {
-                    "reactions": [
-                        {
-                            "emoji_name": "thumbs_up",
-                            "emoji_code": "1f44d",
-                            "user": {
-                                "email": "iago@zulip.com",
-                                "full_name": "Iago",
-                                "id": 5,
-                            },
-                            "reaction_type": "unicode_emoji",
-                        },
-                        {
-                            "emoji_name": "zulip",
-                            "emoji_code": "zulip",
-                            "user": {
-                                "email": "iago@zulip.com",
-                                "full_name": "Iago",
-                                "id": 5,
-                            },
-                            "reaction_type": "zulip_extra_emoji",
-                        },
-                        {
-                            "emoji_name": "zulip",
-                            "emoji_code": "zulip",
-                            "user": {
-                                "email": "AARON@zulip.com",
-                                "full_name": "aaron",
-                                "id": 1,
-                            },
-                            "reaction_type": "zulip_extra_emoji",
-                        },
-                        {
-                            "emoji_name": "heart",
-                            "emoji_code": "2764",
-                            "user": {
-                                "email": "iago@zulip.com",
-                                "full_name": "Iago",
-                                "id": 5,
-                            },
-                            "reaction_type": "unicode_emoji",
-                        },
-                    ],
-                },
-                " :thumbs_up: 1   :zulip: 2   :heart: 1  ",
-                [
-                    ("reaction", 15),
-                    (None, 1),
-                    ("reaction_mine", 11),
-                    (None, 1),
-                    ("reaction", 11),
-                ],
-            ),
-            case(
-                {
-                    "reactions": [
-                        {
-                            "emoji_name": "thumbs_up",
-                            "emoji_code": "1f44d",
-                            "user": {
-                                "email": "iago@zulip.com",
-                                "full_name": "Iago",
-                                "id": 5,
-                            },
-                            "reaction_type": "unicode_emoji",
-                        },
-                        {
-                            "emoji_name": "zulip",
-                            "emoji_code": "zulip",
-                            "user": {
-                                "email": "AARON@zulip.com",
-                                "full_name": "aaron",
-                                "id": 1,
-                            },
-                            "reaction_type": "zulip_extra_emoji",
-                        },
-                        {
-                            "emoji_name": "heart",
-                            "emoji_code": "2764",
-                            "user": {
-                                "email": "iago@zulip.com",
-                                "full_name": "Iago",
-                                "id": 5,
-                            },
-                            "reaction_type": "unicode_emoji",
-                        },
-                    ],
-                },
-                " :thumbs_up: Iago   :zulip: You   :heart: Iago  ",
-                [
-                    ("reaction", 18),
-                    (None, 1),
-                    ("reaction_mine", 13),
-                    (None, 1),
-                    ("reaction", 14),
-                ],
-            ),
-            case(
-                {
-                    "reactions": [
-                        {
-                            "emoji_name": "zulip",
-                            "emoji_code": "zulip",
-                            "user": {
-                                "email": "iago@zulip.com",
-                                "full_name": "Iago",
-                                "id": 5,
-                            },
-                            "reaction_type": "unicode_emoji",
-                        },
-                        {
-                            "emoji_name": "zulip",
-                            "emoji_code": "zulip",
-                            "user": {
-                                "email": "AARON@zulip.com",
-                                "full_name": "aaron",
-                                "id": 1,
-                            },
-                            "reaction_type": "zulip_extra_emoji",
-                        },
-                        {
-                            "emoji_name": "zulip",
-                            "emoji_code": "zulip",
-                            "user": {
-                                "email": "shivam@zulip.com",
-                                "full_name": "Shivam",
-                                "id": 6,
-                            },
-                            "reaction_type": "unicode_emoji",
-                        },
-                    ],
-                },
-                " :zulip: Iago, Shivam, You  ",
-                [
-                    ("reaction_mine", 27),
-                ],
-            ),
-            case(
-                {
-                    "reactions": [
-                        {
-                            "emoji_name": "heart",
-                            "emoji_code": "2764",
-                            "user": {
-                                "email": "iago@zulip.com",
-                                "full_name": "Iago",
-                                "id": 5,
-                            },
-                            "reaction_type": "unicode_emoji",
-                        },
-                        {
-                            "emoji_name": "zulip",
-                            "emoji_code": "zulip",
-                            "user": {
-                                "email": "AARON@zulip.com",
-                                "full_name": "aaron",
-                                "id": 1,
-                            },
-                            "reaction_type": "zulip_extra_emoji",
-                        },
-                        {
-                            "emoji_name": "zulip",
-                            "emoji_code": "zulip",
-                            "user": {
-                                "email": "shivam@zulip.com",
-                                "full_name": "Shivam",
-                                "id": 6,
-                            },
-                            "reaction_type": "unicode_emoji",
-                        },
-                    ],
-                },
-                " :heart: Iago   :zulip: Shivam, You  ",
-                [
-                    ("reaction", 14),
-                    (None, 1),
-                    ("reaction_mine", 21),
-                ],
-            ),
-            case(
-                {
-                    "reactions": [
-                        {
-                            "emoji_name": "zulip",
-                            "emoji_code": "zulip",
-                            "user": {
-                                "email": "AARON@zulip.com",
-                                "full_name": "aaron",
-                                "id": 1,
-                            },
-                            "reaction_type": "zulip_extra_emoji",
-                        },
-                        {
-                            "emoji_name": "zulip",
-                            "emoji_code": "zulip",
-                            "user": {
-                                "email": "shivam@zulip.com",
-                                "full_name": "Shivam",
-                                "id": 6,
-                            },
-                            "reaction_type": "unicode_emoji",
-                        },
-                    ],
-                },
-                " :zulip: Shivam, You  ",
-                [
-                    ("reaction_mine", 21),
-                ],
-            ),
-        ],
-    )
-    def test_reactions_view(
-        self,
-        message_fixture,
-        to_vary_in_each_message,
-        expected_text,
-        expected_attributes,
-    ):
-        self.model.user_id = 1
-        varied_message = dict(message_fixture, **to_vary_in_each_message)
-        msg_box = MessageBox(varied_message, self.model, None)
-        reactions = to_vary_in_each_message["reactions"]
-
-        reactions_view = msg_box.reactions_view(reactions)
-
-        assert reactions_view.original_widget.text == expected_text
-        assert reactions_view.original_widget.attrib == expected_attributes
-
-    @pytest.mark.parametrize(
-        "message_links, expected_text, expected_attrib, expected_footlinks_width",
-        [
-            case(
-                OrderedDict(
-                    [
-                        (
-                            "https://github.com/zulip/zulip-terminal/pull/1",
-                            ("#T1", 1, True),
-                        ),
-                    ]
-                ),
-                "1: https://github.com/zulip/zulip-terminal/pull/1",
-                [("msg_link_index", 2), (None, 1), ("msg_link", 46)],
-                49,
-                id="one_footlink",
-            ),
-            case(
-                OrderedDict(
-                    [
-                        ("https://foo.com", ("Foo!", 1, True)),
-                        ("https://bar.com", ("Bar!", 2, True)),
-                    ]
-                ),
-                "1: https://foo.com\n2: https://bar.com",
-                [
-                    ("msg_link_index", 2),
-                    (None, 1),
-                    ("msg_link", 15),
-                    (None, 1),
-                    ("msg_link_index", 2),
-                    (None, 1),
-                    ("msg_link", 15),
-                ],
-                18,
-                id="more_than_one_footlink",
-            ),
-            case(
-                OrderedDict(
-                    [
-                        ("https://example.com", ("https://example.com", 1, False)),
-                        ("http://example.com", ("http://example.com", 2, False)),
-                    ]
-                ),
-                None,
-                None,
-                0,
-                id="similar_link_and_text",
-            ),
-            case(
-                OrderedDict(
-                    [
-                        ("https://foo.com", ("https://foo.com, Text", 1, True)),
-                        ("https://bar.com", ("Text, https://bar.com", 2, True)),
-                    ]
-                ),
-                "1: https://foo.com\n2: https://bar.com",
-                [
-                    ("msg_link_index", 2),
-                    (None, 1),
-                    ("msg_link", 15),
-                    (None, 1),
-                    ("msg_link_index", 2),
-                    (None, 1),
-                    ("msg_link", 15),
-                ],
-                18,
-                id="different_link_and_text",
-            ),
-            case(
-                OrderedDict(
-                    [
-                        ("https://foo.com", ("Foo!", 1, True)),
-                        ("http://example.com", ("example.com", 2, False)),
-                        ("https://bar.com", ("Bar!", 3, True)),
-                    ]
-                ),
-                "1: https://foo.com\n3: https://bar.com",
-                [
-                    ("msg_link_index", 2),
-                    (None, 1),
-                    ("msg_link", 15),
-                    (None, 1),
-                    ("msg_link_index", 2),
-                    (None, 1),
-                    ("msg_link", 15),
-                ],
-                18,
-                id="http_default_scheme",
-            ),
-        ],
-    )
-    def test_footlinks_view(
-        self, message_links, expected_text, expected_attrib, expected_footlinks_width
-    ):
-        footlinks, footlinks_width = MessageBox.footlinks_view(
-            message_links,
-            maximum_footlinks=3,
-            padded=True,
-            wrap="ellipsis",
-        )
-
-        if expected_text:
-            assert footlinks.original_widget.text == expected_text
-            assert footlinks.original_widget.attrib == expected_attrib
-            assert footlinks_width == expected_footlinks_width
-        else:
-            assert footlinks is None
-            assert not hasattr(footlinks, "original_widget")
-
-    @pytest.mark.parametrize(
-        "maximum_footlinks, expected_instance",
-        [
-            (0, type(None)),
-            (1, Padding),
-            (3, Padding),
-        ],
-    )
-    def test_footlinks_limit(self, maximum_footlinks, expected_instance):
-        message_links = OrderedDict(
-            [
-                ("https://github.com/zulip/zulip-terminal", ("ZT", 1, True)),
-            ]
-        )
-
-        footlinks, _ = MessageBox.footlinks_view(
-            message_links,
-            maximum_footlinks=maximum_footlinks,
-            padded=True,
-            wrap="ellipsis",
-        )
-
-        assert isinstance(footlinks, expected_instance)
-
-    @pytest.mark.parametrize(
-        "key", keys_for_command("ENTER"), ids=lambda param: f"left_click-key:{param}"
-    )
-    def test_mouse_event_left_click(
-        self, mocker, msg_box, key, widget_size, compose_box_is_open
-    ):
-        size = widget_size(msg_box)
-        col = 1
-        row = 1
-        focus = mocker.Mock()
-        mocker.patch(BOXES + ".keys_for_command", return_value=[key])
-        mocker.patch.object(msg_box, "keypress")
-        msg_box.model = mocker.Mock()
-        msg_box.model.controller.is_in_editor_mode.return_value = compose_box_is_open
-
-        msg_box.mouse_event(size, "mouse press", 1, col, row, focus)
-
-        if compose_box_is_open:
-            msg_box.keypress.assert_not_called()
-        else:
-            msg_box.keypress.assert_called_once_with(size, key)
diff --git a/tests/ui_tools/test_messages.py b/tests/ui_tools/test_messages.py
new file mode 100644
index 0000000..4c423c8
--- /dev/null
+++ b/tests/ui_tools/test_messages.py
@@ -0,0 +1,1876 @@
+from collections import OrderedDict, defaultdict
+from datetime import date
+
+import pytest
+import pytz
+from bs4 import BeautifulSoup
+from pytest import param as case
+from urwid import Columns, Divider, Padding, Text
+
+from zulipterminal.config.keys import keys_for_command
+from zulipterminal.config.symbols import (
+    QUOTED_TEXT_MARKER,
+    STATUS_INACTIVE,
+    STREAM_TOPIC_SEPARATOR,
+    TIME_MENTION_MARKER,
+)
+from zulipterminal.ui_tools.messages import MessageBox
+
+
+MODULE = "zulipterminal.ui_tools.messages"
+
+
+SERVER_URL = "https://chat.zulip.zulip"
+
+
+class TestMessageBox:
+    @pytest.fixture(autouse=True)
+    def mock_external_classes(self, mocker, initial_index):
+        self.model = mocker.MagicMock()
+        self.model.index = initial_index
+
+    @pytest.mark.parametrize(
+        "message_type, set_fields",
+        [
+            ("stream", [("stream_name", ""), ("stream_id", None), ("topic_name", "")]),
+            ("private", [("email", ""), ("user_id", None)]),
+        ],
+    )
+    def test_init(self, mocker, message_type, set_fields):
+        mocker.patch.object(MessageBox, "main_view")
+        message = dict(
+            display_recipient=[
+                {"id": 7, "email": "boo@zulip.com", "full_name": "Boo is awesome"}
+            ],
+            stream_id=5,
+            subject="hi",
+            sender_email="foo@zulip.com",
+            sender_id=4209,
+            type=message_type,
+        )
+
+        msg_box = MessageBox(message, self.model, None)
+
+        assert msg_box.last_message == defaultdict(dict)
+        for field, invalid_default in set_fields:
+            assert getattr(msg_box, field) != invalid_default
+        if message_type == "stream":
+            assert msg_box.topic_links == OrderedDict()
+        assert msg_box.message_links == OrderedDict()
+        assert msg_box.time_mentions == list()
+
+    def test_init_fails_with_bad_message_type(self):
+        message = dict(type="BLAH")
+
+        with pytest.raises(RuntimeError):
+            msg_box = MessageBox(message, self.model, None)
+
+    def test_private_message_to_self(self, mocker):
+        message = dict(
+            type="private",
+            display_recipient=[
+                {"full_name": "Foo Foo", "email": "foo@zulip.com", "id": None}
+            ],
+            sender_id=9,
+            content="<p> self message. </p>",
+            sender_full_name="Foo Foo",
+            sender_email="foo@zulip.com",
+            timestamp=150989984,
+        )
+        self.model.user_email = "foo@zulip.com"
+        mocker.patch(
+            MODULE + ".MessageBox._is_private_message_to_self", return_value=True
+        )
+        mocker.patch.object(MessageBox, "main_view")
+        msg_box = MessageBox(message, self.model, None)
+
+        assert msg_box.recipient_emails == ["foo@zulip.com"]
+        msg_box._is_private_message_to_self.assert_called_once_with()
+
+    @pytest.mark.parametrize(
+        "content, expected_markup",
+        [
+            case("", [], id="empty"),
+            case("<p>hi</p>", ["", "hi"], id="p"),
+            case(
+                '<span class="user-mention">@Bob Smith',
+                [("msg_mention", "@Bob Smith")],
+                id="user-mention",
+            ),
+            case("<h1>heading1</h1>", [("msg_heading", "heading1")], id="h1"),
+            case("<h2>heading2</h2>", [("msg_heading", "heading2")], id="h2"),
+            case("<h3>heading3</h3>", [("msg_heading", "heading3")], id="h3"),
+            case("<h4>heading4</h4>", [("msg_heading", "heading4")], id="h4"),
+            case("<h5>heading5</h5>", [("msg_heading", "heading5")], id="h5"),
+            case("<h6>heading6</h6>", [("msg_heading", "heading6")], id="h6"),
+            case(
+                '<span class="user-group-mention">@A Group',
+                [("msg_mention", "@A Group")],
+                id="group-mention",
+            ),
+            case("<code>some code", [("msg_code", "some code")], id="code"),
+            case(
+                '<div class="codehilite" data-code-language="python">'
+                "<pre><span></span>"
+                "<code><span>def</span> <span>func</span><span>():</span>\n"
+                '    <span class="pg">print</span><span>()</span><span></span>\n'
+                "\n"
+                "<span>class</span> <span>New</span><span>:</span>\n"
+                '    <span>name</span> <span>=</span> <span>"name"</span>\n'
+                "</code></pre></div>",
+                [
+                    ("pygments:w", "def"),
+                    ("pygments:w", " "),
+                    ("pygments:w", "func"),
+                    ("pygments:w", "():"),
+                    ("pygments:w", "\n" "    "),
+                    ("pygments:pg", "print"),
+                    ("pygments:w", "()"),
+                    ("pygments:w", "\n" "\n"),
+                    ("pygments:w", "class"),
+                    ("pygments:w", " "),
+                    ("pygments:w", "New"),
+                    ("pygments:w", ":"),
+                    ("pygments:w", "\n" "    "),
+                    ("pygments:w", "name"),
+                    ("pygments:w", " "),
+                    ("pygments:w", "="),
+                    ("pygments:w", " "),
+                    ("pygments:w", '"name"'),
+                    ("pygments:w", "\n"),
+                ],
+                id="codehilite-code",
+            ),
+            case(
+                '<div class="codehilite" data-code-language="python">'
+                "<pre><span></span>"
+                "<span>def</span> <span>func</span><span>():</span>\n"
+                '    <span class="pg">print</span><span>()</span>\n'
+                "\n"
+                "<span>class</span> <span>New</span><span>:</span>\n"
+                '    <span>name</span> <span>=</span> <span>"name"</span>\n'
+                "</pre></div>",
+                [
+                    ("pygments:w", "def"),
+                    ("pygments:w", " "),
+                    ("pygments:w", "func"),
+                    ("pygments:w", "():"),
+                    ("pygments:w", "\n" "    "),
+                    ("pygments:pg", "print"),
+                    ("pygments:w", "()"),
+                    ("pygments:w", "\n" "\n"),
+                    ("pygments:w", "class"),
+                    ("pygments:w", " "),
+                    ("pygments:w", "New"),
+                    ("pygments:w", ":"),
+                    ("pygments:w", "\n" "    "),
+                    ("pygments:w", "name"),
+                    ("pygments:w", " "),
+                    ("pygments:w", "="),
+                    ("pygments:w", " "),
+                    ("pygments:w", '"name"'),
+                    ("pygments:w", "\n"),
+                ],
+                id="codehilite-code-old",
+            ),
+            case(
+                '<div class="codehilite">'
+                "<pre><span></span>"
+                "<code>This is a\n"
+                "    Plain\n"
+                "\n"
+                "    Codeblock\n"
+                "</code></pre></div>",
+                [
+                    ("pygments:w", "This is a\n    Plain\n\n    Codeblock\n"),
+                ],
+                id="codehilite-plain-text-codeblock",
+            ),
+            case(
+                '<div class="codehilite">'
+                "<pre><span></span>"
+                "This is a\n"
+                "    Plain\n"
+                "\n"
+                "    Codeblock\n"
+                "</pre></div>",
+                [
+                    ("pygments:w", "This is a\n    Plain\n\n    Codeblock\n"),
+                ],
+                id="codehilite-plain-text-codeblock-old",
+            ),
+            case("<strong>Something", [("msg_bold", "Something")], id="strong"),
+            case("<em>Something", [("msg_bold", "Something")], id="em"),
+            case("<blockquote>stuff", [("msg_quote", ["", "stuff"])], id="blockquote"),
+            # FIXME Unsupported:
+            case(
+                '<div class="message_embed">',
+                ["[EMBEDDED CONTENT NOT RENDERED]"],
+                id="embedded_content",
+            ),
+            # TODO: Generate test cases to work with both soup2markup and
+            # footlinks_view.
+            case(
+                '<a href="http://foo">Foo</a><a href="https://bar.org">Bar</a>',
+                [
+                    ("msg_link", "Foo"),
+                    " ",
+                    ("msg_link_index", "[1]"),
+                    ("msg_link", "Bar"),
+                    " ",
+                    ("msg_link_index", "[2]"),
+                ],
+                id="link_two",
+            ),
+            case(
+                '<a href="http://foo">Foo</a><a href="http://foo">Another foo</a>',
+                [
+                    ("msg_link", "Foo"),
+                    " ",
+                    ("msg_link_index", "[1]"),
+                    ("msg_link", "Another foo"),
+                    " ",
+                    ("msg_link_index", "[1]"),
+                ],
+                id="link_samelinkdifferentname",
+            ),
+            case(
+                '<a href="http://foo">Foo</a><a href="https://bar.org">Bar</a>'
+                '<a href="http://foo">Foo</a><a href="https://bar.org">Bar</a>',
+                [
+                    ("msg_link", "Foo"),
+                    " ",
+                    ("msg_link_index", "[1]"),
+                    ("msg_link", "Bar"),
+                    " ",
+                    ("msg_link_index", "[2]"),
+                    ("msg_link", "Foo"),
+                    " ",
+                    ("msg_link_index", "[1]"),
+                    ("msg_link", "Bar"),
+                    " ",
+                    ("msg_link_index", "[2]"),
+                ],
+                id="link_duplicatelink",
+            ),
+            case(
+                '<a href="http://baz.com/">http://baz.com/</a>',
+                [("msg_link", "http://baz.com"), " ", ("msg_link_index", "[1]")],
+                id="link_trailingslash",
+            ),
+            case(
+                '<a href="http://foo.com/">Foo</a><a href="http://foo.com">Foo</a>',
+                [
+                    ("msg_link", "Foo"),
+                    " ",
+                    ("msg_link_index", "[1]"),
+                    ("msg_link", "Foo"),
+                    " ",
+                    ("msg_link_index", "[1]"),
+                ],
+                id="link_trailingslashduplicatelink",
+            ),
+            case(
+                '<a href="http://foo">http://foo</a>',
+                [("msg_link", "http://foo"), " ", ("msg_link_index", "[1]")],
+                id="link_sametext",
+            ),
+            case(
+                '<a href="http://foo/bar.png">http://foo/bar.png</a>',
+                [("msg_link", "bar.png"), " ", ("msg_link_index", "[1]")],
+                id="link_sameimage",
+            ),
+            case(
+                '<a href="http://foo">bar</a>',
+                [("msg_link", "bar"), " ", ("msg_link_index", "[1]")],
+                id="link_differenttext",
+            ),
+            case(
+                '<a href="/user_uploads/blah.gif"',
+                [("msg_link", "blah.gif"), " ", ("msg_link_index", "[1]")],
+                id="link_userupload",
+            ),
+            case(
+                '<a href="/api"',
+                [("msg_link", "/api"), " ", ("msg_link_index", "[1]")],
+                id="link_api",
+            ),
+            case(
+                f'<a href="some/relative_url">{SERVER_URL}/some/relative_url</a>',
+                [("msg_link", "/some/relative_url"), " ", ("msg_link_index", "[1]")],
+                id="link_serverrelative_same",
+            ),
+            case(
+                '<a href="http://foo.com/bar">foo.com/bar</a>',
+                [("msg_link", "foo.com"), " ", ("msg_link_index", "[1]")],
+                id="link_textwithoutscheme",
+            ),
+            case(
+                '<a href="http://foo.com">foo.com</a>'
+                '<a href="http://foo.com">http://foo.com</a>'
+                '<a href="https://foo.com">https://foo.com</a>'
+                '<a href="http://foo.com">Text</a>',
+                [
+                    ("msg_link", "foo.com"),
+                    " ",
+                    ("msg_link_index", "[1]"),
+                    ("msg_link", "http://foo.com"),
+                    " ",
+                    ("msg_link_index", "[1]"),
+                    ("msg_link", "https://foo.com"),
+                    " ",
+                    ("msg_link_index", "[2]"),
+                    ("msg_link", "Text"),
+                    " ",
+                    ("msg_link_index", "[1]"),
+                ],
+                id="link_differentscheme",
+            ),
+            case("<li>Something", ["\n", "  \N{BULLET} ", "", "Something"], id="li"),
+            case("<li></li>", ["\n", "  \N{BULLET} ", ""], id="empty_li"),
+            case(
+                "<li>\n<p>Something",
+                ["\n", "  \N{BULLET} ", "", "", "", "Something"],
+                id="li_with_li_p_newline",
+            ),
+            case(
+                "<li>Something<li>else",
+                [
+                    "\n",
+                    "  \N{BULLET} ",
+                    "",
+                    "Something",
+                    "\n",
+                    "  \N{BULLET} ",
+                    "",
+                    "else",
+                ],
+                id="two_li",
+            ),
+            case(
+                "<li>\n<p>Something</p>\n</li><li>else",
+                [
+                    "\n",
+                    "  \N{BULLET} ",
+                    "",
+                    "",
+                    "",
+                    "Something",
+                    "",
+                    "\n",
+                    "  \N{BULLET} ",
+                    "",
+                    "else",
+                ],
+                id="two_li_with_li_p_newlines",
+            ),
+            case(
+                "<ul><li>Something<ul><li>nested",
+                [
+                    "",
+                    "  \N{BULLET} ",
+                    "",
+                    "Something",
+                    "",
+                    "\n",
+                    "    \N{RING OPERATOR} ",
+                    "",
+                    "nested",
+                ],
+                id="li_nested",
+            ),
+            case(
+                "<ul><li>Something<ul><li>nested<ul><li>a<ul><li>lot",
+                [
+                    "",
+                    "  \N{BULLET} ",
+                    "",
+                    "Something",
+                    "",
+                    "\n",
+                    "    \N{RING OPERATOR} ",
+                    "",
+                    "nested",
+                    "",
+                    "\n",
+                    "      \N{HYPHEN} ",
+                    "",
+                    "a",
+                    "",
+                    "\n",
+                    "        \N{BULLET} ",
+                    "",
+                    "lot",
+                ],
+                id="li_heavily_nested",
+            ),
+            case("<br>", [], id="br"),
+            case("<br/>", [], id="br2"),
+            case("<hr>", ["[RULER NOT RENDERED]"], id="hr"),
+            case("<hr/>", ["[RULER NOT RENDERED]"], id="hr2"),
+            case("<img>", ["[IMAGE NOT RENDERED]"], id="img"),
+            case("<img/>", ["[IMAGE NOT RENDERED]"], id="img2"),
+            case(
+                "<table><thead><tr><th>Firstname</th><th>Lastname</th></tr></thead>"
+                "<tbody><tr><td>John</td><td>Doe</td></tr><tr><td>Mary</td><td>Moe"
+                "</td></tr></tbody></table>",
+                [
+                    "┌─",
+                    "─────────",
+                    "─┬─",
+                    "────────",
+                    "─┐\n",
+                    "│ ",
+                    ("table_head", "Firstname"),
+                    " │ ",
+                    ("table_head", "Lastname"),
+                    " │\n",
+                    "├─",
+                    "─────────",
+                    "─┼─",
+                    "────────",
+                    "─┤\n",
+                    "│ ",
+                    (None, "John     "),
+                    " │ ",
+                    (None, "Doe     "),
+                    " │\n",
+                    "│ ",
+                    (None, "Mary     "),
+                    " │ ",
+                    (None, "Moe     "),
+                    " │\n",
+                    "└─",
+                    "─────────",
+                    "─┴─",
+                    "────────",
+                    "─┘",
+                ],
+                id="table_default",
+            ),
+            case(
+                '<table><thead><tr><th align="left">Name</th><th align="right">Id'
+                '</th></tr></thead><tbody><tr><td align="left">Robert</td>'
+                '<td align="right">1</td></tr><tr><td align="left">Mary</td>'
+                '<td align="right">100</td></tr></tbody></table>',
+                [
+                    "┌─",
+                    "──────",
+                    "─┬─",
+                    "───",
+                    "─┐\n",
+                    "│ ",
+                    ("table_head", "Name  "),
+                    " │ ",
+                    ("table_head", " Id"),
+                    " │\n",
+                    "├─",
+                    "──────",
+                    "─┼─",
+                    "───",
+                    "─┤\n",
+                    "│ ",
+                    (None, "Robert"),
+                    " │ ",
+                    (None, "  1"),
+                    " │\n",
+                    "│ ",
+                    (None, "Mary  "),
+                    " │ ",
+                    (None, "100"),
+                    " │\n",
+                    "└─",
+                    "──────",
+                    "─┴─",
+                    "───",
+                    "─┘",
+                ],
+                id="table_with_left_and_right_alignments",
+            ),
+            case(
+                '<table><thead><tr><th align="center">Name</th><th align="right">Id'
+                '</th></tr></thead><tbody><tr><td align="center">Robert</td>'
+                '<td align="right">1</td></tr><tr><td align="center">Mary</td>'
+                '<td align="right">100</td></tr></tbody></table>',
+                [
+                    "┌─",
+                    "──────",
+                    "─┬─",
+                    "───",
+                    "─┐\n",
+                    "│ ",
+                    ("table_head", " Name "),
+                    " │ ",
+                    ("table_head", " Id"),
+                    " │\n",
+                    "├─",
+                    "──────",
+                    "─┼─",
+                    "───",
+                    "─┤\n",
+                    "│ ",
+                    (None, "Robert"),
+                    " │ ",
+                    (None, "  1"),
+                    " │\n",
+                    "│ ",
+                    (None, " Mary "),
+                    " │ ",
+                    (None, "100"),
+                    " │\n",
+                    "└─",
+                    "──────",
+                    "─┴─",
+                    "───",
+                    "─┘",
+                ],
+                id="table_with_center_and_right_alignments",
+            ),
+            case(
+                "<table><thead><tr><th>Name</th></tr></thead><tbody><tr><td>Foo</td>"
+                "</tr><tr><td>Bar</td></tr><tr><td>Baz</td></tr></tbody></table>",
+                [
+                    "┌─",
+                    "────",
+                    "─┐\n",
+                    "│ ",
+                    ("table_head", "Name"),
+                    " │\n",
+                    "├─",
+                    "────",
+                    "─┤\n",
+                    "│ ",
+                    (None, "Foo "),
+                    " │\n",
+                    "│ ",
+                    (None, "Bar "),
+                    " │\n",
+                    "│ ",
+                    (None, "Baz "),
+                    " │\n",
+                    "└─",
+                    "────",
+                    "─┘",
+                ],
+                id="table_with_single_column",
+            ),
+            case(
+                "<table><thead><tr><th>Column1</th></tr></thead><tbody><tr><td></td>"
+                "</tr></tbody></table>",
+                [
+                    "┌─",
+                    "───────",
+                    "─┐\n",
+                    "│ ",
+                    ("table_head", "Column1"),
+                    " │\n",
+                    "├─",
+                    "───────",
+                    "─┤\n",
+                    "│ ",
+                    (None, "       "),
+                    " │\n",
+                    "└─",
+                    "───────",
+                    "─┘",
+                ],
+                id="table_with_the_bare_minimum",
+            ),
+            case(
+                '<time datetime="2020-08-07T04:30:00Z"> Fri, Aug 7 2020, 10:00AM IST'
+                "</time>",
+                [("msg_time", f" {TIME_MENTION_MARKER} Fri, Aug 7 2020, 10:00 (IST) ")],
+                id="time_human_readable_input",
+            ),
+            case(
+                '<time datetime="2020-08-11T16:32:58Z"> 1597163578</time>',
+                [
+                    (
+                        "msg_time",
+                        f" {TIME_MENTION_MARKER} Tue, Aug 11 2020, 22:02 (IST) ",
+                    )
+                ],
+                id="time_UNIX_timestamp_input",
+            ),
+            case(
+                # Markdown:
+                # ```math
+                # some-math
+                # ```
+                '<span class="katex-display"><span class="katex"><semantics>'
+                "<annotation>some-math</annotation></semantics></span></span>",
+                [("msg_math", "some-math")],
+                id="katex_HTML_response_math_fenced_markdown",
+            ),
+            case(
+                # Markdown:
+                # $$ some-math $$
+                '<span class="katex"><semantics><annotation>some-math</annotation>'
+                "</semantics></span>",
+                [("msg_math", "some-math")],
+                id="katex_HTML_response_double_$_fenced_markdown",
+            ),
+            case("<ul><li>text</li></ul>", ["", "  \N{BULLET} ", "", "text"], id="ul"),
+            case(
+                "<ul>\n<li>text</li>\n</ul>",
+                ["", "", "  \N{BULLET} ", "", "text", ""],
+                id="ul_with_ul_li_newlines",
+            ),
+            case("<ol><li>text</li></ol>", ["", "  1. ", "", "text"], id="ol"),
+            case(
+                "<ol>\n<li>text</li>\n</ol>",
+                ["", "", "  1. ", "", "text", ""],
+                id="ol_with_ol_li_newlines",
+            ),
+            case(
+                '<ol start="5"><li>text</li></ol>',
+                ["", "  5. ", "", "text"],
+                id="ol_starting_at_5",
+            ),
+            # FIXME Strikethrough
+            case("<del>text</del>", ["", "text"], id="strikethrough_del"),
+            # FIXME inline image?
+            case(
+                '<div class="message_inline_image">'
+                '<a href="x"><img src="x"></a></div>',
+                [],
+                id="inline_image",
+            ),
+            # FIXME inline ref?
+            case('<div class="message_inline_ref">blah</div>', [], id="inline_ref"),
+            case(
+                '<span class="emoji">:smile:</span>',
+                [("msg_emoji", ":smile:")],
+                id="emoji",
+            ),
+            case(
+                '<div class="inline-preview-twitter"',
+                ["[TWITTER PREVIEW NOT RENDERED]"],
+                id="preview-twitter",
+            ),
+            case(
+                '<img class="emoji" title="zulip"/>',
+                [("msg_emoji", ":zulip:")],
+                id="zulip_extra_emoji",
+            ),
+            case(
+                '<img class="emoji" title="github"/>',
+                [("msg_emoji", ":github:")],
+                id="custom_emoji",
+            ),
+        ],
+    )
+    def test_soup2markup(self, content, expected_markup, mocker):
+        mocker.patch(
+            MODULE + ".get_localzone", return_value=pytz.timezone("Asia/Kolkata")
+        )
+        soup = BeautifulSoup(content, "lxml").find(name="body")
+        metadata = dict(
+            server_url=SERVER_URL,
+            message_links=OrderedDict(),
+            time_mentions=list(),
+            bq_len=0,
+        )
+
+        markup, *_ = MessageBox.soup2markup(soup, metadata)
+
+        assert markup == [""] + expected_markup
+
+    @pytest.mark.parametrize(
+        "message, last_message",
+        [
+            (
+                {
+                    "sender_id": 1,
+                    "display_recipient": "Verona",
+                    "sender_full_name": "aaron",
+                    "submessages": [],
+                    "stream_id": 5,
+                    "subject": "Verona2",
+                    "id": 37,
+                    "subject_links": [],
+                    "content": (
+                        "<p>It's nice and it feels more modern, but I think"
+                        " this will take some time to get used to</p>"
+                    ),
+                    "timestamp": 1531716583,
+                    "sender_realm_str": "zulip",
+                    "client": "populate_db",
+                    "content_type": "text/html",
+                    "reactions": [],
+                    "type": "stream",
+                    "is_me_message": False,
+                    "flags": ["read"],
+                    "sender_email": "AARON@zulip.com",
+                },
+                None,
+            ),
+            (
+                {
+                    "sender_id": 5,
+                    "display_recipient": [
+                        {
+                            "is_mirror_dummy": False,
+                            "email": "AARON@zulip.com",
+                            "id": 1,
+                            "full_name": "aaron",
+                        },
+                        {
+                            "is_mirror_dummy": False,
+                            "email": "iago@zulip.com",
+                            "id": 5,
+                            "full_name": "Iago",
+                        },
+                    ],
+                    "sender_full_name": "Iago",
+                    "submessages": [],
+                    "subject": "",
+                    "id": 107,
+                    "subject_links": [],
+                    "content": "<p>what are you planning to do this week</p>",
+                    "timestamp": 1532103879,
+                    "sender_realm_str": "zulip",
+                    "client": "ZulipTerminal",
+                    "content_type": "text/html",
+                    "reactions": [],
+                    "type": "private",
+                    "is_me_message": False,
+                    "flags": ["read"],
+                    "sender_email": "iago@zulip.com",
+                },
+                None,
+            ),
+        ],
+    )
+    def test_main_view(self, mocker, message, last_message):
+        self.model.stream_dict = {
+            5: {
+                "color": "#bd6",
+            },
+        }
+        msg_box = MessageBox(message, self.model, last_message)
+
+    @pytest.mark.parametrize(
+        "message",
+        [
+            {
+                "id": 4,
+                "type": "stream",
+                "display_recipient": "Verona",
+                "stream_id": 5,
+                "subject": "Test topic",
+                "is_me_message": True,  # will be overridden by test function.
+                "flags": [],
+                "content": "",  # will be overridden by test function.
+                "reactions": [],
+                "sender_full_name": "Alice",
+                "timestamp": 1532103879,
+            }
+        ],
+    )
+    @pytest.mark.parametrize(
+        "content, is_me_message",
+        [
+            ("<p>/me is excited!</p>", True),
+            ("<p>/me is excited! /me is not excited.</p>", True),
+            ("<p>This is /me not.</p>", False),
+            ("<p>/me is excited!</p>", False),
+        ],
+    )
+    def test_main_view_renders_slash_me(self, mocker, message, content, is_me_message):
+        mocker.patch(MODULE + ".urwid.Text")
+        message["content"] = content
+        message["is_me_message"] = is_me_message
+        msg_box = MessageBox(message, self.model, message)
+        msg_box.main_view()
+        name_index = 11 if is_me_message else -1  # 11 = len(<str><strong>)
+        assert (
+            msg_box.message["content"].find(message["sender_full_name"]) == name_index
+        )
+
+    @pytest.mark.parametrize(
+        "message",
+        [
+            {
+                "id": 4,
+                "type": "stream",
+                "display_recipient": "Verona",
+                "stream_id": 5,
+                "subject": "Test topic",
+                "flags": [],
+                "is_me_message": False,
+                "content": "<p>what are you planning to do this week</p>",
+                "reactions": [],
+                "sender_full_name": "Alice",
+                "timestamp": 1532103879,
+            }
+        ],
+    )
+    @pytest.mark.parametrize(
+        "to_vary_in_last_message",
+        [
+            {"display_recipient": "Verona offtopic"},
+            {"subject": "Test topic (previous)"},
+            {"type": "private"},
+        ],
+        ids=[
+            "different_stream_before",
+            "different_topic_before",
+            "PM_before",
+        ],
+    )
+    def test_main_view_generates_stream_header(
+        self, mocker, message, to_vary_in_last_message
+    ):
+        self.model.stream_dict = {
+            5: {
+                "color": "#bd6",
+            },
+        }
+        last_message = dict(message, **to_vary_in_last_message)
+        msg_box = MessageBox(message, self.model, last_message)
+        view_components = msg_box.main_view()
+        assert len(view_components) == 3
+
+        assert isinstance(view_components[0], Columns)
+
+        assert isinstance(view_components[0][0], Text)
+        assert isinstance(view_components[0][1], Text)
+        assert isinstance(view_components[0][2], Divider)
+
+    @pytest.mark.parametrize(
+        "message",
+        [
+            {
+                "id": 4,
+                "type": "private",
+                "sender_email": "iago@zulip.com",
+                "sender_id": 5,
+                "display_recipient": [
+                    {"email": "AARON@zulip.com", "id": 1, "full_name": "aaron"},
+                    {"email": "iago@zulip.com", "id": 5, "full_name": "Iago"},
+                ],
+                "flags": [],
+                "is_me_message": False,
+                "content": "<p>what are you planning to do this week</p>",
+                "reactions": [],
+                "sender_full_name": "Alice",
+                "timestamp": 1532103879,
+            },
+        ],
+    )
+    @pytest.mark.parametrize(
+        "to_vary_in_last_message",
+        [
+            {
+                "display_recipient": [
+                    {"email": "AARON@zulip.com", "id": 1, "full_name": "aaron"},
+                    {"email": "iago@zulip.com", "id": 5, "full_name": "Iago"},
+                    {"email": "SE@zulip.com", "id": 6, "full_name": "Someone Else"},
+                ],
+            },
+            {"type": "stream"},
+        ],
+        ids=[
+            "larger_pm_group",
+            "stream_before",
+        ],
+    )
+    def test_main_view_generates_PM_header(
+        self, mocker, message, to_vary_in_last_message
+    ):
+        last_message = dict(message, **to_vary_in_last_message)
+        msg_box = MessageBox(message, self.model, last_message)
+        view_components = msg_box.main_view()
+        assert len(view_components) == 3
+
+        assert isinstance(view_components[0], Columns)
+
+        assert isinstance(view_components[0][0], Text)
+        assert isinstance(view_components[0][1], Text)
+        assert isinstance(view_components[0][2], Divider)
+
+    @pytest.mark.parametrize(
+        "msg_narrow, msg_type, assert_header_bar, assert_search_bar",
+        [
+            ([], 0, f"PTEST {STREAM_TOPIC_SEPARATOR} ", "All messages"),
+            ([], 1, "You and ", "All messages"),
+            ([], 2, "You and ", "All messages"),
+            (
+                [["stream", "PTEST"]],
+                0,
+                f"PTEST {STREAM_TOPIC_SEPARATOR} ",
+                ("bar", [("s#bd6", "PTEST")]),
+            ),
+            (
+                [["stream", "PTEST"], ["topic", "b"]],
+                0,
+                f"PTEST {STREAM_TOPIC_SEPARATOR}",
+                ("bar", [("s#bd6", "PTEST"), ("s#bd6", ": topic narrow")]),
+            ),
+            ([["is", "private"]], 1, "You and ", "All private messages"),
+            ([["is", "private"]], 2, "You and ", "All private messages"),
+            ([["pm_with", "boo@zulip.com"]], 1, "You and ", "Private conversation"),
+            (
+                [["pm_with", "boo@zulip.com, bar@zulip.com"]],
+                2,
+                "You and ",
+                "Group private conversation",
+            ),
+            (
+                [["is", "starred"]],
+                0,
+                f"PTEST {STREAM_TOPIC_SEPARATOR} ",
+                "Starred messages",
+            ),
+            ([["is", "starred"]], 1, "You and ", "Starred messages"),
+            ([["is", "starred"]], 2, "You and ", "Starred messages"),
+            ([["is", "starred"], ["search", "FOO"]], 1, "You and ", "Starred messages"),
+            (
+                [["search", "FOO"]],
+                0,
+                f"PTEST {STREAM_TOPIC_SEPARATOR} ",
+                "All messages",
+            ),
+            ([["is", "mentioned"]], 0, f"PTEST {STREAM_TOPIC_SEPARATOR} ", "Mentions"),
+            ([["is", "mentioned"]], 1, "You and ", "Mentions"),
+            ([["is", "mentioned"]], 2, "You and ", "Mentions"),
+            ([["is", "mentioned"], ["search", "FOO"]], 1, "You and ", "Mentions"),
+        ],
+    )
+    def test_msg_generates_search_and_header_bar(
+        self,
+        mocker,
+        messages_successful_response,
+        msg_type,
+        msg_narrow,
+        assert_header_bar,
+        assert_search_bar,
+    ):
+        self.model.stream_dict = {
+            205: {
+                "color": "#bd6",
+            },
+        }
+        self.model.narrow = msg_narrow
+        messages = messages_successful_response["messages"]
+        current_message = messages[msg_type]
+        msg_box = MessageBox(current_message, self.model, messages[0])
+        search_bar = msg_box.top_search_bar()
+        header_bar = msg_box.top_header_bar(msg_box)
+
+        assert header_bar[0].text.startswith(assert_header_bar)
+        assert search_bar.text_to_fill == assert_search_bar
+
+    # Assume recipient (PM/stream/topic) header is unchanged below
+    @pytest.mark.parametrize(
+        "message",
+        [
+            {
+                "id": 4,
+                "type": "stream",
+                "display_recipient": "Verona",
+                "stream_id": 5,
+                "subject": "Test topic",
+                "flags": [],
+                "is_me_message": False,
+                "content": "<p>what are you planning to do this week</p>",
+                "reactions": [],
+                "sender_full_name": "alice",
+                "timestamp": 1532103879,
+            }
+        ],
+    )
+    @pytest.mark.parametrize(
+        "current_year", [2018, 2019, 2050], ids=["now_2018", "now_2019", "now_2050"]
+    )
+    @pytest.mark.parametrize(
+        "starred_msg",
+        ["this", "last", "neither"],
+        ids=["this_starred", "last_starred", "no_stars"],
+    )
+    @pytest.mark.parametrize(
+        "expected_header, to_vary_in_last_message",
+        [
+            (
+                [STATUS_INACTIVE, "alice", " ", "DAYDATETIME"],
+                {"sender_full_name": "bob"},
+            ),
+            ([" ", " ", " ", "DAYDATETIME"], {"timestamp": 1532103779}),
+            ([STATUS_INACTIVE, "alice", " ", "DAYDATETIME"], {"timestamp": 0}),
+        ],
+        ids=[
+            "show_author_as_authors_different",
+            "merge_messages_as_only_slightly_earlier_message",
+            "dont_merge_messages_as_much_earlier_message",
+        ],
+    )
+    def test_main_view_content_header_without_header(
+        self,
+        mocker,
+        message,
+        expected_header,
+        current_year,
+        starred_msg,
+        to_vary_in_last_message,
+    ):
+        mocked_date = mocker.patch(MODULE + ".date")
+        mocked_date.today.return_value = date(current_year, 1, 1)
+        mocked_date.side_effect = lambda *args, **kw: date(*args, **kw)
+
+        output_date_time = "Fri Jul 20 21:54"  # corresponding to timestamp
+
+        self.model.formatted_local_time.side_effect = [  # for this- and last-message
+            output_date_time,
+            " ",
+        ] * 2  # called once in __init__ and then in main_view explicitly
+
+        # The empty dict is responsible for INACTIVE status of test user.
+        self.model.user_dict = {}  # called once in main_view explicitly
+
+        stars = {
+            msg: ({"flags": ["starred"]} if msg == starred_msg else {})
+            for msg in ("this", "last")
+        }
+        this_msg = dict(message, **stars["this"])
+        all_to_vary = dict(to_vary_in_last_message, **stars["last"])
+        last_msg = dict(message, **all_to_vary)
+
+        msg_box = MessageBox(this_msg, self.model, last_msg)
+
+        expected_header[2] = output_date_time
+        if current_year > 2018:
+            expected_header[2] = "2018 - " + expected_header[2]
+        expected_header[3] = "*" if starred_msg == "this" else " "
+
+        view_components = msg_box.main_view()
+
+        assert len(view_components) == 2
+        assert isinstance(view_components[0], Columns)
+        assert [w.text for w in view_components[0].widget_list] == expected_header
+        assert isinstance(view_components[1], Padding)
+
+    @pytest.mark.parametrize(
+        "to_vary_in_each_message",
+        [
+            {"sender_full_name": "bob"},
+            {"timestamp": 1532103779},
+            {"timestamp": 0},
+            {},
+            {"flags": ["starred"]},
+        ],
+        ids=[
+            "common_author",
+            "common_timestamp",
+            "common_early_timestamp",
+            "common_unchanged_message",
+            "both_starred",
+        ],
+    )
+    def test_main_view_compact_output(
+        self, mocker, message_fixture, to_vary_in_each_message
+    ):
+        message_fixture.update({"id": 4})
+        varied_message = dict(message_fixture, **to_vary_in_each_message)
+        msg_box = MessageBox(varied_message, self.model, varied_message)
+        view_components = msg_box.main_view()
+        assert len(view_components) == 1
+        assert isinstance(view_components[0], Padding)
+
+    def test_main_view_generates_EDITED_label(
+        self, mocker, messages_successful_response
+    ):
+        messages = messages_successful_response["messages"]
+        for message in messages:
+            self.model.index["edited_messages"].add(message["id"])
+            msg_box = MessageBox(message, self.model, message)
+            view_components = msg_box.main_view()
+
+            label = view_components[0].original_widget.contents[0]
+            assert label[0].text == "EDITED"
+            assert label[1][1] == 7
+
+    @pytest.mark.parametrize(
+        "to_vary_in_last_message, update_required",
+        [
+            ({"sender_full_name": "Unique name (won't be in next message)"}, True),
+            ({}, False),
+        ],
+        ids=[
+            "author_field_present",
+            "author_field_not_present",
+        ],
+    )
+    def test_update_message_author_status(
+        self,
+        message_fixture,
+        update_required,
+        to_vary_in_last_message,
+    ):
+        message = message_fixture
+        last_msg = dict(message, **to_vary_in_last_message)
+
+        msg_box = MessageBox(message, self.model, last_msg)
+
+        assert msg_box.update_message_author_status() == update_required
+
+    @pytest.mark.parametrize("key", keys_for_command("STREAM_MESSAGE"))
+    @pytest.mark.parametrize(
+        "narrow, expect_to_prefill",
+        [
+            ([], False),
+            ([["stream", "general"]], True),
+            ([["stream", "general"], ["topic", "Test"]], True),
+            ([["is", "starred"]], False),
+            ([["is", "mentioned"]], False),
+            ([["is", "private"]], False),
+            ([["pm_with", "notification-bot@zulip.com"]], False),
+        ],
+        ids=[
+            "all_messages_narrow",
+            "stream_narrow",
+            "topic_narrow",
+            "private_conversation_narrow",
+            "starred_messages_narrow",
+            "mentions_narrow",
+            "private_messages_narrow",
+        ],
+    )
+    def test_keypress_STREAM_MESSAGE(
+        self, mocker, msg_box, widget_size, narrow, expect_to_prefill, key
+    ):
+        write_box = msg_box.model.controller.view.write_box
+        msg_box.model.narrow = narrow
+        size = widget_size(msg_box)
+
+        msg_box.keypress(size, key)
+
+        if expect_to_prefill:
+            write_box.stream_box_view.assert_called_once_with(
+                caption="PTEST",
+                stream_id=205,
+            )
+        else:
+            write_box.stream_box_view.assert_called_once_with(0)
+
+    @pytest.mark.parametrize("key", keys_for_command("EDIT_MESSAGE"))
+    @pytest.mark.parametrize(
+        [
+            "to_vary_in_each_message",
+            "realm_editing_allowed",
+            "msg_body_edit_limit",
+            "expect_msg_body_edit_enabled",
+            "expect_editing_to_succeed",
+            "expect_footer_text",
+        ],
+        [
+            case(
+                {"sender_id": 2, "timestamp": 45, "subject": "test"},
+                True,
+                60,
+                {"stream": False, "private": False},
+                {"stream": False, "private": False},
+                {
+                    "stream": " You can't edit messages sent by other users that already have a topic.",
+                    "private": " You can't edit private messages sent by other users.",
+                },
+                id="msg_sent_by_other_user_with_topic",
+            ),
+            case(
+                {"sender_id": 1, "timestamp": 1, "subject": "test"},
+                True,
+                60,
+                {"stream": False, "private": False},
+                {"stream": True, "private": False},
+                {
+                    "stream": " Only topic editing allowed."
+                    " Time Limit for editing the message body has been exceeded.",
+                    "private": " Time Limit for editing the message has been exceeded.",
+                },
+                id="topic_edit_only_after_time_limit",
+            ),
+            case(
+                {"sender_id": 1, "timestamp": 45, "subject": "test"},
+                False,
+                60,
+                {"stream": False, "private": False},
+                {"stream": False, "private": False},
+                {
+                    "stream": " Editing sent message is disabled.",
+                    "private": " Editing sent message is disabled.",
+                },
+                id="realm_editing_not_allowed",
+            ),
+            case(
+                {"sender_id": 1, "timestamp": 45, "subject": "test"},
+                True,
+                60,
+                {"stream": True, "private": True},
+                {"stream": True, "private": True},
+                {"stream": None, "private": None},
+                id="realm_editing_allowed_and_within_time_limit",
+            ),
+            case(
+                {"sender_id": 1, "timestamp": 1, "subject": "test"},
+                True,
+                0,
+                {"stream": True, "private": True},
+                {"stream": True, "private": True},
+                {"stream": None, "private": None},
+                id="no_msg_body_edit_limit",
+            ),
+            case(
+                {"sender_id": 1, "timestamp": 1, "subject": "(no topic)"},
+                True,
+                60,
+                {"stream": False, "private": False},
+                {"stream": True, "private": False},
+                {
+                    "stream": " Only topic editing allowed."
+                    " Time Limit for editing the message body has been exceeded.",
+                    "private": " Time Limit for editing the message has been exceeded.",
+                },
+                id="msg_sent_by_me_with_no_topic",
+            ),
+            case(
+                {"sender_id": 2, "timestamp": 1, "subject": "(no topic)"},
+                True,
+                60,
+                {"stream": False, "private": False},
+                {"stream": True, "private": False},
+                {
+                    "stream": " Only topic editing is allowed."
+                    " This is someone else's message but with (no topic).",
+                    "private": " You can't edit private messages sent by other users.",
+                },
+                id="msg_sent_by_other_with_no_topic",
+            ),
+            case(
+                {"sender_id": 1, "timestamp": 1, "subject": "(no topic)"},
+                False,
+                60,
+                {"stream": False, "private": False},
+                {"stream": False, "private": False},
+                {
+                    "stream": " Editing sent message is disabled.",
+                    "private": " Editing sent message is disabled.",
+                },
+                id="realm_editing_not_allowed_for_no_topic",
+            ),
+            case(
+                {"sender_id": 1, "timestamp": 45, "subject": "(no topic)"},
+                True,
+                0,
+                {"stream": True, "private": True},
+                {"stream": True, "private": True},
+                {"stream": None, "private": None},
+                id="no_msg_body_edit_limit_with_no_topic",
+            ),
+        ],
+    )
+    def test_keypress_EDIT_MESSAGE(
+        self,
+        mocker,
+        message_fixture,
+        widget_size,
+        to_vary_in_each_message,
+        realm_editing_allowed,
+        msg_body_edit_limit,
+        expect_msg_body_edit_enabled,
+        expect_editing_to_succeed,
+        expect_footer_text,
+        key,
+    ):
+        if message_fixture["type"] == "private":
+            to_vary_in_each_message["subject"] = ""
+        varied_message = dict(message_fixture, **to_vary_in_each_message)
+        message_type = varied_message["type"]
+        msg_box = MessageBox(varied_message, self.model, message_fixture)
+        size = widget_size(msg_box)
+        msg_box.model.user_id = 1
+        msg_box.model.initial_data = {
+            "realm_allow_message_editing": realm_editing_allowed,
+            "realm_message_content_edit_limit_seconds": msg_body_edit_limit,
+        }
+        msg_box.model.fetch_raw_message_content.return_value = "Edit this message"
+        write_box = msg_box.model.controller.view.write_box
+        write_box.msg_edit_state = None
+        write_box.msg_body_edit_enabled = None
+        report_error = msg_box.model.controller.report_error
+        report_warning = msg_box.model.controller.report_warning
+        mocker.patch(MODULE + ".time", return_value=100)
+
+        msg_box.keypress(size, key)
+
+        if expect_editing_to_succeed[message_type]:
+            assert write_box.msg_edit_state.message_id == varied_message["id"]
+            assert write_box.msg_edit_state.old_topic == varied_message["subject"]
+            write_box.msg_write_box.set_edit_text.assert_called_once_with(
+                "Edit this message"
+            )
+            assert (
+                write_box.msg_body_edit_enabled
+                == expect_msg_body_edit_enabled[message_type]
+            )
+        else:
+            assert write_box.msg_edit_state is None
+            write_box.msg_write_box.set_edit_text.assert_not_called()
+        if expect_footer_text[message_type]:
+            if expect_editing_to_succeed[message_type]:
+                report_warning.assert_called_once_with(
+                    [expect_footer_text[message_type]]
+                )
+            else:
+                report_error.assert_called_once_with([expect_footer_text[message_type]])
+
+    @pytest.mark.parametrize(
+        "raw_html, expected_content",
+        [
+            # Avoid reformatting to preserve quote result readability
+            # fmt: off
+            case("""<blockquote>
+                        <p>A</p>
+                    </blockquote>
+                    <p>B</p>""",
+                 ("{} A\n\n"
+                  "B"),
+                 id="quoted level 1"),
+            case("""<blockquote>
+                        <blockquote>
+                            <p>A</p>
+                        </blockquote>
+                        <p>B</p>
+                    </blockquote>
+                    <p>C</p>""",
+                 ("{} {} A\n\n"
+                  "{} B\n\n"
+                  "C"),
+                 id="quoted level 2"),
+            case("""<blockquote>
+                        <blockquote>
+                            <blockquote>
+                                <p>A</p>
+                            </blockquote>
+                            <p>B</p>
+                        </blockquote>
+                        <p>C</p>
+                    </blockquote>
+                    <p>D</p>""",
+                 ("{} {} {} A\n\n"
+                  "{} {} B\n\n"
+                  "{} C\n\n"
+                  "D"),
+                 id="quoted level 3"),
+            case("""<blockquote>
+                        <p>A<br>
+                        B</p>
+                    </blockquote>
+                    <p>C</p>""",
+                 ("{} A\n"
+                  "{} B\n\n"
+                  "C"),
+                 id="multi-line quoting"),
+            case("""<blockquote>
+                        <p><a href='https://chat.zulip.org/'>czo</a></p>
+                    </blockquote>""",
+                 ("{} czo [1]\n"),
+                 id="quoting with links"),
+            case("""<blockquote>
+                        <blockquote>
+                            <p>A<br>
+                            B</p>
+                        </blockquote>
+                    </blockquote>""",
+                 ("{} {} A\n"
+                  "{} {} B\n\n"),
+                 id="multi-line level 2"),
+            case("""<blockquote>
+                        <blockquote>
+                            <p>A</p>
+                        </blockquote>
+                        <p>B</p>
+                        <blockquote>
+                            <p>C</p>
+                        </blockquote>
+                    </blockquote>""",
+                 ("{} {} A\n"
+                  "{} B\n"
+                  "{} \n"
+                  "{} {} C\n\n"),
+                 id="quoted level 2-1-2"),
+            case("""<p><a href='https://chat.zulip.org/1'>czo</a></p>
+                    <blockquote>
+                        <p><a href='https://chat.zulip.org/2'>czo</a></p>
+                        <blockquote>
+                            <p>A<br>
+                            B</p>
+                        </blockquote>
+                        <p>C</p>
+                    </blockquote>
+                    <p>D</p>""",
+                 ("czo [1]\n"
+                  "{} czo [2]\n"
+                  "{} \n"
+                  "{} {} A\n"
+                  "{} {} B\n\n"
+                  "{} C\n\n"
+                  "D"),
+                 id="quoted with links level 2"),
+            case("""<blockquote>
+                        <blockquote>
+                            <blockquote>
+                                <p>A</p>
+                            </blockquote>
+                            <p>B</p>
+                            <blockquote>
+                                <p>C</p>
+                            </blockquote>
+                            <p>D</p>
+                        </blockquote>
+                        <p>E</p>
+                    </blockquote>
+                    <p>F</p>""",
+                 ("{} {} {} A\n"
+                  "{} {} B\n"
+                  "{} {} \n"
+                  "{} {} {} C\n\n"
+                  "{} {} D\n\n"
+                  "{} E\n\n"
+                  "F"),
+                 id="quoted level 3-2-3"),
+            case("""<blockquote>
+                        <p>A</p>
+                        <blockquote>
+                            <blockquote>
+                                <blockquote>
+                                    <p>B<br>
+                                    C</p>
+                                </blockquote>
+                            </blockquote>
+                        </blockquote>
+                    </blockquote>""",
+                 ("{} A\n"
+                  "{} {} {} B\n"
+                  "{} {} {} C\n"),
+                 id="quoted level 1-3",
+                 marks=pytest.mark.xfail(reason="rendered_bug")),
+            case("""<blockquote>
+                        <p><a href="https://chat.zulip.org/1">czo</a></p>
+                        <blockquote>
+                            <p><a href="https://chat.zulip.org/2">czo</a></p>
+                            <blockquote>
+                                <p>A<br>
+                                B</p>
+                            </blockquote>
+                            <p>C</p>
+                        </blockquote>
+                        <p>D<br>
+                        E</p>
+                    </blockquote>""",
+                 ("{} czo [1]\n"
+                  "{} {} czo [2]\n"
+                  "{} {} {} A\n"
+                  "{} {} {} B\n"
+                  "{} {} C\n"
+                  "{} D\n"
+                  "{} E\n"),
+                 id="quoted with links level 1-3-1",
+                 marks=pytest.mark.xfail(reason="rendered_bug")),
+            # fmt: on
+        ],
+    )
+    def test_transform_content(self, mocker, raw_html, expected_content):
+        expected_content = expected_content.replace("{}", QUOTED_TEXT_MARKER)
+
+        content, *_ = MessageBox.transform_content(raw_html, SERVER_URL)
+
+        rendered_text = Text(content)
+        assert rendered_text.text == expected_content
+
+    # FIXME This is the same parametrize as MsgInfoView:test_height_reactions
+    @pytest.mark.parametrize(
+        "to_vary_in_each_message, expected_text, expected_attributes",
+        [
+            case(
+                {
+                    "reactions": [
+                        {
+                            "emoji_name": "thumbs_up",
+                            "emoji_code": "1f44d",
+                            "user": {
+                                "email": "iago@zulip.com",
+                                "full_name": "Iago",
+                                "id": 5,
+                            },
+                            "reaction_type": "unicode_emoji",
+                        },
+                        {
+                            "emoji_name": "zulip",
+                            "emoji_code": "zulip",
+                            "user": {
+                                "email": "iago@zulip.com",
+                                "full_name": "Iago",
+                                "id": 5,
+                            },
+                            "reaction_type": "zulip_extra_emoji",
+                        },
+                        {
+                            "emoji_name": "zulip",
+                            "emoji_code": "zulip",
+                            "user": {
+                                "email": "AARON@zulip.com",
+                                "full_name": "aaron",
+                                "id": 1,
+                            },
+                            "reaction_type": "zulip_extra_emoji",
+                        },
+                        {
+                            "emoji_name": "heart",
+                            "emoji_code": "2764",
+                            "user": {
+                                "email": "iago@zulip.com",
+                                "full_name": "Iago",
+                                "id": 5,
+                            },
+                            "reaction_type": "unicode_emoji",
+                        },
+                    ],
+                },
+                " :thumbs_up: 1   :zulip: 2   :heart: 1  ",
+                [
+                    ("reaction", 15),
+                    (None, 1),
+                    ("reaction_mine", 11),
+                    (None, 1),
+                    ("reaction", 11),
+                ],
+            ),
+            case(
+                {
+                    "reactions": [
+                        {
+                            "emoji_name": "thumbs_up",
+                            "emoji_code": "1f44d",
+                            "user": {
+                                "email": "iago@zulip.com",
+                                "full_name": "Iago",
+                                "id": 5,
+                            },
+                            "reaction_type": "unicode_emoji",
+                        },
+                        {
+                            "emoji_name": "zulip",
+                            "emoji_code": "zulip",
+                            "user": {
+                                "email": "AARON@zulip.com",
+                                "full_name": "aaron",
+                                "id": 1,
+                            },
+                            "reaction_type": "zulip_extra_emoji",
+                        },
+                        {
+                            "emoji_name": "heart",
+                            "emoji_code": "2764",
+                            "user": {
+                                "email": "iago@zulip.com",
+                                "full_name": "Iago",
+                                "id": 5,
+                            },
+                            "reaction_type": "unicode_emoji",
+                        },
+                    ],
+                },
+                " :thumbs_up: Iago   :zulip: You   :heart: Iago  ",
+                [
+                    ("reaction", 18),
+                    (None, 1),
+                    ("reaction_mine", 13),
+                    (None, 1),
+                    ("reaction", 14),
+                ],
+            ),
+            case(
+                {
+                    "reactions": [
+                        {
+                            "emoji_name": "zulip",
+                            "emoji_code": "zulip",
+                            "user": {
+                                "email": "iago@zulip.com",
+                                "full_name": "Iago",
+                                "id": 5,
+                            },
+                            "reaction_type": "unicode_emoji",
+                        },
+                        {
+                            "emoji_name": "zulip",
+                            "emoji_code": "zulip",
+                            "user": {
+                                "email": "AARON@zulip.com",
+                                "full_name": "aaron",
+                                "id": 1,
+                            },
+                            "reaction_type": "zulip_extra_emoji",
+                        },
+                        {
+                            "emoji_name": "zulip",
+                            "emoji_code": "zulip",
+                            "user": {
+                                "email": "shivam@zulip.com",
+                                "full_name": "Shivam",
+                                "id": 6,
+                            },
+                            "reaction_type": "unicode_emoji",
+                        },
+                    ],
+                },
+                " :zulip: Iago, Shivam, You  ",
+                [
+                    ("reaction_mine", 27),
+                ],
+            ),
+            case(
+                {
+                    "reactions": [
+                        {
+                            "emoji_name": "heart",
+                            "emoji_code": "2764",
+                            "user": {
+                                "email": "iago@zulip.com",
+                                "full_name": "Iago",
+                                "id": 5,
+                            },
+                            "reaction_type": "unicode_emoji",
+                        },
+                        {
+                            "emoji_name": "zulip",
+                            "emoji_code": "zulip",
+                            "user": {
+                                "email": "AARON@zulip.com",
+                                "full_name": "aaron",
+                                "id": 1,
+                            },
+                            "reaction_type": "zulip_extra_emoji",
+                        },
+                        {
+                            "emoji_name": "zulip",
+                            "emoji_code": "zulip",
+                            "user": {
+                                "email": "shivam@zulip.com",
+                                "full_name": "Shivam",
+                                "id": 6,
+                            },
+                            "reaction_type": "unicode_emoji",
+                        },
+                    ],
+                },
+                " :heart: Iago   :zulip: Shivam, You  ",
+                [
+                    ("reaction", 14),
+                    (None, 1),
+                    ("reaction_mine", 21),
+                ],
+            ),
+            case(
+                {
+                    "reactions": [
+                        {
+                            "emoji_name": "zulip",
+                            "emoji_code": "zulip",
+                            "user": {
+                                "email": "AARON@zulip.com",
+                                "full_name": "aaron",
+                                "id": 1,
+                            },
+                            "reaction_type": "zulip_extra_emoji",
+                        },
+                        {
+                            "emoji_name": "zulip",
+                            "emoji_code": "zulip",
+                            "user": {
+                                "email": "shivam@zulip.com",
+                                "full_name": "Shivam",
+                                "id": 6,
+                            },
+                            "reaction_type": "unicode_emoji",
+                        },
+                    ],
+                },
+                " :zulip: Shivam, You  ",
+                [
+                    ("reaction_mine", 21),
+                ],
+            ),
+        ],
+    )
+    def test_reactions_view(
+        self,
+        message_fixture,
+        to_vary_in_each_message,
+        expected_text,
+        expected_attributes,
+    ):
+        self.model.user_id = 1
+        varied_message = dict(message_fixture, **to_vary_in_each_message)
+        msg_box = MessageBox(varied_message, self.model, None)
+        reactions = to_vary_in_each_message["reactions"]
+
+        reactions_view = msg_box.reactions_view(reactions)
+
+        assert reactions_view.original_widget.text == expected_text
+        assert reactions_view.original_widget.attrib == expected_attributes
+
+    @pytest.mark.parametrize(
+        "message_links, expected_text, expected_attrib, expected_footlinks_width",
+        [
+            case(
+                OrderedDict(
+                    [
+                        (
+                            "https://github.com/zulip/zulip-terminal/pull/1",
+                            ("#T1", 1, True),
+                        ),
+                    ]
+                ),
+                "1: https://github.com/zulip/zulip-terminal/pull/1",
+                [("msg_link_index", 2), (None, 1), ("msg_link", 46)],
+                49,
+                id="one_footlink",
+            ),
+            case(
+                OrderedDict(
+                    [
+                        ("https://foo.com", ("Foo!", 1, True)),
+                        ("https://bar.com", ("Bar!", 2, True)),
+                    ]
+                ),
+                "1: https://foo.com\n2: https://bar.com",
+                [
+                    ("msg_link_index", 2),
+                    (None, 1),
+                    ("msg_link", 15),
+                    (None, 1),
+                    ("msg_link_index", 2),
+                    (None, 1),
+                    ("msg_link", 15),
+                ],
+                18,
+                id="more_than_one_footlink",
+            ),
+            case(
+                OrderedDict(
+                    [
+                        ("https://example.com", ("https://example.com", 1, False)),
+                        ("http://example.com", ("http://example.com", 2, False)),
+                    ]
+                ),
+                None,
+                None,
+                0,
+                id="similar_link_and_text",
+            ),
+            case(
+                OrderedDict(
+                    [
+                        ("https://foo.com", ("https://foo.com, Text", 1, True)),
+                        ("https://bar.com", ("Text, https://bar.com", 2, True)),
+                    ]
+                ),
+                "1: https://foo.com\n2: https://bar.com",
+                [
+                    ("msg_link_index", 2),
+                    (None, 1),
+                    ("msg_link", 15),
+                    (None, 1),
+                    ("msg_link_index", 2),
+                    (None, 1),
+                    ("msg_link", 15),
+                ],
+                18,
+                id="different_link_and_text",
+            ),
+            case(
+                OrderedDict(
+                    [
+                        ("https://foo.com", ("Foo!", 1, True)),
+                        ("http://example.com", ("example.com", 2, False)),
+                        ("https://bar.com", ("Bar!", 3, True)),
+                    ]
+                ),
+                "1: https://foo.com\n3: https://bar.com",
+                [
+                    ("msg_link_index", 2),
+                    (None, 1),
+                    ("msg_link", 15),
+                    (None, 1),
+                    ("msg_link_index", 2),
+                    (None, 1),
+                    ("msg_link", 15),
+                ],
+                18,
+                id="http_default_scheme",
+            ),
+        ],
+    )
+    def test_footlinks_view(
+        self, message_links, expected_text, expected_attrib, expected_footlinks_width
+    ):
+        footlinks, footlinks_width = MessageBox.footlinks_view(
+            message_links,
+            maximum_footlinks=3,
+            padded=True,
+            wrap="ellipsis",
+        )
+
+        if expected_text:
+            assert footlinks.original_widget.text == expected_text
+            assert footlinks.original_widget.attrib == expected_attrib
+            assert footlinks_width == expected_footlinks_width
+        else:
+            assert footlinks is None
+            assert not hasattr(footlinks, "original_widget")
+
+    @pytest.mark.parametrize(
+        "maximum_footlinks, expected_instance",
+        [
+            (0, type(None)),
+            (1, Padding),
+            (3, Padding),
+        ],
+    )
+    def test_footlinks_limit(self, maximum_footlinks, expected_instance):
+        message_links = OrderedDict(
+            [
+                ("https://github.com/zulip/zulip-terminal", ("ZT", 1, True)),
+            ]
+        )
+
+        footlinks, _ = MessageBox.footlinks_view(
+            message_links,
+            maximum_footlinks=maximum_footlinks,
+            padded=True,
+            wrap="ellipsis",
+        )
+
+        assert isinstance(footlinks, expected_instance)
+
+    @pytest.mark.parametrize(
+        "key", keys_for_command("ENTER"), ids=lambda param: f"left_click-key:{param}"
+    )
+    def test_mouse_event_left_click(
+        self, mocker, msg_box, key, widget_size, compose_box_is_open
+    ):
+        size = widget_size(msg_box)
+        col = 1
+        row = 1
+        focus = mocker.Mock()
+        mocker.patch.object(msg_box, "keypress")
+        msg_box.model = mocker.Mock()
+        msg_box.model.controller.is_in_editor_mode.return_value = compose_box_is_open
+
+        msg_box.mouse_event(size, "mouse press", 1, col, row, focus)
+
+        if compose_box_is_open:
+            msg_box.keypress.assert_not_called()
+        else:
+            msg_box.keypress.assert_called_once_with(size, key)
diff --git a/tests/ui_tools/test_popups.py b/tests/ui_tools/test_popups.py
index d2a6623..098b89d 100644
--- a/tests/ui_tools/test_popups.py
+++ b/tests/ui_tools/test_popups.py
@@ -10,7 +10,7 @@ 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.ui_tools.boxes import MessageBox
+from zulipterminal.ui_tools.messages import MessageBox
 from zulipterminal.ui_tools.views import (
     AboutView,
     EditHistoryTag,