diff --git a/docs/hotkeys.md b/docs/hotkeys.md index c9677e8258..dac5b4c291 100644 --- a/docs/hotkeys.md +++ b/docs/hotkeys.md @@ -76,6 +76,7 @@ ## Composing a Message |Command|Key Combination| | :--- | :---: | +|Upload file|ctrl + o| |Cycle through recipient and content boxes|tab| |Send a message|ctrl + d / meta + enter| |Save current message as a draft|meta + s| diff --git a/tests/model/test_model.py b/tests/model/test_model.py index aa5c9bf587..1ca25318dc 100644 --- a/tests/model/test_model.py +++ b/tests/model/test_model.py @@ -1,5 +1,7 @@ import copy import json +import os +import tempfile from collections import OrderedDict from copy import deepcopy from typing import Any, List, Optional, Tuple @@ -810,6 +812,49 @@ def test_send_stream_message( req, self.controller ) + @pytest.mark.parametrize( + "file_name, upload_result, expected_result", + [ + case( + "existing_file.txt", + {"result": "success", "uri": "http://example.com/success_uri"}, + "http://example.com/success_uri", + id="exisiting_file_with_successful_response", + ), + case( + "existing_file.txt", + {"result": "failure", "error_message": "Upload failed"}, + None, + id="exisiting_file_with_unsuccessful_response", + ), + case( + "non_existing_file.txt", + None, + None, + id="non_exisiting_file_with_no_response", + ), + ], + ) + def test_get_file_upload_uri( + self, mocker, model, file_name, upload_result, expected_result + ): + self.client.upload_file = mocker.Mock(return_value=upload_result) + with tempfile.TemporaryDirectory() as temp_dir: + if upload_result is not None: + temp_file_path = os.path.join(temp_dir, file_name) + with open(temp_file_path, "w") as temp_file: + temp_file.write("Test content") + else: + temp_file_path = f"random_path/{file_name}" + + result = model.get_file_upload_uri(temp_file_path) + + if upload_result is not None: + self.client.upload_file.assert_called_once() + else: + self.client.upload_file.assert_not_called() + assert result == expected_result + @pytest.mark.parametrize( "response, return_value", [ diff --git a/tests/ui_tools/test_boxes.py b/tests/ui_tools/test_boxes.py index 975b14c416..3290fe16eb 100644 --- a/tests/ui_tools/test_boxes.py +++ b/tests/ui_tools/test_boxes.py @@ -1526,6 +1526,35 @@ def test_keypress_typeahead_mode_autocomplete_key( else: self.view.set_footer_text.assert_not_called() + @pytest.mark.parametrize( + "file_name, uri, expected_result", + [ + case( + "example.txt", + "http://example.com/example.txt", + "Initial content[example.txt](http://example.com/example.txt)", + id="txt_file", + ), + case( + "file.pdf", + "http://example.com/file.pdf", + "Initial content[file.pdf](http://example.com/file.pdf)", + id="pdf_file", + ), + ], + ) + def test_append_uri_and_filename( + self, write_box: WriteBox, file_name: str, uri: str, expected_result: str + ) -> None: + initial_edit_text = "Initial content" + write_box.private_box_view(recipient_user_ids=[]) + write_box.msg_write_box.edit_text = initial_edit_text + + write_box.append_uri_and_filename(file_name, uri) + result_edit_text = write_box.msg_write_box.edit_text + + assert result_edit_text == expected_result + @pytest.mark.parametrize( [ "initial_focus_name", diff --git a/tests/ui_tools/test_popups.py b/tests/ui_tools/test_popups.py index c378ba9eb0..f0f4508803 100644 --- a/tests/ui_tools/test_popups.py +++ b/tests/ui_tools/test_popups.py @@ -1,15 +1,19 @@ from collections import OrderedDict +from pathlib import Path from typing import Any, Callable, Dict, List, Optional, Tuple +from unittest.mock import MagicMock import pytest from pytest import param as case from pytest_mock import MockerFixture from urwid import Columns, Pile, Text, Widget +from tests.ui_tools.test_boxes import TestWriteBox from zulipterminal.api_types import Message from zulipterminal.config.keys import is_command_key, keys_for_command from zulipterminal.config.ui_mappings import EDIT_MODE_CAPTIONS from zulipterminal.helper import CustomProfileData, TidiedUserInfo +from zulipterminal.ui_tools.boxes import WriteBox from zulipterminal.ui_tools.messages import MessageBox from zulipterminal.ui_tools.views import ( AboutView, @@ -17,6 +21,7 @@ EditHistoryView, EditModeView, EmojiPickerView, + FileUploadView, FullRawMsgView, FullRenderedMsgView, HelpView, @@ -872,6 +877,90 @@ def test_keypress_exit_popup( assert self.controller.exit_popup.called +class TestFileUploadView: + @pytest.fixture(scope="class") + def write_box(self) -> Any: + return TestWriteBox().write_box + + @pytest.fixture(autouse=True) + def mock_external_classes(self, mocker: MockerFixture, write_box: WriteBox) -> None: + self.controller = mocker.Mock() + mocker.patch.object( + self.controller, "maximum_popup_dimensions", return_value=(64, 64) + ) + mocker.patch(LISTWALKER, return_value=[]) + self.file_upload_view = FileUploadView( + self.controller, write_box, "Upload File" + ) + + def test_keypress_any_key( + self, widget_size: Callable[[Widget], urwid_Size] + ) -> None: + key = "a" + size = widget_size(self.file_upload_view) + self.file_upload_view.keypress(size, key) + assert not self.controller.exit_popup.called + + @pytest.mark.parametrize("key", {*keys_for_command("GO_BACK")}) + def test_keypress_exit_popup( + self, key: str, widget_size: Callable[[Widget], urwid_Size] + ) -> None: + size = widget_size(self.file_upload_view) + self.file_upload_view.keypress(size, key) + assert self.controller.exit_popup.called + + @pytest.mark.parametrize( + "file_location, expected_uri, expected_error_message", + [ + case( + "example.txt", + "http://example.txt/uploaded_file", + None, + id="txt_file_with_successful_uri", + ), + case( + "example.pdf", + "http://example.pdf/uploaded_file", + None, + id="pdf_file_with_successful_uri", + ), + case( + "invalid.txt", + "", + ["ERROR: Unable to get the URI"], + id="invalid_txt_file_with_error", + ), + case( + "invalid.pdf", + "", + ["ERROR: Unable to get the URI"], + id="invalid_pdf_file_with_error", + ), + ], + ) + def test__handle_file_upload( + self, + file_location: str, + expected_uri: str, + expected_error_message: Optional[str], + ) -> None: + self.file_upload_view.write_box = MagicMock() + self.controller.model.get_file_upload_uri.return_value = expected_uri + + self.file_upload_view._handle_file_upload(file_location) + + self.controller.model.get_file_upload_uri.assert_called_once_with(file_location) + if not expected_error_message: + file_name = Path(file_location).name + self.file_upload_view.write_box.append_uri_and_filename.assert_called_once_with( + file_name, self.file_upload_view.uri + ) + else: + self.controller.append_uri_and_filename.assert_not_called() + self.controller.report_error.assert_called_with(expected_error_message) + self.controller.exit_popup.assert_called() + + class TestHelpView: @pytest.fixture(autouse=True) def mock_external_classes(self, mocker: MockerFixture) -> None: diff --git a/zulipterminal/config/keys.py b/zulipterminal/config/keys.py index 27c9f35428..2b9460aa61 100644 --- a/zulipterminal/config/keys.py +++ b/zulipterminal/config/keys.py @@ -126,6 +126,11 @@ class KeyBinding(TypedDict): 'help_text': 'New message to a person or group of people', 'key_category': 'msg_actions', }, + 'FILE_UPLOAD': { + 'keys': ['ctrl o'], + 'help_text': 'Upload file', + 'key_category': 'msg_compose', + }, 'CYCLE_COMPOSE_FOCUS': { 'keys': ['tab'], 'help_text': 'Cycle through recipient and content boxes', diff --git a/zulipterminal/core.py b/zulipterminal/core.py index b72b1f870b..9168d6d2e3 100644 --- a/zulipterminal/core.py +++ b/zulipterminal/core.py @@ -29,12 +29,14 @@ from zulipterminal.model import Model from zulipterminal.platform_code import PLATFORM from zulipterminal.ui import Screen, View +from zulipterminal.ui_tools.boxes import WriteBox from zulipterminal.ui_tools.utils import create_msg_box_list from zulipterminal.ui_tools.views import ( AboutView, EditHistoryView, EditModeView, EmojiPickerView, + FileUploadView, FullRawMsgView, FullRenderedMsgView, HelpView, @@ -274,6 +276,10 @@ def show_msg_info( ) self.show_pop_up(msg_info_view, "area:msg") + def show_file_upload_popup(self, write_box: WriteBox) -> None: + file_upload_view = FileUploadView(self, write_box, "Upload File") + self.show_pop_up(file_upload_view, "area:msg") + def show_emoji_picker(self, message: Message) -> None: all_emoji_units = [ (emoji_name, emoji["code"], emoji["aliases"]) diff --git a/zulipterminal/model.py b/zulipterminal/model.py index ac4cd1dea0..f191ea6601 100644 --- a/zulipterminal/model.py +++ b/zulipterminal/model.py @@ -4,6 +4,7 @@ import itertools import json +import os import time from collections import defaultdict from concurrent.futures import Future, ThreadPoolExecutor, wait @@ -560,6 +561,17 @@ def send_stream_message(self, stream: str, topic: str, content: str) -> bool: notify_if_message_sent_outside_narrow(composition, self.controller) return message_was_sent + def get_file_upload_uri(self, file_location: str) -> Optional[str]: + if os.path.exists(file_location): + with open(file_location, "rb") as fp: + result = self.client.upload_file(fp) + if result["result"] == "success": + return result["uri"] + else: + return None + else: + return None + def update_private_message(self, msg_id: int, content: str) -> bool: request: PrivateMessageUpdateRequest = { "message_id": msg_id, diff --git a/zulipterminal/ui_tools/boxes.py b/zulipterminal/ui_tools/boxes.py index 36128f444a..d7a6b58f76 100644 --- a/zulipterminal/ui_tools/boxes.py +++ b/zulipterminal/ui_tools/boxes.py @@ -710,6 +710,14 @@ def autocomplete_emojis( return emoji_typeahead, emojis + def append_uri_and_filename(self, file_name: str, uri: str) -> None: + edit_widget = self.contents[self.FOCUS_CONTAINER_MESSAGE][ + self.FOCUS_MESSAGE_BOX_BODY + ] + edit_widget.edit_text += f"[{file_name}]({str(uri)})" + # Places the cursor after the URI + edit_widget.set_edit_pos(len(edit_widget.get_edit_text())) + def keypress(self, size: urwid_Size, key: str) -> Optional[str]: if self.is_in_typeahead_mode and not ( is_command_key("AUTOCOMPLETE", key) @@ -719,6 +727,9 @@ def keypress(self, size: urwid_Size, key: str) -> Optional[str]: self.is_in_typeahead_mode = False self.view.set_footer_text() + if is_command_key("FILE_UPLOAD", key): + self.model.controller.show_file_upload_popup(self) + if is_command_key("SEND_MESSAGE", key): self.send_stop_typing_status() if self.compose_box_status == "open_with_stream": diff --git a/zulipterminal/ui_tools/views.py b/zulipterminal/ui_tools/views.py index 04acd52b8f..d373776c69 100644 --- a/zulipterminal/ui_tools/views.py +++ b/zulipterminal/ui_tools/views.py @@ -4,6 +4,7 @@ import threading from datetime import datetime +from pathlib import Path from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Union import pytz @@ -43,7 +44,7 @@ match_user, ) from zulipterminal.server_url import near_message_url -from zulipterminal.ui_tools.boxes import PanelSearchBox +from zulipterminal.ui_tools.boxes import PanelSearchBox, WriteBox from zulipterminal.ui_tools.buttons import ( EmojiButton, HomeButton, @@ -1195,6 +1196,44 @@ def __init__(self, controller: Any, title: str) -> None: super().__init__(controller, widgets, "HELP", popup_width, title) +class FileUploadView(PopUpView): + def __init__( + self, + controller: Any, + write_box: WriteBox, + title: str, + ) -> None: + self.controller = controller + self.model = controller.model + self.write_box = write_box + max_cols, max_rows = controller.maximum_popup_dimensions() + self.predefined_text = urwid.Text("Location : ") + self.file_location_edit = urwid.Edit() + columns = [self.predefined_text, self.file_location_edit] + super().__init__( + controller, + columns, + "GO_BACK", + max_cols, + title, + ) + + def _handle_file_upload(self, file_location: str) -> None: + self.uri = self.model.get_file_upload_uri(file_location) + if self.uri: + file_path = Path(file_location) + file_name = file_path.name + self.write_box.append_uri_and_filename(file_name, self.uri) + else: + self.controller.report_error(["ERROR: Unable to get the URI"]) + self.controller.exit_popup() + + def keypress(self, size: urwid_Size, key: str) -> str: + if is_command_key("ENTER", key): + self._handle_file_upload(self.file_location_edit.edit_text) + return super().keypress(size, key) + + class MarkdownHelpView(PopUpView): def __init__(self, controller: Any, title: str) -> None: raw_menu_content = [] # to calculate table dimensions