text: import datetime
  import threading
  from collections import OrderedDict
  from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Union
  
  import pytz
  import urwid
  from typing_extensions import Literal
  
  from zulipterminal.api_types import EditPropagateMode
  from zulipterminal.config.keys import (
      HELP_CATEGORIES,
      KEY_BINDINGS,
      is_command_key,
      keys_for_command,
  )
  from zulipterminal.config.symbols import (
      CHECK_MARK,
      COLUMN_TITLE_BAR_LINE,
      PINNED_STREAMS_DIVIDER,
      STREAM_MARKER_PRIVATE,
      STREAM_MARKER_PUBLIC,
  )
  from zulipterminal.config.ui_mappings import (
      BOT_TYPE_BY_ID,
      EDIT_MODE_CAPTIONS,
      ROLE_BY_ID,
      STATE_ICON,
  )
  from zulipterminal.helper import (
      Message,
      TidiedUserInfo,
      asynch,
      match_stream,
      match_user,
  )
  from zulipterminal.server_url import near_message_url
  from zulipterminal.ui_tools.boxes import MessageBox, PanelSearchBox
  from zulipterminal.ui_tools.buttons import (
      HomeButton,
      MentionedButton,
      MessageLinkButton,
      PMButton,
      StarredButton,
      StreamButton,
      TopicButton,
      UserButton,
  )
  from zulipterminal.ui_tools.utils import create_msg_box_list
  from zulipterminal.urwid_types import urwid_Size
  
  
  class ModListWalker(urwid.SimpleFocusListWalker):
      def set_focus(self, position: int) -> None:
          # When setting focus via set_focus method.
          self.focus = position
          self._modified()
          if hasattr(self, "read_message"):
              self.read_message()
  
      def _set_focus(self, index: int) -> None:
          # This method is called when directly setting focus via
          # self.focus = focus_position
          if not self:
              self._focus = 0
              return
          if index < 0 or index >= len(self):
              raise IndexError(f"focus index is out of range: {index}")
          if index != int(index):
              raise IndexError(f"invalid focus index: {index}")
          index = int(index)
          if index != self._focus:
              self._focus_changed(index)
          self._focus = index
          if hasattr(self, "read_message"):
              self.read_message()
  
      def extend(self, items: List[Any], focus_position: Optional[int] = None) -> int:
          if focus_position is None:
              focus = self._adjust_focus_on_contents_modified(
                  slice(len(self), len(self)), items
              )
          else:
              focus = focus_position
          rval = super(urwid.MonitoredFocusList, self).extend(items)
          self._set_focus(focus)
          return rval
  
  
  class MessageView(urwid.ListBox):
      def __init__(self, model: Any, view: Any) -> None:
          self.model = model
          self.view = view
          # Initialize for reference
          self.focus_msg = 0
          self.log = ModListWalker(self.main_view())
          self.log.read_message = self.read_message
  
          super().__init__(self.log)
          self.set_focus(self.focus_msg)
          # if loading new/old messages - True
          self.old_loading = False
          self.new_loading = False
  
      def main_view(self) -> List[Any]:
          msg_btn_list = create_msg_box_list(self.model)
          focus_msg = self.model.get_focus_in_current_narrow()
          if focus_msg == set():
              focus_msg = len(msg_btn_list) - 1
          self.focus_msg = focus_msg
          return msg_btn_list
  
      @asynch
      def load_old_messages(self, anchor: int) -> None:
          self.old_loading = True
  
          ids_to_keep = self.model.get_message_ids_in_current_narrow()
          if self.log:
              top_message_id = self.log[0].original_widget.message["id"]
              ids_to_keep.remove(top_message_id)  # update this id
              no_update_baseline = {top_message_id}
          else:
              no_update_baseline = set()
  
          self.model.get_messages(num_before=30, num_after=0, anchor=anchor)
          ids_to_process = self.model.get_message_ids_in_current_narrow() - ids_to_keep
  
          # Only update if more messages are provided
          if ids_to_process != no_update_baseline:
              if self.log:
                  self.log.remove(self.log[0])  # avoid duplication when updating
  
              message_list = create_msg_box_list(self.model, ids_to_process)
              message_list.reverse()
              for msg_w in message_list:
                  self.log.insert(0, msg_w)
  
              self.set_focus(self.focus_msg)  # Return focus to original message
  
              self.model.controller.update_screen()
  
          self.old_loading = False
  
      @asynch
      def load_new_messages(self, anchor: int) -> None:
          self.new_loading = True
          current_ids = self.model.get_message_ids_in_current_narrow()
          self.model.get_messages(num_before=0, num_after=30, anchor=anchor)
          new_ids = self.model.get_message_ids_in_current_narrow() - current_ids
          if self.log:
              last_message = self.log[-1].original_widget.message
          else:
              last_message = None
  
          message_list = create_msg_box_list(
              self.model, new_ids, last_message=last_message
          )
          self.log.extend(message_list)
  
          self.model.controller.update_screen()
          self.new_loading = False
  
      def mouse_event(
          self, size: urwid_Size, event: str, button: int, col: int, row: int, focus: bool
      ) -> bool:
          if event == "mouse press":
              if button == 4:
                  self.keypress(size, "up")
                  return True
              if button == 5:
                  self.keypress(size, "down")
                  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("GO_DOWN", key) and not self.new_loading:
              try:
                  position = self.log.next_position(self.focus_position)
                  self.set_focus(position, "above")
                  self.set_focus_valign("middle")
  
                  return key
              except Exception:
                  if self.focus:
                      id = self.focus.original_widget.message["id"]
                      self.load_new_messages(id)
                  return key
  
          elif is_command_key("GO_UP", key) and not self.old_loading:
              try:
                  position = self.log.prev_position(self.focus_position)
                  self.set_focus(position, "below")
                  self.set_focus_valign("middle")
                  return key
              except Exception:
                  if self.focus:
                      id = self.focus.original_widget.message["id"]
                      self.load_old_messages(id)
                  return key
  
          elif is_command_key("SCROLL_UP", key) and not self.old_loading:
              if self.focus is not None and self.focus_position == 0:
                  return self.keypress(size, "up")
              else:
                  return super().keypress(size, "page up")
  
          elif is_command_key("SCROLL_DOWN", key) and not self.old_loading:
              if self.focus is not None and self.focus_position == len(self.log) - 1:
                  return self.keypress(size, "down")
              else:
                  return super().keypress(size, "page down")
  
          elif is_command_key("THUMBS_UP", key):
              if self.focus is not None:
                  self.model.react_to_message(
                      self.focus.original_widget.message, reaction_to_toggle="thumbs_up"
                  )
  
          elif is_command_key("TOGGLE_STAR_STATUS", key):
              if self.focus is not None:
                  message = self.focus.original_widget.message
                  self.model.toggle_message_star_status(message)
  
          key = super().keypress(size, key)
          return key
  
      def update_search_box_narrow(self, message_view: Any) -> None:
          if not hasattr(self.model.controller, "view"):
              return
          # if view is ready display current narrow
          # at the bottom of the view.
          recipient_bar = message_view.top_header_bar(message_view)
          top_header = message_view.top_search_bar()
          self.model.controller.view.search_box.conversation_focus.set_text(
              top_header.markup
          )
          self.model.controller.view.search_box.msg_narrow.set_text(recipient_bar.markup)
          self.model.controller.update_screen()
  
      def read_message(self, index: int = -1) -> None:
          # Message currently in focus
          if hasattr(self.model.controller, "view"):
              view = self.model.controller.view
          else:
              return
          msg_w, curr_pos = self.body.get_focus()
          if msg_w is None:
              return
          self.update_search_box_narrow(msg_w.original_widget)
  
          # Do not read messages in explore mode.
          if self.model.controller.in_explore_mode:
              return
  
          # Do not read messages in any search narrow.
          if self.model.is_search_narrow():
              return
  
          # If this the last message in the view and focus is set on this message
          # then read the message.
          last_message_focused = curr_pos == len(self.log) - 1
          # Only allow reading a message when middle column is
          # in focus.
          if not (view.body.focus_col == 1 or last_message_focused):
              return
          # save the current focus
          self.model.set_focus_in_current_narrow(self.focus_position)
          # msg ids that have been read
          read_msg_ids = list()
          # until we find a read message above the current message
          while msg_w.attr_map == {None: "unread"}:
              msg_id = msg_w.original_widget.message["id"]
              read_msg_ids.append(msg_id)
              self.model.index["messages"][msg_id]["flags"].append("read")
              msg_w.set_attr_map({None: None})
              msg_w, curr_pos = self.body.get_prev(curr_pos)
              if msg_w is None:
                  break
          self.model.mark_message_ids_as_read(read_msg_ids)
  
  
  class StreamsViewDivider(urwid.Divider):
      """
      A custom urwid.Divider to visually separate pinned and unpinned streams.
      """
  
      def __init__(self) -> None:
          # FIXME: Necessary since the divider is treated as a StreamButton.
          # NOTE: This is specifically for stream search to work correctly.
          self.stream_id = -1
          self.stream_name = ""
          super().__init__(div_char=PINNED_STREAMS_DIVIDER)
  
  
  class StreamsView(urwid.Frame):
      def __init__(self, streams_btn_list: List[Any], view: Any) -> None:
          self.view = view
          self.log = urwid.SimpleFocusListWalker(streams_btn_list)
          self.streams_btn_list = streams_btn_list
          self.focus_index_before_search = 0
          list_box = urwid.ListBox(self.log)
          self.stream_search_box = PanelSearchBox(
              self, "SEARCH_STREAMS", self.update_streams
          )
          super().__init__(
              list_box,
              header=urwid.LineBox(
                  self.stream_search_box,
                  tlcorner="─",
                  tline="",
                  lline="",
                  trcorner="─",
                  blcorner="─",
                  rline="",
                  bline="─",
                  brcorner="─",
              ),
          )
          self.search_lock = threading.Lock()
          self.empty_search = False
  
      @asynch
      def update_streams(self, search_box: Any, new_text: str) -> None:
          if not self.view.controller.is_in_editor_mode():
              return
          # wait for any previously started search to finish to avoid
          # displaying wrong stream list.
          with self.search_lock:
              stream_buttons = [
                  (stream, stream.stream_name) for stream in self.streams_btn_list.copy()
              ]
              streams_display = match_stream(
                  stream_buttons, new_text, self.view.pinned_streams
              )[0]
  
              streams_display_num = len(streams_display)
              self.empty_search = streams_display_num == 0
  
              # Add a divider to separate pinned streams from the rest.
              pinned_stream_names = [
                  stream["name"] for stream in self.view.pinned_streams
              ]
              first_unpinned_index = streams_display_num
              for index, stream in enumerate(streams_display):
                  if stream.stream_name not in pinned_stream_names:
                      first_unpinned_index = index
                      break
              if first_unpinned_index not in [0, streams_display_num]:
                  # Do not add a divider when it is already present. This can
                  # happen when new_text=''.
                  if not isinstance(
                      streams_display[first_unpinned_index], StreamsViewDivider
                  ):
                      streams_display.insert(first_unpinned_index, StreamsViewDivider())
  
              self.log.clear()
              if not self.empty_search:
                  self.log.extend(streams_display)
              else:
                  self.log.extend([self.stream_search_box.search_error])
              self.view.controller.update_screen()
  
      def mouse_event(
          self, size: urwid_Size, event: str, button: int, col: int, row: int, focus: bool
      ) -> bool:
          if event == "mouse press":
              if button == 4:
                  self.keypress(size, "up")
                  return True
              elif button == 5:
                  self.keypress(size, "down")
                  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("SEARCH_STREAMS", key):
              _, self.focus_index_before_search = self.log.get_focus()
              self.set_focus("header")
              return key
          elif is_command_key("GO_BACK", key):
              self.stream_search_box.reset_search_text()
              self.log.clear()
              self.log.extend(self.streams_btn_list)
              self.set_focus("body")
              self.log.set_focus(self.focus_index_before_search)
              self.view.controller.update_screen()
              return key
          return super().keypress(size, key)
  
  
  class TopicsView(urwid.Frame):
      def __init__(
          self, topics_btn_list: List[Any], view: Any, stream_button: Any
      ) -> None:
          self.view = view
          self.log = urwid.SimpleFocusListWalker(topics_btn_list)
          self.topics_btn_list = topics_btn_list
          self.stream_button = stream_button
          self.focus_index_before_search = 0
          self.list_box = urwid.ListBox(self.log)
          self.topic_search_box = PanelSearchBox(
              self, "SEARCH_TOPICS", self.update_topics
          )
          self.header_list = urwid.Pile(
              [self.stream_button, urwid.Divider("─"), self.topic_search_box]
          )
          super().__init__(
              self.list_box,
              header=urwid.LineBox(
                  self.header_list,
                  tlcorner="─",
                  tline="",
                  lline="",
                  trcorner="─",
                  blcorner="─",
                  rline="",
                  bline="─",
                  brcorner="─",
              ),
          )
          self.search_lock = threading.Lock()
          self.empty_search = False
  
      @asynch
      def update_topics(self, search_box: Any, new_text: str) -> None:
          if not self.view.controller.is_in_editor_mode():
              return
          # wait for any previously started search to finish to avoid
          # displaying wrong topics list.
          with self.search_lock:
              new_text = new_text.lower()
              topics_to_display = [
                  topic
                  for topic in self.topics_btn_list.copy()
                  if new_text in topic.topic_name.lower()
              ]
              self.empty_search = len(topics_to_display) == 0
  
              self.log.clear()
              if not self.empty_search:
                  self.log.extend(topics_to_display)
              else:
                  self.log.extend([self.topic_search_box.search_error])
              self.view.controller.update_screen()
  
      def update_topics_list(
          self, stream_id: int, topic_name: str, sender_id: int
      ) -> None:
          # More recent topics are found towards the beginning
          # of the list.
          for topic_iterator, topic_button in enumerate(self.log):
              if topic_button.topic_name == topic_name:
                  self.log.insert(0, self.log.pop(topic_iterator))
                  self.list_box.set_focus_valign("bottom")
                  if sender_id == self.view.model.user_id:
                      self.list_box.set_focus(0)
                  return
          # No previous topics with same topic names are found
          # hence we create a new topic button for it.
          new_topic_button = TopicButton(
              stream_id,
              topic_name,
              self.view.controller,
              self.view,
              self.view.LEFT_WIDTH,
              0,
          )
          self.log.insert(0, new_topic_button)
          self.list_box.set_focus_valign("bottom")
          if sender_id == self.view.model.user_id:
              self.list_box.set_focus(0)
  
      def mouse_event(
          self, size: urwid_Size, event: str, button: int, col: int, row: int, focus: bool
      ) -> bool:
          if event == "mouse press":
              if button == 4:
                  self.keypress(size, "up")
                  return True
              elif button == 5:
                  self.keypress(size, "down")
                  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("SEARCH_TOPICS", key):
              _, self.focus_index_before_search = self.log.get_focus()
              self.set_focus("header")
              self.header_list.set_focus(2)
              return key
          elif is_command_key("GO_BACK", key):
              self.topic_search_box.reset_search_text()
              self.log.clear()
              self.log.extend(self.topics_btn_list)
              self.set_focus("body")
              self.log.set_focus(self.focus_index_before_search)
              self.view.controller.update_screen()
              return key
          return super().keypress(size, key)
  
  
  class UsersView(urwid.ListBox):
      def __init__(self, controller: Any, users_btn_list: List[Any]) -> None:
          self.users_btn_list = users_btn_list
          self.log = urwid.SimpleFocusListWalker(users_btn_list)
          self.controller = controller
          super().__init__(self.log)
  
      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.controller.is_in_editor_mode():
                      return True
              if button == 4:
                  for _ in range(5):
                      self.keypress(size, "up")
                  return True
              elif button == 5:
                  for _ in range(5):
                      self.keypress(size, "down")
          return super().mouse_event(size, event, button, col, row, focus)
  
  
  class MiddleColumnView(urwid.Frame):
      def __init__(self, view: Any, model: Any, write_box: Any, search_box: Any) -> None:
          message_view = MessageView(model, view)
          self.model = model
          self.controller = model.controller
          self.view = view
          self.last_unread_topic = None
          self.last_unread_pm = None
          self.search_box = search_box
          view.message_view = message_view
          super().__init__(message_view, header=search_box, footer=write_box)
  
      def get_next_unread_topic(self) -> Optional[Tuple[int, str]]:
          topics = list(self.model.unread_counts["unread_topics"].keys())
          next_topic = False
          for topic in topics:
              if next_topic is True:
                  self.last_unread_topic = topic
                  return topic
              if topic == self.last_unread_topic:
                  next_topic = True
          if len(topics) > 0:
              topic = topics[0]
              self.last_unread_topic = topic
              return topic
          return None
  
      def get_next_unread_pm(self) -> Optional[int]:
          pms = list(self.model.unread_counts["unread_pms"].keys())
          next_pm = False
          for pm in pms:
              if next_pm is True:
                  self.last_unread_pm = pm
                  return pm
              if pm == self.last_unread_pm:
                  next_pm = True
          if len(pms) > 0:
              pm = pms[0]
              self.last_unread_pm = pm
              return pm
          return None
  
      def update_message_list_status_markers(self) -> None:
          for message_w in self.body.log:
              message_box = message_w.original_widget
  
              message_box.update_message_author_status()
  
          self.controller.update_screen()
  
      def keypress(self, size: urwid_Size, key: str) -> Optional[str]:
          if is_command_key("GO_BACK", key):
              self.header.keypress(size, "esc")
              self.footer.keypress(size, "esc")
              self.set_focus("body")
  
          elif self.focus_position in ["footer", "header"]:
              return super().keypress(size, key)
  
          elif is_command_key("SEARCH_MESSAGES", key):
              self.controller.enter_editor_mode_with(self.search_box)
              self.set_focus("header")
              return key
  
          elif is_command_key("REPLY_MESSAGE", key):
              self.body.keypress(size, "enter")
              if self.footer.focus is not None:
                  self.set_focus("footer")
                  self.footer.focus_position = 1
              return key
  
          elif is_command_key("STREAM_MESSAGE", key):
              self.body.keypress(size, "c")
              # For new streams with no previous conversation.
              if self.footer.focus is None:
                  stream_id = self.model.stream_id
                  stream_dict = self.model.stream_dict
                  self.footer.stream_box_view(caption=stream_dict[stream_id]["name"])
              self.set_focus("footer")
              self.footer.focus_position = 0
              return key
  
          elif is_command_key("REPLY_AUTHOR", key):
              self.body.keypress(size, "R")
              if self.footer.focus is not None:
                  self.set_focus("footer")
                  self.footer.focus_position = 1
              return key
  
          elif is_command_key("NEXT_UNREAD_TOPIC", key):
              # narrow to next unread topic
              stream_topic = self.get_next_unread_topic()
              if stream_topic is None:
                  return key
              stream_id, topic = stream_topic
              self.controller.narrow_to_topic(
                  stream_name=self.model.stream_dict[stream_id]["name"],
                  topic_name=topic,
              )
              return key
          elif is_command_key("NEXT_UNREAD_PM", key):
              # narrow to next unread pm
              pm = self.get_next_unread_pm()
              if pm is None:
                  return key
              email = self.model.user_id_email_dict[pm]
              self.controller.narrow_to_user(
                  recipient_emails=[email],
                  contextual_message_id=pm,
              )
          elif is_command_key("PRIVATE_MESSAGE", key):
              # Create new PM message
              self.footer.private_box_view()
              self.set_focus("footer")
              self.footer.focus_position = 0
              return key
          elif is_command_key("GO_LEFT", key):
              self.view.show_left_panel(visible=True)
          elif is_command_key("GO_RIGHT", key):
              self.view.show_right_panel(visible=True)
          return super().keypress(size, key)
  
  
  class RightColumnView(urwid.Frame):
      """
      Displays the users list on the right side of the app.
      """
  
      def __init__(self, width: int, view: Any) -> None:
          self.width = width
          self.view = view
          self.user_search = PanelSearchBox(self, "SEARCH_PEOPLE", self.update_user_list)
          self.view.user_search = self.user_search
          search_box = urwid.LineBox(
              self.user_search,
              tlcorner="─",
              tline="",
              lline="",
              trcorner="─",
              blcorner="─",
              rline="",
              bline="─",
              brcorner="─",
          )
          self.allow_update_user_list = True
          self.search_lock = threading.Lock()
          self.empty_search = False
          super().__init__(self.users_view(), header=search_box)
  
      @asynch
      def update_user_list(
          self,
          search_box: Any = None,
          new_text: Optional[str] = None,
          user_list: Any = None,
      ) -> None:
          """
          Updates user list via PanelSearchBox and _start_presence_updates.
          """
          assert (user_list is None and search_box is not None) or (  # PanelSearchBox.
              user_list is not None and search_box is None and new_text is None
          )  # _start_presence_updates.
  
          # Return if the method is called by PanelSearchBox (urwid.Edit) while
          # the search is inactive and user_list is None.
          # NOTE: The additional not user_list check is to not false trap
          # _start_presence_updates but allow it to update the user list.
          if not self.view.controller.is_in_editor_mode() and not user_list:
              return
  
          # Return if the method is called from _start_presence_updates while the
          # search, via PanelSearchBox, is active.
          if not self.allow_update_user_list and new_text is None:
              return
  
          # wait for any previously started search to finish to avoid
          # displaying wrong user list.
          with self.search_lock:
              if user_list:
                  self.view.users = user_list
  
              users = self.view.users.copy()
              if new_text:
                  users_display = [user for user in users if match_user(user, new_text)]
              else:
                  users_display = users
  
              self.empty_search = len(users_display) == 0
  
              # FIXME Update log directly?
              if not self.empty_search:
                  self.body = self.users_view(users_display)
              else:
                  self.body = UsersView(
                      self.view.controller, [self.user_search.search_error]
                  )
              self.set_body(self.body)
              self.view.controller.update_screen()
  
      def users_view(self, users: Any = None) -> Any:
          reset_default_view_users = False
          if users is None:
              users = self.view.users.copy()
              reset_default_view_users = True
  
          users_btn_list = list()
          for user in users:
              status = user["status"]
              # Only include `inactive` users in search result.
              if status == "inactive" and not self.view.controller.is_in_editor_mode():
                  continue
              unread_count = self.view.model.unread_counts["unread_pms"].get(
                  user["user_id"], 0
              )
              is_current_user = user["user_id"] == self.view.model.user_id
              users_btn_list.append(
                  UserButton(
                      user,
                      controller=self.view.controller,
                      view=self.view,
                      width=self.width,
                      state_marker=STATE_ICON[status],
                      color=f"user_{status}",
                      count=unread_count,
                      is_current_user=is_current_user,
                  )
              )
          user_w = UsersView(self.view.controller, users_btn_list)
          # Donot reset them while searching.
          if reset_default_view_users:
              self.users_btn_list = users_btn_list
              self.view.user_w = user_w
          return user_w
  
      def keypress(self, size: urwid_Size, key: str) -> Optional[str]:
          if is_command_key("SEARCH_PEOPLE", key):
              self.allow_update_user_list = False
              self.set_focus("header")
              return key
          elif is_command_key("GO_BACK", key):
              self.user_search.reset_search_text()
              self.allow_update_user_list = True
              self.body = UsersView(self.view.controller, self.users_btn_list)
              self.set_body(self.body)
              self.set_focus("body")
              self.view.controller.update_screen()
              return key
          elif is_command_key("GO_LEFT", key):
              self.view.show_right_panel(visible=False)
          return super().keypress(size, key)
  
  
  class LeftColumnView(urwid.Pile):
      """
      Displays the buttons at the left column of the app.
      """
  
      def __init__(self, width: int, view: Any) -> None:
          self.model = view.model
          self.view = view
          self.controller = view.controller
          self.width = width
          self.menu_v = self.menu_view()
          self.stream_v = self.streams_view()
  
          self.is_in_topic_view = False
          contents = [(4, self.menu_v), self.stream_v]
          super().__init__(contents)
  
      def menu_view(self) -> Any:
          count = self.model.unread_counts.get("all_msg", 0)
          self.view.home_button = HomeButton(
              self.controller, count=count, width=self.width
          )
  
          count = self.model.unread_counts.get("all_pms", 0)
          self.view.pm_button = PMButton(self.controller, count=count, width=self.width)
  
          self.view.mentioned_button = MentionedButton(
              self.controller,
              width=self.width,
              count=self.model.unread_counts["all_mentions"],
          )
  
          # Starred messages are by definition read already
          count = len(self.model.initial_data["starred_messages"])
          self.view.starred_button = StarredButton(
              self.controller, width=self.width, count=count
          )
          menu_btn_list = [
              self.view.home_button,
              self.view.pm_button,
              self.view.mentioned_button,
              self.view.starred_button,
          ]
          w = urwid.ListBox(urwid.SimpleFocusListWalker(menu_btn_list))
          return w
  
      def streams_view(self) -> Any:
          streams_btn_list = [
              StreamButton(
                  stream,
                  controller=self.controller,
                  view=self.view,
                  width=self.width,
                  count=self.model.unread_counts["streams"].get(stream["id"], 0),
              )
              for stream in self.view.pinned_streams
          ]
  
          if len(streams_btn_list):
              streams_btn_list += [StreamsViewDivider()]
  
          streams_btn_list += [
              StreamButton(
                  stream,
                  controller=self.controller,
                  view=self.view,
                  width=self.width,
                  count=self.model.unread_counts["streams"].get(stream["id"], 0),
              )
              for stream in self.view.unpinned_streams
          ]
  
          self.view.stream_id_to_button = {
              stream.stream_id: stream
              for stream in streams_btn_list
              if hasattr(stream, "stream_id")
          }
  
          self.view.stream_w = StreamsView(streams_btn_list, self.view)
          w = urwid.LineBox(
              self.view.stream_w,
              title="Streams",
              title_attr="column_title",
              tlcorner=COLUMN_TITLE_BAR_LINE,
              tline=COLUMN_TITLE_BAR_LINE,
              trcorner=COLUMN_TITLE_BAR_LINE,
              blcorner="",
              rline="",
              lline="",
              bline="",
              brcorner="─",
          )
          return w
  
      def topics_view(self, stream_button: Any) -> Any:
          stream_id = stream_button.stream_id
          topics = self.model.topics_in_stream(stream_id)
          topics_btn_list = [
              TopicButton(
                  stream_id=stream_id,
                  topic=topic,
                  controller=self.controller,
                  view=self.view,
                  width=self.width,
                  count=self.model.unread_counts["unread_topics"].get(
                      (stream_id, topic), 0
                  ),
              )
              for topic in topics
          ]
  
          self.view.topic_w = TopicsView(topics_btn_list, self.view, stream_button)
          w = urwid.LineBox(
              self.view.topic_w,
              title="Topics",
              title_attr="column_title",
              tlcorner=COLUMN_TITLE_BAR_LINE,
              tline=COLUMN_TITLE_BAR_LINE,
              trcorner=COLUMN_TITLE_BAR_LINE,
              blcorner="",
              rline="",
              lline="",
              bline="",
              brcorner="─",
          )
          return w
  
      def is_in_topic_view_with_stream_id(self, stream_id: int) -> bool:
          return (
              self.is_in_topic_view
              and stream_id == self.view.topic_w.stream_button.stream_id
          )
  
      def update_stream_view(self) -> None:
          self.stream_v = self.streams_view()
          if not self.is_in_topic_view:
              self.show_stream_view()
  
      def show_stream_view(self) -> None:
          self.is_in_topic_view = False
          self.contents[1] = (self.stream_v, self.options(height_type="weight"))
  
      def show_topic_view(self, stream_button: Any) -> None:
          self.is_in_topic_view = True
          self.contents[1] = (
              self.topics_view(stream_button),
              self.options(height_type="weight"),
          )
  
      def keypress(self, size: urwid_Size, key: str) -> Optional[str]:
          if is_command_key("SEARCH_STREAMS", key) or is_command_key(
              "SEARCH_TOPICS", key
          ):
              self.focus_position = 1
              if self.is_in_topic_view:
                  self.view.topic_w.keypress(size, key)
              else:
                  self.view.stream_w.keypress(size, key)
              return key
          elif is_command_key("GO_RIGHT", key):
              self.view.show_left_panel(visible=False)
          return super().keypress(size, key)
  
  
  PopUpViewTableContent = Sequence[Tuple[str, Sequence[Union[str, Tuple[str, str]]]]]
  
  
  class PopUpView(urwid.ListBox):
      def __init__(
          self,
          controller: Any,
          widgets: List[Any],
          command: str,
          requested_width: int,
          title: str,
      ) -> None:
          self.controller = controller
          self.command = command
          self.title = title
          self.log = urwid.SimpleFocusListWalker(widgets)
  
          max_cols, max_rows = controller.maximum_popup_dimensions()
  
          self.width = min(max_cols, requested_width)
  
          height = self.calculate_popup_height(widgets, self.width)
          self.height = min(max_rows, height)
  
          super().__init__(self.log)
  
      @staticmethod
      def calculate_popup_height(widgets: List[Any], popup_width: int) -> int:
          """
          Returns popup height. The popup height is calculated using urwid's
          .rows method on every widget.
          """
          return sum(widget.rows((popup_width,)) for widget in widgets)
  
      @staticmethod
      def calculate_table_widths(
          contents: PopUpViewTableContent, title_len: int, dividechars: int = 2
      ) -> Tuple[int, List[int]]:
          """
          Returns a tuple that contains the required width for the popup and a
          list that has column widths.
          """
          # Add 4 (for 2 Unicode characters on either side) to the popup title
          # length to make sure that the title gets displayed even when the
          # content or the category is shorter than the title length (+4 Unicode
          # characters).
          title_width = title_len + 4
  
          category_width = 0
          text_width = 0
          strip_widths = []
          for category, content in contents:
              category_width = max(category_width, len(category))
              for row in content:
                  if isinstance(row, str):
                      # Measure the longest line if the text is separated by
                      # newline(s).
                      text_width = max(text_width, len(max(row.split("\n"), key=len)))
                  elif isinstance(row, tuple):
                      # Measure the longest line if the text is separated by
                      # newline(s).
                      max_row_lengths = [
                          len(max(text.split("\n"), key=len)) for text in row
                      ]
                      strip_widths.append(max_row_lengths)
          column_widths = [max(width) for width in zip(*strip_widths)]
  
          popup_width = max(
              sum(column_widths) + dividechars, title_width, category_width, text_width
          )
          return (popup_width, column_widths)
  
      @staticmethod
      def make_table_with_categories(
          contents: PopUpViewTableContent, column_widths: List[int], dividechars: int = 2
      ) -> List[Any]:
          """
          Returns a list of widgets to render a table with different categories.
          """
          widgets: List[Any] = []
          for category, content in contents:
              if category:
                  if len(widgets) > 0:  # Separate categories with newline.
                      widgets.append(urwid.Text(""))
                  widgets.append(urwid.Text(("popup_category", category)))
              for index, row in enumerate(content):
                  if isinstance(row, str) and row:
                      widgets.append(urwid.Text(row))
                  elif isinstance(row, tuple):
                      label, data = row
                      strip = urwid.Columns(
                          [(column_widths[0], urwid.Text(label)), urwid.Text(data)],
                          dividechars=dividechars,
                      )
                      widgets.append(
                          urwid.AttrWrap(strip, None if index % 2 else "popup_contrast")
                      )
          return widgets
  
      def keypress(self, size: urwid_Size, key: str) -> str:
          if is_command_key("GO_BACK", key) or is_command_key(self.command, key):
              self.controller.exit_popup()
          elif is_command_key("GO_UP", key):
              key = "up"
          elif is_command_key("GO_DOWN", key):
              key = "down"
          elif is_command_key("SCROLL_UP", key):
              key = "page up"
          elif is_command_key("SCROLL_DOWN", key):
              key = "page down"
          elif is_command_key("GO_TO_BOTTOM", key):
              key = "end"
          return super().keypress(size, key)
  
  
  class NoticeView(PopUpView):
      def __init__(
          self, controller: Any, notice_text: str, width: int, title: str
      ) -> None:
          widgets = [
              urwid.Divider(),
              urwid.Padding(urwid.Text(notice_text), left=1, right=1),
              urwid.Divider(),
          ]
          super().__init__(controller, widgets, "GO_BACK", width, title)
  
  
  class AboutView(PopUpView):
      def __init__(
          self,
          controller: Any,
          title: str,
          *,
          zt_version: str,
          server_version: str,
          server_feature_level: Optional[int],
          theme_name: str,
          color_depth: int,
          autohide_enabled: bool,
          maximum_footlinks: int,
          notify_enabled: bool,
      ) -> None:
          self.feature_level_content = (
              [("Feature level", str(server_feature_level))]
              if server_feature_level
              else []
          )
          contents = [
              ("Application", [("Zulip Terminal", zt_version)]),
              ("Server", [("Version", server_version)] + self.feature_level_content),
              (
                  "Application Configuration",
                  [
                      ("Theme", theme_name),
                      ("Autohide", "enabled" if autohide_enabled else "disabled"),
                      ("Maximum footlinks", str(maximum_footlinks)),
                      ("Color depth", str(color_depth)),
                      ("Notifications", "enabled" if notify_enabled else "disabled"),
                  ],
              ),
          ]
  
          popup_width, column_widths = self.calculate_table_widths(contents, len(title))
          widgets = self.make_table_with_categories(contents, column_widths)
  
          super().__init__(controller, widgets, "ABOUT", popup_width, title)
  
  
  class UserInfoView(PopUpView):
      def __init__(self, controller: Any, user_id: int, title: str) -> None:
  
          display_data = self._fetch_user_data(controller, user_id)
  
          user_details = [
              (key, value) for key, value in display_data.items() if key != "Name"
          ]
          user_view_content = [(display_data["Name"], user_details)]
  
          popup_width, column_widths = self.calculate_table_widths(
              user_view_content, len(title)
          )
          widgets = self.make_table_with_categories(user_view_content, column_widths)
  
          super().__init__(controller, widgets, "USER_INFO", popup_width, title)
  
      @staticmethod
      def _fetch_user_data(controller: Any, user_id: int) -> Dict[str, Any]:
          # Get user data from model
          data: TidiedUserInfo = controller.model.get_user_info(user_id)
  
          if not data:
              display_data = {
                  "Name": "(Unavailable)",
                  "Error": "User data not found",
              }
              return display_data
  
          # Style the data obtained to make it displayable
          display_data = {"Name": data["full_name"]}
  
          if data["email"]:
              display_data["Email"] = data["email"]
          if data["date_joined"]:
              display_data["Date joined"] = data["date_joined"][:10]
          if data["timezone"]:
              display_data["Timezone"] = data["timezone"].replace("_", " ")
  
              # Converting all timestamps to UTC
              utc_time = datetime.datetime.now()
              tz = pytz.timezone(data["timezone"])
              time = utc_time.astimezone(tz).replace(tzinfo=None).timestamp()
  
              # Take 24h vs AM/PM format into consideration
              local_time = controller.model.formatted_local_time(
                  round(time), show_seconds=False
              )
              display_data["Local time"] = local_time[11:]
  
          if data["is_bot"]:
              display_data["Role"] = BOT_TYPE_BY_ID.get(
                  data["bot_type"], "Unknown Bot Type"
              )
              if data["bot_owner_name"]:
                  display_data["Owner"] = data["bot_owner_name"]
          else:
              display_data["Role"] = ROLE_BY_ID[data["role"]]["name"]
  
              if data["last_active"]:
                  display_data["Last active"] = data["last_active"]
  
          return display_data
  
  
  class HelpView(PopUpView):
      def __init__(self, controller: Any, title: str) -> None:
          help_menu_content = []
          for category in HELP_CATEGORIES:
              keys_in_category = (
                  binding
                  for binding in KEY_BINDINGS.values()
                  if binding["key_category"] == category
              )
              key_bindings = []
              for binding in keys_in_category:
                  key_bindings.append((binding["help_text"], ", ".join(binding["keys"])))
              help_menu_content.append((HELP_CATEGORIES[category], key_bindings))
  
          popup_width, column_widths = self.calculate_table_widths(
              help_menu_content, len(title)
          )
          widgets = self.make_table_with_categories(help_menu_content, column_widths)
  
          super().__init__(controller, widgets, "HELP", popup_width, title)
  
  
  class PopUpConfirmationView(urwid.Overlay):
      def __init__(
          self, controller: Any, question: Any, success_callback: Callable[[], None]
      ):
          self.controller = controller
          self.success_callback = success_callback
          yes = urwid.Button("Yes", self.exit_popup_yes)
          no = urwid.Button("No", self.exit_popup_no)
          yes._w = urwid.AttrMap(urwid.SelectableIcon("Yes", 4), None, "selected")
          no._w = urwid.AttrMap(urwid.SelectableIcon("No", 4), None, "selected")
          display_widget = urwid.GridFlow([yes, no], 3, 5, 1, "center")
          wrapped_widget = urwid.WidgetWrap(display_widget)
          prompt = urwid.LineBox(
              urwid.ListBox(
                  urwid.SimpleFocusListWalker([question, urwid.Divider(), wrapped_widget])
              )
          )
          urwid.Overlay.__init__(
              self,
              prompt,
              self.controller.view,
              align="left",
              valign="top",
              width=self.controller.view.LEFT_WIDTH + 1,
              height=8,
          )
  
      def exit_popup_yes(self, args: Any) -> None:
          self.success_callback()
          self.controller.exit_popup()
  
      def exit_popup_no(self, args: Any) -> None:
          self.controller.exit_popup()
  
      def keypress(self, size: urwid_Size, key: str) -> str:
          if is_command_key("GO_BACK", key):
              self.controller.exit_popup()
          return super().keypress(size, key)
  
  
  class StreamInfoView(PopUpView):
      def __init__(self, controller: Any, stream_id: int) -> None:
          self.stream_id = stream_id
          self.controller = controller
          stream = controller.model.stream_dict[stream_id]
  
          total_members = len(stream["subscribers"])
          member_keys = ", ".join(map(repr, keys_for_command("STREAM_MEMBERS")))
  
          weekly_msg_count = stream["stream_weekly_traffic"]
  
          stream_marker = (
              STREAM_MARKER_PRIVATE if stream["invite_only"] else STREAM_MARKER_PUBLIC
          )
          title = f"{stream_marker} {stream['name']}"
          rendered_desc = stream["rendered_description"]
          self.markup_desc, message_links, _ = MessageBox.transform_content(
              rendered_desc,
              self.controller.model.server_url,
          )
          desc = urwid.Text(self.markup_desc)
  
          stream_info_content = [
              (
                  "Stream Details",
                  [
                      ("Weekly Message Count", str(weekly_msg_count)),
                      (
                          "Stream Members",
                          f"{total_members} (Press {member_keys} to view list)",
                      ),
                  ],
              ),
              ("Stream settings", []),
          ]  # type: PopUpViewTableContent
  
          popup_width, column_widths = self.calculate_table_widths(
              stream_info_content, len(title)
          )
  
          muted_setting = urwid.CheckBox(
              label="Muted",
              state=controller.model.is_muted_stream(stream_id),
              checked_symbol=CHECK_MARK,
          )
          urwid.connect_signal(muted_setting, "change", self.toggle_mute_status)
          pinned_state = controller.model.is_pinned_stream(stream_id)
          pinned_setting = urwid.CheckBox(
              label="Pinned to top", state=pinned_state, checked_symbol=CHECK_MARK
          )
          urwid.connect_signal(pinned_setting, "change", self.toggle_pinned_status)
          visual_notification = urwid.CheckBox(
              label="Visual desktop notification",
              state=controller.model.is_visual_notifications_enabled(stream_id),
              checked_symbol=CHECK_MARK,
          )
          urwid.connect_signal(
              visual_notification, "change", self.toggle_visual_notification
          )
  
          footlinks, footlinks_width = MessageBox.footlinks_view(
              message_links=message_links,
              maximum_footlinks=10,  # Show 'all', as no other way to add them
              padded=False,
              wrap="space",
          )
  
          # Manual because calculate_table_widths does not support checkboxes.
          # Add 4 to checkbox label to accommodate the checkbox itself.
          popup_width = max(
              popup_width,
              len(muted_setting.label) + 4,
              len(pinned_setting.label) + 4,
              desc.pack()[0],
              footlinks_width,
              len(visual_notification.label) + 4,
          )
          if not self.controller.notify_enabled:
              visual_notification = urwid.WidgetDisable(
                  urwid.AttrMap(visual_notification, "widget_disabled")
              )
          self.widgets = self.make_table_with_categories(
              stream_info_content, column_widths
          )
  
          # Stream description.
          self.widgets.insert(0, desc)
          desc_newline = 1
          if footlinks:
              self.widgets.insert(1, footlinks)
              desc_newline = 2
          self.widgets.insert(desc_newline, urwid.Text(""))  # Add a newline.
  
          self.widgets.append(muted_setting)
          self.widgets.append(pinned_setting)
          self.widgets.append(visual_notification)
          super().__init__(controller, self.widgets, "STREAM_DESC", popup_width, title)
  
      def toggle_mute_status(self, button: Any, new_state: bool) -> None:
          self.controller.model.toggle_stream_muted_status(self.stream_id)
  
      def toggle_pinned_status(self, button: Any, new_state: bool) -> None:
          self.controller.model.toggle_stream_pinned_status(self.stream_id)
  
      def toggle_visual_notification(self, button: Any, new_state: bool) -> None:
          self.controller.model.toggle_stream_visual_notifications(self.stream_id)
  
      def keypress(self, size: urwid_Size, key: str) -> str:
          if is_command_key("STREAM_MEMBERS", key):
              self.controller.show_stream_members(stream_id=self.stream_id)
          return super().keypress(size, key)
  
  
  class StreamMembersView(PopUpView):
      def __init__(self, controller: Any, stream_id: int) -> None:
          self.stream_id = stream_id
          self.controller = controller
          model = controller.model
  
          user_ids = model.get_other_subscribers_in_stream(stream_id=stream_id)
          user_names = [model.user_name_from_id(id) for id in user_ids]
          sorted_user_names = sorted(user_names)
          sorted_user_names.insert(0, model.user_full_name)
          title = "Stream Members (up/down scrolls)"
  
          stream_users_content = [("", [(name, "") for name in sorted_user_names])]
          popup_width, column_width = self.calculate_table_widths(
              stream_users_content, len(title)
          )
          widgets = self.make_table_with_categories(stream_users_content, column_width)
  
          super().__init__(controller, widgets, "STREAM_DESC", popup_width, title)
  
      def keypress(self, size: urwid_Size, key: str) -> str:
          if is_command_key("GO_BACK", key) or is_command_key("STREAM_MEMBERS", key):
              self.controller.show_stream_info(stream_id=self.stream_id)
              return key
          return super().keypress(size, key)
  
  
  class MsgInfoView(PopUpView):
      def __init__(
          self,
          controller: Any,
          msg: Message,
          title: str,
          topic_links: "OrderedDict[str, Tuple[str, int, bool]]",
          message_links: "OrderedDict[str, Tuple[str, int, bool]]",
          time_mentions: List[Tuple[str, str]],
      ) -> None:
          self.msg = msg
          self.topic_links = topic_links
          self.message_links = message_links
          self.time_mentions = time_mentions
          self.server_url = controller.model.server_url
          date_and_time = controller.model.formatted_local_time(
              msg["timestamp"], show_seconds=True, show_year=True
          )
          view_in_browser_keys = ", ".join(map(repr, keys_for_command("VIEW_IN_BROWSER")))
  
          msg_info = [
              (
                  "",
                  [
                      ("Date & Time", date_and_time),
                      ("Sender", msg["sender_full_name"]),
                      ("Sender's Email ID", msg["sender_email"]),
                      (
                          "View message in browser",
                          f"Press {view_in_browser_keys} to view message in browser",
                      ),
                  ],
              ),
          ]
          # Only show the 'Edit History' label for edited messages.
          self.show_edit_history_label = (
              self.msg["id"] in controller.model.index["edited_messages"]
              and controller.model.initial_data["realm_allow_edit_history"]
          )
          if self.show_edit_history_label:
              msg_info[0][1][0] = ("Date & Time (Original)", date_and_time)
  
              keys = ", ".join(map(repr, keys_for_command("EDIT_HISTORY")))
              msg_info[0][1].append(("Edit History", f"Press {keys} to view"))
          # Render the category using the existing table methods if links exist.
          if topic_links:
              msg_info.append(("Topic Links", []))
          if message_links:
              msg_info.append(("Message Links", []))
          if time_mentions:
              msg_info.append(("Time mentions", time_mentions))
          if msg["reactions"]:
              reactions = sorted(
                  (reaction["emoji_name"], reaction["user"]["full_name"])
                  for reaction in msg["reactions"]
              )
              grouped_reactions: Dict[str, str] = dict()
              for reaction, user in reactions:
                  if reaction in grouped_reactions:
                      grouped_reactions[reaction] += f"\n{user}"
                  else:
                      grouped_reactions[reaction] = user
              msg_info.append(("Reactions", list(grouped_reactions.items())))
  
          popup_width, column_widths = self.calculate_table_widths(msg_info, len(title))
          widgets = self.make_table_with_categories(msg_info, column_widths)
  
          # To keep track of buttons (e.g., button links) and to facilitate
          # computing their slice indexes
          button_widgets = []  # type: List[Any]
  
          if topic_links:
              topic_link_widgets, topic_link_width = self.create_link_buttons(
                  controller, topic_links
              )
  
              # slice_index = Number of labels before topic links + 1 newline
              #               + 1 'Topic Links' category label.
              slice_index = len(msg_info[0][1]) + 2
              slice_index += sum([len(w) + 2 for w in button_widgets])
              button_widgets.append(topic_links)
  
              widgets = widgets[:slice_index] + topic_link_widgets + widgets[slice_index:]
              popup_width = max(popup_width, topic_link_width)
  
          if message_links:
              message_link_widgets, message_link_width = self.create_link_buttons(
                  controller, message_links
              )
  
              # slice_index = Number of labels before message links + 1 newline
              #               + 1 'Message Links' category label.
              slice_index = len(msg_info[0][1]) + 2
              slice_index += sum([len(w) + 2 for w in button_widgets])
              button_widgets.append(message_links)
  
              widgets = (
                  widgets[:slice_index] + message_link_widgets + widgets[slice_index:]
              )
              popup_width = max(popup_width, message_link_width)
  
          super().__init__(controller, widgets, "MSG_INFO", popup_width, title)
  
      @staticmethod
      def create_link_buttons(
          controller: Any, links: "OrderedDict[str, Tuple[str, int, bool]]"
      ) -> Tuple[List[MessageLinkButton], int]:
          link_widgets = []
          link_width = 0
  
          for index, link in enumerate(links):
              text, link_index, _ = links[link]
              if text:
                  caption = f"{link_index}: {text}\n{link}"
              else:
                  caption = f"{link_index}: {link}"
              link_width = max(link_width, len(max(caption.split("\n"), key=len)))
  
              display_attr = None if index % 2 else "popup_contrast"
              link_widgets.append(
                  MessageLinkButton(controller, caption, link, display_attr)
              )
  
          return link_widgets, link_width
  
      def keypress(self, size: urwid_Size, key: str) -> str:
          if is_command_key("EDIT_HISTORY", key) and self.show_edit_history_label:
              self.controller.show_edit_history(
                  message=self.msg,
                  topic_links=self.topic_links,
                  message_links=self.message_links,
                  time_mentions=self.time_mentions,
              )
          elif is_command_key("VIEW_IN_BROWSER", key):
              url = near_message_url(self.server_url[:-1], self.msg)
              self.controller.open_in_browser(url)
          return super().keypress(size, key)
  
  
  class EditModeView(PopUpView):
      def __init__(self, controller: Any, button: Any):
          self.edit_mode_button = button
          self.widgets: List[urwid.RadioButton] = []
  
          for mode in EDIT_MODE_CAPTIONS.keys():
              self.add_radio_button(mode)
          super().__init__(
              controller, self.widgets, "ENTER", 62, "Topic edit propagation mode"
          )
          # Set cursor to marked checkbox.
          for i in range(len(self.widgets)):
              if self.widgets[i].state:
                  self.set_focus(i)
  
      def set_selected_mode(self, button: Any, new_state: bool, mode: str) -> None:
          if new_state:
              self.edit_mode_button.set_selected_mode(mode)
  
      def add_radio_button(self, mode: EditPropagateMode) -> None:
          state = mode == self.edit_mode_button.mode
          radio_button = urwid.RadioButton(
              self.widgets, EDIT_MODE_CAPTIONS[mode], state=state
          )
          urwid.connect_signal(radio_button, "change", self.set_selected_mode, mode)
  
      def keypress(self, size: urwid_Size, key: str) -> str:
          # Use space to select radio-button and exit popup too.
          if key == " ":
              key = "enter"
          return super().keypress(size, key)
  
  
  EditHistoryTag = Literal["(Current Version)", "(Original Version)", ""]
  
  
  class EditHistoryView(PopUpView):
      def __init__(
          self,
          controller: Any,
          message: Message,
          topic_links: "OrderedDict[str, Tuple[str, int, bool]]",
          message_links: "OrderedDict[str, Tuple[str, int, bool]]",
          time_mentions: List[Tuple[str, str]],
          title: str,
      ) -> None:
          self.controller = controller
          self.message = message
          self.topic_links = topic_links
          self.message_links = message_links
          self.time_mentions = time_mentions
          width = 64
          widgets: List[Any] = []
  
          message_history = self.controller.model.fetch_message_history(
              message_id=self.message["id"],
          )
          for index, snapshot in enumerate(message_history):
              if len(widgets) > 0:  # Separate edit blocks with newline.
                  widgets.append(urwid.Text(""))
  
              tag: EditHistoryTag = ""
              if index == 0:
                  tag = "(Original Version)"
              elif index == len(message_history) - 1:
                  tag = "(Current Version)"
  
              widgets.append(self._make_edit_block(snapshot, tag))
  
          if not widgets:
              feedback = [
                  "Could not find any message history. See ",
                  ("msg_bold", "footer"),
                  " for the error message.",
              ]
              widgets.append(urwid.Text(feedback, align="center"))
  
          super().__init__(controller, widgets, "MSG_INFO", width, title)
  
      def _make_edit_block(self, snapshot: Dict[str, Any], tag: EditHistoryTag) -> Any:
          content = snapshot["content"]
          topic = snapshot["topic"]
          date_and_time = self.controller.model.formatted_local_time(
              snapshot["timestamp"], show_seconds=True
          )
  
          user_id = snapshot.get("user_id")
          if user_id:
              author_name = self.controller.model.user_name_from_id(user_id)
          else:
              author_name = "Author N/A"
          author_prefix = self._get_author_prefix(snapshot, tag)
          author = f"{author_prefix} by {author_name}"
  
          header = [
              urwid.Text(("edit_topic", topic)),
              # 18 = max(EditHistoryTag).
              (18, urwid.Text(("edit_tag", tag), align="right")),
          ]
          subheader = [
              urwid.Text(("edit_author", author)),
              # 22 = len(timestamp).
              (22, urwid.Text(("edit_time", date_and_time), align="right")),
          ]
  
          edit_block = [
              urwid.AttrWrap(
                  urwid.Columns(header, dividechars=2),
                  "popup_contrast",
              ),
              urwid.Columns(subheader, dividechars=2),
              urwid.Text(content),
          ]
          return urwid.Pile(edit_block)
  
      @staticmethod
      def _get_author_prefix(snapshot: Dict[str, Any], tag: EditHistoryTag) -> str:
          if tag == "(Original Version)":
              return "Posted"
  
          # NOTE: The false alarm bit in the subsequent code block is a
          # workaround for the inconsistency in the message history.
          content = snapshot["content"]
          topic = snapshot["topic"]
  
          false_alarm_content = content == snapshot.get("prev_content")
          false_alarm_topic = topic == snapshot.get("prev_topic")
          if (
              "prev_topic" in snapshot
              and "prev_content" in snapshot
              and not (false_alarm_content or false_alarm_topic)
          ):
              author_prefix = "Content & Topic edited"
          elif "prev_content" in snapshot and not false_alarm_content:
              author_prefix = "Content edited"
          elif "prev_topic" in snapshot and not false_alarm_topic:
              author_prefix = "Topic edited"
          else:
              author_prefix = "Edited but no changes made"
  
          return author_prefix
  
      def keypress(self, size: urwid_Size, key: str) -> str:
          if is_command_key("GO_BACK", key) or is_command_key("EDIT_HISTORY", key):
              self.controller.show_msg_info(
                  msg=self.message,
                  topic_links=self.topic_links,
                  message_links=self.message_links,
                  time_mentions=self.time_mentions,
              )
              return key
          return super().keypress(size, key)
  