diff --git a/tests/core/test_core.py b/tests/core/test_core.py index ff6701a41d..5bc1373692 100644 --- a/tests/core/test_core.py +++ b/tests/core/test_core.py @@ -518,8 +518,8 @@ def test_stream_muting_confirmation_popup( "search_within_topic_narrow", ], ) - @pytest.mark.parametrize("msg_ids", [({200, 300, 400}), (set()), ({100})]) - def test_search_message( + @pytest.mark.parametrize("msg_ids", [({200, 300, 400}), ({100})]) + def test_search_message__hits( self, initial_narrow: List[Any], final_narrow: List[Any], @@ -550,6 +550,60 @@ def set_msg_ids(*args: Any, **kwargs: Any) -> None: create_msg.assert_called_once_with(controller.model, msg_ids) assert controller.model.index == dict(index_search_messages, search=msg_ids) + @pytest.mark.parametrize( + "initial_narrow, final_narrow", + [ + ([], [["search", "FOO"]]), + ([["search", "BOO"]], [["search", "FOO"]]), + ([["stream", "PTEST"]], [["stream", "PTEST"], ["search", "FOO"]]), + ( + [["pm-with", "foo@zulip.com"], ["search", "BOO"]], + [["pm-with", "foo@zulip.com"], ["search", "FOO"]], + ), + ( + [["stream", "PTEST"], ["topic", "RDS"]], + [["stream", "PTEST"], ["topic", "RDS"], ["search", "FOO"]], + ), + ], + ids=[ + "Default_all_msg_search", + "redo_default_search", + "search_within_stream", + "pm_search_again", + "search_within_topic_narrow", + ], + ) + def test_search_message__no_hits( + self, + initial_narrow: List[Any], + final_narrow: List[Any], + controller: Controller, + mocker: MockerFixture, + index_search_messages: Index, + msg_ids: Set[int] = set(), + ) -> None: + get_message = mocker.patch(MODEL + ".get_messages") + create_msg = mocker.patch(MODULE + ".create_msg_box_list") + mocker.patch(MODEL + ".get_message_ids_in_current_narrow", return_value=msg_ids) + controller.model.index = index_search_messages # Any initial search index + controller.view.message_view = mocker.patch("urwid.ListBox") + controller.model.narrow = initial_narrow + + def set_msg_ids(*args: Any, **kwargs: Any) -> None: + controller.model.index["search"].update(msg_ids) + + get_message.side_effect = set_msg_ids + assert controller.model.index["search"] == {500} + + controller.search_messages("FOO") + + assert controller.model.narrow == final_narrow + get_message.assert_called_once_with( + num_after=0, num_before=30, anchor=10000000000 + ) + create_msg.assert_not_called() + assert controller.model.index == dict(index_search_messages, search=msg_ids) + @pytest.mark.parametrize( "screen_size, expected_popup_size", [ diff --git a/tests/model/test_model.py b/tests/model/test_model.py index 119b3aecab..c8bb51537c 100644 --- a/tests/model/test_model.py +++ b/tests/model/test_model.py @@ -1913,6 +1913,7 @@ def test__handle_message_event_with_Falsey_log( mocker.patch(MODEL + "._update_topic_index") mocker.patch(MODULE + ".index_messages", return_value={}) self.controller.view.message_view = mocker.Mock(log=[]) + self.controller.is_in_empty_narrow = False create_msg_box_list = mocker.patch( MODULE + ".create_msg_box_list", return_value=["msg_w"] ) @@ -1932,6 +1933,7 @@ def test__handle_message_event_with_valid_log(self, mocker, model, message_fixtu mocker.patch(MODEL + "._update_topic_index") mocker.patch(MODULE + ".index_messages", return_value={}) self.controller.view.message_view = mocker.Mock(log=[mocker.Mock()]) + self.controller.is_in_empty_narrow = False create_msg_box_list = mocker.patch( MODULE + ".create_msg_box_list", return_value=["msg_w"] ) @@ -1954,6 +1956,7 @@ def test__handle_message_event_with_flags(self, mocker, model, message_fixture): mocker.patch(MODEL + "._update_topic_index") mocker.patch(MODULE + ".index_messages", return_value={}) self.controller.view.message_view = mocker.Mock(log=[mocker.Mock()]) + self.controller.is_in_empty_narrow = False mocker.patch(MODULE + ".create_msg_box_list", return_value=["msg_w"]) model.notify_user = mocker.Mock() set_count = mocker.patch(MODULE + ".set_count") @@ -2097,6 +2100,7 @@ def test__handle_message_event( ( self.controller.view.left_panel.is_in_topic_view_with_stream_id.return_value ) = False + self.controller.is_in_empty_narrow = False model.notify_user = mocker.Mock() model.narrow = narrow model.recipients = recipients diff --git a/tests/ui/test_ui_tools.py b/tests/ui/test_ui_tools.py index 0a4d9ec030..653e67ffb7 100644 --- a/tests/ui/test_ui_tools.py +++ b/tests/ui/test_ui_tools.py @@ -276,6 +276,7 @@ def test_mouse_event(self, mocker, msg_view, mouse_scroll_event, widget_size): @pytest.mark.parametrize("key", keys_for_command("GO_DOWN")) def test_keypress_GO_DOWN(self, mocker, msg_view, key, widget_size): size = widget_size(msg_view) + msg_view.model.controller.is_in_empty_narrow = False msg_view.new_loading = False mocker.patch(MESSAGEVIEW + ".focus_position", return_value=0) mocker.patch(MESSAGEVIEW + ".set_focus_valign") @@ -291,6 +292,7 @@ def test_keypress_GO_DOWN_exception( self, mocker, msg_view, key, widget_size, view_is_focused ): size = widget_size(msg_view) + msg_view.model.controller.is_in_empty_narrow = False msg_view.new_loading = False mocker.patch(MESSAGEVIEW + ".focus_position", return_value=0) mocker.patch(MESSAGEVIEW + ".set_focus_valign") @@ -315,6 +317,7 @@ def test_keypress_GO_DOWN_exception( @pytest.mark.parametrize("key", keys_for_command("GO_UP")) def test_keypress_GO_UP(self, mocker, msg_view, key, widget_size): size = widget_size(msg_view) + msg_view.model.controller.is_in_empty_narrow = False mocker.patch(MESSAGEVIEW + ".focus_position", return_value=0) mocker.patch(MESSAGEVIEW + ".set_focus_valign") msg_view.old_loading = False @@ -330,6 +333,7 @@ def test_keypress_GO_UP_exception( self, mocker, msg_view, key, widget_size, view_is_focused ): size = widget_size(msg_view) + msg_view.model.controller.is_in_empty_narrow = False msg_view.old_loading = False mocker.patch(MESSAGEVIEW + ".focus_position", return_value=0) mocker.patch(MESSAGEVIEW + ".set_focus_valign") diff --git a/tests/ui_tools/test_messages.py b/tests/ui_tools/test_messages.py index 08ba7d0661..2be27c212c 100644 --- a/tests/ui_tools/test_messages.py +++ b/tests/ui_tools/test_messages.py @@ -48,6 +48,7 @@ def test_init(self, mocker, message_type, set_fields): display_recipient=[ {"id": 7, "email": "boo@zulip.com", "full_name": "Boo is awesome"} ], + id=457823, stream_id=5, subject="hi", sender_email="foo@zulip.com", @@ -65,18 +66,13 @@ def test_init(self, mocker, message_type, set_fields): 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): - 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} ], + id=457823, sender_id=9, content="

self message.

", sender_full_name="Foo Foo", @@ -88,6 +84,7 @@ def test_private_message_to_self(self, mocker): 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"] @@ -759,6 +756,7 @@ def test_main_view(self, mocker, message, last_message): "color": "#bd6", }, } + self.model.controller.is_in_empty_narrow = False MessageBox(message, self.model, last_message) @pytest.mark.parametrize( @@ -838,6 +836,7 @@ def test_main_view_generates_stream_header( "color": "#bd6", }, } + self.model.controller.is_in_empty_narrow = False last_message = dict(message, **to_vary_in_last_message) msg_box = MessageBox(message, self.model, last_message) view_components = msg_box.main_view() @@ -890,6 +889,7 @@ def test_main_view_generates_stream_header( def test_main_view_generates_PM_header( self, mocker, message, to_vary_in_last_message ): + self.model.controller.is_in_empty_narrow = False last_message = dict(message, **to_vary_in_last_message) msg_box = MessageBox(message, self.model, last_message) view_components = msg_box.main_view() @@ -1032,9 +1032,11 @@ def test_msg_generates_search_and_header_bar( }, } self.model.narrow = msg_narrow + self.model.controller.is_in_empty_narrow = False 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.recipient_header() @@ -1151,8 +1153,11 @@ def test_main_view_compact_output( ): message_fixture.update({"id": 4}) varied_message = dict(message_fixture, **to_vary_in_each_message) + self.model.controller.is_in_empty_narrow = False 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) @@ -1162,7 +1167,9 @@ def test_main_view_generates_EDITED_label( messages = messages_successful_response["messages"] for message in messages: self.model.index["edited_messages"].add(message["id"]) + self.model.controller.is_in_empty_narrow = False msg_box = MessageBox(message, self.model, message) + view_components = msg_box.main_view() label = view_components[0].original_widget.contents[0] @@ -1188,6 +1195,7 @@ def test_update_message_author_status( ): message = message_fixture last_msg = dict(message, **to_vary_in_last_message) + self.model.controller.is_in_empty_narrow = False msg_box = MessageBox(message, self.model, last_msg) @@ -1391,6 +1399,7 @@ def test_keypress_EDIT_MESSAGE( to_vary_in_each_message["subject"] = "" varied_message = dict(message_fixture, **to_vary_in_each_message) message_type = varied_message["type"] + self.model.controller.is_in_empty_narrow = False msg_box = MessageBox(varied_message, self.model, message_fixture) size = widget_size(msg_box) msg_box.model.user_id = 1 @@ -1405,6 +1414,7 @@ def test_keypress_EDIT_MESSAGE( 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.model.controller.is_in_empty_narrow = False msg_box.keypress(size, key) @@ -1820,6 +1830,7 @@ def test_reactions_view( 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"] + msg_box.model.controller.is_in_empty_narrow = False reactions_view = msg_box.reactions_view(reactions) @@ -1976,6 +1987,7 @@ def test_mouse_event_left_click( 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.model.controller.is_in_empty_narrow = False msg_box.mouse_event(size, "mouse press", 1, col, row, focus) diff --git a/zulipterminal/core.py b/zulipterminal/core.py index a23b1596f1..6972a80be6 100644 --- a/zulipterminal/core.py +++ b/zulipterminal/core.py @@ -93,6 +93,7 @@ def __init__( self.active_conversation_info: Dict[str, Any] = {} self.is_typing_notification_in_progress = False + self.is_in_empty_narrow = False self.show_loading() client_identifier = f"ZulipTerminal/{ZT_VERSION} {platform()}" @@ -503,13 +504,15 @@ def show_media_confirmation_popup( self, question, callback, location="center" ) - def search_messages(self, text: str) -> None: + def search_messages(self, text: str) -> bool: # Search for a text in messages self.model.index["search"].clear() self.model.set_search_narrow(text) self.model.get_messages(num_after=0, num_before=30, anchor=10000000000) msg_id_list = self.model.get_message_ids_in_current_narrow() + if len(msg_id_list) == 0: + return False w_list = create_msg_box_list(self.model, msg_id_list) self.view.message_view.log.clear() @@ -517,6 +520,7 @@ def search_messages(self, text: str) -> None: focus_position = 0 if 0 <= focus_position < len(w_list): self.view.message_view.set_focus(focus_position) + return True def save_draft_confirmation_popup(self, draft: Composition) -> None: question = urwid.Text( @@ -598,6 +602,7 @@ def _narrow_to(self, anchor: Optional[int], **narrow: Any) -> None: if len(msg_id_list) == 0 or (anchor is not None and anchor not in msg_id_list): self.model.get_messages(num_before=30, num_after=10, anchor=anchor) msg_id_list = self.model.get_message_ids_in_current_narrow() + self.is_in_empty_narrow = bool(len(msg_id_list) == 0) w_list = create_msg_box_list(self.model, msg_id_list, focus_msg_id=anchor) diff --git a/zulipterminal/model.py b/zulipterminal/model.py index 1d7688cdeb..c12acb62e1 100644 --- a/zulipterminal/model.py +++ b/zulipterminal/model.py @@ -1742,6 +1742,9 @@ def _handle_message_event(self, event: Event) -> None: msg_w = msg_w_list[0] if self.current_narrow_contains_message(message): + if self.controller.is_in_empty_narrow: + del msg_log[0] + self.controller.is_in_empty_narrow = False msg_log.append(msg_w) self.controller.update_screen() diff --git a/zulipterminal/ui_tools/boxes.py b/zulipterminal/ui_tools/boxes.py index 1a479cadad..c1803ef67e 100644 --- a/zulipterminal/ui_tools/boxes.py +++ b/zulipterminal/ui_tools/boxes.py @@ -1014,7 +1014,7 @@ def main_view(self) -> Any: self.text_box, ] ) - self.msg_narrow = urwid.Text("DONT HIDE") + self.msg_narrow = urwid.Text("") self.recipient_bar = urwid.LineBox( self.msg_narrow, title="Current message recipients", @@ -1032,9 +1032,11 @@ def keypress(self, size: urwid_Size, key: str) -> Optional[str]: return key elif is_command_key("EXECUTE_SEARCH", key): - self.controller.exit_editor_mode() - self.controller.search_messages(self.text_box.edit_text) - self.controller.view.middle_column.set_focus("body") + if self.controller.search_messages(self.text_box.edit_text): + self.controller.exit_editor_mode() + self.controller.view.middle_column.set_focus("body") + else: + self.controller.report_error(["No results found."]) return key key = super().keypress(size, key) diff --git a/zulipterminal/ui_tools/messages.py b/zulipterminal/ui_tools/messages.py index b8552fdc92..f7799ceca9 100644 --- a/zulipterminal/ui_tools/messages.py +++ b/zulipterminal/ui_tools/messages.py @@ -81,13 +81,12 @@ def __init__(self, message: Message, model: "Model", last_message: Any) -> None: 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") + # Get sender details only for non-empty narrows + if self.message.get("id") is not None: + self.email = self.message["sender_email"] + self.user_id = self.message["sender_id"] - if self.message["type"] == "private": if self._is_private_message_to_self(): recipient = self.message["display_recipient"][0] self.recipients_names = recipient["full_name"] @@ -115,6 +114,8 @@ def __init__(self, message: Message, model: "Model", last_message: Any) -> None: super().__init__(self.main_view()) def need_recipient_header(self) -> bool: + if self.model.controller.is_in_empty_narrow: + return False # Prevent redundant information in recipient bar if len(self.model.narrow) == 1 and self.model.narrow[0][0] == "pm-with": return False @@ -198,8 +199,23 @@ def private_header(self) -> Any: header.markup = title_markup return header + def empty_narrow_header(self) -> Any: + title_markup = ("header", [("general_narrow", "No selected message")]) + 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 recipient_header(self) -> Any: - if self.message["type"] == "stream": + if self.model.controller.is_in_empty_narrow: + return self.empty_narrow_header() + elif self.message["type"] == "stream": return self.stream_header() else: return self.private_header() @@ -669,7 +685,8 @@ def main_view(self) -> List[Any]: "recipients": recipient_header is not None, "author": message["this"]["author"] != message["last"]["author"], "24h": ( - message["last"]["datetime"] is not None + message["this"]["datetime"] is not None + and message["last"]["datetime"] is not None and ((message["this"]["datetime"] - message["last"]["datetime"]).days) ), "timestamp": ( @@ -904,7 +921,8 @@ def mouse_event( 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): + is_in_empty_narrow = self.model.controller.is_in_empty_narrow + if is_command_key("REPLY_MESSAGE", key) and not is_in_empty_narrow: if self.message["type"] == "private": self.model.controller.view.write_box.private_box_view( recipient_user_ids=self.recipient_ids, @@ -923,7 +941,7 @@ def keypress(self, size: urwid_Size, key: str) -> Optional[str]: ) else: self.model.controller.view.write_box.stream_box_view(0) - elif is_command_key("STREAM_NARROW", key): + elif is_command_key("STREAM_NARROW", key) and not is_in_empty_narrow: if self.message["type"] == "private": self.model.controller.narrow_to_user( recipient_emails=self.recipient_emails, @@ -934,7 +952,7 @@ def keypress(self, size: urwid_Size, key: str) -> Optional[str]: stream_name=self.stream_name, contextual_message_id=self.message["id"], ) - elif is_command_key("TOGGLE_NARROW", key): + elif is_command_key("TOGGLE_NARROW", key) and not is_in_empty_narrow: self.model.unset_search_narrow() if self.message["type"] == "private": if len(self.model.narrow) == 1 and self.model.narrow[0][0] == "pm-with": @@ -958,7 +976,7 @@ def keypress(self, size: urwid_Size, key: str) -> Optional[str]: topic_name=self.topic_name, contextual_message_id=self.message["id"], ) - elif is_command_key("TOPIC_NARROW", key): + elif is_command_key("TOPIC_NARROW", key) and not is_in_empty_narrow: if self.message["type"] == "private": self.model.controller.narrow_to_user( recipient_emails=self.recipient_emails, @@ -974,12 +992,12 @@ def keypress(self, size: urwid_Size, key: str) -> Optional[str]: self.model.controller.narrow_to_all_messages( contextual_message_id=self.message["id"] ) - elif is_command_key("REPLY_AUTHOR", key): + elif is_command_key("REPLY_AUTHOR", key) and not is_in_empty_narrow: # 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): + elif is_command_key("MENTION_REPLY", key) and not is_in_empty_narrow: 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) @@ -987,7 +1005,7 @@ def keypress(self, size: urwid_Size, key: str) -> Optional[str]: len(mention) ) self.model.controller.view.middle_column.set_focus("footer") - elif is_command_key("QUOTE_REPLY", key): + elif is_command_key("QUOTE_REPLY", key) and not is_in_empty_narrow: self.keypress(size, primary_key_for_command("REPLY_MESSAGE")) # To correctly quote a message that contains quote/code-blocks, @@ -1015,7 +1033,7 @@ def keypress(self, size: urwid_Size, key: str) -> Optional[str]: 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): + elif is_command_key("EDIT_MESSAGE", key) and not is_in_empty_narrow: # 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 @@ -1119,12 +1137,12 @@ def keypress(self, size: urwid_Size, key: str) -> Optional[str]: 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): + elif is_command_key("MSG_INFO", key) and not is_in_empty_narrow: self.model.controller.show_msg_info( self.message, self.topic_links, self.message_links, self.time_mentions ) - elif is_command_key("ADD_REACTION", key): + elif is_command_key("ADD_REACTION", key) and not is_in_empty_narrow: self.model.controller.show_emoji_picker(self.message) - elif is_command_key("MSG_SENDER_INFO", key): + elif is_command_key("MSG_SENDER_INFO", key) and not is_in_empty_narrow: self.model.controller.show_msg_sender_info(self.message["sender_id"]) return key diff --git a/zulipterminal/ui_tools/utils.py b/zulipterminal/ui_tools/utils.py index d055af44b7..c3d0473ce4 100644 --- a/zulipterminal/ui_tools/utils.py +++ b/zulipterminal/ui_tools/utils.py @@ -29,6 +29,46 @@ def create_msg_box_list( focus_msg = None last_msg = last_message muted_msgs = 0 # No of messages that are muted. + + # Add a dummy message if no new or old messages are found + if not message_list and not last_msg: + message_type = "stream" if model.stream_id is not None else "private" + dummy_message = { + "id": None, + "content": ( + "No search hits" if model.is_search_narrow() else "No messages here" + ), + "is_me_message": False, + "flags": ["read"], + "reactions": [], + "type": message_type, + "stream_id": model.stream_id if message_type == "stream" else None, + "stream_name": model.stream_dict[model.stream_id]["name"] + if message_type == "stream" + else None, + "subject": next( + (subnarrow[1] for subnarrow in model.narrow if subnarrow[0] == "topic"), + "No topics in channel", + ) + if message_type == "stream" + else None, + "display_recipient": ( + model.stream_dict[model.stream_id]["name"] + if message_type == "stream" + else [ + { + "email": model.user_id_email_dict[recipient_id], + "full_name": model.user_dict[ + model.user_id_email_dict[recipient_id] + ]["full_name"], + "id": recipient_id, + } + for recipient_id in model.recipients + ] + ), + } + message_list.append(dummy_message) + for msg in message_list: if is_unsubscribed_message(msg, model): continue diff --git a/zulipterminal/ui_tools/views.py b/zulipterminal/ui_tools/views.py index 6d01a82566..f5d3e86c7b 100644 --- a/zulipterminal/ui_tools/views.py +++ b/zulipterminal/ui_tools/views.py @@ -193,6 +193,8 @@ def mouse_event( return super().mouse_event(size, event, button, col, row, focus) def keypress(self, size: urwid_Size, key: str) -> Optional[str]: + if self.model.controller.is_in_empty_narrow: + return super().keypress(size, key) if is_command_key("GO_DOWN", key) and not self.new_loading: try: position = self.log.next_position(self.focus_position)