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)