Proper display name retrieval implementation

For any name not found in rooms data, rely on new
nio.HttpClient.get_displayname() function to get and cache it,
e.g. for our own name if no room is joined and past events from users
who left the room.

@futurize now returns PyQtFuture objects, wrapper for the
concurrent.futures.Future objects that can be used from QML,
to ensure name retrieval does not block the GUI.
This commit is contained in:
miruka 2019-04-19 02:07:01 -04:00
parent 11d900965a
commit 1d0cce402e
16 changed files with 146 additions and 58 deletions

View File

@ -3,14 +3,16 @@
PKG_DIR = harmonyqml PKG_DIR = harmonyqml
PYTHON = python3 PYTHON = python3
PIP = pip3 PIP = pip3
PYLINT = pylint PYLINT = pylint
CLOC = cloc VULTURE = vulture
CLOC = cloc
ARCHIVE_FORMATS = gztar ARCHIVE_FORMATS = gztar
INSTALL_FLAGS = --user --editable INSTALL_FLAGS = --user --editable
PYLINT_FLAGS = --output-format colorized PYLINT_FLAGS = --output-format colorized
VULTURE_FLAGS = --min-confidence 100
CLOC_FLAGS = --ignore-whitespace CLOC_FLAGS = --ignore-whitespace
.PHONY: all clean dist install upload test .PHONY: all clean dist install upload test
@ -45,4 +47,6 @@ upload: dist
test: test:
- ${PYLINT} ${PYLINT_FLAGS} ${PKG_DIR} *.py - ${PYLINT} ${PYLINT_FLAGS} ${PKG_DIR} *.py
@echo @echo
- ${VULTURE} ${PKG_DIR} ${VULTURE_FLAGS}
@echo
${CLOC} ${CLOC_FLAGS} ${PKG_DIR} ${CLOC} ${CLOC_FLAGS} ${PKG_DIR}

View File

@ -2,7 +2,6 @@
- Invited → Accept/Deny dialog - Invited → Accept/Deny dialog
- Keep the room header name and topic updated - Keep the room header name and topic updated
- Merge login page - Merge login page
- Show actual display name for AccountDelegate
- When inviting someone to direct chat, room is "Empty room" until accepted, - When inviting someone to direct chat, room is "Empty room" until accepted,
it should be the peer's display name instead. it should be the peer's display name instead.
@ -19,8 +18,6 @@
- Migrate more JS functions to their own files - Migrate more JS functions to their own files
- Accept room\_id arg for getUser
- Set Qt parents for all QObject - Set Qt parents for all QObject
- `<pre>` scrollbar on overflow - `<pre>` scrollbar on overflow
@ -39,3 +36,5 @@
- ![A picture](https://picsum.photos/256/256) not clickable? - ![A picture](https://picsum.photos/256/256) not clickable?
- On sync, check messages API, if a limited sync timeline was received - On sync, check messages API, if a limited sync timeline was received
- Graphic bug when resizing window vertically for side pane?

View File

@ -2,18 +2,23 @@
# This file is part of harmonyqml, licensed under GPLv3. # This file is part of harmonyqml, licensed under GPLv3.
import hashlib import hashlib
from concurrent.futures import ThreadPoolExecutor
from typing import Dict, Set from typing import Dict, Set
from PyQt5.QtCore import QObject, pyqtProperty, pyqtSlot from PyQt5.QtCore import QObject, pyqtProperty, pyqtSlot
from .html_filter import HtmlFilter from .html_filter import HtmlFilter
from .model.items import User
from .model.qml_models import QMLModels from .model.qml_models import QMLModels
from .pyqt_future import futurize
class Backend(QObject): class Backend(QObject):
def __init__(self) -> None: def __init__(self) -> None:
super().__init__() super().__init__()
self.pool: ThreadPoolExecutor = ThreadPoolExecutor(max_workers=6)
self._queried_displaynames: Dict[str, str] = {}
self.past_tokens: Dict[str, str] = {} self.past_tokens: Dict[str, str] = {}
self.fully_loaded_rooms: Set[str] = set() self.fully_loaded_rooms: Set[str] = set()
@ -21,7 +26,6 @@ class Backend(QObject):
self._client_manager: ClientManager = ClientManager(self) self._client_manager: ClientManager = ClientManager(self)
self._models: QMLModels = QMLModels() self._models: QMLModels = QMLModels()
self._html_filter: HtmlFilter = HtmlFilter() self._html_filter: HtmlFilter = HtmlFilter()
# a = self._client_manager; m = self._models
from .signal_manager import SignalManager from .signal_manager import SignalManager
self._signal_manager: SignalManager = SignalManager(self) self._signal_manager: SignalManager = SignalManager(self)
@ -42,16 +46,30 @@ class Backend(QObject):
return self._html_filter return self._html_filter
@pyqtSlot(str, result="QVariantMap") @pyqtSlot(str, result="QVariant")
def getUser(self, user_id: str) -> Dict[str, str]: @pyqtSlot(str, bool, result="QVariant")
@futurize
def getUserDisplayName(self, user_id: str, can_block: bool = True) -> str:
if user_id in self._queried_displaynames:
return self._queried_displaynames[user_id]
for client in self.clientManager.clients.values(): for client in self.clientManager.clients.values():
for room in client.nio.rooms.values(): for room in client.nio.rooms.values():
displayname = room.user_name(user_id)
name = room.user_name(user_id) if displayname:
if name: return displayname
return User(user_id=user_id, display_name=name)._asdict()
return User(user_id=user_id, display_name=user_id)._asdict() return self._query_user_displayname(user_id) if can_block else user_id
def _query_user_displayname(self, user_id: str) -> str:
client = next(iter(self.clientManager.clients.values()))
response = client.net.talk(client.nio.get_displayname, user_id)
displayname = getattr(response, "displayname", "") or user_id
self._queried_displaynames[user_id] = displayname
return displayname
@pyqtSlot(str, result=float) @pyqtSlot(str, result=float)

View File

@ -1,13 +1,9 @@
# Copyright 2019 miruka # Copyright 2019 miruka
# This file is part of harmonyqml, licensed under GPLv3. # This file is part of harmonyqml, licensed under GPLv3.
import functools from concurrent.futures import ThreadPoolExecutor
import logging from threading import Event
import sys from typing import DefaultDict
import traceback
from concurrent.futures import Future, ThreadPoolExecutor
from threading import Event, currentThread
from typing import Callable, DefaultDict
from PyQt5.QtCore import QObject, pyqtProperty, pyqtSignal, pyqtSlot from PyQt5.QtCore import QObject, pyqtProperty, pyqtSignal, pyqtSlot
@ -15,6 +11,7 @@ import nio
import nio.responses as nr import nio.responses as nr
from .network_manager import NetworkManager from .network_manager import NetworkManager
from .pyqt_future import futurize
# One pool per hostname/remote server; # One pool per hostname/remote server;
# multiple Client for different accounts on the same server can exist. # multiple Client for different accounts on the same server can exist.
@ -22,22 +19,6 @@ _POOLS: DefaultDict[str, ThreadPoolExecutor] = \
DefaultDict(lambda: ThreadPoolExecutor(max_workers=6)) DefaultDict(lambda: ThreadPoolExecutor(max_workers=6))
def futurize(func: Callable) -> Callable:
@functools.wraps(func)
def wrapper(*args, **kwargs) -> Future:
def run_and_catch_errs():
# Without this, exceptions are silently ignored
try:
func(*args, **kwargs)
except Exception:
traceback.print_exc()
logging.error("Exiting %s due to exception.", currentThread())
sys.exit(1)
return args[0].pool.submit(run_and_catch_errs) # args[0] = self
return wrapper
class Client(QObject): class Client(QObject):
roomInvited = pyqtSignal(str) roomInvited = pyqtSignal(str)
roomJoined = pyqtSignal(str) roomJoined = pyqtSignal(str)
@ -98,7 +79,6 @@ class Client(QObject):
self.net_sync.write(self.nio_sync.connect()) self.net_sync.write(self.nio_sync.connect())
self.nio_sync.receive_response(response) self.nio_sync.receive_response(response)
self.startSyncing()
@pyqtSlot(str, str, str) @pyqtSlot(str, str, str)
@ -111,7 +91,6 @@ class Client(QObject):
self.net_sync.write(self.nio_sync.connect()) self.net_sync.write(self.nio_sync.connect())
self.nio_sync.receive_response(response) self.nio_sync.receive_response(response)
self.startSyncing()
@pyqtSlot() @pyqtSlot()

View File

@ -65,6 +65,7 @@ class ClientManager(QObject):
def _on_connected(self, client: Client) -> None: def _on_connected(self, client: Client) -> None:
self.clients[client.userID] = client self.clients[client.userID] = client
self.clientAdded.emit(client) self.clientAdded.emit(client)
client.startSyncing()
@pyqtSlot(str) @pyqtSlot(str)

View File

@ -5,10 +5,12 @@ from typing import Dict, List, NamedTuple, Optional
from PyQt5.QtCore import QDateTime from PyQt5.QtCore import QDateTime
from ..pyqt_future import PyQtFuture
class User(NamedTuple): class User(NamedTuple):
user_id: str user_id: str
display_name: str display_name: PyQtFuture
avatar_url: Optional[str] = None avatar_url: Optional[str] = None
status_message: Optional[str] = None status_message: Optional[str] = None

View File

@ -0,0 +1,79 @@
# Copyright 2019 miruka
# This file is part of harmonyqml, licensed under GPLv3.
import functools
import logging
import sys
import traceback
from concurrent.futures import Future
from threading import currentThread
from typing import Callable, Optional, Union
from PyQt5.QtCore import QObject, pyqtProperty, pyqtSignal, pyqtSlot
class PyQtFuture(QObject):
gotResult = pyqtSignal()
def __init__(self, future: Future, parent: QObject) -> None:
super().__init__(parent)
self.future = future
self._result = None
self.future.add_done_callback(lambda _: self.gotResult.emit())
def __repr__(self) -> str:
return "%s(%s)" % (type(self).__name__, repr(self.future))
@pyqtSlot()
def cancel(self):
self.future.cancel()
@pyqtProperty(bool)
def cancelled(self):
return self.future.cancelled()
@pyqtProperty(bool)
def running(self):
return self.future.running()
@pyqtProperty(bool)
def done(self):
return self.future.done()
@pyqtSlot(result="QVariant")
@pyqtSlot(int, result="QVariant")
@pyqtSlot(float, result="QVariant")
def result(self, timeout: Optional[Union[int, float]] = None):
return self.future.result(timeout)
@pyqtProperty("QVariant", notify=gotResult)
def value(self):
return self.future.result() if self.done else None
def add_done_callback(self, fn: Callable[[Future], None]) -> None:
self.future.add_done_callback(fn)
def futurize(func: Callable) -> Callable:
@functools.wraps(func)
def wrapper(self, *args, **kwargs) -> PyQtFuture:
def run_and_catch_errs():
# Without this, exceptions are silently ignored
try:
return func(self, *args, **kwargs)
except Exception:
traceback.print_exc()
logging.error("Exiting %s due to exception.", currentThread())
sys.exit(1)
return PyQtFuture(self.pool.submit(run_and_catch_errs), self)
return wrapper

View File

@ -32,7 +32,7 @@ class SignalManager(QObject):
self.connectClient(client) self.connectClient(client)
self.backend.models.accounts.append(User( self.backend.models.accounts.append(User(
user_id = client.userID, user_id = client.userID,
display_name = client.userID.lstrip("@").split(":")[0], display_name = self.backend.getUserDisplayName(client.userID),
)) ))
@ -68,7 +68,7 @@ class SignalManager(QObject):
item = Room( item = Room(
room_id = room_id, room_id = room_id,
display_name = room.name or room.canonical_alias or group_name(), display_name = room.name or room.canonical_alias or group_name(),
description = getattr(room, "topic", ""), # FIXME: outside init description = room.topic,
) )
try: try:

View File

@ -4,10 +4,15 @@ import QtQuick.Layouts 1.4
Item { Item {
property bool invisible: false property bool invisible: false
property var name: null property var name: null // null, string or PyQtFuture
property var imageSource: null property var imageSource: null
property int dimmension: 48 property int dimmension: 48
readonly property string resolved_name:
! name ? "?" :
typeof(name) == "string" ? name :
(name.value ? name.value : "?")
id: "root" id: "root"
width: dimmension width: dimmension
height: invisible ? 1 : dimmension height: invisible ? 1 : dimmension
@ -16,13 +21,13 @@ Item {
id: "letterRectangle" id: "letterRectangle"
anchors.fill: parent anchors.fill: parent
visible: ! invisible && imageSource === null visible: ! invisible && imageSource === null
color: name ? color: resolved_name === "?" ?
Qt.hsla(Backend.hueFromString(name), 0.22, 0.5, 1) : Qt.hsla(0, 0, 0.22, 1) :
Qt.hsla(0, 0, 0.22, 1) Qt.hsla(Backend.hueFromString(resolved_name), 0.22, 0.5, 1)
HLabel { HLabel {
anchors.centerIn: parent anchors.centerIn: parent
text: name ? name.charAt(0) : "?" text: resolved_name.charAt(0)
color: "white" color: "white"
font.pixelSize: letterRectangle.height / 1.4 font.pixelSize: letterRectangle.height / 1.4
} }

View File

@ -6,7 +6,7 @@ import "utils.js" as ChatJS
RowLayout { RowLayout {
id: row id: row
spacing: standardSpacing spacing: standardSpacing / 2
layoutDirection: isOwn ? Qt.RightToLeft : Qt.LeftToRight layoutDirection: isOwn ? Qt.RightToLeft : Qt.LeftToRight
anchors.right: isOwn ? parent.right : undefined anchors.right: isOwn ? parent.right : undefined
@ -24,7 +24,7 @@ RowLayout {
id: contentLabel id: contentLabel
text: "<font color='" + text: "<font color='" +
(isUndecryptableEvent ? "darkred" : "gray") + "'>" + (isUndecryptableEvent ? "darkred" : "gray") + "'>" +
displayName + " " + contentText + (displayName.value || dict.sender) + " " + contentText +
"&nbsp;&nbsp;<font size=" + smallSize + "px color='gray'>" + "&nbsp;&nbsp;<font size=" + smallSize + "px color='gray'>" +
Qt.formatDateTime(date_time, "hh:mm:ss") + Qt.formatDateTime(date_time, "hh:mm:ss") +
"</font></font>" "</font></font>"

View File

@ -17,7 +17,7 @@ Row {
Base.HLabel { Base.HLabel {
visible: ! combine visible: ! combine
id: nameLabel id: nameLabel
text: displayName text: displayName.value || dict.sender
background: Rectangle {color: "#DDD"} background: Rectangle {color: "#DDD"}
color: isOwn ? "teal" : "purple" color: isOwn ? "teal" : "purple"
elide: Text.ElideRight elide: Text.ElideRight

View File

@ -21,8 +21,8 @@ Column {
readonly property bool isUndecryptableEvent: readonly property bool isUndecryptableEvent:
type === "OlmEvent" || type === "MegolmEvent" type === "OlmEvent" || type === "MegolmEvent"
readonly property string displayName: readonly property var displayName:
Backend.getUser(dict.sender).display_name Backend.getUserDisplayName(dict.sender)
readonly property bool isOwn: readonly property bool isOwn:
chatPage.user_id === dict.sender chatPage.user_id === dict.sender

View File

@ -20,7 +20,7 @@ Rectangle {
Base.Avatar { Base.Avatar {
id: "avatar" id: "avatar"
name: Backend.getUser(chatPage.user_id).display_name name: Backend.getUserDisplayName(chatPage.user_id)
dimmension: root.Layout.minimumHeight dimmension: root.Layout.minimumHeight
//visible: textArea.text === "" //visible: textArea.text === ""
visible: textArea.height <= root.Layout.minimumHeight visible: textArea.height <= root.Layout.minimumHeight

View File

@ -91,8 +91,9 @@ function get_member_event_text(dict) {
break break
case "invite": case "invite":
var name = Backend.getUser(dict.state_key).display_name var name = Backend.getUserDisplayName(dict.state_key, false)
var name = name === dict.state_key ? info.displayname : name var name = name === dict.state_key ?
info.displayname : name.result()
return "invited " + name + " to the room." return "invited " + name + " to the room."
break break

View File

@ -21,7 +21,7 @@ ColumnLayout {
Base.HLabel { Base.HLabel {
id: "accountLabel" id: "accountLabel"
text: display_name text: display_name.value || user_id
elide: Text.ElideRight elide: Text.ElideRight
maximumLineCount: 1 maximumLineCount: 1
Layout.fillWidth: true Layout.fillWidth: true

View File

@ -15,7 +15,7 @@ function get_last_room_event_text(room_id) {
if (! found) { return "" } if (! found) { return "" }
var name = Backend.getUser(ev.dict.sender).display_name var name = Backend.getUserDisplayName(ev.dict.sender, false).result()
var undecryptable = ev.type === "OlmEvent" || ev.type === "MegolmEvent" var undecryptable = ev.type === "OlmEvent" || ev.type === "MegolmEvent"
if (undecryptable || ev.type.startsWith("RoomMessage")) { if (undecryptable || ev.type.startsWith("RoomMessage")) {