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:
parent
11d900965a
commit
1d0cce402e
4
Makefile
4
Makefile
|
@ -6,11 +6,13 @@ PKG_DIR = harmonyqml
|
||||||
PYTHON = python3
|
PYTHON = python3
|
||||||
PIP = pip3
|
PIP = pip3
|
||||||
PYLINT = pylint
|
PYLINT = pylint
|
||||||
|
VULTURE = vulture
|
||||||
CLOC = cloc
|
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}
|
||||||
|
|
5
TODO.md
5
TODO.md
|
@ -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?
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
79
harmonyqml/backend/pyqt_future.py
Normal file
79
harmonyqml/backend/pyqt_future.py
Normal 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
|
|
@ -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:
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 +
|
||||||
" <font size=" + smallSize + "px color='gray'>" +
|
" <font size=" + smallSize + "px color='gray'>" +
|
||||||
Qt.formatDateTime(date_time, "hh:mm:ss") +
|
Qt.formatDateTime(date_time, "hh:mm:ss") +
|
||||||
"</font></font>"
|
"</font></font>"
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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")) {
|
||||||
|
|
Loading…
Reference in New Issue
Block a user