Commit a435f3aa authored by Dan Pascu's avatar Dan Pascu

Added support for drag and drop on the chat window

  - text will be sent as if it was copy/pasted
  - images will be sent inlined
  - other files will initiate a file transfer
parent dc3e1fef
...@@ -9,14 +9,15 @@ import os ...@@ -9,14 +9,15 @@ import os
import re import re
from PyQt4 import uic from PyQt4 import uic
from PyQt4.QtCore import Qt, QEasingCurve, QEvent, QPoint, QPointF, QPropertyAnimation, QRect, QRectF, QSettings, QSize, QSizeF, QTimer, QUrl, pyqtSignal from PyQt4.QtCore import Qt, QBuffer, QEasingCurve, QEvent, QPoint, QPointF, QPropertyAnimation, QRect, QRectF, QSettings, QSize, QSizeF, QTimer, QUrl, pyqtSignal
from PyQt4.QtGui import QAction, QBrush, QColor, QIcon, QLabel, QLinearGradient, QListView, QMenu, QPainter, QPalette, QPen, QPixmap, QPolygonF, QTextCursor, QTextDocument, QTextEdit, QToolButton from PyQt4.QtGui import QAction, QBrush, QColor, QIcon, QLabel, QLinearGradient, QListView, QMenu, QPainter, QPalette, QPen, QPixmap, QPolygonF, QTextCursor, QTextDocument, QTextEdit, QToolButton
from PyQt4.QtGui import QApplication, QDesktopServices from PyQt4.QtGui import QApplication, QDesktopServices, QImageReader, QKeyEvent
from PyQt4.QtWebKit import QWebPage, QWebSettings, QWebView from PyQt4.QtWebKit import QWebPage, QWebSettings, QWebView
from abc import ABCMeta, abstractmethod from abc import ABCMeta, abstractmethod
from application.notification import IObserver, NotificationCenter, ObserverWeakrefProxy from application.notification import IObserver, NotificationCenter, ObserverWeakrefProxy
from application.python import Null, limit from application.python import Null, limit
from application.python.descriptor import WriteOnceAttribute
from application.python.types import MarkerType from application.python.types import MarkerType
from application.system import makedirs from application.system import makedirs
from collections import MutableSet from collections import MutableSet
...@@ -37,7 +38,7 @@ from blink.configuration.datatypes import FileURL, GraphTimeScale ...@@ -37,7 +38,7 @@ from blink.configuration.datatypes import FileURL, GraphTimeScale
from blink.configuration.settings import BlinkSettings from blink.configuration.settings import BlinkSettings
from blink.contacts import URIUtils from blink.contacts import URIUtils
from blink.resources import IconManager, Resources from blink.resources import IconManager, Resources
from blink.sessions import ChatSessionModel, ChatSessionListView, StreamDescription from blink.sessions import ChatSessionModel, ChatSessionListView, SessionManager, StreamDescription
from blink.util import run_in_gui_thread from blink.util import run_in_gui_thread
from blink.widgets.color import ColorHelperMixin from blink.widgets.color import ColorHelperMixin
from blink.widgets.graph import Graph from blink.widgets.graph import Graph
...@@ -389,6 +390,9 @@ class ChatWebView(QWebView): ...@@ -389,6 +390,9 @@ class ChatWebView(QWebView):
print "create window of type", window_type print "create window of type", window_type
return None return None
def dragEnterEvent(self, event):
event.ignore() # let the parent process DND
def resizeEvent(self, event): def resizeEvent(self, event):
super(ChatWebView, self).resizeEvent(event) super(ChatWebView, self).resizeEvent(event)
self.sizeChanged.emit() self.sizeChanged.emit()
...@@ -411,6 +415,9 @@ class ChatTextInput(QTextEdit): ...@@ -411,6 +415,9 @@ class ChatTextInput(QTextEdit):
last_block = document.lastBlock() last_block = document.lastBlock()
return document.characterCount() <= 1 and not last_block.textList() return document.characterCount() <= 1 and not last_block.textList()
def dragEnterEvent(self, event):
event.ignore() # let the parent process DND
def keyPressEvent(self, event): def keyPressEvent(self, event):
key, modifiers = event.key(), event.modifiers() key, modifiers = event.key(), event.modifiers()
if key in (Qt.Key_Enter, Qt.Key_Return) and modifiers == Qt.NoModifier: if key in (Qt.Key_Enter, Qt.Key_Return) and modifiers == Qt.NoModifier:
...@@ -481,6 +488,60 @@ class IconDescriptor(object): ...@@ -481,6 +488,60 @@ class IconDescriptor(object):
raise AttributeError("attribute cannot be deleted") raise AttributeError("attribute cannot be deleted")
class Thumbnail(object):
def __new__(cls, filename):
image_reader = QImageReader(filename)
if image_reader.canRead():
instance = super(Thumbnail, cls).__new__(cls)
image_size = image_reader.size()
if image_size.height() > 720:
image_reader.setScaledSize(image_size * 720 / image_size.height())
image = QPixmap.fromImageReader(image_reader)
image_buffer = QBuffer()
image_format = 'png' if image.hasAlphaChannel() else 'jpeg'
image.save(image_buffer, image_format)
instance.__dict__['data'] = str(image_buffer.data())
instance.__dict__['type'] = 'image/{}'.format(image_format)
else:
instance = None
return instance
@property
def data(self):
return self.__dict__['data']
@property
def type(self):
return self.__dict__['type']
class FileDescriptor(object):
filename = WriteOnceAttribute()
thumbnail = WriteOnceAttribute()
def __init__(self, filename):
self.filename = filename
self.thumbnail = Thumbnail(filename)
def __hash__(self):
return hash(self.filename)
def __eq__(self, other):
if isinstance(other, FileDescriptor):
return self.filename == other.filename
return NotImplemented
def __ne__(self, other):
return not (self == other)
def __repr__(self):
return 'FileDescriptor({})'.format(self.filename)
@property
def fileurl(self):
return QUrl.fromLocalFile(self.filename).toString()
ui_class, base_class = uic.loadUiType(Resources.get('chat_widget.ui')) ui_class, base_class = uic.loadUiType(Resources.get('chat_widget.ui'))
class ChatWidget(base_class, ui_class): class ChatWidget(base_class, ui_class):
...@@ -490,6 +551,8 @@ class ChatWidget(base_class, ui_class): ...@@ -490,6 +551,8 @@ class ChatWidget(base_class, ui_class):
chat_template = open(Resources.get('chat/template.html')).read() chat_template = open(Resources.get('chat/template.html')).read()
image_data_re = re.compile(r"data:(?P<type>image/.+?);base64,(?P<data>.*)", re.I|re.U)
def __init__(self, session, parent=None): def __init__(self, session, parent=None):
super(ChatWidget, self).__init__(parent) super(ChatWidget, self).__init__(parent)
with Resources.directory: with Resources.directory:
...@@ -514,6 +577,10 @@ class ChatWidget(base_class, ui_class): ...@@ -514,6 +577,10 @@ class ChatWidget(base_class, ui_class):
self.chat_view.page().mainFrame().contentsSizeChanged.connect(self._SH_ChatViewFrameContentsSizeChanged) self.chat_view.page().mainFrame().contentsSizeChanged.connect(self._SH_ChatViewFrameContentsSizeChanged)
self.composing_timer.timeout.connect(self._SH_ComposingTimerTimeout) self.composing_timer.timeout.connect(self._SH_ComposingTimerTimeout)
@property
def user_icon(self):
return IconManager().get('avatar') or self.default_user_icon
def add_message(self, message): def add_message(self, message):
insertion_point = self.chat_element.findFirst('#insert') insertion_point = self.chat_element.findFirst('#insert')
if message.is_related_to(self.last_message): if message.is_related_to(self.last_message):
...@@ -524,6 +591,22 @@ class ChatWidget(base_class, ui_class): ...@@ -524,6 +591,22 @@ class ChatWidget(base_class, ui_class):
self.chat_element.appendInside(message.to_html(self.style, user_icons=self.user_icons_css_class)) self.chat_element.appendInside(message.to_html(self.style, user_icons=self.user_icons_css_class))
self.last_message = message self.last_message = message
def send_message(self, content, content_type='text/plain', recipients=None, courtesy_recipients=None, subject=None, timestamp=None, required=None, additional_headers=None):
blink_session = self.session.blink_session
if blink_session.state in ('initialized', 'ended'):
blink_session.init_outgoing(blink_session.account, blink_session.contact, blink_session.contact_uri, [StreamDescription('chat')], reinitialize=True)
blink_session.connect()
elif blink_session.state == 'connected/*':
if self.session.chat_stream is None:
self.session.blink_session.add_stream(StreamDescription('chat'))
elif blink_session.state == 'connecting/*' and self.session.chat_stream is not None:
pass
else:
raise RuntimeError("Cannot send messages in the '%s' state" % blink_session.state)
self.session.chat_stream.send_message(content, content_type, recipients, courtesy_recipients, subject, timestamp, required, additional_headers)
def _align_chat(self, scroll=False): def _align_chat(self, scroll=False):
#frame_height = self.chat_view.page().mainFrame().contentsSize().height() #frame_height = self.chat_view.page().mainFrame().contentsSize().height()
widget_height = self.chat_view.size().height() widget_height = self.chat_view.size().height()
...@@ -551,6 +634,79 @@ class ChatWidget(base_class, ui_class): ...@@ -551,6 +634,79 @@ class ChatWidget(base_class, ui_class):
print "%d out of %d, %d+%d=%d (%d)" % (frame.scrollBarValue(Qt.Vertical), frame.scrollBarMaximum(Qt.Vertical), frame.scrollBarValue(Qt.Vertical), self.chat_view.size().height(), print "%d out of %d, %d+%d=%d (%d)" % (frame.scrollBarValue(Qt.Vertical), frame.scrollBarMaximum(Qt.Vertical), frame.scrollBarValue(Qt.Vertical), self.chat_view.size().height(),
frame.scrollBarValue(Qt.Vertical)+self.chat_view.size().height(), frame.contentsSize().height()) frame.scrollBarValue(Qt.Vertical)+self.chat_view.size().height(), frame.contentsSize().height())
def dragEnterEvent(self, event):
mime_data = event.mimeData()
if mime_data.hasUrls() or mime_data.hasHtml() or mime_data.hasText():
event.accept()
else:
event.ignore()
def dragLeaveEvent(self, event):
event.accept()
def dragMoveEvent(self, event):
if event.possibleActions() & (Qt.CopyAction | Qt.LinkAction):
event.accept(self.rect())
else:
event.ignore(self.rect())
def dropEvent(self, event):
event.acceptProposedAction()
mime_data = event.mimeData()
if mime_data.hasUrls():
urls = mime_data.urls()
schemes = {url.scheme() for url in urls}
if schemes == {'file'}:
self._DH_Files(urls)
else:
self._DH_Text('\n'.join(url.toString() for url in urls))
else:
mime_types = set(mime_data.formats())
if mime_types.issuperset({'text/html', 'text/_moz_htmlcontext'}):
text = unicode(mime_data.data('text/html'), encoding='utf16')
else:
text = mime_data.html() or mime_data.text()
self._DH_Text(text)
def _DH_Files(self, urls):
session_manager = SessionManager()
blink_session = self.session.blink_session
file_descriptors = [FileDescriptor(url.toLocalFile()) for url in urls]
image_descriptors = [descriptor for descriptor in file_descriptors if descriptor.thumbnail is not None]
other_descriptors = [descriptor for descriptor in file_descriptors if descriptor.thumbnail is None]
for image in image_descriptors:
try:
self.send_message(image.thumbnail.data, content_type=image.thumbnail.type)
except Exception, e:
self.add_message(ChatStatus("Error sending image '%s': %s" % (os.path.basename(image.filename), e))) # decide what type to use here. -Dan
else:
content = u'''<a href="{}"><img src="data:{};base64,{}" class="scaled-to-fit" /></a>'''.format(image.fileurl, image.thumbnail.type, image.thumbnail.data.encode('base64').rstrip())
sender = ChatSender(blink_session.account.display_name, blink_session.account.id, self.user_icon.filename)
self.add_message(ChatMessage(content, sender, 'outgoing'))
for descriptor in other_descriptors:
session_manager.send_file(blink_session.contact, blink_session.contact_uri, descriptor.filename, account=blink_session.account)
def _DH_Text(self, text):
match = self.image_data_re.match(text)
if match is not None:
try:
self.send_message(match.group('data').decode('base64'), content_type=match.group('type'))
except Exception, e:
self.add_message(ChatStatus('Error sending image: %s' % e)) # decide what type to use here. -Dan
else:
account = self.session.blink_session.account
content = u'''<img src="{}" class="scaled-to-fit" />'''.format(text)
sender = ChatSender(account.display_name, account.id, self.user_icon.filename)
self.add_message(ChatMessage(content, sender, 'outgoing'))
else:
user_text = self.chat_input.toHtml()
self.chat_input.setHtml(text)
self.chat_input.keyPressEvent(QKeyEvent(QEvent.KeyPress, Qt.Key_Return, Qt.NoModifier, text=u'\r'))
self.chat_input.setHtml(user_text)
def _SH_ChatViewSizeChanged(self): def _SH_ChatViewSizeChanged(self):
#print "chat view size changed" #print "chat view size changed"
self._align_chat(scroll=True) self._align_chat(scroll=True)
...@@ -580,40 +736,14 @@ class ChatWidget(base_class, ui_class): ...@@ -580,40 +736,14 @@ class ChatWidget(base_class, ui_class):
def _SH_ChatInputTextEntered(self, text): def _SH_ChatInputTextEntered(self, text):
self.composing_timer.stop() self.composing_timer.stop()
blink_session = self.session.blink_session
if blink_session.state == 'initialized':
blink_session.connect() # what if it was initialized, but is doesn't have a chat stream? -Dan
elif blink_session.state == 'ended':
blink_session.init_outgoing(blink_session.account, blink_session.contact, blink_session.contact_uri, [StreamDescription('chat')], reinitialize=True)
blink_session.connect()
elif blink_session.state == 'connected/*':
if self.session.chat_stream is None:
self.session.blink_session.add_stream(StreamDescription('chat'))
if self.session.chat_stream is None:
self.add_message(ChatStatus('Could not add chat stream'))
return
else: # cannot send chat message in any other state (what about when connecting -Dan)
self.add_message(ChatStatus("Cannot send chat messages in the '%s' state" % blink_session.state))
return
chat_stream = self.session.chat_stream
try: try:
chat_stream.send_message(text, content_type='text/html') self.send_message(text, content_type='text/html')
except Exception, e: except Exception, e:
self.add_message(ChatStatus('Error sending chat message: %s' % e)) # decide what type to use here. -Dan self.add_message(ChatStatus('Error sending message: %s' % e)) # decide what type to use here. -Dan
return
# TODO: cache this
identity = chat_stream.local_identity
if identity is not None:
display_name = identity.display_name
uri = '%s@%s' % (identity.uri.user, identity.uri.host)
else: else:
account = chat_stream.blink_session.account account = self.session.blink_session.account
display_name = account.display_name
uri = account.id
icon = IconManager().get('avatar') or self.default_user_icon
sender = ChatSender(display_name, uri, icon.filename)
content = HtmlProcessor.autolink(text) content = HtmlProcessor.autolink(text)
sender = ChatSender(account.display_name, account.id, self.user_icon.filename)
self.add_message(ChatMessage(content, sender, 'outgoing')) self.add_message(ChatMessage(content, sender, 'outgoing'))
def _SH_ComposingTimerTimeout(self): def _SH_ComposingTimerTimeout(self):
...@@ -1820,28 +1950,36 @@ class ChatWindow(base_class, ui_class, ColorHelperMixin): ...@@ -1820,28 +1950,36 @@ class ChatWindow(base_class, ui_class, ColorHelperMixin):
def _NH_ChatStreamGotMessage(self, notification): def _NH_ChatStreamGotMessage(self, notification):
blink_session = notification.sender.blink_session blink_session = notification.sender.blink_session
session = blink_session.items.chat session = blink_session.items.chat
if session is None: if session is None:
return return
message = notification.data.message message = notification.data.message
if not message.content_type.startswith('text/'):
# TODO: check with OSX version what special messages we could get -Saul
return
if message.body.startswith('?OTRv2?'): if message.body.startswith('?OTRv2?'):
# TODO: add support for OTR -Saul # TODO: add support for OTR -Saul
return return
if message.content_type.startswith('image/'):
content = u'''<img src="data:{};base64,{}" class="scaled-to-fit" />'''.format(message.content_type, message.body.encode('base64').rstrip())
elif message.content_type.startswith('text/'):
content = HtmlProcessor.autolink(message.body if message.content_type=='text/html' else QTextDocument(message.body).toHtml())
else:
return
uri = '%s@%s' % (message.sender.uri.user, message.sender.uri.host) uri = '%s@%s' % (message.sender.uri.user, message.sender.uri.host)
account_manager = AccountManager() account_manager = AccountManager()
if account_manager.has_account(uri): if account_manager.has_account(uri):
account = account_manager.get_account(uri) account = account_manager.get_account(uri)
icon = IconManager().get('avatar') or session.chat_widget.default_user_icon sender = ChatSender(message.sender.display_name or account.display_name, uri, session.chat_widget.user_icon.filename)
sender = ChatSender(message.sender.display_name or account.display_name, uri, icon.filename)
elif blink_session.remote_focus: elif blink_session.remote_focus:
contact, contact_uri = URIUtils.find_contact(uri) contact, contact_uri = URIUtils.find_contact(uri)
sender = ChatSender(message.sender.display_name or contact.name, uri, contact.icon.filename) sender = ChatSender(message.sender.display_name or contact.name, uri, contact.icon.filename)
else: else:
sender = ChatSender(message.sender.display_name or session.name, uri, session.icon.filename) sender = ChatSender(message.sender.display_name or session.name, uri, session.icon.filename)
content = HtmlProcessor.autolink(message.body if message.content_type=='text/html' else QTextDocument(message.body).toHtml())
session.chat_widget.add_message(ChatMessage(content, sender, 'incoming')) session.chat_widget.add_message(ChatMessage(content, sender, 'incoming'))
session.remote_composing = False session.remote_composing = False
settings = SIPSimpleSettings() settings = SIPSimpleSettings()
if settings.sounds.play_message_alerts and self.selected_session is session: if settings.sounds.play_message_alerts and self.selected_session is session:
...@@ -2021,7 +2159,6 @@ class ChatWindow(base_class, ui_class, ColorHelperMixin): ...@@ -2021,7 +2159,6 @@ class ChatWindow(base_class, ui_class, ColorHelperMixin):
def _AH_Connect(self): def _AH_Connect(self):
blink_session = self.selected_session.blink_session blink_session = self.selected_session.blink_session
if blink_session.state == 'ended':
blink_session.init_outgoing(blink_session.account, blink_session.contact, blink_session.contact_uri, stream_descriptions=[StreamDescription('chat')], reinitialize=True) blink_session.init_outgoing(blink_session.account, blink_session.contact, blink_session.contact_uri, stream_descriptions=[StreamDescription('chat')], reinitialize=True)
blink_session.connect() blink_session.connect()
......
...@@ -10,6 +10,9 @@ ...@@ -10,6 +10,9 @@
<height>521</height> <height>521</height>
</rect> </rect>
</property> </property>
<property name="acceptDrops">
<bool>true</bool>
</property>
<property name="windowTitle"> <property name="windowTitle">
<string>Chat session</string> <string>Chat session</string>
</property> </property>
...@@ -21,7 +24,7 @@ ...@@ -21,7 +24,7 @@
<number>0</number> <number>0</number>
</property> </property>
<item> <item>
<widget class="ChatWebView" name="chat_view"> <widget class="ChatWebView" name="chat_view" native="true">
<property name="sizePolicy"> <property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Expanding"> <sizepolicy hsizetype="Expanding" vsizetype="Expanding">
<horstretch>0</horstretch> <horstretch>0</horstretch>
...@@ -78,13 +81,13 @@ ...@@ -78,13 +81,13 @@
<header>QtWebKit/QWebView</header> <header>QtWebKit/QWebView</header>
</customwidget> </customwidget>
<customwidget> <customwidget>
<class>ChatTextInput</class> <class>ChatWebView</class>
<extends>QTextEdit</extends> <extends>QWebView</extends>
<header>blink.chatwindow</header> <header>blink.chatwindow</header>
</customwidget> </customwidget>
<customwidget> <customwidget>
<class>ChatWebView</class> <class>ChatTextInput</class>
<extends>QWebView</extends> <extends>QTextEdit</extends>
<header>blink.chatwindow</header> <header>blink.chatwindow</header>
</customwidget> </customwidget>
</customwidgets> </customwidgets>
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment