Daniel Brötzmann pushed to branch master at gajim / gajim
Commits: a6037e25 by wurstsalat at 2022-11-24T22:23:04+00:00 change: Add FileTransferSelector as a central page for sending files
Fixes #9511
- - - - - 53d01bd6 by wurstsalat at 2022-11-24T23:33:00+01:00 refactor: Remove obsolete FileTransferSend dialog
- - - - -
17 changed files:
- gajim/common/events.py - gajim/common/modules/httpupload.py - gajim/data/gui/file_transfer_send.ui → gajim/data/gui/file_transfer_selector.ui - gajim/data/gui/message_actions_box.ui - gajim/data/style/gajim.css - gajim/gtk/builder.pyi - gajim/gtk/chat_function_page.py - gajim/gtk/chat_stack.py - gajim/gtk/const.py - gajim/gtk/control.py - + gajim/gtk/file_transfer_selector.py - − gajim/gtk/file_transfer_send.py - gajim/gtk/filetransfer.py - gajim/gtk/main.py - gajim/gtk/menus.py - gajim/gtk/message_actions_box.py - gajim/gui_interface.py
Changes:
===================================== gajim/common/events.py ===================================== @@ -44,9 +44,9 @@
if typing.TYPE_CHECKING: from gajim.common.client import Client + from gajim.common.modules.httpupload import HTTPFileTransfer from gajim.plugins.pluginmanager import PluginManifest
- ChatListEventT = Union[ 'MessageReceived', 'MamMessageReceived', @@ -237,6 +237,22 @@ class FileSendError(ApplicationEvent): error_msg: str = ''
+@dataclass +class HTTPUploadStarted(ApplicationEvent): + name: str = field(init=False, default='http-upload-started') + account: str + jid: JID + transfer: HTTPFileTransfer + + +@dataclass +class HTTPUploadError(ApplicationEvent): + name: str = field(init=False, default='http-upload-error') + account: str + jid: JID + error_msg: str + + @dataclass class AccountEnabled(ApplicationEvent): name: str = field(init=False, default='account-enabled')
===================================== gajim/common/modules/httpupload.py ===================================== @@ -25,6 +25,7 @@ from urllib.parse import urlparse import mimetypes from collections import defaultdict +from pathlib import Path
from nbxmpp.errors import StanzaError from nbxmpp.errors import MalformedStanzaError @@ -42,6 +43,8 @@
from gajim.common import app from gajim.common import types +from gajim.common.events import HTTPUploadError +from gajim.common.events import HTTPUploadStarted from gajim.common.i18n import _ from gajim.common.helpers import get_tls_error_phrase from gajim.common.helpers import get_account_proxy @@ -49,6 +52,7 @@ from gajim.common.filetransfer import FileTransfer from gajim.common.modules.base import BaseModule from gajim.common.exceptions import FileError +from gajim.common.structs import OutgoingMessage
class HTTPUpload(BaseModule): @@ -105,18 +109,44 @@ def get_running_transfers(self,
return self._running_transfers.get((contact.account, contact.jid))
- def make_transfer(self, - path: str, - encryption: Optional[str], - contact: types.ContactT, - groupchat: bool = False - ) -> HTTPFileTransfer: - if not path or not os.path.exists(path): + def send_file(self, contact: types.ChatContactT, path: Path) -> None: + encryption = contact.settings.get('encryption') or None + + try: + transfer = self._make_transfer( + path, + encryption, + contact) + except FileError as error: + event = HTTPUploadError( + contact.account, + contact.jid, + _('Could not open file (%s)') % str(error)) + app.ged.raise_event(event) + return + + transfer.connect('cancel', self._on_cancel_upload) + transfer.connect('state-changed', self._on_http_upload_state_changed) + + event = HTTPUploadStarted( + contact.account, + contact.jid, + transfer) + app.ged.raise_event(event) + self._start_transfer(transfer) + + def _make_transfer(self, + path: Path, + encryption: Optional[str], + contact: types.ChatContactT, + ) -> HTTPFileTransfer: + + if not path or not path.exists(): raise FileError(_('Could not access file'))
invalid_file = False msg = '' - stat = os.stat(path) + stat = path.stat()
if os.path.isfile(path): if stat[6] == 0: @@ -143,18 +173,43 @@ def make_transfer(self, self._log.info('Detected MIME type of file: %s', mime)
transfer = HTTPFileTransfer(self._account, - path, + str(path), contact, mime, encryption, - groupchat) + contact.is_groupchat)
key = (contact.account, contact.jid) self._running_transfers[key].add(transfer)
return transfer
- def cancel_transfer(self, transfer: HTTPFileTransfer) -> None: + def _on_http_upload_state_changed(self, + transfer: HTTPFileTransfer, + _signal_name: str, + state: FTState + ) -> None: + + if state.is_finished: + uri = transfer.get_transformed_uri() + + type_ = 'chat' + if transfer.is_groupchat: + type_ = 'groupchat' + + message = OutgoingMessage(account=transfer.account, + contact=transfer.contact, + message=uri, + type_=type_, + oob_url=uri) + + self._client.send_message(message) + + def _on_cancel_upload(self, + transfer: HTTPFileTransfer, + _signal_name: str + ) -> None: + transfer.set_cancelled()
key = (transfer.account, transfer.contact.jid) @@ -166,14 +221,14 @@ def cancel_transfer(self, transfer: HTTPFileTransfer) -> None:
self._session.cancel_message(message, Soup.Status.CANCELLED)
- def start_transfer(self, transfer: HTTPFileTransfer) -> None: + def _start_transfer(self, transfer: HTTPFileTransfer) -> None: if transfer.encryption is not None and not transfer.is_encrypted: transfer.set_encrypting() plugin = app.plugin_manager.encryption_plugins[transfer.encryption] if hasattr(plugin, 'encrypt_file'): plugin.encrypt_file(transfer, self._account, - self.start_transfer) + self._start_transfer) else: transfer.set_error('encryption-not-available')
===================================== gajim/data/gui/file_transfer_send.ui → gajim/data/gui/file_transfer_selector.ui ===================================== @@ -1,107 +1,85 @@ <?xml version="1.0" encoding="UTF-8"?> -<!-- Generated with glade 3.38.2 --> +<!-- Generated with glade 3.40.0 --> <interface> <requires lib="gtk+" version="3.24"/> - <object class="GtkStack" id="send_stack"> + <object class="GtkBox" id="file_box"> <property name="visible">True</property> <property name="can-focus">False</property> - <property name="transition-type">slide-left-right</property> + <property name="spacing">12</property> <child> - <object class="GtkBox"> + <object class="GtkBox" id="preview_image_box"> <property name="visible">True</property> <property name="can-focus">False</property> - <property name="border-width">18</property> + <property name="valign">center</property> <property name="orientation">vertical</property> - <property name="spacing">6</property> <child> - <object class="GtkLabel"> + <object class="GtkImage" id="preview_image"> <property name="visible">True</property> <property name="can-focus">False</property> - <property name="label" translatable="yes">Files to send</property> - <property name="xalign">0</property> + <property name="icon_size">6</property> <style> - <class name="dim-label"/> + <class name="preview-image"/> </style> </object> <packing> <property name="expand">False</property> <property name="fill">True</property> - <property name="position">1</property> + <property name="position">0</property> + </packing> + </child> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="position">0</property> + </packing> + </child> + <child> + <object class="GtkBox"> + <property name="visible">True</property> + <property name="can-focus">False</property> + <property name="orientation">vertical</property> + <property name="spacing">3</property> + <child> + <object class="GtkLabel" id="file_name_label"> + <property name="visible">True</property> + <property name="can-focus">False</property> + <property name="ellipsize">end</property> + <property name="xalign">0</property> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="position">0</property> </packing> </child> <child> <object class="GtkBox"> <property name="visible">True</property> <property name="can-focus">False</property> - <property name="hexpand">True</property> - <property name="vexpand">True</property> - <property name="orientation">vertical</property> + <property name="spacing">6</property> <child> - <object class="GtkScrolledWindow"> + <object class="GtkLabel" id="file_size_label"> <property name="visible">True</property> - <property name="can-focus">True</property> - <property name="hscrollbar-policy">never</property> - <property name="shadow-type">in</property> - <child> - <object class="GtkViewport"> - <property name="visible">True</property> - <property name="can-focus">False</property> - <child> - <object class="GtkListBox" id="listbox"> - <property name="visible">True</property> - <property name="can-focus">False</property> - <property name="vexpand">True</property> - <property name="selection-mode">multiple</property> - <property name="activate-on-single-click">False</property> - <signal name="add" handler="_on_files_changed" swapped="no"/> - <signal name="remove" handler="_on_files_changed" swapped="no"/> - </object> - </child> - </object> - </child> + <property name="can-focus">False</property> + <style> + <class name="dim-label"/> + </style> </object> <packing> - <property name="expand">True</property> + <property name="expand">False</property> <property name="fill">True</property> <property name="position">0</property> </packing> </child> <child> - <object class="GtkToolbar"> - <property name="visible">True</property> + <object class="GtkLabel" id="warning_label"> <property name="can-focus">False</property> - <property name="show-arrow">False</property> - <property name="icon_size">1</property> - <child> - <object class="GtkToolButton"> - <property name="visible">True</property> - <property name="can-focus">False</property> - <property name="tooltip-text" translatable="yes">Add Files</property> - <property name="use-underline">True</property> - <property name="icon-name">list-add-symbolic</property> - <signal name="clicked" handler="_select_files" swapped="no"/> - </object> - <packing> - <property name="expand">False</property> - <property name="homogeneous">False</property> - </packing> - </child> - <child> - <object class="GtkToolButton"> - <property name="visible">True</property> - <property name="can-focus">False</property> - <property name="tooltip-text" translatable="yes">Remove Files</property> - <property name="use-underline">True</property> - <property name="icon-name">list-remove-symbolic</property> - <signal name="clicked" handler="_remove_files" swapped="no"/> - </object> - <packing> - <property name="expand">False</property> - <property name="homogeneous">True</property> - </packing> - </child> + <property name="no-show-all">True</property> + <property name="ellipsize">end</property> + <property name="xalign">0</property> <style> - <class name="inline-toolbar"/> + <class name="warning-color"/> </style> </object> <packing> @@ -114,89 +92,143 @@ <packing> <property name="expand">False</property> <property name="fill">True</property> - <property name="position">2</property> + <property name="position">1</property> </packing> </child> + </object> + <packing> + <property name="expand">True</property> + <property name="fill">True</property> + <property name="position">1</property> + </packing> + </child> + <child> + <object class="GtkButton" id="remove_file_button"> + <property name="visible">True</property> + <property name="can-focus">True</property> + <property name="receives-default">True</property> + <property name="tooltip-text" translatable="yes">Remove</property> + <property name="valign">center</property> <child> - <object class="GtkLabel"> + <object class="GtkImage"> <property name="visible">True</property> <property name="can-focus">False</property> - <property name="label" translatable="yes">Description</property> - <property name="xalign">0</property> - <style> - <class name="dim-label"/> - </style> + <property name="icon-name">list-remove-symbolic</property> </object> - <packing> - <property name="expand">False</property> - <property name="fill">True</property> - <property name="position">3</property> - </packing> </child> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="position">2</property> + </packing> + </child> + </object> + <object class="GtkStack" id="stack"> + <property name="visible">True</property> + <property name="can-focus">False</property> + <property name="transition-type">slide-left-right</property> + <child> + <object class="GtkBox"> + <property name="visible">True</property> + <property name="can-focus">False</property> + <property name="orientation">vertical</property> + <property name="spacing">12</property> <child> <object class="GtkScrolledWindow"> - <property name="height-request">70</property> + <property name="width-request">500</property> + <property name="height-request">350</property> <property name="visible">True</property> <property name="can-focus">True</property> <property name="hscrollbar-policy">never</property> <property name="shadow-type">in</property> <child> - <object class="GtkTextView" id="description"> + <object class="GtkViewport"> <property name="visible">True</property> - <property name="can-focus">True</property> - <property name="wrap-mode">word-char</property> - <property name="left-margin">6</property> - <property name="right-margin">6</property> - <property name="top-margin">6</property> - <property name="bottom-margin">6</property> + <property name="can-focus">False</property> + <child> + <object class="GtkListBox" id="listbox"> + <property name="visible">True</property> + <property name="can-focus">False</property> + <property name="selection-mode">none</property> + <property name="activate-on-single-click">False</property> + <signal name="add" handler="_on_files_changed" swapped="no"/> + <signal name="remove" handler="_on_files_changed" swapped="no"/> + <child type="placeholder"> + <object class="GtkBox"> + <property name="visible">True</property> + <property name="can-focus">False</property> + <property name="halign">center</property> + <property name="valign">center</property> + <property name="orientation">vertical</property> + <property name="spacing">18</property> + <child> + <object class="GtkLabel"> + <property name="visible">True</property> + <property name="can-focus">False</property> + <property name="label" translatable="yes">Drop files here</property> + <style> + <class name="dim-label"/> + </style> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="position">0</property> + </packing> + </child> + <style> + <class name="dnd-area"/> + </style> + </object> + </child> + <style> + <class name="gajim-listbox"/> + </style> + </object> + </child> </object> </child> </object> <packing> - <property name="expand">False</property> + <property name="expand">True</property> <property name="fill">True</property> - <property name="position">4</property> + <property name="position">1</property> </packing> </child> <child> - <object class="GtkButton" id="files_send"> - <property name="label" translatable="yes">_Send Files</property> + <object class="GtkButton"> + <property name="label" translatable="yes">Add Files…</property> <property name="visible">True</property> - <property name="sensitive">False</property> <property name="can-focus">True</property> <property name="receives-default">True</property> <property name="halign">center</property> - <property name="use-underline">True</property> - <signal name="clicked" handler="_on_send_clicked" swapped="no"/> - <style> - <class name="suggested-action"/> - </style> + <signal name="clicked" handler="_on_choose_files_clicked" swapped="no"/> </object> <packing> <property name="expand">False</property> <property name="fill">True</property> - <property name="position">5</property> + <property name="position">2</property> </packing> </child> </object> <packing> - <property name="name">file_selection</property> + <property name="name">file-selection</property> </packing> </child> <child> <object class="GtkBox" id="resource_box"> <property name="visible">True</property> <property name="can-focus">False</property> - <property name="border-width">18</property> <property name="orientation">vertical</property> - <property name="spacing">6</property> + <property name="spacing">18</property> <child> <object class="GtkLabel"> <property name="visible">True</property> <property name="can-focus">False</property> <property name="label" translatable="yes">Choose Device</property> <style> - <class name="large-header"/> + <class name="bold16"/> </style> </object> <packing> @@ -205,27 +237,6 @@ <property name="position">0</property> </packing> </child> - <child> - <object class="GtkButton" id="resource_send"> - <property name="label" translatable="yes">_Send Files</property> - <property name="visible">True</property> - <property name="sensitive">False</property> - <property name="can-focus">True</property> - <property name="receives-default">False</property> - <property name="halign">center</property> - <property name="use-underline">True</property> - <signal name="clicked" handler="_on_send_to_resource" swapped="no"/> - <style> - <class name="suggested-action"/> - </style> - </object> - <packing> - <property name="expand">False</property> - <property name="fill">True</property> - <property name="pack-type">end</property> - <property name="position">1</property> - </packing> - </child> <child> <object class="GtkLabel" id="resource_instructions"> <property name="visible">True</property> @@ -240,7 +251,7 @@ <packing> <property name="expand">False</property> <property name="fill">True</property> - <property name="position">2</property> + <property name="position">1</property> </packing> </child> <child> @@ -248,7 +259,7 @@ </child> </object> <packing> - <property name="name">resource_selection</property> + <property name="name">resource-selection</property> <property name="position">1</property> </packing> </child>
===================================== gajim/data/gui/message_actions_box.ui ===================================== @@ -117,6 +117,7 @@ <property name="focus-on-click">False</property> <property name="receives-default">True</property> <property name="action-name">win.send-file</property> + <property name="action-target">['']</property> <property name="relief">none</property> <child> <object class="GtkImage">
===================================== gajim/data/style/gajim.css ===================================== @@ -920,9 +920,6 @@ #FeaturesInfoGrid > list > row { padding: 10px 20px 10px 10px; } #FeaturesInfoGrid > list > label { padding:10px; color: @insensitive_fg_color; font-weight: bold; } #FeaturesInfoGrid > list > row.activatable:active { box-shadow: none; }
-/* SendFileDialog */ -#SendFileDialog grid list > row { padding: 10px 20px 10px 10px; } - /* AccountsWindow */ .accounts-menu { border-right: 1px solid @borders;
===================================== gajim/gtk/builder.pyi ===================================== @@ -326,13 +326,17 @@ class FileTransferJingleBuilder(Builder): progress_bar: Gtk.ProgressBar
-class FileTransferSendBuilder(Builder): - send_stack: Gtk.Stack +class FileTransferSelectorBuilder(Builder): + file_box: Gtk.Box + preview_image_box: Gtk.Box + preview_image: Gtk.Image + file_name_label: Gtk.Label + file_size_label: Gtk.Label + warning_label: Gtk.Label + remove_file_button: Gtk.Button + stack: Gtk.Stack listbox: Gtk.ListBox - description: Gtk.TextView - files_send: Gtk.Button resource_box: Gtk.Box - resource_send: Gtk.Button resource_instructions: Gtk.Label
@@ -964,7 +968,7 @@ def get_builder(file_name: Literal['file_transfer.ui'], widgets: list[str] = ... @overload def get_builder(file_name: Literal['file_transfer_jingle.ui'], widgets: list[str] = ...) -> FileTransferJingleBuilder: ... @overload -def get_builder(file_name: Literal['file_transfer_send.ui'], widgets: list[str] = ...) -> FileTransferSendBuilder: ... +def get_builder(file_name: Literal['file_transfer_selector.ui'], widgets: list[str] = ...) -> FileTransferSelectorBuilder: ... @overload def get_builder(file_name: Literal['filetransfers.ui'], widgets: list[str] = ...) -> FiletransfersBuilder: ... @overload
===================================== gajim/gtk/chat_function_page.py ===================================== @@ -17,6 +17,7 @@ from typing import Optional
from enum import Enum +from pathlib import Path
from gi.repository import GObject from gi.repository import Gtk @@ -33,11 +34,13 @@ from gajim.common.modules.contacts import GroupchatContact
from .dataform import DataFormWidget +from .file_transfer_selector import FileTransferSelector from .groupchat_inviter import GroupChatInviter
class FunctionMode(Enum): INVITE = 'invite' + SEND_FILE = 'send-file' CHANGE_NICKNAME = 'change-nickname' KICK = 'kick' BAN = 'ban' @@ -135,7 +138,8 @@ def _reset(self) -> None: def set_mode(self, contact: types.ChatContactT, mode: FunctionMode, - data: Optional[str] = None + data: Optional[str] = None, + files: Optional[list[str]] = None ) -> None:
self._reset() @@ -159,6 +163,15 @@ def set_mode(self, self._widget.connect('listbox-changed', self._on_ready) self._widget.load_contacts()
+ elif mode == FunctionMode.SEND_FILE: + self._confirm_button.set_label(_('Send Files')) + self._confirm_button.get_style_context().add_class( + 'suggested-action') + self._widget = FileTransferSelector(self._contact, data) + self._widget.connect('changed', self._on_ready) + if files is not None: + self._widget.add_files(files) + elif mode == FunctionMode.CHANGE_NICKNAME: self._confirm_button.set_label(_('Change')) self._confirm_button.get_style_context().add_class( @@ -264,6 +277,13 @@ def _on_confirm_clicked(self, _button: Gtk.Button) -> None: for jid in invitees: self._invite(JID.from_string(jid))
+ elif self._mode == FunctionMode.SEND_FILE: + assert isinstance(self._widget, FileTransferSelector) + if self._widget.transfer_resource_required(): + return + + self._send_files(self._widget.get_catalog()) + elif self._mode == FunctionMode.CHANGE_NICKNAME: assert isinstance(self._widget, InputWidget) nickname = self._widget.get_text() @@ -347,6 +367,24 @@ def _invite(self, invited_jid: JID) -> None: 'message', _('%s has been invited to this group chat') % invited_contact.name)
+ def _send_files(self, catalog: list[tuple[Path, str, JID]]) -> None: + assert self._contact is not None + client = app.get_client(self._contact.account) + + # catalog: list[(file Path, transfer method, recipient JID)] + for path, method, jid in catalog: + if method == 'httpupload': + client.get_module('HTTPUpload').send_file( + self._contact, path) + continue + + # Send using Jingle + app.interface.instances['file_transfers'].send_file( + self._contact.account, + self._contact, + jid, + str(path)) +
class InputWidget(Gtk.Box):
===================================== gajim/gtk/chat_stack.py ===================================== @@ -564,16 +564,19 @@ def _on_action(self, app.call_manager.start_call(account, jid, CallType.VIDEO)
elif action_name.startswith('send-file'): + method = None name = action.get_name() if 'httpupload' in name: - app.interface.start_file_transfer(contact, method='httpupload') - return + method = 'httpupload'
if 'jingle' in name: - app.interface.start_file_transfer(contact, method='jingle') - return + method = 'jingle'
- app.interface.start_file_transfer(contact) + file_paths = None + if param is not None: + file_paths = param.get_strv() or None + self._show_chat_function_page( + FunctionMode.SEND_FILE, method, file_paths)
elif action_name == 'add-to-roster': if (isinstance(contact, GroupchatParticipant) and @@ -676,12 +679,13 @@ def _on_drag_motion(self,
def _show_chat_function_page(self, function_mode: FunctionMode, - data: Optional[str] = None + data: Optional[str] = None, + files: Optional[list[str]] = None ) -> None:
assert self._current_contact is not None self._chat_function_page.set_mode( - self._current_contact, function_mode, data) + self._current_contact, function_mode, data, files) self.set_transition_type(Gtk.StackTransitionType.SLIDE_UP_DOWN) self.set_visible_child_name('function')
===================================== gajim/gtk/const.py ===================================== @@ -198,9 +198,9 @@ def __str__(self): ('retract-message', 'a{sv}', False), ('quote', 's', False), ('mention', 's', False), - ('send-file-httpupload', None, False), - ('send-file-jingle', None, False), - ('send-file', None, False), + ('send-file-httpupload', 'as', False), + ('send-file-jingle', 'as', False), + ('send-file', 'as', False), ('add-to-roster', None, True), ('start-voice-call', None, False), ('start-video-call', None, False),
===================================== gajim/gtk/control.py ===================================== @@ -233,6 +233,8 @@ def _register_events(self) -> None: ged.GUI2, self._on_jingle_request_received), ('file-request-received', ged.GUI2, self._on_file_request_event), ('file-request-sent', ged.GUI2, self._on_file_request_event), + ('http-upload-started', ged.GUI2, self._on_http_upload_started), + ('http-upload-error', ged.GUI2, self._on_http_upload_error), ])
def _is_event_processable(self, event: Any) -> bool: @@ -440,6 +442,12 @@ def _on_file_request_event(self,
self.add_jingle_file_transfer(event=event)
+ def _on_http_upload_started(self, event: events.HTTPUploadStarted) -> None: + self.add_file_transfer(event.transfer) + + def _on_http_upload_error(self, event: events.HTTPUploadError) -> None: + self.add_info_message(event.error_msg) + @property def is_chat(self) -> bool: return isinstance(self.contact, BareContact) @@ -488,22 +496,18 @@ def _on_jump_to_end(self, _button: Gtk.Button) -> None: self.reset_view()
def drag_data_file_transfer(self, selection: Gtk.SelectionData) -> None: - # we may have more than one file dropped - uri_splitted = selection.get_uris() - for uri in uri_splitted: + uris = selection.get_uris() + paths: list[str] = [] + for uri in uris: path = get_file_path_from_dnd_dropped_uri(uri) - if not path: - self.add_info_message( - _('The following address was not understood and was ' - 'not uploaded: %s') % uri) - continue - if not path.is_file(): # is it a regular file? + if path is None or not path.is_file(): self.add_info_message( - _('The following file could not be accessed and was ' - 'not uploaded: %s') % path) + _('You dropped something Gajim could not ' + 'process: %s' % path)) continue + paths.append(str(path))
- app.interface.start_file_transfer(self.contact, str(path)) + app.window.activate_action('send-file', GLib.Variant('as', paths))
def get_our_nick(self) -> str: if isinstance(self.contact, GroupchatParticipant):
===================================== gajim/gtk/file_transfer_selector.py ===================================== @@ -0,0 +1,313 @@ +# This file is part of Gajim. +# +# Gajim is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published +# by the Free Software Foundation; version 3 only. +# +# Gajim is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Gajim. If not, see http://www.gnu.org/licenses/. + +from __future__ import annotations + +from typing import Any +from typing import cast +from typing import Optional +from typing import Union + +import logging +from pathlib import Path + +from gi.repository import Gdk +from gi.repository import GLib +from gi.repository import GObject +from gi.repository import Gtk + +from nbxmpp.namespaces import Namespace +from nbxmpp.protocol import JID + +from gajim.common import app +from gajim.common import types +from gajim.common.helpers import get_file_path_from_dnd_dropped_uri +from gajim.common.helpers import load_file_async +from gajim.common.i18n import _ +from gajim.common.modules.contacts import BareContact +from gajim.common.preview import PREVIEWABLE_MIME_TYPES +from gajim.common.preview_helpers import create_thumbnail +from gajim.common.preview_helpers import guess_mime_type +from gajim.common.preview_helpers import get_icon_for_mime_type +from gajim.common.preview_helpers import pixbuf_from_data + +from .builder import get_builder +from .filechoosers import FileChooserDialog +from .resource_selector import ResourceSelector + +PREVIEW_SIZE = 72 + +log = logging.getLogger('gajim.gui.file_transfer_selector') + + +class FileTransferSelector(Gtk.Box): + + __gsignals__ = { + 'changed': (GObject.SignalFlags.RUN_LAST, None, (bool,)), + } + + def __init__(self, + contact: types.ChatContactT, + method: Optional[str] = None + ) -> None: + + Gtk.Box.__init__(self) + + self._contact = contact + self._method = method or app.window.get_preferred_ft_method(contact) + + client = app.get_client(contact.account) + self._max_file_size = client.get_module('HTTPUpload').max_file_size or 0 + + self._ui = get_builder('file_transfer_selector.ui') + self.add(self._ui.stack) + + self.connect('destroy', self._on_destroy) + self._ui.connect_signals(self) + + self._resource_selector = None + if isinstance(contact, BareContact): + # No jingle file transfers in group chats + self._resource_selector = ResourceSelector( + contact, + constraints=[Namespace.JINGLE_FILE_TRANSFER_5]) + self._resource_selector.connect( + 'selection-changed', self._on_resource_selection) + self._ui.resource_box.pack_start( + self._resource_selector, True, False, 0) + + self._ui.resource_instructions.set_text( + _('%s is online with multiple devices.\n' + 'Choose the device you would like to send the ' + 'files to.') % self._contact.name) + + uri_entry = Gtk.TargetEntry.new( + 'text/uri-list', Gtk.TargetFlags.OTHER_APP, 80) + dst_targets = Gtk.TargetList.new([uri_entry]) + + self._ui.listbox.drag_dest_set( + Gtk.DestDefaults.ALL, + [uri_entry], + Gdk.DragAction.COPY | Gdk.DragAction.MOVE) + self._ui.listbox.drag_dest_set_target_list(dst_targets) + self._ui.listbox.connect( + 'drag-data-received', self._on_drag_data_received) + + self.show_all() + + def transfer_resource_required(self) -> bool: + if not isinstance(self._contact, BareContact): + # No jingle file transfer for group chats + return False + + if self._method is None: + log.error('No file transfer method available') + return False + + current_page = self._ui.stack.get_visible_child_name() + if self._method == 'httpupload': + for path in self._get_file_paths(): + if path.stat().st_size > self._max_file_size: + if current_page == 'resource-selection': + return False + + self._ui.stack.set_visible_child_name('resource-selection') + self.emit('changed', False) + return True + return False + + if current_page == 'resource-selection': + return False + + self._ui.stack.set_visible_child_name('resource-selection') + self.emit('changed', False) + return True + + def get_catalog(self) -> list[tuple[Path, str, JID]]: + # catalog: list[(file Path, transfer method, recipient JID)] + catalog: list[tuple[Path, str, JID]] = [] + + file_paths = self._get_file_paths() + for path in file_paths: + if (self._method == 'jingle' or + path.stat().st_size > self._max_file_size): + assert self._resource_selector is not None + item = (path, 'jingle', self._resource_selector.get_jid()) + elif self._method == 'httpupload': + item = (path, 'httpupload', self._contact.jid) + else: + # No file transfer method available + continue + + catalog.append(item) + + return catalog + + def add_files(self, file_paths: Union[list[Path], list[str]]) -> None: + for path in file_paths: + if isinstance(path, str): + path = Path(path) + + if path.is_dir(): + continue + + size_warning = bool(self._method == 'httpupload' and + path.stat().st_size > self._max_file_size) + jingle_warning = bool(self._method == 'jingle') + + row = FileRow(path, size_warning, jingle_warning) + self._ui.listbox.add(row) + app.settings.set('last_send_dir', str(path)) + + self._ui.listbox.show_all() + + def _on_destroy(self, _widget: FileTransferSelector) -> None: + app.check_finalize(self) + + def _get_file_paths(self) -> list[Path]: + paths: list[Path] = [] + for row in cast(list[FileRow], self._ui.listbox.get_children()): + paths.append(row.file_path) + + return paths + + def _on_drag_data_received(self, + _widget: Gtk.Widget, + _context: Gdk.DragContext, + _x_coord: int, + _y_coord: int, + selection: Gtk.SelectionData, + target_type: int, + _timestamp: int + ) -> None: + if not selection.get_data(): + return + + if target_type == 80: + uris = selection.get_uris() + paths: list[Path] = [] + for uri in uris: + path = get_file_path_from_dnd_dropped_uri(uri) + if path is None or not path.is_file(): + log.warning('Could not add file %s', str(path)) + continue + paths.append(path) + + self.add_files(paths) + + def _on_files_changed(self, _listbox: Gtk.ListBox, _row: FileRow) -> None: + file_paths = self._get_file_paths() + if not file_paths: + self.emit('changed', False) + return + + if self._method == 'httpupload' and self._contact.is_groupchat: + # Enforce HTTPUpload file size limit for group chats + for path in file_paths: + if path.stat().st_size > self._max_file_size: + self.emit('changed', False) + return + + self.emit('changed', True) + + def _on_resource_selection(self, + _selector: ResourceSelector, + state: bool + ) -> None: + + self.emit('changed', state) + + def _on_choose_files_clicked(self, _button: Gtk.Button) -> None: + FileChooserDialog(self.add_files, + select_multiple=True, + transient_for=app.window, + path=app.settings.get('last_send_dir')) + + +class FileRow(Gtk.ListBoxRow): + def __init__(self, + path: Path, + size_warning: bool, + jingle_warning: bool + ) -> None: + + Gtk.ListBoxRow.__init__(self) + self.file_path = path + + self._ui = get_builder('file_transfer_selector.ui') + self.add(self._ui.file_box) + + self._ui.remove_file_button.connect('clicked', self._on_remove_clicked) + + self._ui.file_name_label.set_text(path.name) + + if app.settings.get('use_kib_mib'): + units = GLib.FormatSizeFlags.IEC_UNITS + else: + units = GLib.FormatSizeFlags.DEFAULT + + file_size_string = GLib.format_size_full(path.stat().st_size, units) + self._ui.file_size_label.set_text(file_size_string) + + if size_warning: + self._ui.warning_label.set_text( + _('File too big, will use direct transfer (not encrypted)')) + self._ui.warning_label.show() + + if jingle_warning: + self._ui.warning_label.set_text( + _('Direct transfer (not encrypted)')) + self._ui.warning_label.show() + + self._ui.preview_image_box.set_size_request(PREVIEW_SIZE, -1) + self._set_preview(path) + + def _set_preview(self, path: Path) -> None: + mime_type = guess_mime_type(path) + if mime_type not in PREVIEWABLE_MIME_TYPES: + self._set_icon_for_mime_type(mime_type) + return + + load_file_async(self.file_path, self._on_load_finished, mime_type) + + def _on_load_finished(self, + data: Optional[bytes], + _error: Optional[GLib.Error], + mime_type: Any + ) -> None: + + if data is None: + self._set_icon_for_mime_type(mime_type) + return + + preview_bytes = create_thumbnail(data, PREVIEW_SIZE, mime_type) + if preview_bytes is None: + self._set_icon_for_mime_type(mime_type) + return + + pixbuf = pixbuf_from_data(preview_bytes) + if pixbuf is None: + self._set_icon_for_mime_type(mime_type) + return + + self._ui.preview_image.set_from_pixbuf(pixbuf) + + def _set_icon_for_mime_type(self, mime_type: str) -> None: + icon = get_icon_for_mime_type(mime_type) + self._ui.preview_image.set_from_gicon(icon, Gtk.IconSize.DIALOG) + + def _on_remove_clicked(self, _button: Gtk.Button) -> None: + listbox = cast(Gtk.ListBox, self.get_parent()) + listbox.remove(self) + self.destroy()
===================================== gajim/gtk/file_transfer_send.py deleted ===================================== @@ -1,157 +0,0 @@ -# Copyright (C) 2003-2014 Yann Leboulanger <asterix AT lagaule.org> -# Copyright (C) 2005-2006 Dimitur Kirov <dkirov AT gmail.com> -# Copyright (C) 2005-2007 Nikos Kouremenos <kourem AT gmail.com> -# Copyright (C) 2006 Travis Shirk <travis AT pobox.com> -# -# This file is part of Gajim. -# -# Gajim is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published -# by the Free Software Foundation; version 3 only. -# -# Gajim is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with Gajim. If not, see http://www.gnu.org/licenses/. - -from __future__ import annotations - -from typing import Callable -from typing import cast -from typing import Optional - -from pathlib import Path - -from gi.repository import Gdk -from gi.repository import Gtk -from gi.repository import Pango - -from nbxmpp.namespaces import Namespace -from nbxmpp.protocol import JID - -from gajim.common import app -from gajim.common.i18n import _ -from gajim.common.modules.contacts import BareContact - -from .filechoosers import FileChooserDialog -from .resource_selector import ResourceSelector -from .builder import get_builder - - -class SendFileDialog(Gtk.ApplicationWindow): - def __init__(self, - contact: BareContact, - send_callback: Callable[..., bool], - transient_for: Gtk.Window, - files: Optional[list[str]] = None - ) -> None: - Gtk.ApplicationWindow.__init__(self) - self.set_application(app.app) - self.set_show_menubar(False) - self.set_resizable(True) - self.set_default_size(500, 350) - self.set_type_hint(Gdk.WindowTypeHint.DIALOG) - self.set_transient_for(transient_for) - self.set_title(_('Choose a File to Send…')) - self.set_destroy_with_parent(True) - - self._contact = contact - self._send_callback = send_callback - - self._ui = get_builder('file_transfer_send.ui') - self.add(self._ui.send_stack) - - self._ui.resource_instructions.set_text( - _('%s is online with multiple devices.\n' - 'Choose the device you would like to send the ' - 'file to.') % self._contact.name) - - self._resource_selector = ResourceSelector( - contact, - constraints=[Namespace.JINGLE_FILE_TRANSFER_5]) - self._resource_selector.connect( - 'selection-changed', self._on_resource_selection) - self._ui.resource_box.pack_start( - self._resource_selector, True, False, 0) - - if files is not None: - self.set_files(files) - - self.connect('key-press-event', self._on_key_press) - self._ui.connect_signals(self) - self.show_all() - - def _on_files_changed(self, _listbox: Gtk.ListBox, _row: FileRow) -> None: - sensitive = bool(len(self._ui.listbox.get_children()) > 0) - self._ui.files_send.set_sensitive(sensitive) - - def _on_resource_selection(self, - _selector: ResourceSelector, - state: bool - ) -> None: - self._ui.resource_send.set_sensitive(state) - - def _on_send_to_resource(self, _button: Gtk.Button) -> None: - resource_jid = self._resource_selector.get_jid() - self._send_files(resource_jid) - - def _on_send_clicked(self, _button: Gtk.Button) -> None: - count = len(self._contact.get_resources()) - if count == 0 or count > 1: - self._ui.send_stack.set_visible_child_name('resource_selection') - return - - resource_jid = self._contact.get_resources()[0].jid - self._send_files(resource_jid) - - def _send_files(self, resource_jid: JID) -> None: - files = cast(list[FileRow], self._ui.listbox.get_children()) - for file in files: - self._send_callback( - resource_jid, str(file.file_path), self._get_description()) - self.destroy() - - def _select_files(self, _button: Gtk.Button) -> None: - FileChooserDialog(self.set_files, - select_multiple=True, - transient_for=self, - path=app.settings.get('last_send_dir')) - - def _remove_files(self, _button: Gtk.Button) -> None: - selected = self._ui.listbox.get_selected_rows() - for item in selected: - self._ui.listbox.remove(item) - - def set_files(self, file_names: list[str]) -> None: - last_dir = '' - for file in file_names: - row = FileRow(file) - if row.file_path.is_dir(): - continue - last_dir = row.file_path.parent - self._ui.listbox.add(row) - self._ui.listbox.show_all() - app.settings.set('last_send_dir', str(last_dir)) - self._ui.files_send.set_sensitive(True) - - def _get_description(self) -> str: - buffer_ = self._ui.description.get_buffer() - start, end = buffer_.get_bounds() - return buffer_.get_text(start, end, False) - - def _on_key_press(self, _widget: Gtk.Widget, event: Gdk.EventKey) -> None: - if event.keyval == Gdk.KEY_Escape: - self.destroy() - - -class FileRow(Gtk.ListBoxRow): - def __init__(self, path: str) -> None: - Gtk.ListBoxRow.__init__(self) - self.file_path: Path = Path(path) - label = Gtk.Label(label=self.file_path.name) - label.set_ellipsize(Pango.EllipsizeMode.END) - label.set_xalign(0) - self.add(label)
===================================== gajim/gtk/filetransfer.py ===================================== @@ -60,7 +60,6 @@ from .dialogs import ConfirmationDialog from .dialogs import ErrorDialog from .filechoosers import FileSaveDialog -from .file_transfer_send import SendFileDialog from .tooltips import FileTransfersTooltip from .builder import get_builder from .util import format_eta @@ -405,16 +404,9 @@ def _on_yes(): text=_('_Download Again'), callback=_on_yes)]).show()
- def show_file_send_request(self, - account: str, - contact: types.BareContact - ) -> None: - send_callback = partial(self.send_file, account, contact) - SendFileDialog(contact, send_callback, self.window) - def send_file(self, account: str, - contact: types.BareContact, + contact: types.ResourceContact, resource_jid: JID, file_path: str, file_desc: str = ''
===================================== gajim/gtk/main.py ===================================== @@ -39,6 +39,7 @@ from gajim.common.i18n import _ from gajim.common.helpers import play_sound from gajim.common.modules.bytestream import is_transfer_active +from gajim.common.modules.contacts import GroupchatContact from gajim.plugins.pluginmanager import PluginManifest from gajim.plugins.repository import PluginRepository
@@ -984,6 +985,33 @@ def _on_window_active(self, if control.get_autoscroll(): self.mark_as_read(control.contact.account, control.contact.jid)
+ def get_preferred_ft_method(self, + contact: types.ChatContactT + ) -> Optional[str]: + + ft_pref = app.settings.get_account_setting( + contact.account, + 'filetransfer_preference') + httpupload_enabled = app.window.get_action_enabled( + 'send-file-httpupload') + jingle_enabled = app.window.get_action_enabled('send-file-jingle') + + if isinstance(contact, GroupchatContact): + if httpupload_enabled: + return 'httpupload' + return None + + if httpupload_enabled and jingle_enabled: + return ft_pref + + if httpupload_enabled: + return 'httpupload' + + if jingle_enabled: + return 'jingle' + + return None + def show_add_join_groupchat(self, account: str, jid: str,
===================================== gajim/gtk/menus.py ===================================== @@ -103,7 +103,7 @@ def get_singlechat_menu(contact: types.BareContact) -> GajimMenu: def get_private_chat_menu(contact: types.GroupchatParticipant) -> GajimMenu: menu = GajimMenu() menu.add_item(_('Details'), 'win.show-contact-info') - menu.add_item(_('Upload File…'), 'win.send-file-httpupload') + menu.add_item(_('Upload File…'), 'win.send-file-httpupload([""])') menu.add_item(_('Search…'), 'win.search-history')
if can_add_to_roster(contact): @@ -116,8 +116,8 @@ def get_private_chat_menu(contact: types.GroupchatParticipant) -> GajimMenu: def get_send_file_submenu() -> GajimMenu: menu = GajimMenu()
- menu.add_item(_('Upload File…'), 'win.send-file-httpupload') - menu.add_item(_('Send File Directly…'), 'win.send-file-jingle') + menu.add_item(_('Upload File…'), 'win.send-file-httpupload([""])') + menu.add_item(_('Send File Directly…'), 'win.send-file-jingle([""])') return menu
===================================== gajim/gtk/message_actions_box.py ===================================== @@ -25,7 +25,6 @@
from gi.repository import GLib from gi.repository import Gdk -from gi.repository import GdkPixbuf from gi.repository import Gio from gi.repository import Gtk from gi.repository import GObject @@ -45,9 +44,7 @@ from gajim.gui.security_label_selector import SecurityLabelSelector
from .builder import get_builder -from .dialogs import DialogButton from .dialogs import ErrorDialog -from .dialogs import PastePreviewDialog from .emoji_chooser import emoji_chooser from .menus import get_encryption_menu from .menus import get_format_menu @@ -577,32 +574,16 @@ def _on_paste_clipboard(self, clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD) image = clipboard.wait_for_image() if image is None: + log.info('No image pasted') return
- PastePreviewDialog( - _('Paste Image'), - _('You are trying to paste an image'), - _('Are you sure you want to paste your ' - "clipboard's image into the chat window?"), - _('_Do not ask me again'), - image, - [DialogButton.make('Cancel'), - DialogButton.make('Accept', - text=_('_Paste'), - callback=self._paste_event_confirmed, - args=[image])]).show() - - def _paste_event_confirmed(self, - image: GdkPixbuf.Pixbuf - ) -> None: - - dir_ = tempfile.gettempdir() - path = os.path.join(dir_, f'{uuid.uuid4()}.png') + temp_dir = tempfile.gettempdir() + image_path = os.path.join(temp_dir, f'{uuid.uuid4()}.png') + + image.savev(image_path, 'png', [], []) if image is None: log.error('Could not process pasted image') return
- image.savev(path, 'png', [], []) - - assert self._contact is not None - app.interface.start_file_transfer(self._contact, path) + app.window.activate_action( + 'send-file', GLib.Variant('as', [image_path]))
===================================== gajim/gui_interface.py ===================================== @@ -39,7 +39,6 @@ import sys import time import logging -from functools import partial from threading import Thread
from gi.repository import Gtk @@ -50,30 +49,16 @@ from nbxmpp import JID
from gajim.common import app -from gajim.common import exceptions from gajim.common import proxy65_manager from gajim.common import socks5 - from gajim.common.events import FileCompleted from gajim.common.events import FileHashError from gajim.common.events import FileProgress from gajim.common.events import FileError -from gajim.common.modules.contacts import BareContact -from gajim.common.modules.contacts import GroupchatContact -from gajim.common.structs import OutgoingMessage -from gajim.common.i18n import _ -from gajim.common.const import FTState from gajim.common.file_props import FileProp -from gajim.common.types import ChatContactT - -from gajim.common.modules.httpupload import HTTPFileTransfer
from gajim.gui.dialogs import ErrorDialog -from gajim.gui.filechoosers import FileChooserDialog -from gajim.gui.file_transfer_send import SendFileDialog from gajim.gui.filetransfer import FileTransfersWindow -from gajim.gui.control import ChatControl -
log = logging.getLogger('gajim.interface')
@@ -202,130 +187,6 @@ def __compare_hashes(account: str, file_props: FileProp) -> None: if session: session.end_session()
- def send_httpupload(self, - chat_control: ChatControl, - path: Optional[str] = None - ) -> None: - if path is not None: - self._send_httpupload(chat_control, path) - return - - accept_cb = partial(self._on_file_dialog_ok, chat_control) - FileChooserDialog(accept_cb, - select_multiple=True, - transient_for=app.window) - - def _on_file_dialog_ok(self, - chat_control: ChatControl, - paths: list[str] - ) -> None: - for path in paths: - self._send_httpupload(chat_control, path) - - def _send_httpupload(self, chat_control: ChatControl, path: str) -> None: - client = app.get_client(chat_control.contact.account) - encryption = chat_control.contact.settings.get('encryption') or None - try: - transfer = client.get_module('HTTPUpload').make_transfer( - path, - encryption, - chat_control.contact, - chat_control.is_groupchat) - except exceptions.FileError as error: - ErrorDialog(_('Could not Open File'), str(error)) - return - - transfer.connect('cancel', self._on_cancel_upload) - transfer.connect('state-changed', - self._on_http_upload_state_changed) - chat_control.add_file_transfer(transfer) - client.get_module('HTTPUpload').start_transfer(transfer) - - def _on_http_upload_state_changed(self, - transfer: HTTPFileTransfer, - _signal_name: str, - state: FTState - ) -> None: - # Note: This has to be a bound method in order to connect the signal - if state.is_finished: - uri = transfer.get_transformed_uri() - - type_ = 'chat' - if transfer.is_groupchat: - type_ = 'groupchat' - - message = OutgoingMessage(account=transfer.account, - contact=transfer.contact, - message=uri, - type_=type_, - oob_url=uri) - - client = app.get_client(transfer.account) - client.send_message(message) - - def _on_cancel_upload(self, - transfer: HTTPFileTransfer, - _signal_name: str - ) -> None: - # Note: This has to be a bound method in order to connect the signal - client = app.get_client(transfer.account) - client.get_module('HTTPUpload').cancel_transfer(transfer) - - def _get_pref_ft_method(self, contact: ChatContactT) -> Optional[str]: - ft_pref = app.settings.get_account_setting(contact.account, - 'filetransfer_preference') - httpupload = app.window.get_action_enabled('send-file-httpupload') - jingle = app.window.get_action_enabled('send-file-jingle') - - if isinstance(contact, GroupchatContact): - if httpupload: - return 'httpupload' - return None - - if httpupload and jingle: - return ft_pref - - if httpupload: - return 'httpupload' - - if jingle: - return 'jingle' - return None - - def start_file_transfer(self, - contact: ChatContactT, - path: Optional[str] = None, - method: Optional[str] = None - ) -> None: - - if method is None: - method = self._get_pref_ft_method(contact) - if method is None: - return - - control = app.window.get_control() - if not control.has_active_chat(): - return - - if path is None: - if method == 'httpupload': - self.send_httpupload(control) - return - if method == 'jingle': - self.instances['file_transfers'].show_file_send_request( - contact.account, contact) - return - - if method == 'httpupload': - self.send_httpupload(control, path) - else: - assert isinstance(contact, BareContact) - send_callback = partial( - self.instances['file_transfers'].send_file, - contact.account, - contact) - SendFileDialog(contact, send_callback, app.window, [path]) - def process_connections(self) -> bool: ''' Called each foo (200) milliseconds. Check for idlequeue timeouts
View it on GitLab: https://dev.gajim.org/gajim/gajim/-/compare/01fd1b7209f7608207bb2f18261f24f6...