import locale import os import re from PyQt5 import uic from PyQt5.QtCore import Qt, QBuffer, QEasingCurve, QEvent, QPoint, QPointF, QPropertyAnimation, QRect, QRectF, QSettings, QSize, QSizeF, QTimer, QUrl, pyqtSignal from PyQt5.QtGui import QBrush, QColor, QIcon, QImageReader, QKeyEvent, QLinearGradient, QPainter, QPalette, QPen, QPixmap, QPolygonF, QTextCharFormat, QTextCursor, QTextDocument from PyQt5.QtGui import QDesktopServices from PyQt5.QtWebKit import QWebSettings from PyQt5.QtWebKitWidgets import QWebPage, QWebView from PyQt5.QtWidgets import QApplication, QAction, QLabel, QListView, QMenu, QStyle, QStyleOption, QStylePainter, QTextEdit, QToolButton from abc import ABCMeta, abstractmethod from application.notification import IObserver, NotificationCenter, ObserverWeakrefProxy from application.python import Null, limit from application.python.descriptor import WriteOnceAttribute from application.python.types import MarkerType from application.system import makedirs from collections import MutableSet, deque from datetime import datetime, timedelta from itertools import count from lxml import etree, html from lxml.html.clean import autolink from weakref import proxy from zope.interface import implements from sipsimple.account import AccountManager from sipsimple.application import SIPApplication from sipsimple.audio import WavePlayer from sipsimple.configuration.settings import SIPSimpleSettings from sipsimple.streams.msrp.chat import OTRState from sipsimple.threading import run_in_thread from blink.configuration.datatypes import FileURL, GraphTimeScale from blink.configuration.settings import BlinkSettings from blink.contacts import URIUtils from blink.resources import IconManager, Resources from blink.sessions import ChatSessionModel, ChatSessionListView, SessionManager, StreamDescription from blink.util import run_in_gui_thread from blink.widgets.color import ColorHelperMixin from blink.widgets.graph import Graph from blink.widgets.otr import OTRWidget from blink.widgets.util import ContextMenuActions, QtDynamicProperty from blink.widgets.video import VideoSurface from blink.widgets.zrtp import ZRTPWidget __all__ = ['ChatWindow'] class Container(object): pass # Chat style classes # class ChatStyleError(Exception): pass class ChatHtmlTemplates(object): def __init__(self, style_path): try: self.message = open(os.path.join(style_path, 'html/message.html')).read().decode('utf-8') self.message_continuation = open(os.path.join(style_path, 'html/message_continuation.html')).read().decode('utf-8') self.notification = open(os.path.join(style_path, 'html/notification.html')).read().decode('utf-8') except (OSError, IOError): raise ChatStyleError("missing or unreadable chat message html template files in %s" % os.path.join(style_path, 'html')) class ChatMessageStyle(object): def __init__(self, name): self.name = name self.path = Resources.get('chat/styles/%s' % name) try: xml_tree = etree.parse(os.path.join(self.path, 'style.xml'), parser=etree.XMLParser(resolve_entities=False)) except (etree.ParseError, OSError, IOError): self.info = {} else: self.info = dict((element.tag, element.text) for element in xml_tree.getroot()) try: self.variants = tuple(sorted(name[:-len('.style')] for name in os.listdir(self.path) if name.endswith('.style'))) except (OSError, IOError): self.variants = () if not self.variants: raise ChatStyleError("chat style %s contains no variants" % name) self.html = ChatHtmlTemplates(self.path) @property def default_variant(self): default_variant = self.info.get('default_variant') return default_variant if default_variant in self.variants else self.variants[0] @property def font_family(self): return self.info.get('font_family', 'sans-serif') @property def font_size(self): try: return int(self.info['font_size']) except (KeyError, ValueError): return 11 # Chat content classes # class Link(object): __slots__ = 'prev', 'next', 'key', '__weakref__' class OrderedSet(MutableSet): def __init__(self, iterable=None): self.__hardroot = Link() # sentinel node for doubly linked list self.__root = root = proxy(self.__hardroot) root.prev = root.__next__ = root self.__map = {} if iterable is not None: self |= iterable def __len__(self): return len(self.__map) def __contains__(self, key): return key in self.__map def __iter__(self): root = self.__root curr = root.__next__ while curr is not root: yield curr.key curr = curr.__next__ def __reversed__(self): root = self.__root curr = root.prev while curr is not root: yield curr.key curr = curr.prev def __repr__(self): return '%s(%r)' % (self.__class__.__name__, list(self)) def add(self, key): if key not in self.__map: self.__map[key] = link = Link() root = self.__root last = root.prev link.prev, link.next, link.key = last, root, key last.next = link root.prev = proxy(link) def discard(self, key): if key in self.__map: link = self.__map.pop(key) link_prev = link.prev link_next = link.__next__ link_prev.next = link_next link_next.prev = link_prev def clear(self): root = self.__root root.prev = root.__next__ = root self.__map.clear() class ChatContentBooleanOption(object): """Adds/removes name from css classes based on option being True/False""" def __init__(self, name): self.name = name def __get__(self, instance, owner): if instance is None: return self return self.name in instance.__cssclasses__ def __set__(self, obj, value): if value: obj.__cssclasses__.add(self.name) else: obj.__cssclasses__.discard(self.name) def __delete__(self, obj): raise AttributeError('attribute cannot be deleted') class AnyValue(metaclass=MarkerType): pass class ChatContentStringAttribute(object): """A string attribute that is also added as a css class""" def __init__(self, name, allowed_values=AnyValue): self.name = name self.allowed_values = allowed_values def __get__(self, instance, owner): if instance is None: return self try: return instance.__dict__[self.name] except KeyError: raise AttributeError("'{}' attribute is not set".format(self.name)) def __set__(self, obj, value): if self.allowed_values is not AnyValue and value not in self.allowed_values: raise ValueError("invalid value for '{}': '{}'".format(self.name, value)) old_value = obj.__dict__.get(self.name, None) obj.__cssclasses__.discard(old_value) if value is not None: obj.__cssclasses__.add(value) obj.__dict__[self.name] = value def __delete__(self, obj): raise AttributeError('attribute cannot be deleted') class ChatContent(object, metaclass=ABCMeta): __cssclasses__ = () continuation_interval = timedelta(0, 5*60) # 5 minutes history = ChatContentBooleanOption('history') focus = ChatContentBooleanOption('focus') consecutive = ChatContentBooleanOption('consecutive') mention = ChatContentBooleanOption('mention') # keep it here? or keep it at all? -Dan def __init__(self, message, history=False, focus=False): self.__cssclasses__ = OrderedSet(self.__class__.__cssclasses__) self.message = message self.history = history self.focus = focus self.timestamp = datetime.now() @property def css_classes(self): return ' '.join(self.__cssclasses__) @property def date(self): language, encoding = locale.getlocale(locale.LC_TIME) return self.timestamp.strftime('%d %b %Y').decode(encoding or 'ascii') @property def time(self): language, encoding = locale.getlocale(locale.LC_TIME) return self.timestamp.strftime('%H:%M').decode(encoding or 'ascii') @property def text_direction(self): try: return self.__dict__['text_direction'] except KeyError: document = QTextDocument() document.setHtml(self.message) return self.__dict__.setdefault('text_direction', 'rtl' if document.firstBlock().textDirection() == Qt.RightToLeft else 'ltr') def add_css_class(self, name): self.__cssclasses__.add(name) def is_related_to(self, other): return type(self) is type(other) and self.history == other.history and timedelta(0) <= self.timestamp - other.timestamp <= self.continuation_interval @abstractmethod def to_html(self, style, **kw): raise NotImplementedError class ChatNotification(ChatContent): __cssclasses__ = ('event',) def to_html(self, style, **kw): return style.html.notification.format(message=self, **kw) class ChatEvent(ChatNotification): __cssclasses__ = ('event',) class ChatStatus(ChatNotification): __cssclasses__ = ('status',) class ChatMessage(ChatContent): __cssclasses__ = ('message',) direction = ChatContentStringAttribute('direction', allowed_values=('incoming', 'outgoing')) autoreply = ChatContentBooleanOption('autoreply') def __init__(self, message, sender, direction, history=False, focus=False): super(ChatMessage, self).__init__(message, history, focus) self.sender = sender self.direction = direction def is_related_to(self, other): return super(ChatMessage, self).is_related_to(other) and self.sender == other.sender and self.direction == other.direction def to_html(self, style, **kw): if self.consecutive: return style.html.message_continuation.format(message=self, **kw) else: return style.html.message.format(message=self, **kw) class ChatSender(object): __colors__ = ["aqua", "aquamarine", "blue", "blueviolet", "brown", "burlywood", "cadetblue", "chartreuse", "chocolate", "coral", "cornflowerblue", "crimson", "cyan", "darkblue", "darkcyan", "darkgoldenrod", "darkgreen", "darkgrey", "darkkhaki", "darkmagenta", "darkolivegreen", "darkorange", "darkorchid", "darkred", "darksalmon", "darkseagreen", "darkslateblue", "darkslategrey", "darkturquoise", "darkviolet", "deeppink", "deepskyblue", "dimgrey", "dodgerblue", "firebrick", "forestgreen", "fuchsia", "gold", "goldenrod", "green", "greenyellow", "grey", "hotpink", "indianred", "indigo", "lawngreen", "lightblue", "lightcoral", "lightgreen", "lightgrey", "lightpink", "lightsalmon", "lightseagreen", "lightskyblue", "lightslategrey", "lightsteelblue", "lime", "limegreen", "magenta", "maroon", "mediumaquamarine", "mediumblue", "mediumorchid", "mediumpurple", "mediumseagreen", "mediumslateblue", "mediumspringgreen", "mediumturquoise", "mediumvioletred", "midnightblue", "navy", "olive", "olivedrab", "orange", "orangered", "orchid", "palegreen", "paleturquoise", "palevioletred", "peru", "pink", "plum", "powderblue", "purple", "red", "rosybrown", "royalblue", "saddlebrown", "salmon", "sandybrown", "seagreen", "sienna", "silver", "skyblue", "slateblue", "slategrey", "springgreen", "steelblue", "tan", "teal", "thistle", "tomato", "turquoise", "violet", "yellowgreen"] def __init__(self, name, uri, iconpath): self.name = name self.uri = uri self.iconpath = QUrl.fromLocalFile(iconpath).toString() def __eq__(self, other): if not isinstance(other, ChatSender): return NotImplemented return self.name == other.name and self.uri == other.uri def __ne__(self, other): return not (self == other) @property def color(self): return self.__colors__[hash(self.uri) % len(self.__colors__)] class ChatWebPage(QWebPage): def __init__(self, parent=None): super(ChatWebPage, self).__init__(parent) self.setLinkDelegationPolicy(QWebPage.DelegateAllLinks) self.linkClicked.connect(QDesktopServices.openUrl) disable_actions = {QWebPage.OpenLink, QWebPage.OpenLinkInNewWindow, QWebPage.OpenLinkInThisWindow, QWebPage.DownloadLinkToDisk, QWebPage.OpenImageInNewWindow, QWebPage.DownloadImageToDisk, QWebPage.DownloadMediaToDisk, QWebPage.Back, QWebPage.Forward, QWebPage.Stop, QWebPage.Reload} for action in (self.action(action) for action in disable_actions): action.setVisible(False) def acceptNavigationRequest(self, frame, request, navigation_type): # not sure if needed since we already disabled the corresponding actions. (can they be triggered otherwise?) if navigation_type in (QWebPage.NavigationTypeBackOrForward, QWebPage.NavigationTypeReload): return False return super(ChatWebPage, self).acceptNavigationRequest(frame, request, navigation_type) class ChatWebView(QWebView): sizeChanged = pyqtSignal() def __init__(self, parent=None): super(ChatWebView, self).__init__(parent) palette = self.palette() palette.setBrush(QPalette.Base, Qt.transparent) self.setPalette(palette) self.setPage(ChatWebPage(self)) self.setAttribute(Qt.WA_OpaquePaintEvent, False) self.settings().setAttribute(QWebSettings.DeveloperExtrasEnabled, True) # temporary for debugging -Dan def contextMenuEvent(self, event): menu = self.page().createStandardContextMenu() if any(action.isVisible() and not action.isSeparator() for action in menu.actions()): menu.exec_(event.globalPos()) def createWindow(self, window_type): print("create window of type", window_type) return None def dragEnterEvent(self, event): event.ignore() # let the parent process DND def resizeEvent(self, event): super(ChatWebView, self).resizeEvent(event) self.sizeChanged.emit() ui_class, base_class = uic.loadUiType(Resources.get('chat_input_lock.ui')) class ChatInputLock(base_class, ui_class): def __init__(self, parent=None): super(ChatInputLock, self).__init__(parent) with Resources.directory: self.setupUi(self) if parent is not None: parent.installEventFilter(self) def eventFilter(self, watched, event): if event.type() == QEvent.Resize: self.setGeometry(watched.contentsRect()) return False def dragEnterEvent(self, event): event.ignore() # let the parent process DND def paintEvent(self, event): option = QStyleOption() option.initFrom(self) painter = QStylePainter(self) painter.setRenderHint(QStylePainter.Antialiasing, True) painter.drawPrimitive(QStyle.PE_Widget, option) class LockType(object, metaclass=MarkerType): note_text = None button_text = None class EncryptionLock(LockType): note_text = 'Encryption has been terminated by the other party' button_text = 'Confirm' class ChatTextInput(QTextEdit): textEntered = pyqtSignal(str) lockEngaged = pyqtSignal(object) lockReleased = pyqtSignal(object) def __init__(self, parent=None): super(ChatTextInput, self).__init__(parent) self.setTabStopWidth(22) self.lock_widget = ChatInputLock(self) self.lock_widget.hide() self.lock_widget.confirm_button.clicked.connect(self._SH_LockWidgetConfirmButtonClicked) self.document().documentLayout().documentSizeChanged.connect(self._SH_DocumentLayoutSizeChanged) self.lock_queue = deque() self.history = [] self.history_index = 0 # negative indexes with 0 indicating the text being typed. self.stashed_content = None @property def empty(self): document = self.document() last_block = document.lastBlock() return document.characterCount() <= 1 and not last_block.textList() @property def locked(self): return bool(self.lock_queue) def dragEnterEvent(self, event): event.ignore() # let the parent process DND def keyPressEvent(self, event): key, modifiers = event.key(), event.modifiers() if self.isReadOnly(): event.ignore() elif key in (Qt.Key_Enter, Qt.Key_Return) and modifiers == Qt.NoModifier: document = self.document() last_block = document.lastBlock() if document.characterCount() > 1 or last_block.textList(): text = self.toHtml() if not self.history or self.history[-1] != text: self.history.append(text) self.history_index = 0 self.stashed_content = None if document.blockCount() > 1 and not last_block.text() and not last_block.textList(): # prevent an extra empty line being added at the end of the text cursor = self.textCursor() cursor.movePosition(cursor.End) cursor.deletePreviousChar() text = self.toHtml() self.clear() self.textEntered.emit(text) event.accept() elif key == Qt.Key_Up and modifiers == Qt.ControlModifier: try: history_entry = self.history[self.history_index - 1] except IndexError: pass else: if self.history_index == 0: self.stashed_content = self.toHtml() self.history_index -= 1 self.setHtml(history_entry) event.accept() elif key == Qt.Key_Down and modifiers == Qt.ControlModifier: if self.history_index == 0: pass elif self.history_index == -1: self.history_index = 0 self.setHtml(self.stashed_content) self.stashed_content = None else: self.history_index += 1 self.setHtml(self.history[self.history_index]) event.accept() else: QTextEdit.keyPressEvent(self, event) def _SH_DocumentLayoutSizeChanged(self, new_size): self.setFixedHeight(min(new_size.height()+self.contentsMargins().top()+self.contentsMargins().bottom(), self.parent().height()/2)) def _SH_LockWidgetConfirmButtonClicked(self): self.lockReleased.emit(self.lock_queue.popleft()) if self.locked: lock_type = self.lock_queue[0] self.lock_widget.note_label.setText(lock_type.note_text) self.lock_widget.confirm_button.setText(lock_type.button_text) self.lockEngaged.emit(lock_type) else: self.lock_widget.hide() self.setReadOnly(False) def lock(self, lock_type): if lock_type in self.lock_queue: raise ValueError("already locked with {}".format(lock_type)) if not self.locked: self.lock_widget.note_label.setText(lock_type.note_text) self.lock_widget.confirm_button.setText(lock_type.button_text) self.lock_widget.show() self.setReadOnly(True) self.lockEngaged.emit(lock_type) self.lock_queue.append(lock_type) def reset_locks(self): self.setReadOnly(False) self.lock_widget.hide() self.lock_queue.clear() def clear(self): super(ChatTextInput, self).clear() self.setCurrentCharFormat(QTextCharFormat()) # clear() doesn't clear the text formatting, only the content def setHtml(self, text): super(ChatTextInput, self).setHtml(text) cursor = self.textCursor() cursor.movePosition(QTextCursor.End) self.setTextCursor(cursor) class IconDescriptor(object): def __init__(self, filename): self.filename = filename self.icon = None def __get__(self, instance, owner): if self.icon is None: self.icon = QIcon(self.filename) self.icon.filename = self.filename return self.icon def __set__(self, obj, value): raise AttributeError("attribute cannot be set") def __delete__(self, obj): raise AttributeError("attribute cannot be deleted") class Thumbnail(object): def __new__(cls, filename): image_reader = QImageReader(filename) if image_reader.canRead() and image_reader.size().isValid(): if image_reader.supportsAnimation() and image_reader.imageCount() > 1: image_format = str(image_reader.format()) image_data = str(image_reader.device().readAll()) else: file_format = str(image_reader.format()) file_size = image_reader.device().size() 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() or (file_format in {'png', 'tiff', 'ico'} and file_size <= 100*1024) else 'jpeg' image.save(image_buffer, image_format) image_data = str(image_buffer.data()) instance = super(Thumbnail, cls).__new__(cls) instance.__dict__['data'] = image_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')) class ChatWidget(base_class, ui_class): implements(IObserver) default_user_icon = IconDescriptor(Resources.get('icons/default-avatar.png')) 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): super(ChatWidget, self).__init__(parent) with Resources.directory: self.setupUi(self) blink_settings = BlinkSettings() self.style = ChatMessageStyle(blink_settings.chat_window.style) self.style_variant = blink_settings.chat_window.style_variant or self.style.default_variant self.font_family = blink_settings.chat_window.font or self.style.font_family self.font_size = blink_settings.chat_window.font_size or self.style.font_size self.user_icons_css_class = 'show-icons' if blink_settings.chat_window.show_user_icons else 'hide-icons' self.chat_view.setHtml(self.chat_template.format(base_url=FileURL(self.style.path)+'/', style_url=self.style_variant+'.style', font_family=self.font_family, font_size=self.font_size)) self.chat_element = self.chat_view.page().mainFrame().findFirstElement('#chat') self.composing_timer = QTimer() self.last_message = None self.session = session if session is not None: notification_center = NotificationCenter() notification_center.add_observer(ObserverWeakrefProxy(self), sender=session.blink_session) # connect to signals self.chat_input.textChanged.connect(self._SH_ChatInputTextChanged) self.chat_input.textEntered.connect(self._SH_ChatInputTextEntered) self.chat_input.lockReleased.connect(self._SH_ChatInputLockReleased) self.chat_view.sizeChanged.connect(self._SH_ChatViewSizeChanged) self.chat_view.page().mainFrame().contentsSizeChanged.connect(self._SH_ChatViewFrameContentsSizeChanged) 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): insertion_point = self.chat_element.findFirst('#insert') if message.is_related_to(self.last_message): message.consecutive = True insertion_point.replace(message.to_html(self.style, user_icons=self.user_icons_css_class)) else: insertion_point.removeFromDocument() self.chat_element.appendInside(message.to_html(self.style, user_icons=self.user_icons_css_class)) 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): # frame_height = self.chat_view.page().mainFrame().contentsSize().height() widget_height = self.chat_view.size().height() content_height = self.chat_element.geometry().height() # print widget_height, frame_height, content_height if widget_height > content_height: self.chat_element.setStyleProperty('position', 'relative') self.chat_element.setStyleProperty('top', '%dpx' % (widget_height-content_height)) else: self.chat_element.setStyleProperty('position', 'static') self.chat_element.setStyleProperty('top', None) frame = self.chat_view.page().mainFrame() if scroll or frame.scrollBarMaximum(Qt.Vertical) - frame.scrollBarValue(Qt.Vertical) <= widget_height*0.2: # print "scroll requested or scrollbar is closer than %dpx to the bottom" % (widget_height*0.2) # self._print_scrollbar_position() self._scroll_to_bottom() # self._print_scrollbar_position() def _scroll_to_bottom(self): frame = self.chat_view.page().mainFrame() frame.setScrollBarValue(Qt.Vertical, frame.scrollBarMaximum(Qt.Vertical)) def _print_scrollbar_position(self): frame = self.chat_view.page().mainFrame() 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())) 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 = str(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 as 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 = '''<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 as 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 = '''<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='\r')) self.chat_input.setHtml(user_text) def _SH_ChatViewSizeChanged(self): # print "chat view size changed" self._align_chat(scroll=True) def _SH_ChatViewFrameContentsSizeChanged(self, size): # print "frame contents size changed to %r (current=%r)" % (size, self.chat_view.page().mainFrame().contentsSize()) self._align_chat(scroll=True) def _SH_ChatInputTextChanged(self): chat_stream = self.session.chat_stream if chat_stream is None: return if self.chat_input.empty: if self.composing_timer.isActive(): self.composing_timer.stop() try: chat_stream.send_composing_indication('idle') except Exception: pass elif not self.composing_timer.isActive(): try: chat_stream.send_composing_indication('active') except Exception: pass else: self.composing_timer.start(10000) def _SH_ChatInputTextEntered(self, text): self.composing_timer.stop() doc = QTextDocument() doc.setHtml(text) plain_text = doc.toPlainText() if plain_text == '/otr+': try: self.session.chat_stream.encryption.start() except AttributeError: pass return elif plain_text == '/otr-': try: self.session.chat_stream.encryption.stop() except AttributeError: pass return try: self.send_message(text, content_type='text/html') except Exception as e: self.add_message(ChatStatus('Error sending message: %s' % e)) # decide what type to use here. -Dan else: account = self.session.blink_session.account content = HtmlProcessor.autolink(text) sender = ChatSender(account.display_name, account.id, self.user_icon.filename) self.add_message(ChatMessage(content, sender, 'outgoing')) def _SH_ChatInputLockReleased(self, lock_type): if lock_type is EncryptionLock: self.session.chat_stream.encryption.stop() def _SH_ComposingTimerTimeout(self): self.composing_timer.stop() chat_stream = self.session.chat_stream or Null try: chat_stream.send_composing_indication('idle') except Exception: pass @run_in_gui_thread def handle_notification(self, notification): handler = getattr(self, '_NH_%s' % notification.name, Null) handler(notification) def _NH_BlinkSessionDidEnd(self, notification): self.composing_timer.stop() self.chat_input.reset_locks() def _NH_BlinkSessionWasDeleted(self, notification): self.setParent(None) def _NH_BlinkSessionDidRemoveStream(self, notification): if notification.data.stream.type == 'chat': self.composing_timer.stop() self.chat_input.reset_locks() del ui_class, base_class class VideoToolButton(QToolButton): active = QtDynamicProperty('active', bool) def event(self, event): if event.type() == QEvent.DynamicPropertyChange and event.propertyName() == 'active': self.setVisible(self.active) return super(VideoToolButton, self).event(event) ui_class, base_class = uic.loadUiType(Resources.get('video_widget.ui')) class VideoWidget(VideoSurface, ui_class): implements(IObserver) def __init__(self, session_item, parent=None): super(VideoWidget, self).__init__(parent) with Resources.directory: self.setupUi() self.session_item = session_item self.blink_session = session_item.blink_session self.parent_widget = parent self.setWindowFlags(Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint) self.fullscreen_button.clicked.connect(self._SH_FullscreenButtonClicked) self.screenshot_button.clicked.connect(self._SH_ScreenshotButtonClicked) self.detach_button.clicked.connect(self._SH_DetachButtonClicked) self.mute_button.clicked.connect(self._SH_MuteButtonClicked) self.hold_button.clicked.connect(self._SH_HoldButtonClicked) self.close_button.clicked.connect(self._SH_CloseButtonClicked) self.screenshot_button.customContextMenuRequested.connect(self._SH_ScreenshotButtonContextMenuRequested) self.camera_preview.adjusted.connect(self._SH_CameraPreviewAdjusted) self.detach_animation.finished.connect(self._SH_DetachAnimationFinished) self.preview_animation.finished.connect(self._SH_PreviewAnimationFinished) self.idle_timer.timeout.connect(self._SH_IdleTimerTimeout) if parent is not None: parent.installEventFilter(self) self.setGeometry(self.geometryHint()) self.setVisible('video' in session_item.blink_session.streams) settings = SIPSimpleSettings() notification_center = NotificationCenter() notification_center.add_observer(ObserverWeakrefProxy(self), sender=session_item.blink_session) notification_center.add_observer(ObserverWeakrefProxy(self), name='CFGSettingsObjectDidChange', sender=settings) notification_center.add_observer(ObserverWeakrefProxy(self), name='VideoStreamRemoteFormatDidChange') notification_center.add_observer(ObserverWeakrefProxy(self), name='VideoStreamReceivedKeyFrame') notification_center.add_observer(ObserverWeakrefProxy(self), name='VideoDeviceDidChangeCamera') def setupUi(self): super(VideoWidget, self).setupUi(self) self.no_flicker_widget = QLabel() self.no_flicker_widget.setWindowFlags(Qt.FramelessWindowHint) # self.no_flicker_widget.setWindowFlags(Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint) self.camera_preview = VideoSurface(self, framerate=10) self.camera_preview.interactive = True self.camera_preview.mirror = True self.camera_preview.setMinimumHeight(45) self.camera_preview.setMaximumHeight(135) self.camera_preview.setGeometry(QRect(0, 0, self.camera_preview.width_for_height(81), 81)) self.camera_preview.lower() self.camera_preview.scale_factor = 1.0 self.detach_animation = QPropertyAnimation(self, 'geometry') self.detach_animation.setDuration(200) # self.detach_animation.setEasingCurve(QEasingCurve.Linear) self.preview_animation = QPropertyAnimation(self.camera_preview, 'geometry') self.preview_animation.setDuration(500) self.preview_animation.setDirection(QPropertyAnimation.Forward) self.preview_animation.setEasingCurve(QEasingCurve.OutQuad) self.idle_timer = QTimer() self.idle_timer.setSingleShot(True) self.idle_timer.setInterval(3000) for button in self.tool_buttons: button.setCursor(Qt.ArrowCursor) button.installEventFilter(self) button.active = False # fix the SVG icons, as the generated code loads them as pixmaps, losing their ability to scale -Dan fullscreen_icon = QIcon() fullscreen_icon.addFile(Resources.get('icons/fullscreen.svg'), mode=QIcon.Normal, state=QIcon.Off) fullscreen_icon.addFile(Resources.get('icons/fullscreen-exit.svg'), mode=QIcon.Normal, state=QIcon.On) fullscreen_icon.addFile(Resources.get('icons/fullscreen-exit.svg'), mode=QIcon.Active, state=QIcon.On) fullscreen_icon.addFile(Resources.get('icons/fullscreen-exit.svg'), mode=QIcon.Disabled, state=QIcon.On) fullscreen_icon.addFile(Resources.get('icons/fullscreen-exit.svg'), mode=QIcon.Selected, state=QIcon.On) detach_icon = QIcon() detach_icon.addFile(Resources.get('icons/detach.svg'), mode=QIcon.Normal, state=QIcon.Off) detach_icon.addFile(Resources.get('icons/attach.svg'), mode=QIcon.Normal, state=QIcon.On) detach_icon.addFile(Resources.get('icons/attach.svg'), mode=QIcon.Active, state=QIcon.On) detach_icon.addFile(Resources.get('icons/attach.svg'), mode=QIcon.Disabled, state=QIcon.On) detach_icon.addFile(Resources.get('icons/attach.svg'), mode=QIcon.Selected, state=QIcon.On) mute_icon = QIcon() mute_icon.addFile(Resources.get('icons/mic-on.svg'), mode=QIcon.Normal, state=QIcon.Off) mute_icon.addFile(Resources.get('icons/mic-off.svg'), mode=QIcon.Normal, state=QIcon.On) mute_icon.addFile(Resources.get('icons/mic-off.svg'), mode=QIcon.Active, state=QIcon.On) mute_icon.addFile(Resources.get('icons/mic-off.svg'), mode=QIcon.Disabled, state=QIcon.On) mute_icon.addFile(Resources.get('icons/mic-off.svg'), mode=QIcon.Selected, state=QIcon.On) hold_icon = QIcon() hold_icon.addFile(Resources.get('icons/pause.svg'), mode=QIcon.Normal, state=QIcon.Off) hold_icon.addFile(Resources.get('icons/paused.svg'), mode=QIcon.Normal, state=QIcon.On) hold_icon.addFile(Resources.get('icons/paused.svg'), mode=QIcon.Active, state=QIcon.On) hold_icon.addFile(Resources.get('icons/paused.svg'), mode=QIcon.Disabled, state=QIcon.On) hold_icon.addFile(Resources.get('icons/paused.svg'), mode=QIcon.Selected, state=QIcon.On) screenshot_icon = QIcon() screenshot_icon.addFile(Resources.get('icons/screenshot.svg'), mode=QIcon.Normal, state=QIcon.Off) close_icon = QIcon() close_icon.addFile(Resources.get('icons/close.svg'), mode=QIcon.Normal, state=QIcon.Off) close_icon.addFile(Resources.get('icons/close-active.svg'), mode=QIcon.Active, state=QIcon.Off) self.fullscreen_button.setIcon(fullscreen_icon) self.screenshot_button.setIcon(screenshot_icon) self.detach_button.setIcon(detach_icon) self.mute_button.setIcon(mute_icon) self.hold_button.setIcon(hold_icon) self.close_button.setIcon(close_icon) self.screenshot_button_menu = QMenu(self) self.screenshot_button_menu.addAction('Open screenshots folder', self._SH_ScreenshotsFolderActionTriggered) @property def interactive(self): return self.parent() is None and not self.isFullScreen() @property def tool_buttons(self): return tuple(attr for attr in vars(self).values() if isinstance(attr, VideoToolButton)) @property def active_tool_buttons(self): return tuple(button for button in self.tool_buttons if button.active) def eventFilter(self, watched, event): event_type = event.type() if watched is self.parent(): if event_type == QEvent.Resize: self.setGeometry(self.geometryHint()) elif event_type == QEvent.Enter: self.idle_timer.stop() cursor = self.cursor() cursor_pos = cursor.pos() if not watched.rect().translated(watched.mapToGlobal(QPoint(0, 0))).contains(cursor_pos): # sometimes we get invalid enter events for the fullscreen_button after we switch to fullscreen. # simulate a mouse move in and out of the button to force qt to update the button state. cursor.setPos(self.mapToGlobal(watched.geometry().center())) cursor.setPos(cursor_pos) elif event_type == QEvent.Leave: self.idle_timer.start() return False def mousePressEvent(self, event): super(VideoWidget, self).mousePressEvent(event) if self._interaction.active: for button in self.active_tool_buttons: button.show() # show or hide the tool buttons while we move/resize? -Dan self.idle_timer.stop() def mouseReleaseEvent(self, event): if self._interaction.active: for button in self.active_tool_buttons: button.show() self.idle_timer.start() super(VideoWidget, self).mouseReleaseEvent(event) def mouseMoveEvent(self, event): super(VideoWidget, self).mouseMoveEvent(event) if self._interaction.active: return if not self.idle_timer.isActive(): for button in self.active_tool_buttons: button.show() self.setCursor(Qt.ArrowCursor) self.idle_timer.start() def resizeEvent(self, event): if self.preview_animation.state() == QPropertyAnimation.Running: return if not event.oldSize().isValid(): return if self.camera_preview.size() == event.oldSize(): self.camera_preview.resize(event.size()) return old_size = QSizeF(event.oldSize()) new_size = QSizeF(event.size()) ratio = new_size.height() / old_size.height() if ratio == 1: return scaled_preview_geometry = QRectF(QPointF(self.camera_preview.geometry().topLeft()) * ratio, QSizeF(self.camera_preview.size()) * ratio) preview_center = scaled_preview_geometry.center() ideal_geometry = scaled_preview_geometry.toAlignedRect() if ideal_geometry.right() > self.rect().right(): ideal_geometry.moveRight(self.rect().right()) if ideal_geometry.bottom() > self.rect().bottom(): ideal_geometry.moveBottom(self.rect().bottom()) new_height = limit((new_size.height() + 117) / 6 * self.camera_preview.scale_factor, min=self.camera_preview.minimumHeight(), max=self.camera_preview.maximumHeight()) preview_geometry = QRect(0, 0, self.width_for_height(new_height), new_height) quadrant = QRectF(QPointF(0, 0), new_size/3) if quadrant.translated(0, 0).contains(preview_center): # top left gravity preview_geometry.moveTopLeft(ideal_geometry.topLeft()) elif quadrant.translated(quadrant.width(), 0).contains(preview_center): # top gravity preview_geometry.moveCenter(ideal_geometry.center()) preview_geometry.moveTop(ideal_geometry.top()) elif quadrant.translated(2*quadrant.width(), 0).contains(preview_center): # top right gravity preview_geometry.moveTopRight(ideal_geometry.topRight()) elif quadrant.translated(0, quadrant.height()).contains(preview_center): # left gravity preview_geometry.moveCenter(ideal_geometry.center()) preview_geometry.moveLeft(ideal_geometry.left()) elif quadrant.translated(quadrant.width(), quadrant.height()).contains(preview_center): # center gravity preview_geometry.moveCenter(ideal_geometry.center()) elif quadrant.translated(2*quadrant.width(), quadrant.height()).contains(preview_center): # right gravity preview_geometry.moveCenter(ideal_geometry.center()) preview_geometry.moveRight(ideal_geometry.right()) elif quadrant.translated(0, 2*quadrant.height()).contains(preview_center): # bottom left gravity preview_geometry.moveBottomLeft(ideal_geometry.bottomLeft()) elif quadrant.translated(quadrant.width(), 2*quadrant.height()).contains(preview_center): # bottom gravity preview_geometry.moveCenter(ideal_geometry.center()) preview_geometry.moveBottom(ideal_geometry.bottom()) elif quadrant.translated(2*quadrant.width(), 2*quadrant.height()).contains(preview_center): # bottom right gravity preview_geometry.moveBottomRight(ideal_geometry.bottomRight()) self.camera_preview.setGeometry(preview_geometry) def setParent(self, parent): old_parent = self.parent() if old_parent is not None: old_parent.removeEventFilter(self) super(VideoWidget, self).setParent(parent) if parent is not None: parent.installEventFilter(self) self.setGeometry(self.geometryHint()) def setVisible(self, visible): if visible == False and self.isFullScreen(): self.showNormal() if not self.detach_button.isChecked(): self.setParent(self.parent_widget) self.setGeometry(self.parent().rect()) self.fullscreen_button.setChecked(False) super(VideoWidget, self).setVisible(visible) def geometryHint(self, parent=None): parent = parent or self.parent() if parent is not None: origin = QPoint(0, 0) size = QSize(parent.width(), min(self.height_for_width(parent.width()), parent.height() - 175)) else: origin = self.geometry().topLeft() size = QSize(self.width_for_height(self.height()), self.height()) return QRect(origin, size) @run_in_gui_thread def handle_notification(self, notification): handler = getattr(self, '_NH_%s' % notification.name, Null) handler(notification) def _NH_BlinkSessionWillConnect(self, notification): if 'video' in notification.sender.streams: self.setParent(self.parent_widget) self.setGeometry(self.geometryHint()) self.detach_button.setChecked(False) for button in self.tool_buttons: button.active = False self.camera_preview.setMaximumHeight(16777215) self.camera_preview.setGeometry(self.rect()) self.camera_preview.setCursor(Qt.ArrowCursor) self.camera_preview.interactive = False self.camera_preview.scale_factor = 1.0 self.camera_preview.producer = SIPApplication.video_device.producer self.setCursor(Qt.ArrowCursor) self.show() def _NH_BlinkSessionDidConnect(self, notification): video_stream = notification.sender.streams.get('video') if video_stream is not None: if self.parent() is None: self.setParent(self.parent_widget) self.setGeometry(self.geometryHint()) self.detach_button.setChecked(False) for button in self.tool_buttons: button.active = False self.camera_preview.setMaximumHeight(16777215) self.camera_preview.setGeometry(self.rect()) self.camera_preview.setCursor(Qt.ArrowCursor) self.camera_preview.interactive = False self.camera_preview.scale_factor = 1.0 self.camera_preview.producer = SIPApplication.video_device.producer self.producer = video_stream.producer self.setCursor(Qt.ArrowCursor) self.show() else: self.hide() self.producer = None self._image = None self.camera_preview.producer = None self.camera_preview._image = None def _NH_BlinkSessionWillAddStream(self, notification): if notification.data.stream.type == 'video': self.setParent(self.parent_widget) self.setGeometry(self.geometryHint()) self.detach_button.setChecked(False) for button in self.tool_buttons: button.active = False self.camera_preview.setMaximumHeight(16777215) self.camera_preview.setGeometry(self.rect()) self.camera_preview.setCursor(Qt.ArrowCursor) self.camera_preview.interactive = False self.camera_preview.scale_factor = 1.0 self.camera_preview.producer = SIPApplication.video_device.producer self.setCursor(Qt.ArrowCursor) self.show() def _NH_BlinkSessionDidAddStream(self, notification): if notification.data.stream.type == 'video': self.producer = notification.data.stream.producer def _NH_BlinkSessionDidNotAddStream(self, notification): if notification.data.stream.type == 'video': self.hide() self.producer = None self._image = None self.camera_preview.producer = None self.camera_preview._image = None def _NH_BlinkSessionDidRemoveStream(self, notification): if notification.data.stream.type == 'video': self.hide() self.producer = None self._image = None self.camera_preview.producer = None self.camera_preview._image = None def _NH_BlinkSessionDidEnd(self, notification): self.hide() self.producer = None self._image = None self.camera_preview.producer = None self.camera_preview._image = None def _NH_BlinkSessionWasDeleted(self, notification): self.stop() self.setParent(None) self.session_item = None self.blink_session = None self.parent_widget = None self.detach_animation = None self.preview_animation = None def _NH_BlinkSessionDidChangeHoldState(self, notification): self.hold_button.setChecked(notification.data.local_hold) def _NH_VideoStreamRemoteFormatDidChange(self, notification): if notification.sender.blink_session is self.blink_session and not self.isFullScreen(): self.setGeometry(self.geometryHint()) def _NH_VideoStreamReceivedKeyFrame(self, notification): if notification.sender.blink_session is self.blink_session and self.preview_animation.state() != QPropertyAnimation.Running and self.camera_preview.size() == self.size(): self.preview_animation.setStartValue(self.rect()) self.preview_animation.setEndValue(QRect(0, 0, self.camera_preview.width_for_height(81), 81)) self.preview_animation.start() def _NH_VideoDeviceDidChangeCamera(self, notification): # self.camera_preview.producer = SIPApplication.video_device.producer self.camera_preview.producer = notification.data.new_camera def _NH_CFGSettingsObjectDidChange(self, notification): settings = SIPSimpleSettings() if 'audio.muted' in notification.data.modified: self.mute_button.setChecked(settings.audio.muted) def _SH_CameraPreviewAdjusted(self, old_geometry, new_geometry): if new_geometry.size() != old_geometry.size(): default_height_for_size = (self.height() + 117) / 6 self.camera_preview.scale_factor = new_geometry.height() / default_height_for_size def _SH_IdleTimerTimeout(self): for button in self.active_tool_buttons: button.hide() self.setCursor(Qt.BlankCursor) def _SH_FullscreenButtonClicked(self, checked): if checked: if not self.detach_button.isChecked(): geometry = self.rect().translated(self.mapToGlobal(QPoint(0, 0))) self.setParent(None) self.setGeometry(geometry) self.show() # without this, showFullScreen below doesn't work properly self.detach_button.active = False self.mute_button.active = True self.hold_button.active = True self.close_button.active = True self.showFullScreen() self.fullscreen_button.hide() # it seems the leave event after the button is pressed doesn't register and starting the idle timer here doesn't work well either -Dan self.fullscreen_button.show() else: if not self.detach_button.isChecked(): self.setGeometry(self.geometryHint(self.parent_widget)) # force a geometry change before re-parenting, else we will get a change from (-1, -1) to the parent geometry hint self.setParent(self.parent_widget) # this is probably because since it unmaps when it's re-parented, the geometry change won't appear from fullscreen self.setGeometry(self.geometryHint()) # to the new size, since we changed the geometry after returning from fullscreen, while invisible self.mute_button.active = False self.hold_button.active = False self.close_button.active = False self.detach_button.active = True self.showNormal() self.window().show() self.setCursor(Qt.ArrowCursor) def _SH_DetachButtonClicked(self, checked): if checked: if self.isFullScreen(): self.showNormal() desktop = QApplication.desktop() screen_area = desktop.availableGeometry(self) start_rect = self.rect() final_rect = QRect(0, 0, self.width_for_height(261), 261) start_geometry = start_rect.translated(self.mapToGlobal(QPoint(0, 0))) final_geometry = final_rect.translated(screen_area.topRight() - final_rect.topRight() + QPoint(-10, 10)) pixmap = self.grab() self.no_flicker_widget.resize(pixmap.size()) self.no_flicker_widget.setPixmap(pixmap) self.no_flicker_widget.setGeometry(self.rect().translated(self.mapToGlobal(QPoint(0, 0)))) self.no_flicker_widget.show() self.no_flicker_widget.raise_() self.setParent(None) self.setGeometry(start_geometry) self.show() self.no_flicker_widget.hide() self.detach_animation.setDirection(QPropertyAnimation.Forward) self.detach_animation.setEasingCurve(QEasingCurve.OutQuad) self.detach_animation.setStartValue(start_geometry) self.detach_animation.setEndValue(final_geometry) self.detach_animation.start() else: start_geometry = self.geometry() final_geometry = self.geometryHint(self.parent_widget).translated(self.parent_widget.mapToGlobal(QPoint(0, 0))) # do this early or late? -Dan self.parent_widget.window().show() self.detach_animation.setDirection(QPropertyAnimation.Backward) self.detach_animation.setEasingCurve(QEasingCurve.InQuad) self.detach_animation.setStartValue(final_geometry) # start and end are reversed because we go backwards self.detach_animation.setEndValue(start_geometry) self.detach_animation.start() self.fullscreen_button.setChecked(False) def _SH_ScreenshotButtonClicked(self): screenshot = VideoScreenshot(self) screenshot.capture() screenshot.save() def _SH_MuteButtonClicked(self, checked): settings = SIPSimpleSettings() settings.audio.muted = checked settings.save() def _SH_HoldButtonClicked(self, checked): if checked: self.blink_session.hold() else: self.blink_session.unhold() def _SH_CloseButtonClicked(self): if 'screen-sharing' in self.blink_session.streams: self.blink_session.remove_stream(self.session_item.video_stream) else: self.session_item.end() def _SH_ScreenshotButtonContextMenuRequested(self, pos): if not self.isFullScreen(): self.screenshot_button_menu.exec_(self.screenshot_button.mapToGlobal(pos)) def _SH_ScreenshotsFolderActionTriggered(self): settings = BlinkSettings() QDesktopServices.openUrl(QUrl.fromLocalFile(settings.screenshots_directory.normalized)) def _SH_DetachAnimationFinished(self): if self.detach_animation.direction() == QPropertyAnimation.Backward: pixmap = self.grab() self.no_flicker_widget.resize(pixmap.size()) self.no_flicker_widget.setPixmap(pixmap) self.no_flicker_widget.setGeometry(self.geometry()) self.no_flicker_widget.show() self.no_flicker_widget.raise_() # self.no_flicker_widget.repaint() # self.repaint() self.setParent(self.parent_widget) self.setGeometry(self.geometryHint()) self.show() # solve the flicker -Dan # self.repaint() # self.no_flicker_widget.lower() self.no_flicker_widget.hide() # self.window().show() self.mute_button.active = False self.hold_button.active = False self.close_button.active = False else: self.detach_button.hide() # it seems the leave event after the button is pressed doesn't register and starting the idle timer here doesn't work well either -Dan self.detach_button.show() self.mute_button.active = True self.hold_button.active = True self.close_button.active = True self.setCursor(Qt.ArrowCursor) def _SH_PreviewAnimationFinished(self): self.camera_preview.setMaximumHeight(135) self.camera_preview.interactive = True self.setCursor(Qt.ArrowCursor) self.detach_button.active = True self.fullscreen_button.active = True self.screenshot_button.active = True self.idle_timer.start() del ui_class, base_class class NoSessionsLabel(QLabel): def __init__(self, chat_window): super(NoSessionsLabel, self).__init__(chat_window.session_panel) self.chat_window = chat_window font = self.font() font.setPointSize(20) self.setFont(font) self.setAlignment(Qt.AlignCenter) self.setStyleSheet("""QLabel { border: 1px inset palette(dark); border-radius: 3px; background-color: white; color: #545454; }""") self.setText("No Sessions") chat_window.session_panel.installEventFilter(self) def eventFilter(self, watched, event): if event.type() == QEvent.Resize: self.resize(event.size()) return False ui_class, base_class = uic.loadUiType(Resources.get('chat_window.ui')) class ChatWindow(base_class, ui_class, ColorHelperMixin): implements(IObserver) sliding_panels = True __streamtypes__ = {'chat', 'screen-sharing', 'video'} # the stream types for which we show the chat window def __init__(self, parent=None): super(ChatWindow, self).__init__(parent) with Resources.directory: self.setupUi() self.selected_item = None self.session_model = ChatSessionModel(self) self.session_list.setModel(self.session_model) self.session_widget.installEventFilter(self) self.state_label.installEventFilter(self) self.info_panel.installEventFilter(self) self.audio_encryption_label.installEventFilter(self) self.video_encryption_label.installEventFilter(self) self.chat_encryption_label.installEventFilter(self) self.latency_graph.installEventFilter(self) self.packet_loss_graph.installEventFilter(self) self.traffic_graph.installEventFilter(self) self.mute_button.clicked.connect(self._SH_MuteButtonClicked) self.hold_button.clicked.connect(self._SH_HoldButtonClicked) self.record_button.clicked.connect(self._SH_RecordButtonClicked) self.control_button.clicked.connect(self._SH_ControlButtonClicked) self.participants_panel_info_button.clicked.connect(self._SH_InfoButtonClicked) self.participants_panel_files_button.clicked.connect(self._SH_FilesButtonClicked) self.files_panel_info_button.clicked.connect(self._SH_InfoButtonClicked) self.files_panel_participants_button.clicked.connect(self._SH_ParticipantsButtonClicked) self.info_panel_files_button.clicked.connect(self._SH_FilesButtonClicked) self.info_panel_participants_button.clicked.connect(self._SH_ParticipantsButtonClicked) self.latency_graph.updated.connect(self._SH_LatencyGraphUpdated) self.packet_loss_graph.updated.connect(self._SH_PacketLossGraphUpdated) self.traffic_graph.updated.connect(self._SH_TrafficGraphUpdated) self.session_model.sessionAdded.connect(self._SH_SessionModelSessionAdded) self.session_model.sessionRemoved.connect(self._SH_SessionModelSessionRemoved) self.session_model.sessionAboutToBeRemoved.connect(self._SH_SessionModelSessionAboutToBeRemoved) self.session_list.selectionModel().selectionChanged.connect(self._SH_SessionListSelectionChanged) self.otr_widget.nameChanged.connect(self._SH_OTRWidgetNameChanged) self.otr_widget.statusChanged.connect(self._SH_OTRWidgetStatusChanged) self.zrtp_widget.nameChanged.connect(self._SH_ZRTPWidgetNameChanged) self.zrtp_widget.statusChanged.connect(self._SH_ZRTPWidgetStatusChanged) geometry = QSettings().value("chat_window/geometry") if geometry: self.restoreGeometry(geometry) notification_center = NotificationCenter() notification_center.add_observer(self, name='SIPApplicationDidStart') notification_center.add_observer(self, name='BlinkSessionNewIncoming') notification_center.add_observer(self, name='BlinkSessionNewOutgoing') notification_center.add_observer(self, name='BlinkSessionDidReinitializeForIncoming') notification_center.add_observer(self, name='BlinkSessionDidReinitializeForOutgoing') notification_center.add_observer(self, name='ChatStreamGotMessage') notification_center.add_observer(self, name='ChatStreamGotComposingIndication') notification_center.add_observer(self, name='ChatStreamDidSendMessage') notification_center.add_observer(self, name='ChatStreamDidDeliverMessage') notification_center.add_observer(self, name='ChatStreamDidNotDeliverMessage') notification_center.add_observer(self, name='ChatStreamOTREncryptionStateChanged') notification_center.add_observer(self, name='ChatStreamOTRError') notification_center.add_observer(self, name='MediaStreamDidInitialize') notification_center.add_observer(self, name='MediaStreamDidNotInitialize') notification_center.add_observer(self, name='MediaStreamDidStart') notification_center.add_observer(self, name='MediaStreamDidFail') notification_center.add_observer(self, name='MediaStreamDidEnd') notification_center.add_observer(self, name='MediaStreamWillEnd') # self.splitter.splitterMoved.connect(self._SH_SplitterMoved) # check this and decide on what size to have in the window (see Notes) -Dan def _SH_SplitterMoved(self, pos, index): print("-- splitter:", pos, index, self.splitter.sizes()) def setupUi(self): super(ChatWindow, self).setupUi(self) self.session_list = ChatSessionListView(self) self.session_list.setObjectName('session_list') self.no_sessions_label = NoSessionsLabel(self) self.no_sessions_label.setObjectName('no_sessions_label') self.otr_widget = OTRWidget(self.info_panel) self.zrtp_widget = ZRTPWidget(self.info_panel) self.zrtp_widget.stream_type = None self.control_icon = QIcon(Resources.get('icons/cog.svg')) self.cancel_icon = QIcon(Resources.get('icons/cancel.png')) self.pixmaps = Container() self.pixmaps.direct_connection = QPixmap(Resources.get('icons/connection-direct.svg')) self.pixmaps.relay_connection = QPixmap(Resources.get('icons/connection-relay.svg')) self.pixmaps.unknown_connection = QPixmap(Resources.get('icons/connection-unknown.svg')) self.pixmaps.blue_lock = QPixmap(Resources.get('icons/lock-blue-12.svg')) self.pixmaps.grey_lock = QPixmap(Resources.get('icons/lock-grey-12.svg')) self.pixmaps.green_lock = QPixmap(Resources.get('icons/lock-green-12.svg')) self.pixmaps.orange_lock = QPixmap(Resources.get('icons/lock-orange-12.svg')) def blended_pixmap(pixmap, color): blended_pixmap = QPixmap(pixmap) painter = QPainter(blended_pixmap) painter.setRenderHint(QPainter.Antialiasing, True) painter.setCompositionMode(QPainter.CompositionMode_SourceAtop) painter.fillRect(blended_pixmap.rect(), color) painter.end() return blended_pixmap color = QColor(255, 255, 255, 64) self.pixmaps.light_blue_lock = blended_pixmap(self.pixmaps.blue_lock, color) self.pixmaps.light_grey_lock = blended_pixmap(self.pixmaps.grey_lock, color) self.pixmaps.light_green_lock = blended_pixmap(self.pixmaps.green_lock, color) self.pixmaps.light_orange_lock = blended_pixmap(self.pixmaps.orange_lock, color) # fix the SVG icons as the generated code loads them as pixmaps, losing their ability to scale -Dan def svg_icon(filename_off, filename_on): icon = QIcon() icon.addFile(filename_off, mode=QIcon.Normal, state=QIcon.Off) icon.addFile(filename_on, mode=QIcon.Normal, state=QIcon.On) icon.addFile(filename_on, mode=QIcon.Active, state=QIcon.On) return icon self.mute_button.setIcon(svg_icon(Resources.get('icons/mic-on.svg'), Resources.get('icons/mic-off.svg'))) self.hold_button.setIcon(svg_icon(Resources.get('icons/pause.svg'), Resources.get('icons/paused.svg'))) self.record_button.setIcon(svg_icon(Resources.get('icons/record.svg'), Resources.get('icons/recording.svg'))) self.control_button.setIcon(self.control_icon) self.control_menu = QMenu(self.control_button) self.control_button.setMenu(self.control_menu) self.control_button.actions = ContextMenuActions() self.control_button.actions.connect = QAction("Connect", self, triggered=self._AH_Connect) self.control_button.actions.connect_with_audio = QAction("Connect with audio", self, triggered=self._AH_ConnectWithAudio) self.control_button.actions.connect_with_video = QAction("Connect with video", self, triggered=self._AH_ConnectWithVideo) self.control_button.actions.disconnect = QAction("Disconnect", self, triggered=self._AH_Disconnect) self.control_button.actions.add_audio = QAction("Add audio", self, triggered=self._AH_AddAudio) self.control_button.actions.remove_audio = QAction("Remove audio", self, triggered=self._AH_RemoveAudio) self.control_button.actions.add_video = QAction("Add video", self, triggered=self._AH_AddVideo) self.control_button.actions.remove_video = QAction("Remove video", self, triggered=self._AH_RemoveVideo) self.control_button.actions.share_my_screen = QAction("Share my screen", self, triggered=self._AH_ShareMyScreen) self.control_button.actions.request_screen = QAction("Request screen", self, triggered=self._AH_RequestScreen) self.control_button.actions.end_screen_sharing = QAction("End screen sharing", self, triggered=self._AH_EndScreenSharing) self.control_button.actions.main_window = QAction("Main Window", self, triggered=self._AH_MainWindow, shortcut='Ctrl+B', shortcutContext=Qt.ApplicationShortcut) self.addAction(self.control_button.actions.main_window) # make this active even when it's not in the control_button's menu self.slide_direction = self.session_details.RightToLeft # decide if we slide from one direction only -Dan self.slide_direction = self.session_details.Automatic self.session_details.animationDuration = 300 self.session_details.animationEasingCurve = QEasingCurve.OutCirc self.audio_latency_graph = Graph([], color=QColor(0, 100, 215), over_boundary_color=QColor(255, 0, 100)) self.video_latency_graph = Graph([], color=QColor(0, 215, 100), over_boundary_color=QColor(255, 100, 0), enabled=False) # disable for now self.audio_packet_loss_graph = Graph([], color=QColor(0, 100, 215), over_boundary_color=QColor(255, 0, 100)) self.video_packet_loss_graph = Graph([], color=QColor(0, 215, 100), over_boundary_color=QColor(255, 100, 0), enabled=False) # disable for now self.incoming_traffic_graph = Graph([], color=QColor(255, 50, 50)) self.outgoing_traffic_graph = Graph([], color=QColor(0, 100, 215)) self.latency_graph.add_graph(self.audio_latency_graph) self.latency_graph.add_graph(self.video_latency_graph) self.packet_loss_graph.add_graph(self.audio_packet_loss_graph) self.packet_loss_graph.add_graph(self.video_packet_loss_graph) # the graph added 2nd will be displayed on top self.traffic_graph.add_graph(self.incoming_traffic_graph) self.traffic_graph.add_graph(self.outgoing_traffic_graph) self.dummy_tab = None # will be replaced by a dummy ChatWidget during SIPApplicationDidStart (creating a ChatWidget needs access to settings) self.tab_widget.clear() # remove the tab(s) added in designer self.tab_widget.tabBar().hide() self.session_list.hide() self.otr_widget.hide() self.zrtp_widget.hide() self.info_panel_files_button.hide() self.info_panel_participants_button.hide() self.participants_panel_files_button.hide() self.new_messages_button.hide() self.hold_button.hide() self.record_button.hide() self.control_button.setEnabled(False) self.info_label.setForegroundRole(QPalette.Dark) # prepare the RTP stream encryption labels so we can take over their behaviour self.audio_encryption_label.hovered = False self.video_encryption_label.hovered = False self.audio_encryption_label.stream_type = 'audio' self.video_encryption_label.stream_type = 'video' self.chat_encryption_label.hovered = False # prepare self.session_widget so we can take over some of its painting and behaviour self.session_widget.setAttribute(Qt.WA_Hover, True) self.session_widget.hovered = False def _get_selected_session(self): return self.__dict__['selected_session'] def _set_selected_session(self, session): old_session = self.__dict__.get('selected_session', None) new_session = self.__dict__['selected_session'] = session if new_session != old_session: self.otr_widget.hide() self.zrtp_widget.hide() self.zrtp_widget.stream_type = None notification_center = NotificationCenter() if old_session is not None: notification_center.remove_observer(self, sender=old_session) notification_center.remove_observer(self, sender=old_session.blink_session) if new_session is not None: notification_center.add_observer(self, sender=new_session) notification_center.add_observer(self, sender=new_session.blink_session) self._update_widgets_for_session() # clean this up -Dan (too many functions called in 3 different places: on selection changed, here and on notifications handlers) self._update_control_menu() self._update_panel_buttons() self._update_session_info_panel(elements={'session', 'media', 'statistics', 'status'}, update_visibility=True) selected_session = property(_get_selected_session, _set_selected_session) del _get_selected_session, _set_selected_session def _update_widgets_for_session(self): session = self.selected_session widget = session.widget # session widget self.name_label.setText(widget.name_label.text()) self.info_label.setText(widget.info_label.text()) self.icon_label.setPixmap(widget.icon_label.pixmap()) self.state_label.state = widget.state_label.state or 'offline' self.hold_icon.setVisible(widget.hold_icon.isVisibleTo(widget)) self.composing_icon.setVisible(widget.composing_icon.isVisibleTo(widget)) self.audio_icon.setVisible(widget.audio_icon.isVisibleTo(widget)) self.audio_icon.setEnabled(widget.audio_icon.isEnabledTo(widget)) self.chat_icon.setVisible(widget.chat_icon.isVisibleTo(widget)) self.chat_icon.setEnabled(widget.chat_icon.isEnabledTo(widget)) self.video_icon.setVisible(widget.video_icon.isVisibleTo(widget)) self.video_icon.setEnabled(widget.video_icon.isEnabledTo(widget)) self.screen_sharing_icon.setVisible(widget.screen_sharing_icon.isVisibleTo(widget)) self.screen_sharing_icon.setEnabled(widget.screen_sharing_icon.isEnabledTo(widget)) # toolbar buttons self.hold_button.setVisible('audio' in session.blink_session.streams) self.hold_button.setChecked(session.blink_session.local_hold) self.record_button.setVisible('audio' in session.blink_session.streams) self.record_button.setChecked(session.blink_session.recording) def _update_control_menu(self): menu = self.control_menu menu.hide() blink_session = self.selected_session.blink_session state = blink_session.state if state=='connecting/*' and blink_session.direction == 'outgoing' or state == 'connected/sent_proposal': self.control_button.setMenu(None) self.control_button.setIcon(self.cancel_icon) elif state == 'connected/received_proposal': self.control_button.setEnabled(False) else: self.control_button.setEnabled(True) self.control_button.setIcon(self.control_icon) menu.clear() if state not in ('connecting/*', 'connected/*'): menu.addAction(self.control_button.actions.connect) menu.addAction(self.control_button.actions.connect_with_audio) menu.addAction(self.control_button.actions.connect_with_video) else: menu.addAction(self.control_button.actions.disconnect) if state == 'connected': stream_types = blink_session.streams.types if 'audio' not in stream_types: menu.addAction(self.control_button.actions.add_audio) elif stream_types != {'audio'} and not stream_types.intersection({'screen-sharing', 'video'}): menu.addAction(self.control_button.actions.remove_audio) if 'video' not in stream_types: menu.addAction(self.control_button.actions.add_video) elif stream_types != {'video'}: menu.addAction(self.control_button.actions.remove_video) if 'screen-sharing' not in stream_types: menu.addAction(self.control_button.actions.request_screen) menu.addAction(self.control_button.actions.share_my_screen) elif stream_types != {'screen-sharing'}: menu.addAction(self.control_button.actions.end_screen_sharing) self.control_button.setMenu(menu) def _update_panel_buttons(self): self.info_panel_participants_button.setVisible(self.selected_session.blink_session.remote_focus) self.files_panel_participants_button.setVisible(self.selected_session.blink_session.remote_focus) def _update_session_info_panel(self, elements=set(), update_visibility=False): blink_session = self.selected_session.blink_session have_session = blink_session.state in ('connecting/*', 'connected/*', 'ending') if update_visibility: self.status_value_label.setEnabled(have_session) self.duration_value_label.setEnabled(have_session) self.account_value_label.setEnabled(have_session) self.remote_agent_value_label.setEnabled(have_session) self.audio_value_widget.setEnabled('audio' in blink_session.streams) self.video_value_widget.setEnabled('video' in blink_session.streams) self.chat_value_widget.setEnabled('chat' in blink_session.streams) self.screen_value_widget.setEnabled('screen-sharing' in blink_session.streams) session_info = blink_session.info audio_info = blink_session.info.streams.audio video_info = blink_session.info.streams.video chat_info = blink_session.info.streams.chat screen_info = blink_session.info.streams.screen_sharing if 'status' in elements and blink_session.state in ('initialized', 'connecting/*', 'connected/*', 'ended'): state_map = {'initialized': 'Disconnected', 'connecting/dns_lookup': 'Finding destination', 'connecting': 'Connecting', 'connecting/ringing': 'Ringing', 'connecting/starting': 'Starting media', 'connected': 'Connected'} if blink_session.state == 'ended': self.status_value_label.setForegroundRole(QPalette.AlternateBase if blink_session.state.error else QPalette.WindowText) self.status_value_label.setText(blink_session.state.reason) elif blink_session.state in state_map: self.status_value_label.setForegroundRole(QPalette.WindowText) self.status_value_label.setText(state_map[blink_session.state]) want_duration = blink_session.state == 'connected/*' or blink_session.state == 'ended' and not blink_session.state.error self.status_title_label.setVisible(not want_duration) self.status_value_label.setVisible(not want_duration) self.duration_title_label.setVisible(want_duration) self.duration_value_label.setVisible(want_duration) if 'session' in elements: self.account_value_label.setText(blink_session.account.id) self.remote_agent_value_label.setText(session_info.remote_user_agent or 'N/A') if 'media' in elements: self.audio_value_label.setText(audio_info.codec or 'N/A') if audio_info.ice_status == 'succeeded': if 'relay' in {candidate.type.lower() for candidate in (audio_info.local_rtp_candidate, audio_info.remote_rtp_candidate)}: self.audio_connection_label.setPixmap(self.pixmaps.relay_connection) self.audio_connection_label.setToolTip('Using relay') else: self.audio_connection_label.setPixmap(self.pixmaps.direct_connection) self.audio_connection_label.setToolTip('Peer to peer') elif audio_info.ice_status == 'failed': self.audio_connection_label.setPixmap(self.pixmaps.unknown_connection) self.audio_connection_label.setToolTip("Couldn't negotiate ICE") elif audio_info.ice_status == 'disabled': if blink_session.contact.type == 'bonjour': self.audio_connection_label.setPixmap(self.pixmaps.direct_connection) self.audio_connection_label.setToolTip('Peer to peer') else: self.audio_connection_label.setPixmap(self.pixmaps.unknown_connection) self.audio_connection_label.setToolTip('ICE is disabled') elif audio_info.ice_status is None: self.audio_connection_label.setPixmap(self.pixmaps.unknown_connection) self.audio_connection_label.setToolTip('ICE is unavailable') else: self.audio_connection_label.setPixmap(self.pixmaps.unknown_connection) self.audio_connection_label.setToolTip('Negotiating ICE') if audio_info.encryption is not None: self.audio_encryption_label.setToolTip('Media is encrypted using %s (%s)' % (audio_info.encryption, audio_info.encryption_cipher)) else: self.audio_encryption_label.setToolTip('Media is not encrypted') self._update_rtp_encryption_icon(self.audio_encryption_label) self.audio_connection_label.setVisible(audio_info.remote_address is not None) self.audio_encryption_label.setVisible(audio_info.encryption is not None) self.video_value_label.setText(video_info.codec or 'N/A') if video_info.ice_status == 'succeeded': if 'relay' in {candidate.type.lower() for candidate in (video_info.local_rtp_candidate, video_info.remote_rtp_candidate)}: self.video_connection_label.setPixmap(self.pixmaps.relay_connection) self.video_connection_label.setToolTip('Using relay') else: self.video_connection_label.setPixmap(self.pixmaps.direct_connection) self.video_connection_label.setToolTip('Peer to peer') elif video_info.ice_status == 'failed': self.video_connection_label.setPixmap(self.pixmaps.unknown_connection) self.video_connection_label.setToolTip("Couldn't negotiate ICE") elif video_info.ice_status == 'disabled': if blink_session.contact.type == 'bonjour': self.video_connection_label.setPixmap(self.pixmaps.direct_connection) self.video_connection_label.setToolTip('Peer to peer') else: self.video_connection_label.setPixmap(self.pixmaps.unknown_connection) self.video_connection_label.setToolTip('ICE is disabled') elif video_info.ice_status is None: self.video_connection_label.setPixmap(self.pixmaps.unknown_connection) self.video_connection_label.setToolTip('ICE is unavailable') else: self.video_connection_label.setPixmap(self.pixmaps.unknown_connection) self.video_connection_label.setToolTip('Negotiating ICE') if video_info.encryption is not None: self.video_encryption_label.setToolTip('Media is encrypted using %s (%s)' % (video_info.encryption, video_info.encryption_cipher)) else: self.video_encryption_label.setToolTip('Media is not encrypted') self._update_rtp_encryption_icon(self.video_encryption_label) self.video_connection_label.setVisible(video_info.remote_address is not None) self.video_encryption_label.setVisible(video_info.encryption is not None) if self.zrtp_widget.isVisibleTo(self.info_panel): # refresh the ZRTP widget (we need to hide/change/show because in certain configurations it flickers when changed while visible) stream_info = blink_session.info.streams[self.zrtp_widget.stream_type] self.zrtp_widget.hide() self.zrtp_widget.peer_name = stream_info.zrtp_peer_name self.zrtp_widget.peer_verified = stream_info.zrtp_verified self.zrtp_widget.sas = stream_info.zrtp_sas self.zrtp_widget.show() if any(len(path) > 1 for path in (chat_info.full_local_path, chat_info.full_remote_path)): self.chat_value_label.setText('Using relay') self.chat_connection_label.setPixmap(self.pixmaps.relay_connection) self.chat_connection_label.setToolTip('Using relay') elif chat_info.full_local_path and chat_info.full_remote_path: self.chat_value_label.setText('Peer to peer') self.chat_connection_label.setPixmap(self.pixmaps.direct_connection) self.chat_connection_label.setToolTip('Peer to peer') else: self.chat_value_label.setText('N/A') if chat_info.encryption is not None and chat_info.transport == 'tls': self.chat_encryption_label.setToolTip('Media is encrypted using TLS and {0.encryption} ({0.encryption_cipher})'.format(chat_info)) elif chat_info.encryption is not None: self.chat_encryption_label.setToolTip('Media is encrypted using {0.encryption} ({0.encryption_cipher})'.format(chat_info)) elif chat_info.transport == 'tls': self.chat_encryption_label.setToolTip('Media is encrypted using TLS') else: self.chat_encryption_label.setToolTip('Media is not encrypted') self._update_chat_encryption_icon() self.chat_connection_label.setVisible(chat_info.remote_address is not None) self.chat_encryption_label.setVisible(chat_info.remote_address is not None and (chat_info.encryption is not None or chat_info.transport == 'tls')) if self.otr_widget.isVisibleTo(self.info_panel): # refresh the OTR widget (we need to hide/change/show because in certain configurations it flickers when changed while visible) stream_info = blink_session.info.streams.chat self.otr_widget.hide() self.otr_widget.peer_name = stream_info.otr_peer_name self.otr_widget.peer_verified = stream_info.otr_verified self.otr_widget.peer_fingerprint = stream_info.otr_peer_fingerprint self.otr_widget.my_fingerprint = stream_info.otr_key_fingerprint self.otr_widget.smp_status = stream_info.smp_status self.otr_widget.show() if screen_info.remote_address is not None and screen_info.mode == 'active': self.screen_value_label.setText('Viewing remote') elif screen_info.remote_address is not None and screen_info.mode == 'passive': self.screen_value_label.setText('Sharing local') else: self.screen_value_label.setText('N/A') if any(len(path) > 1 for path in (screen_info.full_local_path, screen_info.full_remote_path)): self.screen_connection_label.setPixmap(self.pixmaps.relay_connection) self.screen_connection_label.setToolTip('Using relay') elif screen_info.full_local_path and screen_info.full_remote_path: self.screen_connection_label.setPixmap(self.pixmaps.direct_connection) self.screen_connection_label.setToolTip('Peer to peer') self.screen_encryption_label.setToolTip('Media is encrypted using TLS') self.screen_connection_label.setVisible(screen_info.remote_address is not None) self.screen_encryption_label.setVisible(screen_info.remote_address is not None and screen_info.transport == 'tls') if 'statistics' in elements: self.duration_value_label.value = session_info.duration self.audio_latency_graph.data = audio_info.latency self.video_latency_graph.data = video_info.latency self.audio_packet_loss_graph.data = audio_info.packet_loss self.video_packet_loss_graph.data = video_info.packet_loss self.incoming_traffic_graph.data = audio_info.incoming_traffic self.outgoing_traffic_graph.data = audio_info.outgoing_traffic self.latency_graph.update() self.packet_loss_graph.update() self.traffic_graph.update() def _update_rtp_encryption_icon(self, encryption_label): stream = self.selected_session.blink_session.streams.get(encryption_label.stream_type) stream_info = self.selected_session.blink_session.info.streams[encryption_label.stream_type] if encryption_label.isEnabled() and stream_info.encryption == 'ZRTP': if encryption_label.hovered and stream is not None and not stream._done: encryption_label.setPixmap(self.pixmaps.light_green_lock if stream_info.zrtp_verified else self.pixmaps.light_orange_lock) else: encryption_label.setPixmap(self.pixmaps.green_lock if stream_info.zrtp_verified else self.pixmaps.orange_lock) else: encryption_label.setPixmap(self.pixmaps.grey_lock) def _update_chat_encryption_icon(self): stream = self.selected_session.chat_stream stream_info = self.selected_session.blink_session.info.streams.chat if self.chat_encryption_label.isEnabled() and stream_info.encryption == 'OTR': if self.chat_encryption_label.hovered and stream is not None and not stream._done: self.chat_encryption_label.setPixmap(self.pixmaps.light_green_lock if stream_info.otr_verified else self.pixmaps.light_orange_lock) else: self.chat_encryption_label.setPixmap(self.pixmaps.green_lock if stream_info.otr_verified else self.pixmaps.orange_lock) else: self.chat_encryption_label.setPixmap(self.pixmaps.grey_lock) def show(self): super(ChatWindow, self).show() self.raise_() self.activateWindow() def closeEvent(self, event): QSettings().setValue("chat_window/geometry", self.saveGeometry()) super(ChatWindow, self).closeEvent(event) def eventFilter(self, watched, event): event_type = event.type() if watched is self.session_widget: if event_type == QEvent.HoverEnter: watched.hovered = True elif event_type == QEvent.HoverLeave: watched.hovered = False elif event_type == QEvent.MouseButtonDblClick and event.button() == Qt.LeftButton: self._EH_ShowSessions() elif watched is self.state_label: if event_type == QEvent.MouseButtonRelease and event.button() == Qt.LeftButton and event.modifiers() == Qt.NoModifier: upper_half = QRect(0, 0, self.state_label.width(), self.state_label.height()/2) if upper_half.contains(event.pos()): self._EH_CloseSession() else: self._EH_ShowSessions() elif event_type == QEvent.Paint: # and self.session_widget.hovered: watched.event(event) self.drawSessionWidgetIndicators() return True elif watched in (self.latency_graph, self.packet_loss_graph, self.traffic_graph): if event_type == QEvent.Wheel and event.modifiers() == Qt.ControlModifier: settings = BlinkSettings() wheel_delta = event.angleDelta().y() if wheel_delta > 0 and settings.chat_window.session_info.graph_time_scale > GraphTimeScale.min_value: settings.chat_window.session_info.graph_time_scale -= 1 settings.save() elif wheel_delta < 0 and settings.chat_window.session_info.graph_time_scale < GraphTimeScale.max_value: settings.chat_window.session_info.graph_time_scale += 1 settings.save() elif watched in (self.audio_encryption_label, self.video_encryption_label): if event_type == QEvent.Enter: watched.hovered = True self._update_rtp_encryption_icon(watched) elif event_type == QEvent.Leave: watched.hovered = False self._update_rtp_encryption_icon(watched) elif event_type == QEvent.EnabledChange and not watched.isEnabled(): watched.setPixmap(self.pixmaps.grey_lock) elif event_type in (QEvent.MouseButtonPress, QEvent.MouseButtonDblClick) and event.button() == Qt.LeftButton and event.modifiers() == Qt.NoModifier and watched.isEnabled(): self._EH_RTPEncryptionLabelClicked(watched) elif watched is self.chat_encryption_label: if event_type == QEvent.Enter: watched.hovered = True self._update_chat_encryption_icon() elif event_type == QEvent.Leave: watched.hovered = False self._update_chat_encryption_icon() elif event_type == QEvent.EnabledChange and not watched.isEnabled(): watched.setPixmap(self.pixmaps.grey_lock) elif event_type in (QEvent.MouseButtonPress, QEvent.MouseButtonDblClick) and event.button() == Qt.LeftButton and event.modifiers() == Qt.NoModifier and watched.isEnabled(): self._EH_ChatEncryptionLabelClicked() elif watched is self.info_panel: if event_type == QEvent.Resize: if self.zrtp_widget.isVisibleTo(self.info_panel): rect = self.zrtp_widget.geometry() rect.setWidth(self.info_panel.width()) self.zrtp_widget.setGeometry(rect) if self.otr_widget.isVisibleTo(self.info_panel): rect = self.otr_widget.geometry() rect.setWidth(self.info_panel.width()) self.otr_widget.setGeometry(rect) return False def drawSessionWidgetIndicators(self): painter = QPainter(self.state_label) palette = self.state_label.palette() rect = self.state_label.rect() pen_thickness = 1.6 if self.state_label.state is not None: background_color = self.state_label.state_colors[self.state_label.state] base_contrast_color = self.calc_light_color(background_color) gradient = QLinearGradient(0, 0, 1, 0) gradient.setCoordinateMode(QLinearGradient.ObjectBoundingMode) gradient.setColorAt(0.0, self.color_with_alpha(base_contrast_color, 0.3*255)) gradient.setColorAt(1.0, self.color_with_alpha(base_contrast_color, 0.8*255)) contrast_color = QBrush(gradient) else: background_color = palette.color(QPalette.Window) contrast_color = self.calc_light_color(background_color) foreground_color = palette.color(QPalette.Normal, QPalette.WindowText) line_color = self.deco_color(background_color, foreground_color) pen = QPen(line_color, pen_thickness, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin) contrast_pen = QPen(contrast_color, pen_thickness, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin) # draw the expansion indicator at the bottom (works best with a state_label of width 14) arrow_rect = QRect(0, 0, 14, 14) arrow_rect.moveBottomRight(rect.bottomRight()) arrow = QPolygonF([QPointF(-3, -1.5), QPointF(0.5, 2.5), QPointF(4, -1.5)]) arrow.translate(1, 1) painter.save() painter.setRenderHint(QPainter.Antialiasing, True) painter.setCompositionMode(QPainter.CompositionMode_SourceOver) painter.translate(arrow_rect.center()) painter.translate(0, +1) painter.setPen(contrast_pen) painter.drawPolyline(arrow) painter.translate(0, -1) painter.setPen(pen) painter.drawPolyline(arrow) painter.restore() # draw the close indicator at the top (works best with a state_label of width 14) cross_rect = QRect(0, 0, 14, 14) cross_rect.moveTopRight(rect.topRight()) painter.save() painter.setRenderHint(QPainter.Antialiasing, True) painter.setCompositionMode(QPainter.CompositionMode_SourceOver) painter.translate(cross_rect.center()) painter.translate(+1.5, +1) painter.translate(0, +1) painter.setPen(contrast_pen) painter.drawLine(-3.5, -3.5, 3.5, 3.5) painter.drawLine(-3.5, 3.5, 3.5, -3.5) painter.translate(0, -1) painter.setPen(pen) painter.drawLine(-3.5, -3.5, 3.5, 3.5) painter.drawLine(-3.5, 3.5, 3.5, -3.5) painter.restore() @run_in_gui_thread def handle_notification(self, notification): handler = getattr(self, '_NH_%s' % notification.name, Null) handler(notification) def _NH_SIPApplicationDidStart(self, notification): notification.center.add_observer(self, name='CFGSettingsObjectDidChange') blink_settings = BlinkSettings() if blink_settings.chat_window.session_info.alternate_style: title_role = 'alt-title' value_role = 'alt-value' else: title_role = 'title' value_role = 'value' for label in (attr for name, attr in vars(self).items() if name.endswith('_title_label') and attr.property('role') is not None): label.setProperty('role', title_role) for label in (attr for name, attr in vars(self).items() if name.endswith('_value_label') or name.endswith('_value_widget') and attr.property('role') is not None): label.setProperty('role', value_role) self.info_panel_container_widget.setStyleSheet(self.info_panel_container_widget.styleSheet()) self.latency_graph.horizontalPixelsPerUnit = blink_settings.chat_window.session_info.graph_time_scale self.packet_loss_graph.horizontalPixelsPerUnit = blink_settings.chat_window.session_info.graph_time_scale self.traffic_graph.horizontalPixelsPerUnit = blink_settings.chat_window.session_info.graph_time_scale self.latency_graph.update() self.packet_loss_graph.update() self.traffic_graph.update() self.dummy_tab = ChatWidget(None, self.tab_widget) self.dummy_tab.setDisabled(True) self.tab_widget.addTab(self.dummy_tab, "Dummy") self.tab_widget.setCurrentWidget(self.dummy_tab) def _NH_CFGSettingsObjectDidChange(self, notification): settings = SIPSimpleSettings() blink_settings = BlinkSettings() if notification.sender is settings: if 'audio.muted' in notification.data.modified: self.mute_button.setChecked(settings.audio.muted) elif notification.sender is blink_settings: if 'presence.icon' in notification.data.modified: QWebSettings.clearMemoryCaches() if 'chat_window.session_info.alternate_style' in notification.data.modified: if blink_settings.chat_window.session_info.alternate_style: title_role = 'alt-title' value_role = 'alt-value' else: title_role = 'title' value_role = 'value' for label in (attr for name, attr in vars(self).items() if name.endswith('_title_label') and attr.property('role') is not None): label.setProperty('role', title_role) for label in (attr for name, attr in vars(self).items() if name.endswith('_value_label') or name.endswith('_value_widget') and attr.property('role') is not None): label.setProperty('role', value_role) self.info_panel_container_widget.setStyleSheet(self.info_panel_container_widget.styleSheet()) if 'chat_window.session_info.bytes_per_second' in notification.data.modified: self.traffic_graph.update() if 'chat_window.session_info.graph_time_scale' in notification.data.modified: self.latency_graph.horizontalPixelsPerUnit = blink_settings.chat_window.session_info.graph_time_scale self.packet_loss_graph.horizontalPixelsPerUnit = blink_settings.chat_window.session_info.graph_time_scale self.traffic_graph.horizontalPixelsPerUnit = blink_settings.chat_window.session_info.graph_time_scale self.latency_graph.update() self.packet_loss_graph.update() self.traffic_graph.update() def _NH_BlinkSessionNewIncoming(self, notification): if notification.sender.streams.types.intersection(self.__streamtypes__): self.show() def _NH_BlinkSessionNewOutgoing(self, notification): if notification.sender.stream_descriptions.types.intersection(self.__streamtypes__): self.show() def _NH_BlinkSessionDidReinitializeForIncoming(self, notification): model = self.session_model position = model.sessions.index(notification.sender.items.chat) selection_model = self.session_list.selectionModel() selection_model.select(model.index(position), selection_model.ClearAndSelect) self.session_list.scrollTo(model.index(position), QListView.EnsureVisible) # or PositionAtCenter if notification.sender.streams.types.intersection(self.__streamtypes__): self.show() def _NH_BlinkSessionDidReinitializeForOutgoing(self, notification): model = self.session_model position = model.sessions.index(notification.sender.items.chat) selection_model = self.session_list.selectionModel() selection_model.select(model.index(position), selection_model.ClearAndSelect) self.session_list.scrollTo(model.index(position), QListView.EnsureVisible) # or PositionAtCenter if notification.sender.stream_descriptions.types.intersection(self.__streamtypes__): self.show() # use BlinkSessionNewIncoming/Outgoing to show the chat window if there is a chat stream available (like with reinitialize) instead of using the sessionAdded signal from the model -Dan # or maybe not. sessionAdded means it was added to the model, while during NewIncoming/Outgoing we do not know that yet. but then we have a problem with the DidReinitialize since # they do not check if the session is in the model. maybe the right approach is to always have BlinkSessions in the model and if we need any other kind of sessions we create a # different class for them that posts different notifications. in that case we can do in in NewIncoming/Outgoing -Dan def _NH_BlinkSessionWillAddStream(self, notification): if notification.data.stream.type in self.__streamtypes__: self.show() def _NH_BlinkSessionDidRemoveStream(self, notification): self._update_control_menu() self._update_session_info_panel(update_visibility=True) def _NH_BlinkSessionDidChangeState(self, notification): # even if we use this, we also need to listen for BlinkSessionDidRemoveStream as that transition doesn't change the state at all -Dan self._update_control_menu() self._update_panel_buttons() self._update_session_info_panel(elements={'status'}, update_visibility=True) def _NH_BlinkSessionDidEnd(self, notification): if self.selected_session.active_panel is not self.info_panel: if self.sliding_panels: self.session_details.slideInWidget(self.info_panel, direction=self.slide_direction) else: self.session_details.setCurrentWidget(self.info_panel) self.selected_session.active_panel = self.info_panel def _NH_BlinkSessionInfoUpdated(self, notification): self._update_session_info_panel(elements=notification.data.elements) def _NH_BlinkSessionWillAddParticipant(self, notification): if len(notification.sender.server_conference.participants) == 1 and self.selected_session.active_panel is not self.participants_panel: if self.sliding_panels: self.session_details.slideInWidget(self.participants_panel, direction=self.slide_direction) else: self.session_details.setCurrentWidget(self.participants_panel) self.selected_session.active_panel = self.participants_panel def _NH_ChatSessionItemDidChange(self, notification): self._update_widgets_for_session() def _NH_ChatStreamGotMessage(self, notification): blink_session = notification.sender.blink_session session = blink_session.items.chat if session is None: return message = notification.data.message if message.content_type.startswith('image/'): content = '''<img src="data:{};base64,{}" class="scaled-to-fit" />'''.format(message.content_type, message.content.encode('base64').rstrip()) elif message.content_type.startswith('text/'): content = HtmlProcessor.autolink(message.content if message.content_type == 'text/html' else QTextDocument(message.content).toHtml()) else: return uri = '%s@%s' % (message.sender.uri.user, message.sender.uri.host) account_manager = AccountManager() if account_manager.has_account(uri): account = account_manager.get_account(uri) sender = ChatSender(message.sender.display_name or account.display_name, uri, session.chat_widget.user_icon.filename) elif blink_session.remote_focus: contact, contact_uri = URIUtils.find_contact(uri) sender = ChatSender(message.sender.display_name or contact.name, uri, contact.icon.filename) else: sender = ChatSender(message.sender.display_name or session.name, uri, session.icon.filename) is_status_message = any(h.name == 'Message-Type' and h.value == 'status' and h.namespace == 'urn:ag-projects:xml:ns:cpim' for h in message.additional_headers) if is_status_message: session.chat_widget.add_message(ChatStatus(content)) else: session.chat_widget.add_message(ChatMessage(content, sender, 'incoming')) session.remote_composing = False settings = SIPSimpleSettings() if settings.sounds.play_message_alerts and self.selected_session is session: player = WavePlayer(SIPApplication.alert_audio_bridge.mixer, Resources.get('sounds/message_received.wav'), volume=20) SIPApplication.alert_audio_bridge.add(player) player.start() def _NH_ChatStreamGotComposingIndication(self, notification): session = notification.sender.blink_session.items.chat if session is None: return session.update_composing_indication(notification.data) def _NH_ChatStreamDidSendMessage(self, notification): session = notification.sender.blink_session.items.chat if session is None: return # TODO: do we want to use this? Play the message sent tone? -Saul def _NH_ChatStreamDidDeliverMessage(self, notification): session = notification.sender.blink_session.items.chat if session is None: return # TODO: implement -Saul def _NH_ChatStreamDidNotDeliverMessage(self, notification): session = notification.sender.blink_session.items.chat if session is None: return # TODO: implement -Saul def _NH_ChatStreamOTREncryptionStateChanged(self, notification): session = notification.sender.blink_session.items.chat if session is None: return if notification.data.new_state is OTRState.Encrypted: session.chat_widget.add_message(ChatStatus('Encryption enabled')) elif notification.data.old_state is OTRState.Encrypted: session.chat_widget.add_message(ChatStatus('Encryption disabled')) self.otr_widget.hide() if notification.data.new_state is OTRState.Finished: session.chat_widget.chat_input.lock(EncryptionLock) # todo: play sound here? def _NH_ChatStreamOTRError(self, notification): session = notification.sender.blink_session.items.chat if session is not None: message = "OTR Error: {.error}".format(notification.data) session.chat_widget.add_message(ChatStatus(message)) def _NH_MediaStreamDidInitialize(self, notification): if notification.sender.type != 'chat': return session = notification.sender.blink_session.items.chat if session is None: return # session.chat_widget.add_message(ChatStatus('Connecting...')) # disable it until we can replace it in the DOM -Dan def _NH_MediaStreamDidNotInitialize(self, notification): if notification.sender.type != 'chat': return session = notification.sender.blink_session.items.chat if session is None: return session.chat_widget.add_message(ChatStatus('Failed to initialize chat: %s' % notification.data.reason)) def _NH_MediaStreamDidStart(self, notification): if notification.sender.type != 'chat': return session = notification.sender.blink_session.items.chat if session is None: return session.chat_widget.add_message(ChatStatus('Connected')) def _NH_MediaStreamDidEnd(self, notification): if notification.sender.type != 'chat': return session = notification.sender.blink_session.items.chat if session is None: return if notification.data.error is not None: session.chat_widget.add_message(ChatStatus('Disconnected: %s' % notification.data.error)) else: session.chat_widget.add_message(ChatStatus('Disconnected')) def _NH_MediaStreamWillEnd(self, notification): stream = notification.sender if stream.type == 'chat' and stream.blink_session.items.chat is self.selected_session: self.otr_widget.hide() if stream.type == self.zrtp_widget.stream_type and stream.blink_session.items.chat is self.selected_session: self.zrtp_widget.hide() self.zrtp_widget.stream_type = None # signal handlers # def _SH_InfoButtonClicked(self, checked): if self.sliding_panels: self.session_details.slideInWidget(self.info_panel, direction=self.slide_direction) else: self.session_details.setCurrentWidget(self.info_panel) self.selected_session.active_panel = self.info_panel def _SH_FilesButtonClicked(self, checked): if self.sliding_panels: self.session_details.slideInWidget(self.files_panel, direction=self.slide_direction) else: self.session_details.setCurrentWidget(self.files_panel) self.selected_session.active_panel = self.files_panel def _SH_ParticipantsButtonClicked(self, checked): if self.sliding_panels: self.session_details.slideInWidget(self.participants_panel, direction=self.slide_direction) else: self.session_details.setCurrentWidget(self.participants_panel) self.selected_session.active_panel = self.participants_panel def _SH_LatencyGraphUpdated(self): self.latency_label.setText('Network Latency: %dms, max=%dms' % (max(self.audio_latency_graph.last_value, self.video_latency_graph.last_value), self.latency_graph.max_value)) def _SH_PacketLossGraphUpdated(self): self.packet_loss_label.setText('Packet Loss: %.1f%%, max=%.1f%%' % (max(self.audio_packet_loss_graph.last_value, self.video_packet_loss_graph.last_value), self.packet_loss_graph.max_value)) def _SH_TrafficGraphUpdated(self): blink_settings = BlinkSettings() if blink_settings.chat_window.session_info.bytes_per_second: incoming_traffic = TrafficNormalizer.normalize(self.incoming_traffic_graph.last_value) outgoing_traffic = TrafficNormalizer.normalize(self.outgoing_traffic_graph.last_value) else: incoming_traffic = TrafficNormalizer.normalize(self.incoming_traffic_graph.last_value*8, bits_per_second=True) outgoing_traffic = TrafficNormalizer.normalize(self.outgoing_traffic_graph.last_value*8, bits_per_second=True) self.traffic_label.setText("""<p>Traffic: <span style="font-family: sans-serif; color: #d70000;">\u2193</span> %s <span style="font-family: sans-serif; color: #0064d7;">\u2191</span> %s</p>""" % (incoming_traffic, outgoing_traffic)) def _SH_MuteButtonClicked(self, checked): settings = SIPSimpleSettings() settings.audio.muted = checked settings.save() def _SH_HoldButtonClicked(self, checked): if checked: self.selected_session.blink_session.hold() else: self.selected_session.blink_session.unhold() def _SH_RecordButtonClicked(self, checked): if checked: self.selected_session.blink_session.start_recording() else: self.selected_session.blink_session.stop_recording() def _SH_ControlButtonClicked(self): # this is only called if the control button doesn't have a menu attached if self.selected_session.blink_session.state == 'connected/sent_proposal': self.selected_session.blink_session.sip_session.cancel_proposal() else: self.selected_session.end() def _SH_SessionModelSessionAdded(self, session): model = self.session_model position = model.sessions.index(session) session.chat_widget = ChatWidget(session, self.tab_widget) session.video_widget = VideoWidget(session, session.chat_widget) session.active_panel = self.info_panel self.tab_widget.insertTab(position, session.chat_widget, session.name) self.no_sessions_label.hide() selection_model = self.session_list.selectionModel() selection_model.select(model.index(position), selection_model.ClearAndSelect) self.session_list.scrollTo(model.index(position), QListView.EnsureVisible) # or PositionAtCenter session.chat_widget.chat_input.setFocus(Qt.OtherFocusReason) def _SH_SessionModelSessionRemoved(self, session): self.tab_widget.removeTab(self.tab_widget.indexOf(session.chat_widget)) session.chat_widget = None session.video_widget = None session.active_panel = None if not self.session_model.sessions: self.close() self.no_sessions_label.show() elif not self.session_list.isVisibleTo(self): self.session_list.animation.setDirection(QPropertyAnimation.Forward) self.session_list.animation.setStartValue(self.session_widget.geometry()) self.session_list.animation.setEndValue(self.session_panel.rect()) self.session_list.show() self.session_list.animation.start() def _SH_SessionModelSessionAboutToBeRemoved(self, session): # choose another one to select (a chat only or ended session if available, else one with audio but keep audio on hold? or select nothing and display the dummy tab?) # selection_model = self.session_list.selectionModel() # selection_model.clearSelection() pass def _SH_SessionListSelectionChanged(self, selected, deselected): # print "-- chat selection changed %s -> %s" % ([x.row() for x in deselected.indexes()], [x.row() for x in selected.indexes()]) self.selected_session = selected[0].topLeft().data(Qt.UserRole) if selected else None if self.selected_session is not None: self.tab_widget.setCurrentWidget(self.selected_session.chat_widget) # why do we switch the tab here, but do everything else in the selected_session property setter? -Dan self.session_details.setCurrentWidget(self.selected_session.active_panel) self.participants_list.setModel(self.selected_session.participants_model) self.control_button.setEnabled(True) else: self.tab_widget.setCurrentWidget(self.dummy_tab) self.session_details.setCurrentWidget(self.info_panel) self.participants_list.setModel(None) self.control_button.setEnabled(False) def _SH_OTRWidgetNameChanged(self): stream = self.selected_session.chat_stream or Null stream.encryption.peer_name = self.otr_widget.peer_name def _SH_OTRWidgetStatusChanged(self): stream = self.selected_session.chat_stream or Null stream.encryption.verified = self.otr_widget.peer_verified def _SH_ZRTPWidgetNameChanged(self): stream = self.selected_session.blink_session.streams.get(self.zrtp_widget.stream_type, Null) stream.encryption.zrtp.peer_name = self.zrtp_widget.peer_name def _SH_ZRTPWidgetStatusChanged(self): stream = self.selected_session.blink_session.streams.get(self.zrtp_widget.stream_type, Null) stream.encryption.zrtp.verified = self.zrtp_widget.peer_verified def _AH_Connect(self): blink_session = self.selected_session.blink_session blink_session.init_outgoing(blink_session.account, blink_session.contact, blink_session.contact_uri, stream_descriptions=[StreamDescription('chat')], reinitialize=True) blink_session.connect() def _AH_ConnectWithAudio(self): stream_descriptions = [StreamDescription('audio'), StreamDescription('chat')] blink_session = self.selected_session.blink_session blink_session.init_outgoing(blink_session.account, blink_session.contact, blink_session.contact_uri, stream_descriptions=stream_descriptions, reinitialize=True) blink_session.connect() def _AH_ConnectWithVideo(self): stream_descriptions = [StreamDescription('audio'), StreamDescription('video'), StreamDescription('chat')] blink_session = self.selected_session.blink_session blink_session.init_outgoing(blink_session.account, blink_session.contact, blink_session.contact_uri, stream_descriptions=stream_descriptions, reinitialize=True) blink_session.connect() def _AH_Disconnect(self): self.selected_session.end() def _AH_AddAudio(self): self.selected_session.blink_session.add_stream(StreamDescription('audio')) def _AH_RemoveAudio(self): self.selected_session.blink_session.remove_stream(self.selected_session.blink_session.streams.get('audio')) def _AH_AddVideo(self): if 'audio' in self.selected_session.blink_session.streams: self.selected_session.blink_session.add_stream(StreamDescription('video')) else: self.selected_session.blink_session.add_streams([StreamDescription('video'), StreamDescription('audio')]) def _AH_RemoveVideo(self): self.selected_session.blink_session.remove_stream(self.selected_session.blink_session.streams.get('video')) def _AH_RequestScreen(self): if 'audio' in self.selected_session.blink_session.streams: self.selected_session.blink_session.add_stream(StreamDescription('screen-sharing', mode='viewer')) else: self.selected_session.blink_session.add_streams([StreamDescription('screen-sharing', mode='viewer'), StreamDescription('audio')]) def _AH_ShareMyScreen(self): if 'audio' in self.selected_session.blink_session.streams: self.selected_session.blink_session.add_stream(StreamDescription('screen-sharing', mode='server')) else: self.selected_session.blink_session.add_streams([StreamDescription('screen-sharing', mode='server'), StreamDescription('audio')]) def _AH_EndScreenSharing(self): self.selected_session.blink_session.remove_stream(self.selected_session.blink_session.streams.get('screen-sharing')) def _AH_MainWindow(self): blink = QApplication.instance() blink.main_window.show() def _EH_CloseSession(self): if self.selected_session is not None: self.selected_session.end(delete=True) def _EH_ShowSessions(self): self.session_list.animation.setDirection(QPropertyAnimation.Forward) self.session_list.animation.setStartValue(self.session_widget.geometry()) self.session_list.animation.setEndValue(self.session_panel.rect()) self.session_list.scrollToTop() self.session_list.show() self.session_list.animation.start() def _EH_ChatEncryptionLabelClicked(self): stream = self.selected_session.chat_stream stream_info = self.selected_session.blink_session.info.streams.chat if stream is not None and not stream._done and stream_info.encryption == 'OTR': if self.otr_widget.isVisible(): self.otr_widget.hide() else: encryption_label = self.chat_encryption_label self.zrtp_widget.hide() self.otr_widget.peer_name = stream_info.otr_peer_name self.otr_widget.peer_verified = stream_info.otr_verified self.otr_widget.peer_fingerprint = stream_info.otr_peer_fingerprint self.otr_widget.my_fingerprint = stream_info.otr_key_fingerprint self.otr_widget.smp_status = stream_info.smp_status self.otr_widget.setGeometry(QRect(0, encryption_label.rect().translated(encryption_label.mapTo(self.info_panel, QPoint(0, 0))).bottom() + 3, self.info_panel.width(), 320)) self.otr_widget.verification_stack.setCurrentWidget(self.otr_widget.smp_panel) self.otr_widget.show() self.otr_widget.peer_name_value.setFocus(Qt.OtherFocusReason) def _EH_RTPEncryptionLabelClicked(self, encryption_label): stream = self.selected_session.blink_session.streams.get(encryption_label.stream_type) stream_info = self.selected_session.blink_session.info.streams[encryption_label.stream_type] if stream is not None and not stream._done and stream_info.encryption == 'ZRTP': if self.zrtp_widget.isVisible() and self.zrtp_widget.stream_type == encryption_label.stream_type: self.zrtp_widget.hide() self.zrtp_widget.stream_type = None else: self.zrtp_widget.hide() self.zrtp_widget.peer_name = stream_info.zrtp_peer_name self.zrtp_widget.peer_verified = stream_info.zrtp_verified self.zrtp_widget.sas = stream_info.zrtp_sas self.zrtp_widget.stream_type = encryption_label.stream_type self.zrtp_widget.setGeometry(QRect(0, encryption_label.rect().translated(encryption_label.mapTo(self.info_panel, QPoint(0, 0))).bottom() + 3, self.info_panel.width(), 320)) self.zrtp_widget.show() self.zrtp_widget.peer_name_value.setFocus(Qt.OtherFocusReason) del ui_class, base_class # Helpers # class HtmlProcessor(object): _autolink_re = [re.compile(r""" (?P<body> https?://(?:[^:@/]+(?::[^@]*)?@)?(?P<host>[a-z0-9.-]+)(?::\d*)? # scheme :// [ user [ : password ] @ ] host [ : port ] (?:/(?:[\w/%!$@#*&='~:;,.+-]*(?:\([\w/%!$@#*&='~:;,.+-]*\))?)*)? # [ / path] (?:\?(?:[\w/%!$@#*&='~:;,.+-]*(?:\([\w/%!$@#*&='~:;,.+-]*\))?)*)? # [ ? query] ) """, re.IGNORECASE | re.UNICODE | re.VERBOSE), re.compile(r""" (?P<body> ftps?://(?:[^:@/]+(?::[^@]*)?@)?(?P<host>[a-z0-9.-]+)(?::\d*)? # scheme :// [ user [ : password ] @ ] host [ : port ] (?:/(?:[\w/%!?$@*&='~:,.+-]*(?:\([\w/%!?$@*&='~:,.+-]*\))?)*(?:;type=[aid])?)? # [ / path [ ;type=a/i/d ] ] ) """, re.IGNORECASE | re.UNICODE | re.VERBOSE), re.compile(r'mailto:(?P<body>[\w.-]+@(?P<host>[a-z0-9.-]+))', re.IGNORECASE | re.UNICODE)] @classmethod def autolink(cls, content): if isinstance(content, str): doc = html.fromstring(content) autolink(doc, link_regexes=cls._autolink_re) return html.tostring(doc, encoding='unicode') # add method='xml' to get <br/> xhtml style tags and doctype=doc.getroottree().docinfo.doctype for prepending the DOCTYPE line else: autolink(content, link_regexes=cls._autolink_re) return content @classmethod def normalize(cls, content): return content class TrafficNormalizer(object): boundaries = [( 1024, '%d%ss', 1), ( 10*1024, '%.2fk%ss', 1024.0), ( 1024*1024, '%.1fk%ss', 1024.0), ( 10*1024*1024, '%.2fM%ss', 1024*1024.0), ( 1024*1024*1024, '%.1fM%ss', 1024*1024.0), (10*1024*1024*1024, '%.2fG%ss', 1024*1024*1024.0), (float('infinity'), '%.1fG%ss', 1024*1024*1024.0)] @classmethod def normalize(cls, value, bits_per_second=False): for boundary, format, divisor in cls.boundaries: if value < boundary: return format % (value/divisor, 'bp' if bits_per_second else 'B/') class VideoScreenshot(object): def __init__(self, surface): self.surface = surface self.image = None @classmethod def filename_generator(cls): settings = BlinkSettings() name = os.path.join(settings.screenshots_directory.normalized, 'VideoCall-{:%Y%m%d-%H.%M.%S}'.format(datetime.now())) yield '%s.png' % name for x in count(1): yield "%s-%d.png" % (name, x) def capture(self): try: self.image = self.surface._image.copy() except AttributeError: pass else: player = WavePlayer(SIPApplication.alert_audio_bridge.mixer, Resources.get('sounds/screenshot.wav'), volume=30) SIPApplication.alert_audio_bridge.add(player) player.start() @run_in_thread('file-io') def save(self): if self.image is not None: filename = next(filename for filename in self.filename_generator() if not os.path.exists(filename)) makedirs(os.path.dirname(filename)) self.image.save(filename)