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