From 621c1e6e076be7d988c6eab5e5604287cb3f9285 Mon Sep 17 00:00:00 2001 From: Puneeth Chaganti Date: Thu, 20 Jun 2019 12:15:32 +0530 Subject: [PATCH 1/6] hotkeys/keys: Add VIEW_IN_BROWSER for viewing a message in the browser. --- docs/hotkeys.md | 1 + zulipterminal/config/keys.py | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/docs/hotkeys.md b/docs/hotkeys.md index f9c8b9fd8a..6ebff50eaa 100644 --- a/docs/hotkeys.md +++ b/docs/hotkeys.md @@ -50,6 +50,7 @@ |New message to a person or group of people|x| |Narrow to the stream of the current message|s| |Narrow to the topic of the current message|S| +|View current message in the web browser|v| |Narrow to a topic/private-chat, or stream/all-private-messages|z| |Add/remove thumbs-up reaction to the current message|+| |Add/remove star status of the current message|ctrl + s / *| diff --git a/zulipterminal/config/keys.py b/zulipterminal/config/keys.py index 887fa8f107..37f91ef7b7 100644 --- a/zulipterminal/config/keys.py +++ b/zulipterminal/config/keys.py @@ -143,6 +143,11 @@ class KeyBinding(TypedDict, total=False): 'help_text': 'Narrow to the topic of the current message', 'key_category': 'msg_actions', }), + ('VIEW_IN_BROWSER', { + 'keys': ['v'], + 'help_text': 'View current message in the web browser', + 'key_category': 'msg_actions', + }), ('TOGGLE_NARROW', { 'keys': ['z'], 'help_text': From 2201e67c08eadee4a431d91c98d1215c08601f55 Mon Sep 17 00:00:00 2001 From: Puneeth Chaganti Date: Thu, 20 Jun 2019 12:15:32 +0530 Subject: [PATCH 2/6] boxes/core: Add support for viewing a message in the web browser. This adds view_in_browser to add support for viewing any message in the web browser. For now, the message is always opened in the 'All messages' narrow. A message can be viewed in the browser by pressing VIEW_IN_BROWSER key(s) directly. Test added. --- tests/core/test_core.py | 13 +++++++++++++ zulipterminal/core.py | 9 +++++++++ zulipterminal/ui_tools/boxes.py | 2 ++ 3 files changed, 24 insertions(+) diff --git a/tests/core/test_core.py b/tests/core/test_core.py index 7c18c19330..4361ce02fb 100644 --- a/tests/core/test_core.py +++ b/tests/core/test_core.py @@ -1,3 +1,4 @@ +import os from platform import platform from typing import Any @@ -271,6 +272,18 @@ def test_narrow_to_all_mentions( msg_ids = {widget.original_widget.message['id'] for widget in widgets} assert msg_ids == id_list + def test_view_in_browser(self, mocker, controller): + # Set DISPLAY environ to be able to run test in Travis + os.environ['DISPLAY'] = ':0' + mock_open = mocker.patch('webbrowser.open', mocker.Mock()) + message_id = 123456 + controller.model.server_url = 'https://foo.zulipchat.com/' + controller.view_in_browser(message_id) + assert mock_open.call_count == 1 + url = mock_open.call_args[0][0] + assert url.startswith(controller.model.server_url) + assert url.endswith('/{}'.format(message_id)) + def test_main(self, mocker, controller): controller.view.palette = { 'default': 'theme_properties' diff --git a/zulipterminal/core.py b/zulipterminal/core.py index 14db4ba311..6db1ecf688 100644 --- a/zulipterminal/core.py +++ b/zulipterminal/core.py @@ -2,6 +2,7 @@ import signal import sys import time +import webbrowser from collections import OrderedDict from functools import partial from platform import platform @@ -364,6 +365,14 @@ def narrow_to_all_mentions(self) -> None: # (nothing currently requires narrowing around a message id) self._narrow_to(anchor=None, mentioned=True) + def view_in_browser(self, message_id: int) -> None: + url = '{}#narrow/near/{}'.format(self.model.server_url, message_id) + if (sys.platform != 'darwin' and sys.platform[:3] != 'win' + and not os.environ.get('DISPLAY') and os.environ.get('TERM')): + # Don't try to open web browser if running without a GUI + return + webbrowser.open(url) + def deregister_client(self) -> None: queue_id = self.model.queue_id self.client.deregister(queue_id, 1.0) diff --git a/zulipterminal/ui_tools/boxes.py b/zulipterminal/ui_tools/boxes.py index ee38d6007a..3a67b2d6e3 100644 --- a/zulipterminal/ui_tools/boxes.py +++ b/zulipterminal/ui_tools/boxes.py @@ -1551,6 +1551,8 @@ def keypress(self, size: urwid_Size, key: str) -> Optional[str]: self.model.controller.show_msg_info(self.message, self.message_links, self.time_mentions) + elif is_command_key('VIEW_IN_BROWSER', key): + self.model.controller.view_in_browser(self.message['id']) return key From 4b080fafcbd346cff443746aa5c58d7f99c87780 Mon Sep 17 00:00:00 2001 From: Puneeth Chaganti Date: Thu, 20 Jun 2019 12:15:32 +0530 Subject: [PATCH 3/6] core/helper: Suppress stderr and stdout for view_in_browser(). --- zulipterminal/core.py | 6 ++++-- zulipterminal/helper.py | 20 ++++++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/zulipterminal/core.py b/zulipterminal/core.py index 6db1ecf688..7e9c697189 100644 --- a/zulipterminal/core.py +++ b/zulipterminal/core.py @@ -15,7 +15,7 @@ from zulipterminal.api_types import Composition, Message from zulipterminal.config.themes import ThemeSpec -from zulipterminal.helper import asynch +from zulipterminal.helper import asynch, suppress_output from zulipterminal.model import Model from zulipterminal.ui import Screen, View from zulipterminal.ui_tools.utils import create_msg_box_list @@ -371,7 +371,9 @@ def view_in_browser(self, message_id: int) -> None: and not os.environ.get('DISPLAY') and os.environ.get('TERM')): # Don't try to open web browser if running without a GUI return - webbrowser.open(url) + with suppress_output(): + # Suppress anything on stdout or stderr when opening the browser + webbrowser.open(url) def deregister_client(self) -> None: queue_id = self.model.queue_id diff --git a/zulipterminal/helper.py b/zulipterminal/helper.py index 821cc63b3f..ca746b9e7e 100644 --- a/zulipterminal/helper.py +++ b/zulipterminal/helper.py @@ -3,6 +3,7 @@ import subprocess import time from collections import OrderedDict, defaultdict +from contextlib import contextmanager from functools import wraps from itertools import chain, combinations from re import ASCII, MULTILINE, findall, match @@ -14,6 +15,7 @@ Dict, FrozenSet, Iterable, + Iterator, List, Set, Tuple, @@ -702,3 +704,21 @@ def get_unused_fence(content: str) -> str: len(max(matches, key=len)) + 1) return '`' * max_length_fence + + +@contextmanager +def suppress_output() -> Iterator[None]: + """Context manager to redirect stdout and stderr to /dev/null. + + Adapted from https://stackoverflow.com/a/2323563 + """ + out = os.dup(1) + err = os.dup(2) + os.close(1) + os.close(2) + os.open(os.devnull, os.O_RDWR) + try: + yield + finally: + os.dup2(out, 1) + os.dup2(err, 2) From fa82f8a5cd7dbb4a89889602143e1826a96f7ac7 Mon Sep 17 00:00:00 2001 From: Preet Mishra Date: Wed, 24 Jun 2020 21:35:17 +0530 Subject: [PATCH 4/6] refactor: core: Use MACOS AND WSL in view_in_browser(). --- zulipterminal/core.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/zulipterminal/core.py b/zulipterminal/core.py index 7e9c697189..6745e41585 100644 --- a/zulipterminal/core.py +++ b/zulipterminal/core.py @@ -15,7 +15,7 @@ from zulipterminal.api_types import Composition, Message from zulipterminal.config.themes import ThemeSpec -from zulipterminal.helper import asynch, suppress_output +from zulipterminal.helper import MACOS, WSL, asynch, suppress_output from zulipterminal.model import Model from zulipterminal.ui import Screen, View from zulipterminal.ui_tools.utils import create_msg_box_list @@ -367,8 +367,8 @@ def narrow_to_all_mentions(self) -> None: def view_in_browser(self, message_id: int) -> None: url = '{}#narrow/near/{}'.format(self.model.server_url, message_id) - if (sys.platform != 'darwin' and sys.platform[:3] != 'win' - and not os.environ.get('DISPLAY') and os.environ.get('TERM')): + if (not MACOS and not WSL and not os.environ.get('DISPLAY') + and os.environ.get('TERM')): # Don't try to open web browser if running without a GUI return with suppress_output(): From 7c0c9fcb9194400f98d967c85ab91671b0a4763c Mon Sep 17 00:00:00 2001 From: Preet Mishra Date: Wed, 24 Jun 2020 21:35:17 +0530 Subject: [PATCH 5/6] views: Amend trigger sequence for view_in_browser(). This makes MsgInfoView handle VIEW_IN_BROWSER keypress in order to avoid too many/unintended triggers. Tests amended and added. --- tests/ui_tools/test_popups.py | 16 +++++++++++++--- zulipterminal/ui_tools/views.py | 7 ++++++- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/tests/ui_tools/test_popups.py b/tests/ui_tools/test_popups.py index 01d1630bbc..bb21429307 100644 --- a/tests/ui_tools/test_popups.py +++ b/tests/ui_tools/test_popups.py @@ -504,8 +504,17 @@ def test_keypress_exit_popup(self, key, widget_size): self.msg_info_view.keypress(size, key) assert self.controller.exit_popup.called + @pytest.mark.parametrize('key', keys_for_command('VIEW_IN_BROWSER')) + def test_keypress_view_in_browser(self, widget_size, message_fixture, key): + size = widget_size(self.msg_info_view) + + self.msg_info_view.keypress(size, key) + + (self.controller.view_in_browser. + assert_called_once_with(message_fixture['id'])) + def test_height_noreactions(self): - expected_height = 3 + expected_height = 4 assert self.msg_info_view.height == expected_height # FIXME This is the same parametrize as MessageBox:test_reactions_view @@ -553,8 +562,9 @@ def test_height_reactions(self, message_fixture, to_vary_in_each_message): self.msg_info_view = MsgInfoView(self.controller, varied_message, 'Message Information', OrderedDict(), list()) - # 9 = 3 labels + 1 blank line + 1 'Reactions' (category) + 4 reactions. - expected_height = 9 + # 10 = 4 labels + 1 blank line + 1 'Reactions' (category) + 4 + # reactions (excluding 'Message Links'). + expected_height = 10 assert self.msg_info_view.height == expected_height def test_keypress_navigation(self, mocker, widget_size, diff --git a/zulipterminal/ui_tools/views.py b/zulipterminal/ui_tools/views.py index 4c9819de44..b13c16747f 100644 --- a/zulipterminal/ui_tools/views.py +++ b/zulipterminal/ui_tools/views.py @@ -1232,7 +1232,10 @@ def __init__(self, controller: Any, msg: Message, title: str, msg_info = [ ('', [('Date & Time', date_and_time), ('Sender', msg['sender_full_name']), - ('Sender\'s Email ID', msg['sender_email'])]), + ('Sender\'s Email ID', msg['sender_email']), + ('View message in the web browser', 'Press {}'.format( + ', '.join(map(repr, keys_for_command('VIEW_IN_BROWSER'))))), + ]), ] # Only show the 'Edit History' label for edited messages. self.show_edit_history_label = ( @@ -1303,6 +1306,8 @@ def keypress(self, size: urwid_Size, key: str) -> str: message_links=self.message_links, time_mentions=self.time_mentions, ) + elif is_command_key('VIEW_IN_BROWSER', key): + self.controller.view_in_browser(self.msg['id']) return super().keypress(size, key) From 584c94e59f277b17b354219b1acfa40c5b9a9a49 Mon Sep 17 00:00:00 2001 From: Preet Mishra Date: Thu, 25 Jun 2020 18:35:17 +0530 Subject: [PATCH 6/6] core/views: Use near_message_url() to open messages in the topic narrow. This improves view_in_browser() to open messages in the exact/topic narrow for streams and in the pm-with narrow for PMs and huddles. Tests amended. --- tests/core/test_core.py | 21 ++++++++++++--------- tests/ui_tools/test_popups.py | 2 +- zulipterminal/core.py | 8 +++++--- zulipterminal/ui_tools/views.py | 2 +- 4 files changed, 19 insertions(+), 14 deletions(-) diff --git a/tests/core/test_core.py b/tests/core/test_core.py index 4361ce02fb..9971635968 100644 --- a/tests/core/test_core.py +++ b/tests/core/test_core.py @@ -272,17 +272,20 @@ def test_narrow_to_all_mentions( msg_ids = {widget.original_widget.message['id'] for widget in widgets} assert msg_ids == id_list - def test_view_in_browser(self, mocker, controller): + @pytest.mark.parametrize('server_url', [ + 'https://chat.zulip.org/', + 'https://foo.zulipchat.com/', + ]) + def test_view_in_browser(self, mocker, controller, message_fixture, + server_url): # Set DISPLAY environ to be able to run test in Travis os.environ['DISPLAY'] = ':0' - mock_open = mocker.patch('webbrowser.open', mocker.Mock()) - message_id = 123456 - controller.model.server_url = 'https://foo.zulipchat.com/' - controller.view_in_browser(message_id) - assert mock_open.call_count == 1 - url = mock_open.call_args[0][0] - assert url.startswith(controller.model.server_url) - assert url.endswith('/{}'.format(message_id)) + controller.model.server_url = server_url + mocked_open = mocker.patch(CORE + '.webbrowser.open') + + controller.view_in_browser(message_fixture) + + mocked_open.assert_called_once_with(controller.url) def test_main(self, mocker, controller): controller.view.palette = { diff --git a/tests/ui_tools/test_popups.py b/tests/ui_tools/test_popups.py index bb21429307..8ea46e390b 100644 --- a/tests/ui_tools/test_popups.py +++ b/tests/ui_tools/test_popups.py @@ -511,7 +511,7 @@ def test_keypress_view_in_browser(self, widget_size, message_fixture, key): self.msg_info_view.keypress(size, key) (self.controller.view_in_browser. - assert_called_once_with(message_fixture['id'])) + assert_called_once_with(message_fixture)) def test_height_noreactions(self): expected_height = 4 diff --git a/zulipterminal/core.py b/zulipterminal/core.py index 6745e41585..fd405e8285 100644 --- a/zulipterminal/core.py +++ b/zulipterminal/core.py @@ -17,6 +17,7 @@ from zulipterminal.config.themes import ThemeSpec from zulipterminal.helper import MACOS, WSL, asynch, suppress_output from zulipterminal.model import Model +from zulipterminal.server_url import near_message_url from zulipterminal.ui import Screen, View from zulipterminal.ui_tools.utils import create_msg_box_list from zulipterminal.ui_tools.views import ( @@ -365,15 +366,16 @@ def narrow_to_all_mentions(self) -> None: # (nothing currently requires narrowing around a message id) self._narrow_to(anchor=None, mentioned=True) - def view_in_browser(self, message_id: int) -> None: - url = '{}#narrow/near/{}'.format(self.model.server_url, message_id) + def view_in_browser(self, message: Message) -> None: + # Truncate extra '/' from the server url. + self.url = near_message_url(self.model.server_url[:-1], message) if (not MACOS and not WSL and not os.environ.get('DISPLAY') and os.environ.get('TERM')): # Don't try to open web browser if running without a GUI return with suppress_output(): # Suppress anything on stdout or stderr when opening the browser - webbrowser.open(url) + webbrowser.open(self.url) def deregister_client(self) -> None: queue_id = self.model.queue_id diff --git a/zulipterminal/ui_tools/views.py b/zulipterminal/ui_tools/views.py index b13c16747f..32bf8a62eb 100644 --- a/zulipterminal/ui_tools/views.py +++ b/zulipterminal/ui_tools/views.py @@ -1307,7 +1307,7 @@ def keypress(self, size: urwid_Size, key: str) -> str: time_mentions=self.time_mentions, ) elif is_command_key('VIEW_IN_BROWSER', key): - self.controller.view_in_browser(self.msg['id']) + self.controller.view_in_browser(self.msg) return super().keypress(size, key)