Skip to content

Add support for opening message in browser. #991

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

Merged
merged 3 commits into from
Jun 11, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/hotkeys.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
|Add/remove star status of the current message|<kbd>ctrl</kbd> + <kbd>s</kbd> / <kbd>*</kbd>|
|Show/hide message information|<kbd>i</kbd>|
|Show/hide edit history (from message information)|<kbd>e</kbd>|
|View current message in browser (from message information)|<kbd>v</kbd>|

## Stream list actions
|Command|Key Combination|
Expand Down
34 changes: 34 additions & 0 deletions tests/core/test_core.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import os
import webbrowser
from platform import platform
from typing import Any

Expand Down Expand Up @@ -265,6 +267,38 @@ def test_narrow_to_all_mentions(self, mocker, controller, index_all_mentions):
msg_ids = {widget.original_widget.message["id"] for widget in widgets}
assert msg_ids == id_list

@pytest.mark.parametrize(
"url",
[
"https://chat.zulip.org/#narrow/stream/test",
"https://chat.zulip.org/user_uploads/sent/abcd/efg.png",
"https://github.com/",
],
)
def test_open_in_browser_success(self, mocker, controller, url):
# Set DISPLAY environ to be able to run test in CI
os.environ["DISPLAY"] = ":0"
controller.view.set_footer_text = mocker.Mock()
mock_get = mocker.patch(CORE + ".webbrowser.get")
mock_open = mock_get.return_value.open

controller.open_in_browser(url)

mock_open.assert_called_once_with(url)
controller.view.set_footer_text.assert_called_once_with(
f"The link was successfully opened using {mock_get.return_value.name}", 3
)

def test_open_in_browser_fail__no_browser_controller(self, mocker, controller):
os.environ["DISPLAY"] = ":0"
error = "No runnable browser found"
controller.view.set_footer_text = mocker.Mock()
mocker.patch(CORE + ".webbrowser.get").side_effect = webbrowser.Error(error)

controller.open_in_browser("https://chat.zulip.org/#narrow/stream/test")

controller.view.set_footer_text.assert_called_once_with(f"ERROR: {error}", 3)

def test_main(self, mocker, controller):
controller.view.palette = {"default": "theme_properties"}
mock_tsk = mocker.patch("zulipterminal.ui.Screen.tty_signal_keys")
Expand Down
17 changes: 14 additions & 3 deletions tests/ui_tools/test_popups.py
Original file line number Diff line number Diff line change
Expand Up @@ -580,8 +580,18 @@ 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, mocker, widget_size, message_fixture, key):
size = widget_size(self.msg_info_view)
self.msg_info_view.server_url = "https://chat.zulip.org/"
mocker.patch(VIEWS + ".near_message_url")

self.msg_info_view.keypress(size, key)

assert self.controller.open_in_browser.called

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
Expand Down Expand Up @@ -644,8 +654,9 @@ def test_height_reactions(self, message_fixture, to_vary_in_each_message):
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

@pytest.mark.parametrize(
Expand Down
7 changes: 7 additions & 0 deletions zulipterminal/config/keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,13 @@ class KeyBinding(TypedDict, total=False):
'excluded_from_random_tips': True,
'key_category': 'msg_actions',
}),
('VIEW_IN_BROWSER', {
'keys': ['v'],
'help_text':
'View current message in browser (from message information)',
'excluded_from_random_tips': True,
'key_category': 'msg_actions',
}),
('STREAM_DESC', {
'keys': ['i'],
'help_text': 'Show/hide stream information & modify settings',
Expand Down
32 changes: 31 additions & 1 deletion zulipterminal/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import signal
import sys
import time
import webbrowser
from collections import OrderedDict
from functools import partial
from platform import platform
Expand All @@ -14,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 LINUX, asynch, suppress_output
from zulipterminal.model import Model
from zulipterminal.ui import Screen, View
from zulipterminal.ui_tools.utils import create_msg_box_list
Expand Down Expand Up @@ -304,6 +305,35 @@ def show_edit_history(
"area:msg",
)

def open_in_browser(self, url: str) -> None:
"""
Opens any provided URL in a graphical browser, if found, else
prints an appropriate error message.
"""
# Don't try to open web browser if running without a GUI
# TODO: Explore and eventually support opening links in text-browsers.
if LINUX and not os.environ.get("DISPLAY") and os.environ.get("TERM"):
self.view.set_footer_text(
"No DISPLAY environment variable specified. This could "
"likely mean the ZT host is running without a GUI.",
3,
)
return
try:
# Checks for a runnable browser in the system and returns
# its browser controller, if found, else reports an error
browser_controller = webbrowser.get()
# Suppress stdout and stderr when opening browser
with suppress_output():
browser_controller.open(url)
self.view.set_footer_text(
f"The link was successfully opened using {browser_controller.name}",
3,
)
except webbrowser.Error as e:
# Set a footer text if no runnable browser is located
self.view.set_footer_text(f"ERROR: {e}", 3)

def search_messages(self, text: str) -> None:
# Search for a text in messages
self.model.index["search"].clear()
Expand Down
21 changes: 21 additions & 0 deletions zulipterminal/helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -14,6 +15,7 @@
Dict,
FrozenSet,
Iterable,
Iterator,
List,
Set,
Tuple,
Expand Down Expand Up @@ -714,3 +716,22 @@ def get_unused_fence(content: str) -> str:
max_length_fence = max(max_length_fence, 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
"""
stdout = os.dup(1)
stderr = os.dup(2)
os.close(1)
os.close(2)
os.open(os.devnull, os.O_RDWR)
try:
yield
finally:
os.dup2(stdout, 1)
os.dup2(stderr, 2)
10 changes: 10 additions & 0 deletions zulipterminal/ui_tools/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
)
from zulipterminal.config.ui_mappings import EDIT_MODE_CAPTIONS
from zulipterminal.helper import Message, asynch, match_stream, match_user
from zulipterminal.server_url import near_message_url
from zulipterminal.ui_tools.boxes import MessageBox, PanelSearchBox
from zulipterminal.ui_tools.buttons import (
HomeButton,
Expand Down Expand Up @@ -1297,9 +1298,11 @@ def __init__(
self.topic_links = topic_links
self.message_links = message_links
self.time_mentions = time_mentions
self.server_url = controller.model.server_url
date_and_time = controller.model.formatted_local_time(
msg["timestamp"], show_seconds=True, show_year=True
)
view_in_browser_keys = ", ".join(map(repr, keys_for_command("VIEW_IN_BROWSER")))

msg_info = [
(
Expand All @@ -1308,6 +1311,10 @@ def __init__(
("Date & Time", date_and_time),
("Sender", msg["sender_full_name"]),
("Sender's Email ID", msg["sender_email"]),
(
"View message in browser",
f"Press {view_in_browser_keys} to view message in browser",
),
],
),
]
Expand Down Expand Up @@ -1410,6 +1417,9 @@ 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):
url = near_message_url(self.server_url[:-1], self.msg)
self.controller.open_in_browser(url)
return super().keypress(size, key)


Expand Down