diff --git a/tests/ui/test_ui_tools.py b/tests/ui/test_ui_tools.py index 6f60f7949f..9f51d23950 100644 --- a/tests/ui/test_ui_tools.py +++ b/tests/ui/test_ui_tools.py @@ -1251,6 +1251,304 @@ def test_topics_view(self, mocker, stream_button, width=40): ]) +class TestPopUpView: + @pytest.fixture(autouse=True) + def pop_up_view(self, mocker): + self.controller = mocker.Mock() + mocker.patch.object(self.controller, 'maximum_popup_dimensions', + return_value=(64, 64)) + self.command = 'COMMAND' + self.title = 'Generic title' + self.width = 16 + self.header = mocker.Mock() + self.footer = mocker.Mock() + self.widget = mocker.Mock() + mocker.patch.object(self.widget, 'rows', return_value=1) + self.widgets = [self.widget, ] + self.list_walker = mocker.patch(VIEWS + '.urwid.SimpleFocusListWalker', + return_value=[]) + self.list_box_init = mocker.patch(VIEWS + '.urwid.ListBox.__init__', + return_value=None) + self.super_init = mocker.patch(VIEWS + '.urwid.Frame.__init__') + self.super_keypress = mocker.patch(VIEWS + '.urwid.Frame.keypress') + self.pop_up_view = PopUpView(self.controller, self.widgets, + self.command, self.width, self.title, + self.header, self.footer) + + def test_init(self): + assert self.pop_up_view.controller == self.controller + assert self.pop_up_view.command == self.command + assert self.pop_up_view.title == self.title + assert self.pop_up_view.width == self.width + self.list_walker.assert_called_once_with(self.widgets) + self.list_box_init.assert_called_once_with([]) + self.super_init.assert_called_once_with(self.pop_up_view.log, + header=self.header, + footer=self.footer) + + @pytest.mark.parametrize('key', keys_for_command('GO_BACK')) + def test_keypress_GO_BACK(self, key): + size = (200, 20) + self.pop_up_view.keypress(size, key) + assert self.controller.exit_popup.called + + def test_keypress_command_key(self, mocker): + size = (200, 20) + mocker.patch(VIEWS + '.is_command_key', side_effect=( + lambda command, key: command == self.command + )) + self.pop_up_view.keypress(size, 'cmd_key') + assert self.controller.exit_popup.called + + def test_keypress_navigation(self, mocker, + navigation_key_expected_key_pair): + key, expected_key = navigation_key_expected_key_pair + size = (200, 20) + # Patch `is_command_key` to not raise an 'Invalid Command' exception + # when its parameters are (self.command, key) as there is no + # self.command='COMMAND' command in keys.py. + mocker.patch(VIEWS + '.is_command_key', side_effect=( + lambda command, key: + False if command == self.command + else is_command_key(command, key) + )) + self.pop_up_view.keypress(size, key) + self.super_keypress.assert_called_once_with(size, expected_key) + + +class TestHelpMenu: + @pytest.fixture(autouse=True) + def mock_external_classes(self, mocker, monkeypatch): + self.controller = mocker.Mock() + mocker.patch.object(self.controller, 'maximum_popup_dimensions', + return_value=(64, 64)) + mocker.patch(VIEWS + ".urwid.SimpleFocusListWalker", return_value=[]) + self.help_view = HelpView(self.controller, 'Help Menu') + + def test_keypress_any_key(self): + key = "a" + size = (200, 20) + self.help_view.keypress(size, key) + assert not self.controller.exit_popup.called + + @pytest.mark.parametrize('key', {*keys_for_command('GO_BACK'), + *keys_for_command('HELP')}) + def test_keypress_exit_popup(self, key): + size = (200, 20) + self.help_view.keypress(size, key) + assert self.controller.exit_popup.called + + def test_keypress_navigation(self, mocker, + navigation_key_expected_key_pair): + key, expected_key = navigation_key_expected_key_pair + size = (200, 20) + super_keypress = mocker.patch(VIEWS + '.urwid.ListBox.keypress') + self.help_view.keypress(size, key) + super_keypress.assert_called_once_with(size, expected_key) + + +class TestAboutView: + @pytest.fixture(autouse=True) + def mock_external_classes(self, mocker): + self.controller = mocker.Mock() + mocker.patch.object(self.controller, 'maximum_popup_dimensions', + return_value=(64, 64)) + mocker.patch(VIEWS + '.urwid.SimpleFocusListWalker', return_value=[]) + server_version, server_feature_level = MINIMUM_SUPPORTED_SERVER_VERSION + self.about_view = AboutView(self.controller, 'About', + zt_version=ZT_VERSION, + server_version=server_version, + server_feature_level=server_feature_level) + + @pytest.mark.parametrize('key', {*keys_for_command('GO_BACK'), + *keys_for_command('ABOUT')}) + def test_keypress_exit_popup(self, key): + size = (200, 20) + self.about_view.keypress(size, key) + assert self.controller.exit_popup.called + + def test_keypress_exit_popup_invalid_key(self): + key = 'a' + size = (200, 20) + self.about_view.keypress(size, key) + assert not self.controller.exit_popup.called + + def test_keypress_navigation(self, mocker, + navigation_key_expected_key_pair): + key, expected_key = navigation_key_expected_key_pair + size = (200, 20) + super_keypress = mocker.patch(VIEWS + '.urwid.ListBox.keypress') + self.about_view.keypress(size, key) + super_keypress.assert_called_once_with(size, expected_key) + + def test_feature_level_content(self, mocker, zulip_version): + self.controller = mocker.Mock() + mocker.patch.object(self.controller, 'maximum_popup_dimensions', + return_value=(64, 64)) + mocker.patch(VIEWS + '.urwid.SimpleFocusListWalker', return_value=[]) + server_version, server_feature_level = zulip_version + + about_view = AboutView(self.controller, 'About', zt_version=ZT_VERSION, + server_version=server_version, + server_feature_level=server_feature_level) + + assert len(about_view.feature_level_content) == ( + 1 if server_feature_level else 0 + ) + + +class TestPopUpConfirmationView: + @pytest.fixture + def popup_view(self, mocker, stream_button): + self.controller = mocker.Mock() + self.controller.view.LEFT_WIDTH = 27 + self.callback = mocker.Mock() + self.list_walker = mocker.patch(VIEWS + ".urwid.SimpleFocusListWalker", + return_value=[]) + self.divider = mocker.patch(VIEWS + '.urwid.Divider') + self.text = mocker.patch(VIEWS + '.urwid.Text') + self.wrapper_w = mocker.patch(VIEWS + '.urwid.WidgetWrap') + return PopUpConfirmationView( + self.controller, + self.text, + self.callback, + ) + + def test_init(self, popup_view): + assert popup_view.controller == self.controller + assert popup_view.success_callback == self.callback + self.divider.assert_called_once_with() + self.list_walker.assert_called_once_with( + [self.text, self.divider(), self.wrapper_w()]) + + def test_exit_popup_yes(self, mocker, popup_view): + popup_view.exit_popup_yes(mocker.Mock()) + self.callback.assert_called_once_with() + assert self.controller.exit_popup.called + + def test_exit_popup_no(self, mocker, popup_view): + popup_view.exit_popup_no(mocker.Mock()) + self.callback.assert_not_called() + assert self.controller.exit_popup.called + + @pytest.mark.parametrize('key', keys_for_command('GO_BACK')) + def test_exit_popup_GO_BACK(self, mocker, popup_view, key): + size = (20, 20) + popup_view.keypress(size, key) + self.callback.assert_not_called() + assert self.controller.exit_popup.called + + +class TestStreamInfoView: + @pytest.fixture(autouse=True) + def mock_external_classes(self, mocker, monkeypatch): + self.controller = mocker.Mock() + mocker.patch.object(self.controller, 'maximum_popup_dimensions', + return_value=(64, 64)) + mocker.patch(VIEWS + ".urwid.SimpleFocusListWalker", return_value=[]) + self.stream_info_view = StreamInfoView(self.controller, color='', + desc='', title='# stream-name') + + @pytest.mark.parametrize('key', {*keys_for_command('GO_BACK'), + *keys_for_command('STREAM_DESC')}) + def test_keypress_exit_popup(self, key): + size = (200, 20) + self.stream_info_view.keypress(size, key) + assert self.controller.exit_popup.called + + def test_keypress_navigation(self, mocker, + navigation_key_expected_key_pair): + key, expected_key = navigation_key_expected_key_pair + size = (200, 20) + super_keypress = mocker.patch(VIEWS + '.urwid.ListBox.keypress') + self.stream_info_view.keypress(size, key) + super_keypress.assert_called_once_with(size, expected_key) + + +class TestMsgInfoView: + @pytest.fixture(autouse=True) + def mock_external_classes(self, mocker, monkeypatch, message_fixture): + self.controller = mocker.Mock() + mocker.patch.object(self.controller, 'maximum_popup_dimensions', + return_value=(64, 64)) + mocker.patch(VIEWS + ".urwid.SimpleFocusListWalker", return_value=[]) + self.msg_info_view = MsgInfoView(self.controller, message_fixture, + 'Message Information') + + def test_keypress_any_key(self): + key = "a" + size = (200, 20) + self.msg_info_view.keypress(size, key) + assert not self.controller.exit_popup.called + + @pytest.mark.parametrize('key', {*keys_for_command('GO_BACK'), + *keys_for_command('MSG_INFO')}) + def test_keypress_exit_popup(self, key): + size = (200, 20) + self.msg_info_view.keypress(size, key) + assert self.controller.exit_popup.called + + def test_height_noreactions(self): + expected_height = 3 + assert self.msg_info_view.height == expected_height + + # FIXME This is the same parametrize as MessageBox:test_reactions_view + @pytest.mark.parametrize('to_vary_in_each_message', [ + {'reactions': [{ + 'emoji_name': 'thumbs_up', + 'emoji_code': '1f44d', + 'user': { + 'email': 'iago@zulip.com', + 'full_name': 'Iago', + 'id': 5, + }, + 'reaction_type': 'unicode_emoji' + }, { + 'emoji_name': 'zulip', + 'emoji_code': 'zulip', + 'user': { + 'email': 'iago@zulip.com', + 'full_name': 'Iago', + 'id': 5, + }, + 'reaction_type': 'zulip_extra_emoji' + }, { + 'emoji_name': 'zulip', + 'emoji_code': 'zulip', + 'user': { + 'email': 'AARON@zulip.com', + 'full_name': 'aaron', + 'id': 1, + }, + 'reaction_type': 'zulip_extra_emoji' + }, { + 'emoji_name': 'heart', + 'emoji_code': '2764', + 'user': { + 'email': 'iago@zulip.com', + 'full_name': 'Iago', + 'id': 5, + }, + 'reaction_type': 'unicode_emoji' + }]} + ]) + def test_height_reactions(self, message_fixture, to_vary_in_each_message): + varied_message = dict(message_fixture, **to_vary_in_each_message) + self.msg_info_view = MsgInfoView(self.controller, varied_message, + 'Message Information') + # 9 = 3 labels + 1 blank line + 1 'Reactions' (category) + 4 reactions. + expected_height = 9 + assert self.msg_info_view.height == expected_height + + def test_keypress_navigation(self, mocker, + navigation_key_expected_key_pair): + key, expected_key = navigation_key_expected_key_pair + size = (200, 20) + super_keypress = mocker.patch(VIEWS + '.urwid.ListBox.keypress') + self.msg_info_view.keypress(size, key) + super_keypress.assert_called_once_with(size, expected_key) + + class TestMessageBox: @pytest.fixture(autouse=True) def mock_external_classes(self, mocker, initial_index): diff --git a/zulipterminal/config/keys.py b/zulipterminal/config/keys.py index 67acdef924..45552602cb 100644 --- a/zulipterminal/config/keys.py +++ b/zulipterminal/config/keys.py @@ -133,6 +133,11 @@ 'help_text': 'Cycle through autocomplete suggestions in reverse', 'key_category': 'msg_compose', }), + ('ADD_REACTION', { + 'keys': {':'}, + 'help_text': 'Add reaction to current message', + 'key_category': 'msg_actions', + }), ('STREAM_NARROW', { 'keys': ['s'], 'help_text': 'Narrow to the stream of the current message', diff --git a/zulipterminal/core.py b/zulipterminal/core.py index 5ce948d89b..79baf733bf 100644 --- a/zulipterminal/core.py +++ b/zulipterminal/core.py @@ -16,8 +16,8 @@ from zulipterminal.ui import Screen, View from zulipterminal.ui_tools.utils import create_msg_box_list from zulipterminal.ui_tools.views import ( - AboutView, EditHistoryView, EditModeView, HelpView, MsgInfoView, - NoticeView, PopUpConfirmationView, StreamInfoView, + AboutView, DeleteReactionView, EditHistoryView, EditModeView, EmojiPickerView, + HelpView, MsgInfoView, NoticeView, PopUpConfirmationView, StreamInfoView, ) from zulipterminal.version import ZT_VERSION @@ -149,10 +149,26 @@ def show_msg_info(self, msg: Message, message_links, time_mentions) self.show_pop_up(msg_info_view) + def show_delete_reaction(self, message: Message) -> None: + delete_reaction_view = DeleteReactionView(self, "Remove reaction", + message) + self.show_pop_up(delete_reaction_view) + + def show_emoji_picker(self, message: Message) -> None: + emoji_picker_view = EmojiPickerView(self, "Add/Remove reactions", + list(self.model.active_emoji_data.keys()), + message) + self.show_pop_up(emoji_picker_view) + def show_stream_info(self, stream_id: int) -> None: show_stream_view = StreamInfoView(self, stream_id) self.show_pop_up(show_stream_view) + # TODO: remove + def toggle_message_reaction(self, button: Any) -> None: + self.model.react_to_message(button.message, + button.emoji_name) + def popup_with_message(self, text: str, width: int) -> None: self.show_pop_up(NoticeView(self, text, width, "NOTICE")) diff --git a/zulipterminal/model.py b/zulipterminal/model.py index a1dc32b0eb..98ab722cd5 100644 --- a/zulipterminal/model.py +++ b/zulipterminal/model.py @@ -279,25 +279,30 @@ def _start_presence_updates(self) -> None: user_list=self.users) time.sleep(60) + def user_has_reacted_to_msg(self, emoji_attr: str, message: Message, + key: str) -> bool: + assert key in ('emoji_name', 'emoji_code') + + for reaction in message['reactions']: + if(reaction[key] == emoji_attr + and (reaction['user'].get('user_id', None) == self.user_id + or reaction['user'].get('id', None) == self.user_id)): + return True + return False + @asynch def react_to_message(self, message: Message, reaction_to_toggle: str) -> None: - # FIXME Only support thumbs_up for now - assert reaction_to_toggle == 'thumbs_up' + assert reaction_to_toggle in self.active_emoji_data reaction_to_toggle_spec = dict( - emoji_name='thumbs_up', - emoji_code='1f44d', - reaction_type='unicode_emoji', + emoji_name=reaction_to_toggle, + emoji_code=self.active_emoji_data[reaction_to_toggle]['code'], + reaction_type=self.active_emoji_data[reaction_to_toggle]['type'], message_id=str(message['id'])) - existing_reactions = [ - reaction['emoji_code'] - for reaction in message['reactions'] - if (reaction['user'].get('user_id', None) == self.user_id - or reaction['user'].get('id', None) == self.user_id) - ] - if reaction_to_toggle_spec['emoji_code'] in existing_reactions: + if(self.user_has_reacted_to_msg(reaction_to_toggle_spec['emoji_code'], + message, 'emoji_code')): response = self.client.remove_reaction(reaction_to_toggle_spec) else: response = self.client.add_reaction(reaction_to_toggle_spec) diff --git a/zulipterminal/ui_tools/boxes.py b/zulipterminal/ui_tools/boxes.py index 9f53feec5a..5742852db6 100644 --- a/zulipterminal/ui_tools/boxes.py +++ b/zulipterminal/ui_tools/boxes.py @@ -1244,6 +1244,9 @@ def keypress(self, size: urwid_Size, key: str) -> Optional[str]: self.model.controller.show_msg_info(self.message, self.message_links, self.time_mentions) + self.model.controller.show_msg_info(self.message) + elif is_command_key('ADD_REACTION', key): + self.model.controller.show_emoji_picker(self.message) return key diff --git a/zulipterminal/ui_tools/buttons.py b/zulipterminal/ui_tools/buttons.py index 6281192628..d30e6fed87 100644 --- a/zulipterminal/ui_tools/buttons.py +++ b/zulipterminal/ui_tools/buttons.py @@ -7,7 +7,7 @@ from zulipterminal.config.keys import is_command_key, primary_key_for_command from zulipterminal.helper import ( - StreamData, edit_mode_captions, hash_util_decode, + Message, StreamData, edit_mode_captions, hash_util_decode, ) from zulipterminal.urwid_types import urwid_Size @@ -488,3 +488,30 @@ def set_selected_mode(self, mode: str) -> None: self.mode = mode self._w = urwid.AttrMap(urwid.SelectableIcon( edit_mode_captions[self.mode], self.width), None, 'selected') +class EmojiButton(TopButton): + def __init__(self, controller: Any, width: int, emoji_name: str, + message: Message) -> None: + self.controller = controller + self.emoji_name = emoji_name + self.message = message + super().__init__(controller=controller, + caption=emoji_name, + prefix_character='', + show_function=controller.toggle_message_reaction, + width=width) + if self.user_has_reacted_to_msg(): + self.update_widget('✓ ') + + def keypress(self, size: urwid_Size, key: str) -> Optional[str]: + if is_command_key('ENTER', key): + # Note that this is called before toggle_message_reaction. + if self.user_has_reacted_to_msg(): + self.update_widget('') + else: + self.update_widget('✓ ') + return super().keypress(size, key) + + def user_has_reacted_to_msg(self) -> bool: + return self.controller.model.user_has_reacted_to_msg(self.emoji_name, + self.message, + 'emoji_name') diff --git a/zulipterminal/ui_tools/views.py b/zulipterminal/ui_tools/views.py index 3b3caa9410..85313341cf 100644 --- a/zulipterminal/ui_tools/views.py +++ b/zulipterminal/ui_tools/views.py @@ -17,7 +17,8 @@ ) from zulipterminal.ui_tools.boxes import PanelSearchBox from zulipterminal.ui_tools.buttons import ( - HomeButton, MentionedButton, MessageLinkButton, PMButton, StarredButton, + EmojiButton, HomeButton, MentionedButton, MessageLinkButton, PMButton, + StarredButton, StreamButton, TopicButton, UnreadPMButton, UserButton, ) from zulipterminal.ui_tools.utils import create_msg_box_list @@ -855,30 +856,42 @@ def keypress(self, size: urwid_Size, key: str) -> Optional[str]: Tuple[str, str]]]]] -class PopUpView(urwid.ListBox): - def __init__(self, controller: Any, widgets: List[Any], - command: str, requested_width: int, title: str) -> None: +class PopUpView(urwid.Frame): + def __init__(self, controller: Any, body: List[Any], + command: str, requested_width: int, title: str, + header: List[Any]=None, footer: List[Any]=None) -> None: self.controller = controller self.command = command self.title = title - self.log = urwid.SimpleFocusListWalker(widgets) + self.log = urwid.ListBox(urwid.SimpleFocusListWalker(body)) max_cols, max_rows = controller.maximum_popup_dimensions() self.width = min(max_cols, requested_width) - height = self.calculate_popup_height(widgets, self.width) + height = self.calculate_popup_height(body, header, footer, + self.width) self.height = min(max_rows, height) + if header: + header = urwid.ListBox(urwid.SimpleFocusListWalker(header)) + if footer: + footer = urwid.ListBox(urwid.SimpleFocusListWalker(footer)) - super().__init__(self.log) + super().__init__(self.log, header=header, footer=footer) @staticmethod - def calculate_popup_height(widgets: List[Any], popup_width: int) -> int: + def calculate_popup_height(body: List[Any], header: List[Any], + footer: 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) + body_height = sum(widget.rows((popup_width, )) for widget in body) + header_height = sum(widget.rows((popup_width, )) + for widget in header) if header else 0 + footer_height = sum(widget.rows((popup_width, )) + for widget in footer) if footer else 0 + return body_height + header_height + footer_height @staticmethod def calculate_table_widths(contents: PopUpViewTableContent, @@ -949,16 +962,17 @@ def make_table_with_categories(contents: PopUpViewTableContent, 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' + if not self.controller.is_in_editor_mode(): + if 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) @@ -1323,3 +1337,88 @@ def keypress(self, size: urwid_Size, key: str) -> str: ) return key return super().keypress(size, key) + +class DeleteReactionView(PopUpView): + def __init__(self, controller: Any, title: str, message: Message + ) -> None: + self.width = 30 + self.controller = controller + self.message = message + widgets = [urwid.CheckBox(reaction['emoji_name'], state=True, + checked_symbol='✓', + on_state_change=self.remove_reaction) + for reaction in message['reactions'] + if(controller.model.user_has_reacted_to_msg( + reaction['emoji_name'], message, + 'emoji_name'))] + widgets = sorted(list(set(widgets)), key=lambda cb: cb.label) # remove duplicates + super().__init__(controller, widgets, 'GO_BACK', self.width, + title) + + def keypress(self, size: urwid_Size, key: str) -> str: + if is_command_key('GO_LEFT', key): + self.controller.show_emoji_picker(self.message) + return super().keypress(size, key) + + def remove_reaction(self, checkbox: Any, state: bool) -> None: + if not state: + self.controller.model.react_to_message(self.message, + checkbox.get_label()) + check_boxes = self.contents['body'][0].body + check_boxes.remove(checkbox) + removed_check_boxes = urwid.ListBox( + urwid.SimpleFocusListWalker(check_boxes)) + self.contents['body'] = (removed_check_boxes, None) + + +class EmojiPickerView(PopUpView): + def __init__(self, controller: Any, title: str, emoji_names: List[str], + message: Message) -> None: + self.width = 40 + self.message = message + self.controller = controller + self.emoji_names = emoji_names + emoji_buttons = self.generate_emoji_buttons(emoji_names) + + search_box = urwid.Edit(caption=' Search [{}] '.format( + keys_for_command('ADD_REACTION').pop())) + urwid.connect_signal(search_box, 'change', self.update_emoji_list) + super().__init__(controller, [search_box], 'GO_BACK', self.width, + title, header=None, footer=emoji_buttons) + self.focus_position = 'body' + controller.enter_editor_mode_with(self) + + def keypress(self, size: urwid_Size, key: str) -> str: + if is_command_key('ENTER', key): + if self.controller.is_in_editor_mode(): + self.controller.exit_editor_mode() + self.set_focus('footer') + return key + elif is_command_key('GO_BACK', key): + self.controller.exit_editor_mode() + elif(is_command_key('GO_RIGHT', key) + and not self.controller.is_in_editor_mode()): + self.controller.show_delete_reaction(self.message) + elif is_command_key('ADD_REACTION', key): + self.controller.enter_editor_mode_with(self) + self.set_focus('body') + return key + return super().keypress(size, key) + + def update_emoji_list(self, search_box: Any, new_text: str) -> None: + matching_emojis = [] + for emoji in self.emoji_names: + if emoji.startswith(new_text): + matching_emojis.append(emoji) + emoji_buttons = self.generate_emoji_buttons(matching_emojis) + + self.contents['footer'] = (urwid.ListBox( + urwid.SimpleFocusListWalker( + emoji_buttons)), None) + self.controller.update_screen() + + def generate_emoji_buttons(self, emoji_names: List[str] + ) -> List[EmojiButton]: + return [EmojiButton(self.controller, self.width, emoji_name, + self.message) + for emoji_name in emoji_names[:10]]