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/tests/core/test_core.py b/tests/core/test_core.py index 7c18c19330..9971635968 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,21 @@ def test_narrow_to_all_mentions( msg_ids = {widget.original_widget.message['id'] for widget in widgets} assert msg_ids == id_list + @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' + 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 = { 'default': 'theme_properties' diff --git a/tests/ui_tools/test_popups.py b/tests/ui_tools/test_popups.py index 01d1630bbc..8ea46e390b 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)) + 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/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': diff --git a/zulipterminal/core.py b/zulipterminal/core.py index 14db4ba311..fd405e8285 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 @@ -14,8 +15,9 @@ from zulipterminal.api_types import Composition, Message from zulipterminal.config.themes import ThemeSpec -from zulipterminal.helper import asynch +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 ( @@ -364,6 +366,17 @@ 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: 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(self.url) + def deregister_client(self) -> None: queue_id = self.model.queue_id self.client.deregister(queue_id, 1.0) 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) 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 diff --git a/zulipterminal/ui_tools/views.py b/zulipterminal/ui_tools/views.py index 4c9819de44..32bf8a62eb 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) return super().keypress(size, key)