Skip to content

Commit 3996337

Browse files
committed
Opens message links popup menu
Pressing enter on one of the buttons in links popup menu does one of the following, depending on the link: - narrow to stream - narrow to user - open image in system's default viewer - open link in system's default browser Narrows to stream/user and opens images Narrows to stream/user successfuly. Opens selected image in default viewer. Gives 401 error while trying to open gifs or videos. Opens links in default browser Opens links in default browser (code from #397). Tries to download and open user uploaded media in default system viewer (code from #359), otherwise opens link in default browser. Opens images/gifs/links in default system viewer Opens videos in default viewer
1 parent d9ee88c commit 3996337

File tree

6 files changed

+177
-5
lines changed

6 files changed

+177
-5
lines changed

zulipterminal/config/keys.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,10 @@
196196
'keys': {'ctrl l'},
197197
'help_text': 'Clear message',
198198
}),
199+
('MSG_LINKS', {
200+
'keys': {'v'},
201+
'help_text': 'View all links in the current message',
202+
}),
199203
]) # type: OrderedDict[str, KeyBinding]
200204

201205

zulipterminal/core.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,17 @@
55
import time
66
import signal
77
from functools import partial
8+
import webbrowser
89

910
import urwid
1011
import zulip
1112

1213
from zulipterminal.version import ZT_VERSION
13-
from zulipterminal.helper import asynch
14+
from zulipterminal.helper import asynch, suppress_output
1415
from zulipterminal.model import Model, GetMessagesArgs, ServerConnectionFailure
1516
from zulipterminal.ui import View, Screen
1617
from zulipterminal.ui_tools.utils import create_msg_box_list
17-
from zulipterminal.ui_tools.views import HelpView, MsgInfoView
18+
from zulipterminal.ui_tools.views import HelpView, MsgInfoView, MsgLinksView
1819
from zulipterminal.config.themes import ThemeSpec
1920
from zulipterminal.ui_tools.views import PopUpConfirmationView
2021

@@ -115,6 +116,10 @@ def show_msg_info(self, msg: Any) -> None:
115116
self.show_pop_up(msg_info_view,
116117
"Message Information (up/down scrolls)")
117118

119+
def show_msg_links(self, msg: Any):
120+
msg_links_view = MsgLinksView(self, msg)
121+
self.show_pop_up(msg_links_view, "Message Links (up/down scrolls)")
122+
118123
def search_messages(self, text: str) -> None:
119124
# Search for a text in messages
120125
self.model.set_narrow(search=text)
@@ -245,6 +250,15 @@ def show_all_messages(self, button: Any) -> None:
245250

246251
self._finalize_show(w_list)
247252

253+
def view_in_browser(self, url) -> None:
254+
if (sys.platform != 'darwin' and sys.platform[:3] != 'win' and
255+
not os.environ.get('DISPLAY') and os.environ.get('TERM')):
256+
# Don't try to open web browser if running without a GUI
257+
return
258+
with suppress_output():
259+
# Suppress anything on stdout or stderr when opening the browser
260+
webbrowser.open_new(url)
261+
248262
def show_all_pm(self, button: Any) -> None:
249263
already_narrowed = self.model.set_narrow(pms=True)
250264
if already_narrowed:

zulipterminal/helper.py

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,16 @@
66
from threading import Thread
77
from typing import (
88
Any, Dict, List, Set, Tuple, Optional, DefaultDict, FrozenSet, Union,
9-
Iterable, Callable
9+
Iterator, Iterable, Callable
1010
)
1111
from mypy_extensions import TypedDict
12+
from contextlib import contextmanager
1213

1314
import os
15+
import sys
16+
import requests
17+
import subprocess
18+
import tempfile
1419

1520
Message = Dict[str, Any]
1621

@@ -410,3 +415,59 @@ def canonicalize_color(color: str) -> str:
410415
return color.lower()
411416
else:
412417
raise ValueError('Unknown format for color "{}"'.format(color))
418+
419+
420+
@contextmanager
421+
def suppress_output() -> Iterator[Any]:
422+
"""
423+
Context manager to redirect stdout and stderr to /dev/null.
424+
Adapted from https://stackoverflow.com/a/2323563
425+
"""
426+
out = os.dup(1)
427+
err = os.dup(2)
428+
os.close(1)
429+
os.close(2)
430+
os.open(os.devnull, os.O_RDWR)
431+
try:
432+
yield
433+
finally:
434+
os.dup2(out, 1)
435+
os.dup2(err, 2)
436+
437+
438+
def open_media(controller, url: str):
439+
# Uploaded media
440+
if 'user_uploads' in url:
441+
# Tries to download media and open in default viewer
442+
# If can't, opens in browser
443+
img_name = url.split("/")[-1]
444+
img_dir_path = os.path.join(tempfile.gettempdir(),
445+
"ZulipTerminal_media")
446+
img_path = os.path.join(img_dir_path, img_name)
447+
try:
448+
os.mkdir(img_dir_path)
449+
except FileExistsError:
450+
pass
451+
with requests.get(url, stream=True,
452+
auth=requests.auth.HTTPBasicAuth(
453+
controller.client.email,
454+
controller.client.api_key)) as r:
455+
if r.status_code == 200:
456+
with open(img_path, 'wb') as f:
457+
for chunk in r.iter_content(chunk_size=8192):
458+
if chunk: # filter out keep-alive new chunks
459+
f.write(chunk)
460+
r.close()
461+
462+
try:
463+
imageViewerFromCommandLine = {'linux': 'xdg-open',
464+
'win32': 'explorer',
465+
'darwin': 'open'}[sys.platform]
466+
with suppress_output():
467+
subprocess.run([imageViewerFromCommandLine, img_path])
468+
except(FileNotFoundError):
469+
controller.view_in_browser(url)
470+
471+
# Other urls
472+
else:
473+
controller.view_in_browser(url)

zulipterminal/ui_tools/boxes.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ def __init__(self, message: Dict[str, Any], model: Any,
137137
self.model = model
138138
self.message = message
139139
self.stream_name = ''
140+
self.message['links'] = dict() # type: Dict
140141
self.stream_id = None # type: Union[int, None]
141142
self.topic_name = ''
142143
self.email = ''
@@ -370,6 +371,7 @@ def soup2markup(self, soup: Any) -> List[Any]:
370371
'user-group-mention' in element.attrs.get('class', [])):
371372
# USER MENTIONS & USER-GROUP MENTIONS
372373
markup.append(('span', element.text))
374+
self.message['links'][element.text] = element.text
373375
elif element.name == 'a':
374376
# LINKS
375377
link = element.attrs['href']
@@ -380,12 +382,15 @@ def soup2markup(self, soup: Any) -> List[Any]:
380382
# a link then just display the link
381383
markup.append(text)
382384
else:
385+
if link.startswith('user_uploads/'):
386+
link = self.model.server_url + link
383387
if link.startswith('/user_uploads/'):
384388
# Append org url to before user_uploads to convert it
385389
# into a link.
386-
link = self.model.server_url + link
390+
link = self.model.server_url + link[1:]
387391
markup.append(
388392
('link', '[' + text + ']' + '(' + link + ')'))
393+
self.message['links'][text] = link
389394
elif element.name == 'blockquote':
390395
# BLOCKQUOTE TEXT
391396
markup.append((
@@ -681,6 +686,8 @@ def keypress(self, size: Tuple[int, int], key: str) -> str:
681686
self.model.controller.view.middle_column.set_focus('footer')
682687
elif is_command_key('MSG_INFO', key):
683688
self.model.controller.show_msg_info(self.message)
689+
elif is_command_key('MSG_LINKS', key):
690+
self.model.controller.show_msg_links(self.message)
684691
return key
685692

686693

zulipterminal/ui_tools/buttons.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,14 @@ def keypress(self, size: Tuple[int, int], key: str) -> str:
8686
return super().keypress(size, key)
8787

8888

89+
class MsgLinkButton(TopButton):
90+
def __init__(self, controller: Any, width: int, count: int,
91+
caption: str, function: Callable) -> None:
92+
super().__init__(controller, caption,
93+
function, count=count,
94+
width=width)
95+
96+
8997
class HomeButton(TopButton):
9098
def __init__(self, controller: Any, width: int, count: int=0) -> None:
9199
button_text = ("All messages [" +

zulipterminal/ui_tools/views.py

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import time
77

88
from zulipterminal.config.keys import KEY_BINDINGS, is_command_key
9-
from zulipterminal.helper import asynch, match_user
9+
from zulipterminal.helper import asynch, match_user, open_media
1010
from zulipterminal.ui_tools.buttons import (
1111
TopicButton,
1212
UnreadPMButton,
@@ -18,6 +18,7 @@
1818
)
1919
from zulipterminal.ui_tools.utils import create_msg_box_list
2020
from zulipterminal.ui_tools.boxes import UserSearchBox, StreamSearchBox
21+
from zulipterminal.ui_tools.buttons import MsgLinkButton
2122

2223

2324
class ModListWalker(urwid.SimpleFocusListWalker):
@@ -823,3 +824,80 @@ def keypress(self, size: Tuple[int, int], key: str) -> str:
823824
if is_command_key('GO_BACK', key) or is_command_key('MSG_INFO', key):
824825
self.controller.exit_popup()
825826
return super(MsgInfoView, self).keypress(size, key)
827+
828+
829+
class MsgLinksView(urwid.ListBox):
830+
def __init__(self, controller: Any, msg: Any):
831+
self.controller = controller
832+
self.msg = msg
833+
834+
self.width = 70
835+
self.height = 10
836+
837+
self.log = []
838+
for url in self.msg['links']:
839+
if not url.startswith('/user_uploads'):
840+
if len(url) > 0:
841+
self.log.append(MsgLinkButton(self.controller,
842+
self.width,
843+
0,
844+
url,
845+
self.perform_action))
846+
else:
847+
self.log.append(MsgLinkButton(self.controller,
848+
self.width,
849+
0,
850+
self.msg['links'][url],
851+
self.perform_action))
852+
super(MsgLinksView, self).__init__(self.log)
853+
854+
def perform_action(self, button):
855+
# If stream
856+
if self.msg['links'][button._caption].startswith('/#narrow'):
857+
found_stream = False
858+
for stream_details in self.controller.model.stream_dict.values():
859+
# Check mentioned stream with all subscribed streams
860+
if stream_details['name'] == button._caption[1:]:
861+
# Stream button to call function to narrow to stream
862+
req_details = ['name', 'stream_id', 'color', 'invite_only']
863+
stream = [stream_details[key] for key in req_details]
864+
btn = StreamButton(stream,
865+
controller=self.controller,
866+
view=self.controller.view,
867+
width=self.width,
868+
count=0)
869+
self.controller.narrow_to_stream(btn)
870+
found_stream = True
871+
break
872+
if not found_stream:
873+
self.controller.view.set_footer_text(
874+
"You are not subscribed to this stream.", 3)
875+
876+
# If user
877+
elif self.msg['links'][button._caption].startswith('@'):
878+
found_user = False
879+
for user in self.controller.model.users:
880+
if user['full_name'] == button._caption[1:]:
881+
btn = UserButton(user,
882+
controller=self.controller,
883+
view=self.controller.view,
884+
width=self.width,
885+
color=user['status'],
886+
count=0)
887+
self.controller.narrow_to_user(btn)
888+
btn._narrow_with_compose(btn)
889+
found_user = True
890+
break
891+
if not found_user:
892+
self.controller.view.set_footer_text(
893+
"User not found in realm.\
894+
Their account may be deactivated.", 3)
895+
896+
# If link (uploaded media or other webpage)
897+
else:
898+
open_media(self.controller, self.msg['links'][button._caption])
899+
900+
def keypress(self, size: Tuple[int, int], key: str) -> str:
901+
if is_command_key('GO_BACK', key) or is_command_key('MSG_LINKS', key):
902+
self.controller.exit_popup()
903+
return super(MsgLinksView, self).keypress(size, key)

0 commit comments

Comments
 (0)