Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Handling Empty Narrows #1543

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
4 changes: 4 additions & 0 deletions tests/model/test_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
)
Expand All @@ -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"]
)
Expand All @@ -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")
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions tests/ui/test_ui_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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")
Expand All @@ -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
Expand All @@ -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")
Expand Down
24 changes: 18 additions & 6 deletions tests/ui_tools/test_messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ def test_init(self, mocker, message_type, set_fields):
display_recipient=[
{"id": 7, "email": "[email protected]", "full_name": "Boo is awesome"}
],
id=457823,
stream_id=5,
subject="hi",
sender_email="[email protected]",
Expand All @@ -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": "[email protected]", "id": None}
],
id=457823,
sender_id=9,
content="<p> self message. </p>",
sender_full_name="Foo Foo",
Expand All @@ -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 == ["[email protected]"]
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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)

Expand All @@ -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]
Expand All @@ -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)

Expand Down Expand Up @@ -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
Expand All @@ -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)

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)

Expand Down
2 changes: 2 additions & 0 deletions zulipterminal/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()}"
Expand Down Expand Up @@ -598,6 +599,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)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Was this small commit factored out, so that it would cover Sumanth's original process here? Oh, I think you said that it was where the inspiration came from?

(I'm torn whether this belongs in the model or controller here, but that's probably a refactoring to consider how to structure the whole narrowing behavior)


w_list = create_msg_box_list(self.model, msg_id_list, focus_msg_id=anchor)

Expand Down
3 changes: 3 additions & 0 deletions zulipterminal/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +1745 to +1747
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this the part that is not working properly, in your 'Next steps'?
I would guess that this is blocked by some earlier conditional?

It looks like this commit should come later in the sequencce, at or after where we add an empty message, or else the del is going to trigger an exception?

As per another comment, this isn't updated at all in main right now, so we could handle this incrementally, ie. handle updates as a later part or follow-up. However, it would be good to get this as a base feature if we can, of course :)

Note that we will also want this to be triggered for star changes as it stands, and may be a lot simpler to test in that way, ie. to make a narrow empty using 0/1 starred messages - though of course is a different function under test in that case.
(a quick scan suggests we need to handle mention changes too, among possibly other flags)

msg_log.append(msg_w)

self.controller.update_screen()
Expand Down
2 changes: 1 addition & 1 deletion zulipterminal/ui_tools/boxes.py
Original file line number Diff line number Diff line change
Expand Up @@ -1014,7 +1014,7 @@ def main_view(self) -> Any:
self.text_box,
]
)
self.msg_narrow = urwid.Text("DONT HIDE")
self.msg_narrow = urwid.Text("")
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As I said in the channel, it's great to remove this line, though I think this was associated with the comment some lines above, which we could also remove? (look through the git log via git blame?)

In terms of this commit, it would be helpful to clarify under which conditions the application would display an empty recipient bar, since I very rarely actually see this odd text. This doesn't need to be via an exact analysis, but notes would be helpful, since looking back to this change in the future would help clarify what happens here.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was going to suggest taking advantage of this small commit to eg. refactor the attribute naming to be private. However, this is one of those coupled classes/objects, so many of these names are actually public, so this likely would be best settled with a separate refactoring effort.

In the absence of that, you could add comments to clarify each attribute for now; would that help you next time? If so, it'd help others who next read the code :)

self.recipient_bar = urwid.LineBox(
self.msg_narrow,
title="Current message recipients",
Expand Down
54 changes: 36 additions & 18 deletions zulipterminal/ui_tools/messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was trying to recall the separation of the two sets of branches here. I think we can combine them, perhaps again, for now.

if self._is_private_message_to_self():
recipient = self.message["display_recipient"][0]
self.recipients_names = recipient["full_name"]
Expand Down Expand Up @@ -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
Comment on lines +117 to +118
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This commit is the first of some which make the message and top bar better for each case, and would make sense together. Currently, after this commit you have a number of others that would make more sense as being separate/later instead?

# Prevent redundant information in recipient bar
if len(self.model.narrow) == 1 and self.model.narrow[0][0] == "pm-with":
return False
Expand Down Expand Up @@ -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")])
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm wondering if it would be clearer to simply leave this blank?

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":
Comment on lines -202 to +218
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a partial fix towards the issue? That is, we start to show a different message in the message header and the current-message header? Assuming so, the commit text could indicate that.

Does the new header structure need to follow the form of the others? I know it's rather hacky in places, so it's likely fine for now.

return self.stream_header()
else:
return self.private_header()
Expand Down Expand Up @@ -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
Comment on lines -672 to +689
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I assume this is simply a prep to allow for None datetimes in both, for dummy messages?

It's not clear to me why this is necessary, when it's not applied to other fields?
(the commit text doesn't explain)

I suspect this relates to the crash I saw later.

and ((message["this"]["datetime"] - message["last"]["datetime"]).days)
),
"timestamp": (
Expand Down Expand Up @@ -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
Comment on lines -907 to +924
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If using the message as a placeholder, then I understand the need to disable lots of these hotkey behaviors. However, rather than disabling each of them individually, it would be a lot clearer to branch/exit separately, likely first.


Currently you seem to be allowing only the following:

  • start a stream message
  • narrow to all messages

Should we allow a new DM message (x) too?


Some actions you're excluding clearly don't make a lot of sense, when they have to act on a specific valid message, but I'm not sure about other cases, specifically narrowing.

Should all narrowing be possible from an empty narrow? Maybe a stream/channel narrow is empty, so we can try an all-messages narrow, but then what if that is itself empty? That suggests we can try different (absolute) narrows from any empty narrow?

How does that work 'without' a message id, ie. an empty narrow and 'fake' message? What does the message id fall back to in that case?

Nothing like this works before this PR, so the simplest solution might be to start from including no narrowing operations, and treat this as a followup after discussion.

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,
Expand All @@ -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,
Expand All @@ -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":
Expand All @@ -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,
Expand All @@ -974,20 +992,20 @@ 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)
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):
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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Loading