#PlChat is a pleroma chat client # Copyright (C) 2021 Knott Eye # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . import platform, keyring os = platform.system() if os == "Windows": import keyring.backends.Windows keyring.set_keyring(keyring.backends.Windows.WinVaultKeyring()) elif os == "Darwin": import keyring.backends.OS_X keyring.set_keyring(keyring.backends.OS_X.Keyring()) else: # Probably linux or BSD so we'll just go for it import keyring.backends.SecretService keyring.set_keyring(keyring.backends.SecretService.Keyring()) from PyQt5.QtGui import * from PyQt5.QtCore import * from PyQt5.QtWidgets import * from notifypy import Notify import sys, threading, queue, asyncio, urllib, appdirs, os, time, pleroma, re, magic, monkeypatch, requests, misc, json, timeconvert, videowidget, audiowidget CACHE = appdirs.AppDirs('plchat', 'plchat').user_cache_dir APPDATA = appdirs.AppDirs('plchat', 'plchat').user_data_dir THREADS = {} STATIC_PREF = '' ICON_PATH = os.path.join(os.path.dirname(__file__), "fedi.svg") class App(QMainWindow): settings = QSettings(APPDATA+"/settings.ini") _eventloop = asyncio.new_event_loop() accts = {} def __init__(self, processEvents): super().__init__() self.title = 'PlChat' self.processEvents = processEvents self.initUI() def initUI(self): self.setWindowTitle(self.title) self.setWindowIcon(QIcon(QPixmap(ICON_PATH))) self.setGeometry(self.settings.value('left', type=int) or 10, self.settings.value('top', type=int) or 10, self.settings.value('width', type=int) or 640, self.settings.value('height', type=int) or 480) self._exit = False self.Err = QErrorMessage() exitAction = QAction('&Exit', self) exitAction.setShortcut('Ctrl+Q') exitAction.setToolTip('Exit application') exitAction.triggered.connect(self.exitActionTrigger) newChatAction = QAction('New Chat', self, triggered=self.newChatDialog) newChatAction.setShortcut('Ctrl+N') newChatAction.setToolTip('Add a new chat') closeChatAction = QAction('Close Chat', self, triggered=self.closeTab) closeChatAction.setShortcut('Ctrl+W') closeChatAction.setToolTip('Close the current chat tab') newAcctAction = QAction('New Account', self, triggered=self.newAcctDialog) newAcctAction.setShortcut('Ctrl+Shift+N') newAcctAction.setToolTip('Add a new account') logoutAction = QAction('Logout', self, triggered=self.logout) logoutAction.setToolTip('Log out of the current account') reopenAction = QAction('Reopen All Chats', self, triggered=self.reopenAll) reopenAction.setToolTip('In case something breaks') contactAction = QAction('Contact', self, triggered=self.contactDialog) licenseAction = QAction('License', self, triggered=self.licenseDialog) self.closeToTrayAction = QAction('Close To System Tray', self, checkable=True) self.closeToTrayAction.setChecked(self.settings.value('closeToTray', type=bool)) self.openInTrayAction = QAction('Open In System Tray', self, checkable=True) self.openInTrayAction.setChecked(self.settings.value('openInTray', type=bool)) self.openInTrayAction.setToolTip('Does nothing if not also closing to tray.') self.animatePicturesAction = QAction('Display Animations', self, checkable=True) self.animatePicturesAction.setChecked(self.settings.value('animatePictures', type=bool)) self.animatePicturesAction.changed.connect(updateAnimationPref) self.fetchBackgroundsAction = QAction("Fetch Backgrounds", self, checkable=True) self.fetchBackgroundsAction.setChecked(self.settings.value('fetchBackgrounds', type=bool)) self.fetchBackgroundsAction.setToolTip("Most instances do not set this in a way that PlChat can check for. It will (probably) not work like you expect, but there is nothing I can do about it.") self.darkModeAction = QAction("Dark Mode", self, checkable=True) self.darkModeAction.setChecked(self.settings.value('darkMode', type=bool)) self.darkModeAction.setToolTip("Only affects chat bubbles") self.sendNotificationsAction = QAction("Send Notifications", self, checkable=True) self.sendNotificationsAction.setChecked(self.settings.value('sendNotifications', type=bool)) self.fetchHeadersAction = QAction("Fetch Headers", self, checkable=True) self.fetchHeadersAction.setChecked(self.settings.value('fetchHeaders', type=bool)) self.twoFourTimeAction = QAction("Display 24-Hour Time", self, checkable=True) self.twoFourTimeAction.setChecked(self.settings.value('twoFourTime', type=bool)) self.acctComboBox = QComboBox(self) self.acctComboBox.currentIndexChanged.connect(self.acctSwitch) acctComboBoxAction = QWidgetAction(self) acctComboBoxAction.setDefaultWidget(self.acctComboBox) self.acctComboBox.show() menubar = self.menuBar() if self.closeToTrayAction.isChecked(): systraymenu = QMenu() hideAction = QAction("Hide", self, triggered=self.hide) showAction = QAction("Show", self, triggered=self.show) systraymenu.addAction(showAction) systraymenu.addAction(hideAction) systraymenu.addAction(exitAction) self.trayIcon = QSystemTrayIcon() self.trayIcon.setIcon(QIcon(ICON_PATH)) self.trayIcon.setVisible(True) self.trayIcon.setToolTip("PlChat") self.trayIcon.setContextMenu(systraymenu) self.trayIcon.activated.connect(self.systrayClicked) else: self.trayIcon = None filemenu = menubar.addMenu("File") filemenu.addAction(newChatAction) filemenu.addAction(closeChatAction) filemenu.addAction(newAcctAction) filemenu.addAction(exitAction) editmenu = menubar.addMenu("Edit") editmenu.addAction(reopenAction) editmenu.setToolTipsVisible(True) prefsmenu = editmenu.addMenu("Preferences") prefsmenu.addAction(self.closeToTrayAction) prefsmenu.addAction(self.openInTrayAction) prefsmenu.addAction(self.animatePicturesAction) prefsmenu.addAction(self.darkModeAction) prefsmenu.addAction(self.sendNotificationsAction) prefsmenu.addAction(self.fetchBackgroundsAction) prefsmenu.addAction(self.fetchHeadersAction) prefsmenu.addAction(self.twoFourTimeAction) prefsmenu.setToolTipsVisible(True) accountmenu = menubar.addMenu('Accounts') accountmenu.addAction(acctComboBoxAction) accountmenu.addSeparator() accountmenu.addAction(logoutAction) aboutmenu = menubar.addMenu('About') aboutmenu.addAction(contactAction) aboutmenu.addAction(licenseAction) self.tabs = QTabWidget() self.tabs.setMovable(True) self.tabs.setTabsClosable(True) self.tabs.tabCloseRequested.connect(self.closeTab) self.tabs.currentChanged.connect(self.changeTab) self.setFocusProxy(self.tabs) self.setCentralWidget(self.tabs) if not self.openInTrayAction.isChecked() or not self.closeToTrayAction.isChecked(): self.show() self.HeaderFont = QFont() self.HeaderFont.setPointSize(16) self.headerEmojiFontSize = QFontMetrics(self.HeaderFont).height() self.defaultFontMetrics = QFontMetrics(QFont()) self.emojiFontSize = self.defaultFontMetrics.height() self.TimestampFont = QFont() self.TimestampFont.setPointSize(7) self.installEventFilter(self) asyncio.set_event_loop(self._eventloop) self._eventloop.call_soon(self.eventLoop) acctList = self.settings.value('acctList', type=list) if not acctList: self.newAcctDialog() return for acct in acctList: CallThread(getAvi, None, acct['instance']) self.initAcct(acct['instance'], acct['username']) def systrayClicked(self, reason): if reason == QSystemTrayIcon.Trigger: if self.isVisible(): self.hide() else: self.show() def eventLoop(self): # Custom event loop to process queue events self.processEvents() self._eventloop.call_soon(self.eventLoop) monkeypatch.sleep(0.01) def exitActionTrigger(self): self._exit = True self.close() def closeEvent(self, event): if not self._exit and self.closeToTrayAction.isChecked(): self.hide() return self.settings.setValue("left", self.x()) self.settings.setValue("top", self.y()) self.settings.setValue("width", self.width()) self.settings.setValue("height", self.height()) self.settings.setValue("closeToTray", self.closeToTrayAction.isChecked()) self.settings.setValue("openInTray", self.openInTrayAction.isChecked()) self.settings.setValue("animatePictures", self.animatePicturesAction.isChecked()) self.settings.setValue("darkMode", self.darkModeAction.isChecked()) self.settings.setValue("fetchBackgrounds", self.fetchBackgroundsAction.isChecked()) self.settings.setValue("sendNotifications", self.sendNotificationsAction.isChecked()) self.settings.setValue("fetchHeaders", self.fetchHeadersAction.isChecked()) self.settings.setValue("twoFourTime", self.twoFourTimeAction.isChecked()) event.accept() self._eventloop.stop() def getCurrentAcc(self): if self.acctComboBox.currentIndex() == -1 or not self.accts: return (False, False) # Returns username, instance return self.acctComboBox.currentText().split('@')[1], self.acctComboBox.currentText().split('@')[2] def newAcctDialog(self): dialog = LoginDialog(self) dialog.getInput(self.initAcct) def newChatDialog(self): u, i = self.getCurrentAcc() text, ok = QInputDialog.getText(self, 'Start A New Chat', "Username:", QLineEdit.Normal, "") if ok: if len(text) < 1: self.Err.showMessage("No text provided") else: if text[0] == '@': text = text[1:] if text.find('@'+i) != -1: text = text.replace('@'+i, '') CallThread(self.accts[u+i].getAcctInfo, self.newChatReady, text).start() def newChatReady(self, result): if result is None: self.Err.showMessage("I couldn't find that user!") elif not result['pleroma']['accepts_chat_messages']: self.Err.showMessage("This user is on instance that does not support pleroma chats.\nIt may be too old, or it may not be pleroma.") else: u, i = self.getCurrentAcc() closedList = self.settings.value('closed'+u+i, type=list) or [] for ind in range(0,(len(closedList))): if closedList[ind] == result['acct']: del closedList[ind] break self.settings.setValue('closed'+u+i, closedList) CallThread(self.accts[u+i].addChat, self.populateChats, result['id']).start() def contactDialog(self): dialog = ContactCard(self) dialog.show() def licenseDialog(self): dialog = LicenseCard(self) dialog.show() def reopenAll(self): u, i = self.getCurrentAcc() if not u or not i: return self.settings.setValue('closed'+u+i, []) CallThread(self.accts[u+i].listChats, self.populateChats).start() def initAcct(self, instance, username, password=None): if password: acct = pleroma.Account(instance, username, password) else: token = keyring.get_password('plchat', instance+username+'access_token') refresh_token = keyring.get_password('plchat', instance+username+'refresh_token') clientID = keyring.get_password('plchat', instance+username+'clientID') clientSecret = keyring.get_password('plchat', instance+username+'clientSecret') acct = pleroma.Account(instance, username, token=token, refresh_token=refresh_token, clientID=clientID, clientSecret=clientSecret ) RegisterThread(acct, self.doneRegister).start() def doneRegister(self, acct): self.accts[acct.username+acct.instance] = acct self.acctComboBox.addItem('@'+acct.username+'@'+acct.instance) acctList = self.settings.value('acctList', type=list) or [] concat = True for acc in acctList: if acc['username'] == acct.username and acc['instance'] == acct.instance: concat = False if concat: acctList.append({"username": acct.username, "instance": acct.instance}) self.settings.setValue('acctList', acctList) keyring.set_password('plchat', acct.instance+acct.username+'access_token', acct.token) keyring.set_password('plchat', acct.instance+acct.username+'refresh_token', acct.refresh_token) keyring.set_password('plchat', acct.instance+acct.username+'clientID', acct.clientID) keyring.set_password('plchat', acct.instance+acct.username+'clientSecret', acct.clientSecret) CallThread(getAvi, None, acct.instance).start() CallThread(acct.getInstanceInfo, None).start() def logout(self): u, i = self.getCurrentAcc() if not u or not i: print("Couldn't log out") return self.acctComboBox.removeItem(self.acctComboBox.currentIndex()) del self.accts[u+i] acctList = self.settings.value('acctList', type=list) for j in range(len(acctList)): if acctList[j]['username'] == u and acctList[j]['instance'] == i: keyring.delete_password("plchat", i+u+'access_token') keyring.delete_password("plchat", i+u+'refresh_token') keyring.delete_password("plchat", i+u+'clientID') keyring.delete_password("plchat", i+u+'clientSecret') del acctList[j] self.settings.setValue('acctList', acctList) def acctSwitch(self): u, i = self.getCurrentAcc() if u+i in self.accts and 'title' in self.accts[u+i].getInstanceInfo(): self.setWindowTitle(self.accts[u+i].getInstanceInfo()['title']+' Chat') else: self.setWindowTitle('PlChat') self.tabs.clear() CallThread(self.accts[u+i].listChats, self.populateChats).start() def populateChats(self, chatList): if type(chatList) == dict: chatList = [chatList] u, i = self.getCurrentAcc() if not u or not i: return closedList = self.settings.value('closed'+u+i, type=list) or [] for chat in chatList: c = False for ind in range(0, self.tabs.count()): if self.tabs.widget(ind).acct == chat['account']['acct']: c = True for entry in closedList: if chat['account']['acct'] == entry: c = True if c: continue ctab = ChatArea(chat, self.tabs) self.tabs.addTab(ctab, chat['account']['display_name']) def closeTab(self, index=None): if not index: index = self.tabs.currentIndex() act = self.tabs.widget(index).acct u, i = self.getCurrentAcc() closedList = self.settings.value('closed'+u+i, type=list) or [] f = True for entry in closedList: if entry == act: f = False if f: closedList.append(act) self.settings.setValue('closed'+u+i, closedList) self.tabs.removeTab(index) def handlePleromaEvent(self, acct, event): if event['event'] == 'pleroma:chat_update': payload = json.loads(event['payload']) tmp = 0 for ind in range(0, self.tabs.count()): if self.tabs.widget(ind).acct == payload['account']['acct']: self._eventloop.call_soon_threadsafe(self.tabs.widget(ind).addMessage, payload['last_message']) tmp = ind #self.tabs.widget(ind).addMessage(payload['last_message']) if payload['last_message']['account_id'] != acct.flakeid and (not self.hasFocus() or payload['account']['acct'] != self.tabs.widget(self.tabs.currentIndex()).acct): if self.sendNotificationsAction.isChecked(): CallThread(self.makeNotification, None, payload['last_message']['content'], payload['account']['acct'], payload['account']['avatar_static']).start() app.alert(self, 0) self._eventloop.call_soon_threadsafe(self.setUrgent, tmp) def makeNotification(self, content, user, url): path = getPic(url) sendNotification(content, title=user, icon=path) def setUrgent(self, ind): icon = QIcon() icon.addPixmap(QPixmap(os.path.join(os.path.dirname(__file__), "unread.svg"))) self.tabs.setTabIcon(ind, icon) self.tabs.widget(ind).unread = True if self.trayIcon: self.trayIcon.setIcon(icon) self.setWindowIcon(icon) def changeTab(self, ind): if not self.tabs.widget(ind): return if self.tabs.widget(ind).unread: CallThread(getPic, self.tabs.widget(ind).useAvi, self.tabs.widget(ind).avaURL).start() self.tabs.widget(ind).unread = False flip = True for A in range(0, self.tabs.count()): if self.tabs.widget(A).unread: flip = False if flip: if self.trayIcon: self.trayIcon.setIcon(QIcon(QPixmap(ICON_PATH))) self.setWindowIcon(QIcon(QPixmap(ICON_PATH))) self.tabs.widget(ind).markRead() self.tabs.widget(ind).setFocus(Qt.NoFocusReason) def eventFilter(self, object, event): if event.type() == QEvent.WindowActivate: self.windowActivate() return False def windowActivate(self): ind = self.tabs.currentIndex() if not self.tabs.widget(ind): return if self.tabs.widget(ind).unread: CallThread(getPic, self.tabs.widget(ind).useAvi, self.tabs.widget(ind).avaURL).start() self.tabs.widget(ind).unread = False flip = True for A in range(0, self.tabs.count()): if self.tabs.widget(A).unread: flip = False if flip: if self.trayIcon: self.trayIcon.setIcon(QIcon(QPixmap(ICON_PATH))) self.setWindowIcon(QIcon(QPixmap(ICON_PATH))) self.tabs.widget(ind).markRead() self.tabs.widget(ind).setFocus(Qt.NoFocusReason) class BGWidget(QWidget): def __init__(self, path=None): super().__init__() self.path=path self.pixmap = QPixmap() def setPath(self, path): self.path = path def paintEvent(self, event): super().paintEvent(event) if self.pixmap.isNull() and self.path: self.pixmap = QPixmap(self.path) painter = QPainter(self) if not self.pixmap.isNull(): self.pixmap = self.pixmap.scaled(self.width(), self.height(), Qt.KeepAspectRatioByExpanding, Qt.SmoothTransformation) painter.drawPixmap(0,0, self.pixmap) class ChatArea(QWidget): def __init__(self, chat, parent=None): super().__init__(parent=parent) self.acct = chat['account']['acct'] self.parent = parent self.chatID = chat['id'] self.account = chat['account'] self.unread = False if ex.animatePicturesAction.isChecked(): avatar_prop = 'avatar' header_prop = 'header' else: avatar_prop = 'avatar_static' header_prop = 'header_static' self.avaURL = chat['account'][avatar_prop] CallThread(getPic, self.useAvi, self.avaURL).start() if ex.fetchHeadersAction.isChecked(): CallThread(getPic, self.headerReady, chat['account'][header_prop]).start() self.wrapper = BGWidget() wrapperlayout = QHBoxLayout() wrapperlayout.setContentsMargins(0,0,0,0) wrapperlayout.setSpacing(0) self.wrapper.setLayout(wrapperlayout) self.header = QFrame() headerlayout = QHBoxLayout() headernest = QVBoxLayout() headernestwidget = QWidget() headernestwidget.setContentsMargins(0,0,0,0) self.header.setContentsMargins(0,0,0,0) wrapperlayout.addWidget(self.header) self.header.setLayout(headerlayout) headernestwidget.setLayout(headernest) uDN = EmojiText(chat['account']['display_name'], chat['account']['emojis'], header=True) if self.acct.find('@') == -1: uACT = LinkLabel(chat['account']['url'], '@'+self.acct+'@'+ex.getCurrentAcc()[1]) else: uACT = LinkLabel(chat['account']['url'], '@'+self.acct) uACT.setMargin(0) #uACT.setStyleSheet("color: white;") headernest.addWidget(uDN) headernest.addWidget(uACT) self.avatar = QLabel() self.avatar.setMargin(0) self.avatar.setFixedHeight(round(QDesktopWidget().screenGeometry(-1).height() * 0.06)) headerlayout.addWidget(self.avatar) headerlayout.addWidget(headernestwidget, 100) self.messageArea = MessageArea(self.chatID, self.account, self.callMe) self.msgs = MovingScroll(self.messageArea) if ex.fetchBackgroundsAction.isChecked(): if self.acct.find('@') == -1: remoteURL = ex.getCurrentAcc()[1] else: remoteURL = self.acct.split('@')[1] self.msgs.remoteURL = remoteURL CallThread(requests.get, self.msgs.parseNodeInfo, 'https://'+remoteURL+'/api/v1/instance').start() del remoteURL self.msgs.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self.msgs.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOn) self.msgs.setWidgetResizable(True) self.messageArea.setFixedWidth(self.msgs.getChildWidth()) self.messageArea.update() send = SendArea(self.chatID) layout = QVBoxLayout() layout.addWidget(self.wrapper, 2) layout.addWidget(self.msgs, 35) layout.addWidget(send, 1) self.setFocusProxy(send.getTextbox()) self.setLayout(layout) def callMe(self): self.msgs.setWidget(self.messageArea) def markRead(self): self.messageArea.markRead() def headerReady(self, path): self.wrapper.setPath(path) def addMessage(self, message): self.messageArea.messages.insert(0, message) self.messageArea._update(self.messageArea.messages) def useAvi(self, path): pic = QPixmap(path) ava = QPixmap(path) if pic.isNull(): pic = QPixmap(APPDATA+'/'+ex.getCurrentAcc()[1]+'avi.png') ava = QPixmap(APPDATA+'/'+ex.getCurrentAcc()[1]+'avi.png') icon = QIcon() icon.addPixmap(pic) self.parent.setTabIcon(self.parent.indexOf(self), icon) ava = ava.scaledToHeight(round(QDesktopWidget().screenGeometry(-1).height() * 0.06), mode=Qt.SmoothTransformation) self.avatar.resize(ava.width(), ava.height()) self.avatar.setPixmap(ava) self.avatar.setFixedWidth(self.avatar.width()) def paintEvent(self, event): super().paintEvent(event) if not self.wrapper.pixmap.isNull(): self.header.setStyleSheet(".QFrame{background-color: rgba(0, 0, 0, 0.3);} QFrame{color: white;}") self.paintEvent = super().paintEvent class MovingScroll(QScrollArea): def __init__(self, child, path=None): super().__init__() self.child = child self.remoteURL = '' self.verticalScrollBar().rangeChanged.connect(self.scrollToBottom) self.verticalScrollBar().valueChanged.connect(self.handleValue) def resizeEvent(self, event): self.child.setFixedWidth(self.getChildWidth()) def scrollToBottom(self): self.verticalScrollBar().triggerAction(QAbstractSlider.SliderToMaximum) def getChildWidth(self): return self.width()-self.verticalScrollBar().width()-5 def handleValue(self, pos): if pos < (self.verticalScrollBar().maximum() / 10): self.child.addPage() def parseNodeInfo(self, response): try: CallThread(getPic, self.child.setPath, 'https://'+self.remoteURL+response.json()['background_image']).start() except: print("Couldn't download background image for: "+remoteURL) class MessageArea(QWidget): def __init__(self, chatID, account, callback): super().__init__() self.chatID = chatID self.account = account self.callback = callback self.path = None self.pixmap = QPixmap() self.layout = QVBoxLayout() self.layout.setContentsMargins(10, 0,-5,0) self.last_read_id = None self.messages = [] self.page = 0 self.fetchingPage = False def update(self): u, i = ex.getCurrentAcc() CallThread(ex.accts[u+i].getMessages, self._update, self.chatID).start() def markRead(self): if not self.last_read_id: return u, i = ex.getCurrentAcc() acc = ex.accts[u+i] acc.markChatRead(self.chatID, self.last_read_id) def addPage(self): if self.fetchingPage: return self.fetchingPage = True u, i = ex.getCurrentAcc() self.page += 1 CallThread(ex.accts[u+i].getMessages, self.pageReady, self.chatID, past=self.messages[len(self.messages)-1]['id']).start() def pageReady(self, messages): for message in messages: self.messages.append(message) self._update(self.messages) def _update(self, messages): self.messages = messages for message in messages: if message['account_id'] == self.account['id']: self.last_read_id = message['id'] for i in reversed(range(self.layout.count())): self.layout.itemAt(i).widget().setParent(None) for message in reversed(messages): #for line in re.split(newlineRegex, message['content']): # if line.strip() == '': # continue self.layout.addWidget(SingleMessage(message, self.account, self), 0, Qt.AlignBottom) self.setLayout(self.layout) self.callback() if self.fetchingPage: self.fetchingPage = False def getMessages(self): return self.messages or [] def setPath(self, path): self.path = path def paintEvent(self, event): super().paintEvent(event) if self.pixmap.isNull() and self.path: self.pixmap = QPixmap(self.path) painter = QPainter(self) if not self.pixmap.isNull(): self.pixmap = self.pixmap.scaled(self.width(), self.height(), Qt.KeepAspectRatioByExpanding, Qt.SmoothTransformation) painter.drawPixmap(0,0, self.pixmap) newlineRegex = re.compile('<\/?br\/?>|\n') class SingleMessage(QWidget): def __init__(self, message, account, parent): super().__init__(parent=parent) self.parent = parent self.message = message self.account = account self.layout = QHBoxLayout() self.layout.setContentsMargins(0,0,0,0) self.layout.setSpacing(0) self.setStyleSheet("MessageAvatar{margin: 0em 0.2em 0em 0.2em;border: 1px solid black;}") if ex.animatePicturesAction.isChecked(): avatar_prop = 'avatar' header_prop = 'header' else: avatar_prop = 'avatar_static' header_prop = 'header_static' self.avaURL = self.account[avatar_prop] u,i = ex.getCurrentAcc() self.userPixmap = None self.convoPixmap = None CallThread(getPic, self.setUserPixmap, ex.accts[u+i].acct[avatar_prop]).start() CallThread(getPic, self.setConvoPixmap, self.avaURL).start() if self.message['account_id'] == self.account['id']: self.layout.addWidget(MessageAvatar('convo', self), 0, (Qt.AlignLeft | Qt.AlignTop)) self.layout.addWidget(InternalMessage(self.message, self.account, self), 1, Qt.AlignLeft) #spacer = QWidget() #spacer.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.MinimumExpanding) #self.layout.addWidget(spacer, 9000) else: #spacer = QWidget() #spacer.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.MinimumExpanding) #self.layout.addWidget(spacer, 9000) self.layout.addWidget(InternalMessage(self.message, self.account, self), 1, Qt.AlignRight) self.layout.addWidget(MessageAvatar('user', self), 0, (Qt.AlignRight | Qt.AlignTop)) self.setLayout(self.layout) def setUserPixmap(self, path): p = QPixmap(path) if p.isNull(): p = QPixmap(APPDATA+'/'+ex.getCurrentAcc()[1]+'avi.png') p = p.scaledToHeight(50, mode=Qt.SmoothTransformation) self.userPixmap = p def setConvoPixmap(self, path): p = QPixmap(path) if p.isNull(): p = QPixmap(APPDATA+'/'+ex.getCurrentAcc()[1]+'avi.png') p = p.scaledToHeight(50, mode=Qt.SmoothTransformation) self.convoPixmap = p class MessageAvatar(QLabel): def __init__(self, futurepixmap, parent): super().__init__(parent=parent) self.parent = parent self.pixmap = QPixmap() self.setMargin(0) self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) CallThread(self.awaitPixmap, None, futurepixmap).start() #self.setPixmap(self.pixmap) def setPath(self, path): #if not self.pixmap.isNull(): # return #self.pixmap = QPixmap(path) #if self.pixmap.isNull(): # self.pixmap = QPixmap(APPDATA+'/'+ex.getCurrentAcc()[1]+'avi.png') #self.pixmap = self.pixmap.scaledToHeight(50, mode=Qt.SmoothTransformation) self.pixmap = path self.resize(self.pixmap.width(), self.pixmap.height()) self.setPixmap(self.pixmap) self.setFixedWidth(self.pixmap.width()) def awaitPixmap(self, p): if p == 'user': while not self.parent.userPixmap: monkeypatch.sleep(0.2) self.resize(self.parent.userPixmap.width(), self.parent.userPixmap.height()) self.setPixmap(self.parent.userPixmap) self.setFixedWidth(self.parent.userPixmap.width()) else: while not self.parent.convoPixmap: monkeypatch.sleep(0.2) self.resize(self.parent.convoPixmap.width(), self.parent.convoPixmap.height()) self.setPixmap(self.parent.convoPixmap) self.setFixedWidth(self.parent.convoPixmap.width()) class InternalMessage(QFrame): def __init__(self, message, account, parent): super().__init__(parent=parent) self.parent = parent self.message = message self.account = account self.layout = QHBoxLayout() self.layout.setContentsMargins(0,0,0,0) self.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.MinimumExpanding) self.attachment = None if ex.darkModeAction.isChecked(): color_text="#ffffff" color_bg = "#000000" else: color_text="#000000" color_bg = "#ffffff" self.setStyleSheet("InternalMessage{background-color: "+color_bg+";border-radius: 1em;padding: 0.5em;border: 1px solid "+color_text+";} QWidget{color:"+color_text+";}") if self.message['content']: self.label = EmojiText(self.message['content'], self.message['emojis']) self.label.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.MinimumExpanding) else: self.label = None self.timestamp = QLabel(timeconvert.utc_to_local(self.message['created_at'], ex.twoFourTimeAction.isChecked())) self.timestamp.setFont(ex.TimestampFont) self.timestamp.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) if self.label: self.layout.addWidget(self.label) if not self.message['attachment']: self.layout.addWidget(self.timestamp, 1, (Qt.AlignRight | Qt.AlignBottom)) else: CallThread(self.getMime, self.addAttachment, self.message['attachment']['url']).start() #self.setMinimumSize(QSize(50, computedHeight)) self.setLayout(self.layout) def getMime(self, url): with urllib.request.urlopen(url) as res: # pleroma sux balls at mime types mime = magic.from_buffer(res.read(2048), mime=True) return mime def addAttachment(self, mime): self.attachment = MediaAttachment(self.message['attachment'], mime) self.layout.addWidget(self.attachment) self.layout.addWidget(self.timestamp, 1, (Qt.AlignRight | Qt.AlignBottom)) def sizeHint(self): multiplier = 2 additive = 0 if self.label: multiplier += self.label.layoutfinal.count() if self.attachment: additive += self.attachment.getMedia().height() computedHeight = (ex.defaultFontMetrics.height() * multiplier) + additive computedWidth = 50 if self.attachment: if self.attachment.getMedia().width() > computedWidth: computedWidth = self.attachment.getMedia().width() return QSize(computedWidth, computedHeight) def MediaAttachment(media, mime): if mime.find("image/") != -1: return ImageAttachment(media) elif mime.find("video/") != -1: return VideoAttachment(media) elif mime.find("audio/") != -1: return AudioAttachmentWrapper(media) else: return FileAttachment(media) class FileAttachment(QPushButton): def __init__(self, media): super(FileAttachment, self).__init__() self.url = QUrl(media['url']) self.setCursor(Qt.PointingHandCursor) self.setIcon(self.style().standardIcon(QStyle.SP_FileLinkIcon)) self.setFlat(True) def mouseReleaseEvent(self, event): if event.modifiers() == Qt.NoModifier and event.button() == Qt.LeftButton: QDesktopServices.openUrl(self.url) def getMedia(self): return self class AudioAttachmentWrapper(QWidget): def __init__(self, media): super(AudioAttachmentWrapper, self).__init__() layout = QHBoxLayout() self.file = FileAttachment(media) self.audio = AudioAttachment(media) layout.addWidget(self.file) layout.addWidget(self.audio) self.setLayout(layout) def getMedia(self): return QSize(self.file.getMedia().width()+self.audio.getMedia().width(), self.file.getMedia().height()+self.audio.getMedia().height()) class AudioAttachment(audiowidget.AudioPlayer): def __init__(self, media): super(AudioAttachment, self).__init__() #CallThread(self.openFile, None, media['url']).start() self.openFile(media['url']) def getMedia(self): return super().sizeHint() class LinkLabel(QLabel): def __init__(self, url, *args, **kwargs): super(LinkLabel, self).__init__(*args, **kwargs) self.url = QUrl(url) self.setCursor(Qt.PointingHandCursor) def mouseReleaseEvent(self, event): bttn = event.button() modif = event.modifiers() if modif == Qt.NoModifier and bttn == Qt.LeftButton: QDesktopServices.openUrl(self.url) class VideoAttachment(videowidget.VideoPlayer): def __init__(self, media, *args, **kwargs): super().__init__(media['url'], *args, **kwargs) #CallThread(self.openFile, None, media['url']).start() self.openFile(media['url']) def getMedia(self): return QSize(self.width(), self.videoWidget.height()+50) class ImageAttachment(LinkLabel): def __init__(self, media): super().__init__(media['remote_url']) self.pixmap = QPixmap() CallThread(getPic, self.setPath, media['url']).start() def setPath(self, path): self.pixmap = QPixmap(path) if round(ex.width() * 0.7) < self.pixmap.width(): self.pixmap = self.pixmap.scaledToWidth(round(ex.width() * 0.7), mode=Qt.SmoothTransformation) self.resize(self.pixmap.width(), self.pixmap.height()) self.setPixmap(self.pixmap) #self.setFixedHeight(self.height()) self.url = QUrl(path) def getMedia(self): return self.pixmap class SendArea(QWidget): def __init__(self, chatID): super().__init__() self.layout = QHBoxLayout() self.lastFile = None self.mediaPreview = None self.mediaID = None self.Err = QErrorMessage() self.chatID = chatID self.textbox = TextBox() self.mediaStack = QStackedWidget() self.attachButton = QPushButton(self.style().standardIcon(QStyle.SP_FileDialogStart), 'Attach File') self.attachButton.clicked.connect(self.newFileDialog) self.attachButton.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.MinimumExpanding) sendButton = QPushButton(QIcon(QPixmap(os.path.join(os.path.dirname(__file__), "send.svg"))), '') sendButton.clicked.connect(self.sendMessage) sendButton.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.MinimumExpanding) self.mediaStack.addWidget(self.attachButton) self.mediaStack.setCurrentWidget(self.attachButton) self.layout.addWidget(self.textbox, 8) self.layout.addWidget(self.mediaStack, 1) self.layout.addWidget(sendButton, 1) self.setFocusProxy(self.textbox) self.setLayout(self.layout) self.textbox.setSendFunction(self.sendMessage) def getTextbox(self): return self.textbox def newFileDialog(self): if self.mediaPreview == 'uploading': self.Err.showMessage("Already uploading a file!") return qfd = QFileDialog(self) qfd.setFileMode(QFileDialog.ExistingFile) qfd.setNameFilters(["All Files (*)"]) qfd.filesSelected.connect(self.attachFile) qfd.show() def attachFile(self, paths): if paths[0] == self.lastFile: return elif self.mediaPreview: self.Err.showMessage("File already attached!") return u, i = ex.getCurrentAcc() CallThread(ex.accts[u+i].uploadMedia, self.doneAttachFile, paths[0]).start() self.mediaPreview = 'uploading' self.lastFile = paths[0] def doneAttachFile(self, media): if not 'id' in media: self.Err.showMessage('Error uploading file') self.detachFile() return self.mediaID = media['id'] self.mediaPreview = QPushButton(ex.style().standardIcon(QStyle.SP_FileIcon), '') self.mediaPreview.clicked.connect(self.detachDialog) self.mediaPreview.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.MinimumExpanding) self.mediaStack.addWidget(self.mediaPreview) self.mediaStack.setCurrentWidget(self.mediaPreview) def detachDialog(self): dialog = DetachDialog() dialog.accepted.connect(self.detachFile) dialog.getInput() def detachFile(self): if self.mediaPreview and type(self.mediaPreview) != str: self.mediaStack.removeWidget(self.mediaPreview) self.mediaStack.setCurrentWidget(self.attachButton) self.mediaPreview = None self.lastFile = None self.mediaID = None def sendMessage(self): u, i = ex.getCurrentAcc() acc = ex.accts[u+i] if self.textbox.toPlainText() == '': text = None else: text = self.textbox.toPlainText() CallThread(acc.sendMessage, None, self.chatID, content=text, media=self.mediaID).start() self.textbox.setText('') if self.mediaPreview: self.detachFile() self.textbox.setFocus(Qt.NoFocusReason) class TextBox(QTextEdit): def __init__(self, sendFunction=None): super().__init__() self.sendFunction = sendFunction or self.noop def keyPressEvent(self, event): if not event.matches(QKeySequence.InsertParagraphSeparator): super().keyPressEvent(event) else: self.sendFunction() def setSendFunction(self, sendFunction): self.sendFunction = sendFunction def noop(self): return class DetachDialog(QDialog): def __init__(self, parent=None): super().__init__(parent=parent) self.setWindowTitle("Detach Dialog") QBtn = QDialogButtonBox.Ok | QDialogButtonBox.Cancel self.buttonBox = QDialogButtonBox(QBtn) self.buttonBox.accepted.connect(self.accept) self.buttonBox.rejected.connect(self.reject) self.layout = QVBoxLayout() self.message = QLabel("Detach this file?") self.layout.addWidget(self.message) self.layout.addWidget(self.buttonBox) self.setLayout(self.layout) def getInput(self): self.exec_() self.show() self.raise_() self.activateWindow() class LoginDialog(QDialog): def __init__(self, parent=None): super().__init__(parent=parent) self.setWindowTitle("Log In") QBtn = QDialogButtonBox.Ok | QDialogButtonBox.Cancel self.buttonBox = QDialogButtonBox(QBtn) self.buttonBox.accepted.connect(self.accept) self.buttonBox.rejected.connect(self.reject) self.layout = QVBoxLayout() self.message = QLabel("Log in to your fediverse account") self.username = QLineEdit() self.username.setPlaceholderText('Username (e.g. Moon)') self.instance = QLineEdit() self.instance.setPlaceholderText('Instance (e.g. shitposter.club)') self.password = QLineEdit() self.password.setPlaceholderText('Password') self.password.setEchoMode(QLineEdit.Password) self.layout.addWidget(self.message) self.layout.addWidget(self.username) self.layout.addWidget(self.instance) self.layout.addWidget(self.password) self.layout.addWidget(self.buttonBox) self.setLayout(self.layout) self.setFocusProxy(self.username) def getInput(self, func): self.show() self.raise_() self.activateWindow() self.accepted.connect(self._finished) self.finishedFunc = func def _finished(self): instance = self.instance.text() instance = instance.replace('http://', '') instance = instance.replace('https://', '') if instance[len(instance) - 1] == '/': instance = instance[0:(len(instance) - 2)] self.finishedFunc(self.instance.text(), self.username.text(), password=self.password.text()) class ContactCard(QDialog): def __init__(self, parent=None): super().__init__(parent=parent) self.setWindowTitle("Contact Info") QBtn = QDialogButtonBox.Ok self.buttonBox = QDialogButtonBox(QBtn) self.buttonBox.accepted.connect(self.accept) self.layout = QVBoxLayout() self.message = QLabel("Written by knotteye\nContact me in the following places (in order of preference) with issues") self.fedi = QLabel("Fedi: @knotteye@waldn.net") self.fedi.setOpenExternalLinks(True) self.xmpp = QLabel('XMPP: knotteye@telekem.net') self.xmpp.setOpenExternalLinks(True) self.email = QLabel('Email: knotteye@airmail.cc') self.email.setOpenExternalLinks(True) self.layout.addWidget(self.message) self.layout.addWidget(self.fedi) self.layout.addWidget(self.xmpp) self.layout.addWidget(self.email) self.layout.addWidget(self.buttonBox) self.setLayout(self.layout) self.show() self.raise_() self.activateWindow() class LicenseArea(QScrollArea): def __init__(self, licenseText): super().__init__() label = QLabel(licenseText) self.setWidget(label) class LicenseCard(QDialog): def __init__(self, parent=None): super().__init__(parent=parent) self.setWindowTitle("Required Licensing Information") self.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.MinimumExpanding) QBtn = QDialogButtonBox.Ok self.buttonBox = QDialogButtonBox(QBtn) self.buttonBox.accepted.connect(self.accept) self.layout = QVBoxLayout() self.tabs = QTabWidget() plchatlayout = QVBoxLayout() plchatW = QWidget() plchatW.setLayout(plchatlayout) plchatlabel = QLabel("Repository") plchatlabel.setTextFormat(Qt.RichText) plchatlabel.setOpenExternalLinks(True) plchatLicense = QLabel(misc.plchatLicense) plchatscroll = QScrollArea() plchatscroll.setWidget(plchatLicense) plchatlayout.addWidget(plchatlabel) plchatlayout.addWidget(plchatscroll) self.tabs.addTab(plchatW, 'PlChat') self.tabs.addTab(LicenseArea(misc.pyqt5License), "PyQt5") self.tabs.addTab(LicenseArea(misc.videowidgetlicense), "videowidget.py") self.tabs.addTab(LicenseArea(misc.videowidgetlicense), "audiowidget.py") self.tabs.addTab(LicenseArea(misc.notifypylicense), "notify-py") self.tabs.addTab(LicenseArea(misc.logurulicense), "loguru") self.tabs.addTab(LicenseArea(misc.websocketslicense), "websockets") self.tabs.addTab(LicenseArea(misc.sixlicense), "six") self.tabs.addTab(LicenseArea(misc.magiclicense), "python-magic") self.tabs.addTab(LicenseArea(misc.dateutillicense), "dateutil") self.tabs.addTab(LicenseArea(misc.keyringlicense), "keyring") self.layout.addWidget(self.tabs) self.layout.addWidget(self.buttonBox) self.setLayout(self.layout) self.show() self.raise_() self.activateWindow() def sizeHint(self): return QSize(650, 850) class EmojiText(QWidget): ceRegex = re.compile('(:[^:]+:)') def __init__(self, text, emojiList, header=False): super().__init__() self.layoutfinal = QVBoxLayout() self.layoutfinal.setSpacing(0) self.layoutfinal.setContentsMargins(0,0,0,0) for line in re.split(newlineRegex, text): self.layoutfinal.addWidget(self.makeLine(line, emojiList, header)) self.setLayout(self.layoutfinal) def makeLine(self, text, emojiList, header): widget = QWidget() layout = QWidget() layout = QHBoxLayout() layout.setSpacing(0) layout.setContentsMargins(0,0,0,0) widget.setContentsMargins(0,0,0,0) customEmojis = [] if header: size = ex.headerEmojiFontSize else: size = ex.emojiFontSize for em in re.findall(self.ceRegex, text): for emoji in emojiList: if emoji['shortcode'] == em[1:len(em)-1]: cemurl = emoji[STATIC_PREF+'url'] customEmojis.append(CustomEmoji(em[1:len(em)-1], cemurl, size)) i = 0 for stringpart in re.split(self.ceRegex, text): if len(stringpart) > 2 and stringpart[0] == ':' and stringpart[len(stringpart)-1] == ':': layout.addWidget(customEmojis[i], 1) i += 1 else: label = QLabel(stringpart) if header: label.setFont(ex.HeaderFont) label.setTextFormat(Qt.RichText) label.setWordWrap(True) label.setTextInteractionFlags((Qt.TextBrowserInteraction)) label.setOpenExternalLinks(True) layout.addWidget(label) layout.addWidget(QWidget(), 9000) widget.setLayout(layout) return widget class CustomEmoji(QLabel): def __init__(self, shortcode, url=None, height=12): super().__init__() self.setScaledContents=True if url: CallThread(getPic, self.loadPic, url).start() self.mheight = height self.pixmap = None self.setMargin(0) def loadPic(self, path): self.pixmap = QPixmap(path) self.setPixmap(self.pixmap.scaledToHeight(self.mheight, mode=Qt.SmoothTransformation)) class CallThread(threading.Thread): # Generic thread object that takes some function, a callback and a list of arguments # The entire purpose of this object is to be a wrapper around running functions in a new thread def __init__(self, function, callback, *args, **kwargs): super(CallThread, self).__init__() self.function = function self.args = args or () self.kwargs = kwargs or {} self.callback = callback def run(self): result = self.function(*self.args, **self.kwargs) if not self.callback: return ex._eventloop.call_soon_threadsafe(self.callback, result) class RegisterThread(threading.Thread): def __init__(self, acct, callback): super(RegisterThread, self).__init__() self.acct = acct self.callback = callback def run(self): self.acct.register() self.acct.login() NotifThread(self.acct).start() self.acct.setChatUpdate(ex.handlePleromaEvent) self.callback(self.acct) class NotifThread(threading.Thread): def __init__(self, acct): super(NotifThread, self).__init__() self.acct = acct self.loop = asyncio.new_event_loop() self.daemon = True asyncio.set_event_loop(self.loop) def run(self): self.loop.run_until_complete(self.acct.startStreaming()) class HLine(QFrame): def __init__(self): super(HLine, self).__init__() self.setFrameShape(self.HLine|self.Sunken) class VLine(QFrame): def __init__(self): super(VLine, self).__init__() self.setFrameShape(self.VLine|self.Sunken) class IconLabel(QLabel): def __init__(self, icon, w=30, h=None): super(IconLabel, self).__init__() if not h: h = w #self.icon = self.style().standardIcon(icon) self.icon = icon self.setPixmap(self.icon.pixmap(QSize(w, h))) Notification = Notify( default_notification_title="PlChat", default_notification_icon=ICON_PATH #default_notification_audio=NOTIF_SOUND ) def sendNotification(message, title=None, icon=None): Notification.message = message if title: Notification.title = title if icon: Notification.icon = icon Notification.send(block=False) def getPic(url): filename = urllib.parse.urlparse(url).netloc+os.path.basename(urllib.parse.urlparse(url).path) filepath = CACHE+'/img/'+filename try: f = open(filepath, mode='xb+') THREADS[filepath] = threading.current_thread() except FileExistsError: try: while THREADS[filepath].is_alive(): monkeypatch.sleep(0.2) return filepath except KeyError: return filepath try: imgdata = urllib.request.urlopen(url).read() except Exception as E: print("Got "+str(E)+" while downloading file: "+url) return filepath f.write(imgdata) f.close() return filepath def getVid(url): filename = urllib.parse.urlparse(url).netloc+os.path.basename(urllib.parse.urlparse(url).path) filepath = CACHE+'/vid/'+filename try: f = open(filepath, mode='xb+') THREADS[filepath] = threading.current_thread() except FileExistsError: try: while THREADS[filepath].is_alive(): monkeypatch.sleep(0.2) return filepath except KeyError: return filepath try: viddata = urllib.request.urlopen(url).read() except Exception as E: print("Got "+str(E)+" while downloading file: "+url) return filepath f.write(viddata) f.close() return filepath def getAudio(url): filename = urllib.parse.urlparse(url).netloc+os.path.basename(urllib.parse.urlparse(url).path) filepath = CACHE+'/audio/'+filename try: f = open(filepath, mode='xb+') THREADS[filepath] = threading.current_thread() except FileExistsError: try: while THREADS[filepath].is_alive(): monkeypatch.sleep(0.2) return filepath except KeyError: return filepath try: viddata = urllib.request.urlopen(url).read() except Exception as E: print("Got "+str(E)+" while downloading file: "+url) return filepath f.write(viddata) f.close() return filepath def _mkdir(_dir): if os.path.isdir(_dir): pass elif os.path.isfile(_dir): raise OSError("%s exists as a regular file." % _dir) else: parent, directory = os.path.split(_dir) if parent and not os.path.isdir(parent): _mkdir(parent) if directory: os.mkdir(_dir) def getAvi(instance): filename = instance+'avi.png' filepath = APPDATA+'/'+filename try: f = open(filepath, mode='xb+') except FileExistsError: return try: imgdata = urllib.request.urlopen('https://'+instance+'/images/avi.png').read() except Exception as E: print("Couldn't get default avi for "+instance+", error: "+str(E)) return f.write(imgdata) f.close() return filepath def updateAnimationPref(): if ex.animatePicturesAction.isChecked(): STATIC_PREF = '' else: STATIC_PREF = 'static_' if __name__ == '__main__': _mkdir(CACHE+'/img/') _mkdir(CACHE+'/vid/') _mkdir(CACHE+'/audio/') _mkdir(APPDATA) app = QApplication(sys.argv) app.setOrganizationName("plchat") app.setApplicationName("plchat") ex = App(app.processEvents) sys.exit(ex._eventloop.run_forever())