Start rewriting backend with pyotherside+asyncio
@@ -1,15 +0,0 @@
|
||||
# Copyright 2019 miruka
|
||||
# This file is part of harmonyqml, licensed under GPLv3.
|
||||
|
||||
"""<SHORTDESC>"""
|
||||
|
||||
__pkg_name__ = "harmonyqml"
|
||||
__pretty_name__ = "Harmony QML"
|
||||
__version__ = "0.1.0"
|
||||
__status__ = "Development"
|
||||
# __status__ = "Production"
|
||||
|
||||
__author__ = "miruka"
|
||||
__email__ = "miruka@disroot.org"
|
||||
|
||||
__license__ = "GPLv3"
|
@@ -1,21 +0,0 @@
|
||||
# Copyright 2019 miruka
|
||||
# This file is part of harmonyqml, licensed under GPLv3.
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
# The disk cache is responsible for multiple display bugs when running
|
||||
# the app for the first time/when cache needs to be recompiled, on top
|
||||
# of litering the source folders with .qmlc files.
|
||||
os.environ["QML_DISABLE_DISK_CACHE"] = "1"
|
||||
|
||||
|
||||
def run() -> None:
|
||||
from .app import Application
|
||||
app = Application(sys.argv)
|
||||
|
||||
from .engine import Engine
|
||||
engine = Engine(debug=app.debug)
|
||||
engine.showWindow()
|
||||
|
||||
sys.exit(app.exec_())
|
@@ -1,8 +0,0 @@
|
||||
# Copyright 2019 miruka
|
||||
# This file is part of harmonyqml, licensed under GPLv3.
|
||||
|
||||
"Run app when this package is executed from 'python -m <pkgname>'."
|
||||
|
||||
from . import run
|
||||
|
||||
run()
|
@@ -1,22 +0,0 @@
|
||||
# Copyright 2019 miruka
|
||||
# This file is part of harmonyqml, licensed under GPLv3.
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
from PyQt5.QtGui import QGuiApplication
|
||||
|
||||
from . import __about__
|
||||
|
||||
|
||||
class Application(QGuiApplication):
|
||||
def __init__(self, args: Optional[List[str]] = None) -> None:
|
||||
self.debug = False
|
||||
|
||||
if args and "--debug" in args:
|
||||
del args[args.index("--debug")]
|
||||
self.debug = True
|
||||
|
||||
super().__init__(args or [])
|
||||
|
||||
self.setApplicationName(__about__.__pkg_name__)
|
||||
self.setApplicationDisplayName(__about__.__pretty_name__)
|
@@ -1,4 +0,0 @@
|
||||
# Copyright 2019 miruka
|
||||
# This file is part of harmonyqml, licensed under GPLv3.
|
||||
|
||||
from .backend import Backend
|
@@ -1,168 +0,0 @@
|
||||
# Copyright 2019 miruka
|
||||
# This file is part of harmonyqml, licensed under GPLv3.
|
||||
|
||||
import os
|
||||
import random
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import Deque, Dict, Optional, Sequence, Set, Tuple
|
||||
|
||||
from atomicfile import AtomicFile
|
||||
from PyQt5.QtCore import QObject, QStandardPaths, pyqtProperty, pyqtSlot
|
||||
|
||||
from .html_filter import HtmlFilter
|
||||
from .model import ListModel, ListModelMap
|
||||
from .model.items import User
|
||||
from .network_manager import NioErrorResponse
|
||||
from .pyqt_future import futurize
|
||||
|
||||
|
||||
class Backend(QObject):
|
||||
def __init__(self, parent: QObject) -> None:
|
||||
super().__init__(parent)
|
||||
self.pool: ThreadPoolExecutor = ThreadPoolExecutor(max_workers=6)
|
||||
|
||||
self.past_tokens: Dict[str, str] = {}
|
||||
self.fully_loaded_rooms: Set[str] = set()
|
||||
|
||||
self._html_filter: HtmlFilter = HtmlFilter(self)
|
||||
|
||||
from .client_manager import ClientManager
|
||||
self._client_manager: ClientManager = ClientManager(self)
|
||||
|
||||
self._accounts: ListModel = ListModel(parent=parent)
|
||||
|
||||
self._room_events: ListModelMap = ListModelMap(
|
||||
container = Deque,
|
||||
parent = self
|
||||
)
|
||||
|
||||
self._users: ListModel = ListModel(
|
||||
default_factory = self._query_user,
|
||||
parent = self
|
||||
)
|
||||
|
||||
from .signal_manager import SignalManager
|
||||
self._signal_manager: SignalManager = SignalManager(self)
|
||||
|
||||
self.clients.configLoad()
|
||||
|
||||
|
||||
@pyqtProperty("QVariant", constant=True)
|
||||
def htmlFilter(self):
|
||||
return self._html_filter
|
||||
|
||||
@pyqtProperty("QVariant", constant=True)
|
||||
def clients(self):
|
||||
return self._client_manager
|
||||
|
||||
@pyqtProperty("QVariant", constant=True)
|
||||
def accounts(self):
|
||||
return self._accounts
|
||||
|
||||
@pyqtProperty("QVariant", constant=True)
|
||||
def roomEvents(self):
|
||||
return self._room_events
|
||||
|
||||
@pyqtProperty("QVariant", constant=True)
|
||||
def users(self):
|
||||
return self._users
|
||||
|
||||
@pyqtProperty("QVariant", constant=True)
|
||||
def signals(self):
|
||||
return self._signal_manager
|
||||
|
||||
|
||||
def _query_user(self, user_id: str) -> User:
|
||||
client = random.choice(tuple(self.clients.values())) # nosec
|
||||
|
||||
@futurize(running_value=user_id)
|
||||
def get_displayname(self) -> str:
|
||||
try:
|
||||
response = client.net.talk(client.nio.get_displayname, user_id)
|
||||
return response.displayname or user_id
|
||||
except NioErrorResponse:
|
||||
return user_id
|
||||
|
||||
return User(
|
||||
userId = user_id,
|
||||
displayName = get_displayname(self),
|
||||
devices = ListModel(),
|
||||
)
|
||||
|
||||
|
||||
@pyqtSlot(str, result=float)
|
||||
def hueFromString(self, string: str) -> float:
|
||||
# pylint:disable=no-self-use
|
||||
return sum((ord(char) * 99 for char in string)) % 360 / 360
|
||||
|
||||
|
||||
@pyqtSlot(str)
|
||||
@pyqtSlot(str, int)
|
||||
def loadPastEvents(self, room_id: str, limit: int = 100) -> None:
|
||||
if not room_id in self.past_tokens:
|
||||
return # Initial sync not done yet
|
||||
|
||||
if room_id in self.fully_loaded_rooms:
|
||||
return
|
||||
|
||||
for client in self.clients.values():
|
||||
if room_id in client.nio.rooms:
|
||||
client.loadPastEvents(
|
||||
room_id, self.past_tokens[room_id], limit
|
||||
)
|
||||
break
|
||||
|
||||
|
||||
@pyqtSlot(str)
|
||||
def setRoomFilter(self, pattern: str) -> None:
|
||||
for account in self.accounts:
|
||||
for categ in account.roomCategories:
|
||||
categ.sortedRooms.filter = pattern
|
||||
|
||||
|
||||
@staticmethod
|
||||
def getDir(standard_dir: QStandardPaths.StandardLocation) -> str:
|
||||
path = QStandardPaths.writableLocation(standard_dir)
|
||||
os.makedirs(path, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def getFile(self,
|
||||
standard_dir: QStandardPaths.StandardLocation,
|
||||
relative_file_path: str,
|
||||
initial_content: Optional[str] = None) -> str:
|
||||
|
||||
relative_file_path = relative_file_path.replace("/", os.sep)
|
||||
|
||||
path = QStandardPaths.locate(standard_dir, relative_file_path)
|
||||
if path:
|
||||
return path
|
||||
|
||||
path = os.path.join(self.getDir(standard_dir), relative_file_path)
|
||||
|
||||
if initial_content is not None:
|
||||
with AtomicFile(path, "w") as new:
|
||||
new.write(initial_content)
|
||||
|
||||
return path
|
||||
|
||||
|
||||
@pyqtSlot()
|
||||
@pyqtSlot(list)
|
||||
def pdb(self, additional_data: Sequence = ()) -> None:
|
||||
# pylint: disable=all
|
||||
ad = additional_data
|
||||
cl = self.clients
|
||||
ac = self.accounts
|
||||
re = self.roomEvents
|
||||
us = self.users
|
||||
|
||||
tcl = lambda user: cl[f"@test_{user}:matrix.org"]
|
||||
|
||||
import json
|
||||
jd = lambda obj: print(json.dumps(obj, indent=4, ensure_ascii=False))
|
||||
|
||||
import pdb
|
||||
from PyQt5.QtCore import pyqtRemoveInputHook
|
||||
pyqtRemoveInputHook()
|
||||
pdb.set_trace()
|
@@ -1,357 +0,0 @@
|
||||
# Copyright 2019 miruka
|
||||
# This file is part of harmonyqml, licensed under GPLv3.
|
||||
|
||||
import logging as log
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from threading import Event
|
||||
from typing import DefaultDict, Tuple
|
||||
|
||||
from PyQt5.QtCore import (
|
||||
QObject, QStandardPaths, pyqtProperty, pyqtSignal, pyqtSlot
|
||||
)
|
||||
|
||||
import nio
|
||||
|
||||
from .model.items import Trust
|
||||
from .network_manager import NetworkManager
|
||||
from .pyqt_future import PyQtFuture, futurize
|
||||
|
||||
|
||||
class Client(QObject):
|
||||
roomInvited = pyqtSignal([str, dict], [str])
|
||||
roomJoined = pyqtSignal(str)
|
||||
roomLeft = pyqtSignal([str, dict], [str])
|
||||
|
||||
roomAboutToBeForgotten = pyqtSignal(str)
|
||||
|
||||
roomSyncPrevBatchTokenReceived = pyqtSignal(str, str)
|
||||
roomPastPrevBatchTokenReceived = pyqtSignal(str, str)
|
||||
roomEventReceived = pyqtSignal(str, str, dict)
|
||||
roomTypingMembersUpdated = pyqtSignal(str, list)
|
||||
|
||||
messageAboutToBeSent = pyqtSignal(str, dict)
|
||||
|
||||
deviceIsPresent = pyqtSignal(str, str, str)
|
||||
deviceIsDeleted = pyqtSignal(str, str)
|
||||
|
||||
|
||||
def __init__(self,
|
||||
manager,
|
||||
hostname: str,
|
||||
username: str,
|
||||
device_id: str = "") -> None:
|
||||
super().__init__(manager)
|
||||
self.manager = manager
|
||||
|
||||
host, *port = hostname.split(":")
|
||||
self.host: str = host
|
||||
self.port: int = int(port[0]) if port else 443
|
||||
|
||||
self.pool: ThreadPoolExecutor = ThreadPoolExecutor(6)
|
||||
|
||||
store_path = self.manager.backend.getDir(
|
||||
QStandardPaths.AppDataLocation
|
||||
)
|
||||
|
||||
self.nio: nio.client.HttpClient = nio.client.HttpClient(
|
||||
self.host, username, device_id, store_path=store_path
|
||||
)
|
||||
|
||||
# Since nio clients can't handle more than one talk operation
|
||||
# at a time, this one is used exclusively to poll the sync API
|
||||
self.nio_sync: nio.client.HttpClient = nio.client.HttpClient(
|
||||
self.host, username, device_id, store_path=store_path
|
||||
)
|
||||
|
||||
self.net = NetworkManager(self.host, self.port, self.nio)
|
||||
self.net_sync = NetworkManager(self.host, self.port, self.nio_sync)
|
||||
|
||||
self._stop_sync: Event = Event()
|
||||
|
||||
# {room_id: (was_typing, at_timestamp_secs)}
|
||||
self._last_typing_set: DefaultDict[str, Tuple[bool, float]] = \
|
||||
DefaultDict(lambda: (False, 0))
|
||||
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return "%s(host=%r, port=%r, user_id=%r)" % \
|
||||
(type(self).__name__, self.host, self.port, self.userId)
|
||||
|
||||
|
||||
@pyqtProperty(str, constant=True)
|
||||
def userId(self) -> str:
|
||||
return self.nio.user_id
|
||||
|
||||
|
||||
@futurize(max_running=1, discard_if_max_running=True, pyqt=False)
|
||||
def uploadE2EKeys(self) -> None:
|
||||
self.net.talk(self.nio.keys_upload)
|
||||
|
||||
|
||||
def queryE2EKeys(self) -> None:
|
||||
self._on_query_e2e_keys(self.net.talk(self.nio.keys_query))
|
||||
|
||||
|
||||
def _on_query_e2e_keys(self, response: nio.KeysQueryResponse) -> None:
|
||||
for user_id, device_dict in response.device_keys.items():
|
||||
for device_id, payload in device_dict.items():
|
||||
if device_id == self.nio.device_id:
|
||||
continue
|
||||
|
||||
ed25519_key = payload["keys"][f"ed25519:{device_id}"]
|
||||
self.deviceIsPresent.emit(user_id, device_id, ed25519_key)
|
||||
|
||||
for device_id, device in self.nio.device_store[user_id].items():
|
||||
if device.deleted:
|
||||
self.deviceIsDeleted.emit(user_id, device_id)
|
||||
|
||||
|
||||
def claimE2EKeysForRoom(self, room_id: str) -> None:
|
||||
self.net.talk(self.nio.keys_claim, room_id)
|
||||
|
||||
|
||||
def shareRoomE2ESession(self,
|
||||
room_id: str,
|
||||
ignore_missing_sessions: bool = False) -> None:
|
||||
self.net.talk(
|
||||
self.nio.share_group_session,
|
||||
room_id = room_id,
|
||||
ignore_missing_sessions = ignore_missing_sessions,
|
||||
)
|
||||
|
||||
|
||||
def getDeviceTrust(self, device: nio.crypto.OlmDevice) -> Trust:
|
||||
olm = self.nio.olm
|
||||
return (
|
||||
Trust.trusted if olm.is_device_verified(device) else
|
||||
Trust.blacklisted if olm.is_device_blacklisted(device) else
|
||||
Trust.undecided
|
||||
)
|
||||
|
||||
|
||||
@pyqtSlot(str, result="QVariant")
|
||||
@pyqtSlot(str, str, result="QVariant")
|
||||
@futurize()
|
||||
def login(self, password: str, device_name: str = "") -> "Client":
|
||||
# Main nio client will receive the response here
|
||||
response = self.net.talk(self.nio.login, password, device_name)
|
||||
# Now, receive it with the sync nio client too:
|
||||
self.nio_sync.receive_response(response)
|
||||
return self
|
||||
|
||||
|
||||
@pyqtSlot(str, str, str, result="QVariant")
|
||||
@futurize()
|
||||
def resumeSession(self, user_id: str, token: str, device_id: str
|
||||
) -> "Client":
|
||||
response = nio.LoginResponse(user_id, device_id, token)
|
||||
self.nio.receive_response(response)
|
||||
self.nio_sync.receive_response(response)
|
||||
return self
|
||||
|
||||
|
||||
@pyqtSlot(result="QVariant")
|
||||
@futurize()
|
||||
def logout(self) -> "Client":
|
||||
self._stop_sync.set()
|
||||
self.net.http_disconnect()
|
||||
self.net_sync.http_disconnect()
|
||||
return self
|
||||
|
||||
|
||||
@futurize(pyqt=False)
|
||||
def startSyncing(self) -> None:
|
||||
while True:
|
||||
try:
|
||||
response = self.net_sync.talk(self.nio_sync.sync, timeout=8000)
|
||||
except nio.LocalProtocolError: # logout occured
|
||||
pass
|
||||
else:
|
||||
self._on_sync(response)
|
||||
|
||||
if self._stop_sync.is_set():
|
||||
self._stop_sync.clear()
|
||||
break
|
||||
|
||||
|
||||
def _on_sync(self, response: nio.SyncResponse) -> None:
|
||||
self.nio.receive_response(response)
|
||||
|
||||
if self.nio.should_upload_keys:
|
||||
self.uploadE2EKeys()
|
||||
|
||||
if self.nio.should_query_keys:
|
||||
self.queryE2EKeys()
|
||||
|
||||
for room_id, room_info in response.rooms.invite.items():
|
||||
for ev in room_info.invite_state:
|
||||
member_ev = isinstance(ev, nio.InviteMemberEvent)
|
||||
|
||||
if member_ev and ev.content["membership"] == "join":
|
||||
self.roomInvited.emit(room_id, ev.content)
|
||||
break
|
||||
else:
|
||||
self.roomInvited[str].emit(room_id)
|
||||
|
||||
for room_id, room_info in response.rooms.join.items():
|
||||
self.roomJoined.emit(room_id)
|
||||
|
||||
self.roomSyncPrevBatchTokenReceived.emit(
|
||||
room_id, room_info.timeline.prev_batch
|
||||
)
|
||||
|
||||
for ev in room_info.timeline.events:
|
||||
self.roomEventReceived.emit(
|
||||
room_id, type(ev).__name__, ev.__dict__
|
||||
)
|
||||
|
||||
for ev in room_info.ephemeral:
|
||||
if isinstance(ev, nio.TypingNoticeEvent):
|
||||
self.roomTypingMembersUpdated.emit(room_id, ev.users)
|
||||
else:
|
||||
print("ephemeral event: ", ev)
|
||||
|
||||
for room_id, room_info in response.rooms.leave.items():
|
||||
for ev in room_info.timeline.events:
|
||||
member_ev = isinstance(ev, nio.RoomMemberEvent)
|
||||
|
||||
if member_ev and ev.content["membership"] in ("leave", "ban"):
|
||||
self.roomLeft.emit(room_id, ev.__dict__)
|
||||
break
|
||||
else:
|
||||
self.roomLeft[str].emit(room_id)
|
||||
|
||||
|
||||
@futurize(max_running=1, discard_if_max_running=True)
|
||||
def loadPastEvents(self, room_id: str, start_token: str, limit: int = 100
|
||||
) -> None:
|
||||
# From QML, use Backend.loastPastEvents instead
|
||||
|
||||
self._on_past_events(
|
||||
room_id,
|
||||
self.net.talk(
|
||||
self.nio.room_messages, room_id, start=start_token, limit=limit
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _on_past_events(self, room_id: str, response: nio.RoomMessagesResponse
|
||||
) -> None:
|
||||
self.roomPastPrevBatchTokenReceived.emit(room_id, response.end)
|
||||
|
||||
for ev in response.chunk:
|
||||
self.roomEventReceived.emit(
|
||||
room_id, type(ev).__name__, ev.__dict__
|
||||
)
|
||||
|
||||
|
||||
@pyqtSlot(str, bool)
|
||||
@futurize(max_running=1, discard_if_max_running=True)
|
||||
def setTypingState(self, room_id: str, typing: bool) -> None:
|
||||
set_for_secs = 5
|
||||
last_set, last_time = self._last_typing_set[room_id]
|
||||
|
||||
if not typing and last_set is False:
|
||||
return
|
||||
|
||||
if typing and time.time() - last_time < set_for_secs - 1:
|
||||
return
|
||||
|
||||
self._last_typing_set[room_id] = (typing, time.time())
|
||||
|
||||
self.net.talk(
|
||||
self.nio.room_typing,
|
||||
room_id = room_id,
|
||||
typing_state = typing,
|
||||
timeout = set_for_secs * 1000,
|
||||
)
|
||||
|
||||
|
||||
@pyqtSlot(str, str)
|
||||
def sendMarkdown(self, room_id: str, text: str) -> PyQtFuture:
|
||||
html = self.manager.backend.htmlFilter.fromMarkdown(text)
|
||||
content = {
|
||||
"body": text,
|
||||
"formatted_body": html,
|
||||
"format": "org.matrix.custom.html",
|
||||
"msgtype": "m.text",
|
||||
}
|
||||
self.messageAboutToBeSent.emit(room_id, content)
|
||||
|
||||
# If the thread pool workers are all occupied, and @futurize
|
||||
# wrapped sendMarkdown, the messageAboutToBeSent signal neccessary
|
||||
# for local echoes would not be sent until a thread is free.
|
||||
#
|
||||
# send() only takes the room_id argument explicitely because
|
||||
# of consider_args=True: This means the max number of messages being
|
||||
# sent at a time is one per room at a time.
|
||||
@futurize(max_running=1, consider_args=True)
|
||||
def send(self, room_id: str) -> PyQtFuture:
|
||||
talk = lambda: self.net.talk(
|
||||
self.nio.room_send,
|
||||
room_id = room_id,
|
||||
message_type = "m.room.message",
|
||||
content = content,
|
||||
)
|
||||
|
||||
try:
|
||||
log.debug("Try sending message %r to %r", content, room_id)
|
||||
return talk()
|
||||
except nio.GroupEncryptionError as err:
|
||||
log.warning(err)
|
||||
try:
|
||||
self.shareRoomE2ESession(room_id)
|
||||
except nio.EncryptionError as err:
|
||||
log.warning(err)
|
||||
self.claimE2EKeysForRoom(room_id)
|
||||
self.shareRoomE2ESession(room_id,
|
||||
ignore_missing_sessions=True)
|
||||
|
||||
log.debug("Final try to send %r to %r", content, room_id)
|
||||
return talk()
|
||||
|
||||
return send(self, room_id)
|
||||
|
||||
|
||||
@pyqtSlot(str, result="QVariant")
|
||||
@futurize()
|
||||
def joinRoom(self, room_id: str) -> None:
|
||||
return self.net.talk(self.nio.join, room_id=room_id)
|
||||
|
||||
|
||||
@pyqtSlot(str, result="QVariant")
|
||||
@futurize()
|
||||
def leaveRoom(self, room_id: str) -> None:
|
||||
return self.net.talk(self.nio.room_leave, room_id=room_id)
|
||||
|
||||
|
||||
@pyqtSlot(str, result="QVariant")
|
||||
@futurize()
|
||||
def forgetRoom(self, room_id: str) -> None:
|
||||
self.roomAboutToBeForgotten.emit(room_id)
|
||||
response = self.net.talk(self.nio.room_forget, room_id=room_id)
|
||||
self.nio.invalidate_outbound_session(room_id)
|
||||
return response
|
||||
|
||||
|
||||
@pyqtSlot(str, result=bool)
|
||||
def roomHasUnknownDevices(self, room_id: str) -> bool:
|
||||
return self.nio.room_contains_unverified(room_id)
|
||||
|
||||
|
||||
@pyqtSlot(str, str, result=str)
|
||||
def getMemberFilter(self, room_category: str, room_id: str) -> str:
|
||||
return self.manager.backend.accounts[self.userId]\
|
||||
.roomCategories[room_category]\
|
||||
.rooms[room_id]\
|
||||
.sortedMembers.filter
|
||||
|
||||
|
||||
@pyqtSlot(str, str, str)
|
||||
def setMemberFilter(self, room_category: str, room_id: str, pattern: str
|
||||
) -> None:
|
||||
self.manager.backend.accounts[self.userId]\
|
||||
.roomCategories[room_category]\
|
||||
.rooms[room_id]\
|
||||
.sortedMembers.filter = pattern
|
@@ -1,155 +0,0 @@
|
||||
# Copyright 2018 miruka
|
||||
# This file is part of harmonyqt, licensed under GPLv3.
|
||||
|
||||
import json
|
||||
import platform
|
||||
import threading
|
||||
from collections.abc import Mapping
|
||||
from typing import Dict
|
||||
|
||||
from atomicfile import AtomicFile
|
||||
from PyQt5.QtCore import (
|
||||
QObject, QStandardPaths, pyqtProperty, pyqtSignal, pyqtSlot
|
||||
)
|
||||
|
||||
from harmonyqml import __about__
|
||||
|
||||
from .backend import Backend
|
||||
from .client import Client
|
||||
|
||||
AccountConfig = Dict[str, Dict[str, str]]
|
||||
|
||||
_CONFIG_LOCK = threading.Lock()
|
||||
|
||||
|
||||
class _ClientManagerMeta(type(QObject), type(Mapping)): # type: ignore
|
||||
pass
|
||||
|
||||
|
||||
class ClientManager(QObject, Mapping, metaclass=_ClientManagerMeta):
|
||||
clientAdded = pyqtSignal(Client)
|
||||
clientDeleted = pyqtSignal(str)
|
||||
clientCountChanged = pyqtSignal(int)
|
||||
|
||||
|
||||
def __init__(self, backend: Backend) -> None:
|
||||
super().__init__(backend)
|
||||
self.backend = backend
|
||||
self._clients: Dict[str, Client] = {}
|
||||
|
||||
func = lambda: self.clientCountChanged.emit(len(self))
|
||||
self.clientAdded.connect(func)
|
||||
self.clientDeleted.connect(func)
|
||||
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"{type(self).__name__}(clients={self._clients!r})"
|
||||
|
||||
|
||||
def __getitem__(self, user_id: str) -> Client:
|
||||
return self.get(user_id)
|
||||
|
||||
|
||||
def __len__(self) -> int:
|
||||
return self.count
|
||||
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self._clients)
|
||||
|
||||
|
||||
@pyqtSlot(str, result="QVariant")
|
||||
def get(self, key: str) -> Client:
|
||||
return self._clients[key]
|
||||
|
||||
|
||||
@pyqtProperty(int, notify=clientCountChanged)
|
||||
def count(self):
|
||||
return len(self._clients)
|
||||
|
||||
|
||||
@pyqtSlot()
|
||||
def configLoad(self) -> None:
|
||||
for user_id, info in self.configAccounts().items():
|
||||
client = Client(self, info["hostname"], user_id)
|
||||
client.resumeSession(user_id, info["token"], info["device_id"])\
|
||||
.add_done_callback(lambda _, c=client: self._on_connected(c))
|
||||
|
||||
|
||||
@pyqtSlot(str, str, str, result="QVariant")
|
||||
@pyqtSlot(str, str, str, str, result="QVariant")
|
||||
def new(self, hostname: str, username: str, password: str,
|
||||
device_id: str = "") -> None:
|
||||
|
||||
client = Client(self, hostname, username, device_id)
|
||||
future = client.login(password, self.defaultDeviceName)
|
||||
future.add_done_callback(lambda _: self._on_connected(client))
|
||||
return future
|
||||
|
||||
|
||||
def _on_connected(self, client: Client) -> None:
|
||||
self._clients[client.userId] = client
|
||||
self.clientAdded.emit(client)
|
||||
client.startSyncing()
|
||||
|
||||
|
||||
@pyqtSlot(str)
|
||||
def remove(self, user_id: str) -> None:
|
||||
client = self._clients.pop(user_id, None)
|
||||
if client:
|
||||
self.clientDeleted.emit(user_id)
|
||||
client.logout()
|
||||
|
||||
|
||||
@pyqtSlot()
|
||||
def removeAll(self) -> None:
|
||||
for user_id in self._clients.copy():
|
||||
self.remove(user_id)
|
||||
|
||||
|
||||
@pyqtProperty(str, constant=True)
|
||||
def defaultDeviceName(self) -> str: # pylint: disable=no-self-use
|
||||
os_ = f" on {platform.system()}".rstrip()
|
||||
os_ = f"{os_} {platform.release()}".rstrip() if os_ != " on" else ""
|
||||
return f"{__about__.__pretty_name__}{os_}"
|
||||
|
||||
|
||||
# Config file operations
|
||||
|
||||
def getAccountConfigPath(self) -> str:
|
||||
return self.backend.getFile(
|
||||
QStandardPaths.AppConfigLocation, "accounts.json", "[]"
|
||||
)
|
||||
|
||||
|
||||
def configAccounts(self) -> AccountConfig:
|
||||
with open(self.getAccountConfigPath(), "r") as file:
|
||||
return json.loads(file.read().strip()) or {}
|
||||
|
||||
|
||||
@pyqtSlot("QVariant")
|
||||
def remember(self, client: Client) -> None:
|
||||
self._write_config({
|
||||
**self.configAccounts(),
|
||||
**{client.userId: {
|
||||
"hostname": client.nio.host,
|
||||
"token": client.nio.access_token,
|
||||
"device_id": client.nio.device_id,
|
||||
}}
|
||||
})
|
||||
|
||||
|
||||
@pyqtSlot(str)
|
||||
def forget(self, user_id: str) -> None:
|
||||
self._write_config({
|
||||
uid: info
|
||||
for uid, info in self.configAccounts().items() if uid != user_id
|
||||
})
|
||||
|
||||
|
||||
def _write_config(self, accounts: AccountConfig) -> None:
|
||||
js = json.dumps(accounts, indent=4, ensure_ascii=False, sort_keys=True)
|
||||
|
||||
with _CONFIG_LOCK:
|
||||
with AtomicFile(self.getAccountConfigPath(), "w") as new:
|
||||
new.write(js)
|
@@ -1,154 +0,0 @@
|
||||
# Copyright 2019 miruka
|
||||
# This file is part of harmonyqml, licensed under GPLv3.
|
||||
|
||||
import re
|
||||
|
||||
import mistune
|
||||
from lxml.html import HtmlElement, etree # nosec
|
||||
from PyQt5.QtCore import QObject, pyqtProperty, pyqtSlot
|
||||
|
||||
import html_sanitizer.sanitizer as sanitizer
|
||||
|
||||
|
||||
class HtmlFilter(QObject):
|
||||
link_regexes = [re.compile(r, re.IGNORECASE) for r in [
|
||||
(r"(?P<body>.+://(?P<host>[a-z0-9._-]+)(?:/[/\-_.,a-z0-9%&?;=~]*)?"
|
||||
r"(?:\([/\-_.,a-z0-9%&?;=~]*\))?)"),
|
||||
r"mailto:(?P<body>[a-z0-9._-]+@(?P<host>[a-z0-9_.-]+[a-z]))",
|
||||
r"tel:(?P<body>[0-9+-]+)(?P<host>)",
|
||||
r"(?P<body>magnet:\?xt=urn:[a-z0-9]+:.+)(?P<host>)",
|
||||
]]
|
||||
|
||||
|
||||
def __init__(self, parent: QObject) -> None:
|
||||
super().__init__(parent)
|
||||
self._sanitizer = sanitizer.Sanitizer(self.sanitizer_settings)
|
||||
|
||||
# The whitespace remover doesn't take <pre> into account
|
||||
sanitizer.normalize_overall_whitespace = lambda html: html
|
||||
sanitizer.normalize_whitespace_in_text_or_tail = lambda el: el
|
||||
|
||||
# hard_wrap: convert all \n to <br> without required two spaces
|
||||
self._markdown_to_html = mistune.Markdown(hard_wrap=True)
|
||||
|
||||
|
||||
@pyqtSlot(str, result=str)
|
||||
def fromMarkdown(self, text: str) -> str:
|
||||
return self.filter(self._markdown_to_html(text))
|
||||
|
||||
|
||||
@pyqtSlot(str, result=str)
|
||||
def filter(self, html: str) -> str:
|
||||
html = self._sanitizer.sanitize(html)
|
||||
tree = etree.fromstring(html, parser=etree.HTMLParser())
|
||||
|
||||
if tree is None:
|
||||
return ""
|
||||
|
||||
for el in tree.iter("img"):
|
||||
el = self._wrap_img_in_a(el)
|
||||
|
||||
for el in tree.iter("a"):
|
||||
el = self._append_img_to_a(el)
|
||||
|
||||
result = b"".join((etree.tostring(el, encoding="utf-8")
|
||||
for el in tree[0].iterchildren()))
|
||||
|
||||
return str(result, "utf-8")
|
||||
|
||||
|
||||
@pyqtProperty("QVariantMap")
|
||||
def sanitizer_settings(self) -> dict:
|
||||
# https://matrix.org/docs/spec/client_server/latest.html#m-room-message-msgtypes
|
||||
return {
|
||||
"tags": {
|
||||
# TODO: mx-reply, audio, video
|
||||
"font", "h1", "h2", "h3", "h4", "h5", "h6",
|
||||
"blockquote", "p", "a", "ul", "ol", "sup", "sub", "li",
|
||||
"b", "i", "s", "u", "code", "hr", "br",
|
||||
"table", "thead", "tbody", "tr", "th", "td",
|
||||
"pre", "img",
|
||||
},
|
||||
"attributes": {
|
||||
# TODO: translate font attrs to qt html subset
|
||||
"font": {"data-mx-bg-color", "data-mx-color"},
|
||||
"a": {"href"},
|
||||
"img": {"width", "height", "alt", "title", "src"},
|
||||
"ol": {"start"},
|
||||
"code": {"class"},
|
||||
},
|
||||
"empty": {"hr", "br", "img"},
|
||||
"separate": {
|
||||
"a", "p", "li", "table", "tr", "th", "td", "br", "hr"
|
||||
},
|
||||
"whitespace": {},
|
||||
"add_nofollow": False,
|
||||
"autolink": { # FIXME: arg dict not working
|
||||
"link_regexes": self.link_regexes,
|
||||
"avoid_hosts": [],
|
||||
},
|
||||
"sanitize_href": lambda href: href,
|
||||
"element_preprocessors": [
|
||||
sanitizer.bold_span_to_strong,
|
||||
sanitizer.italic_span_to_em,
|
||||
sanitizer.tag_replacer("strong", "b"),
|
||||
sanitizer.tag_replacer("em", "i"),
|
||||
sanitizer.tag_replacer("strike", "s"),
|
||||
sanitizer.tag_replacer("del", "s"),
|
||||
sanitizer.tag_replacer("span", "font"),
|
||||
self._remove_empty_font,
|
||||
sanitizer.tag_replacer("form", "p"),
|
||||
sanitizer.tag_replacer("div", "p"),
|
||||
sanitizer.tag_replacer("caption", "p"),
|
||||
sanitizer.target_blank_noopener,
|
||||
],
|
||||
"element_postprocessors": [],
|
||||
"is_mergeable": lambda e1, e2: e1.attrib == e2.attrib,
|
||||
}
|
||||
|
||||
|
||||
def _remove_empty_font(self, el: HtmlElement) -> HtmlElement:
|
||||
if el.tag != "font":
|
||||
return el
|
||||
|
||||
if not self.sanitizer_settings["attributes"]["font"] & set(el.keys()):
|
||||
el.clear()
|
||||
|
||||
return el
|
||||
|
||||
|
||||
def _wrap_img_in_a(self, el: HtmlElement) -> HtmlElement:
|
||||
link = el.attrib.get("src", "")
|
||||
width = el.attrib.get("width", "256")
|
||||
height = el.attrib.get("height", "256")
|
||||
|
||||
if el.getparent().tag == "a" or el.tag != "img" or \
|
||||
not self._is_image_path(link):
|
||||
return el
|
||||
|
||||
el.tag = "a"
|
||||
el.attrib.clear()
|
||||
el.attrib["href"] = link
|
||||
el.append(etree.Element("img", src=link, width=width, height=height))
|
||||
return el
|
||||
|
||||
|
||||
def _append_img_to_a(self, el: HtmlElement) -> HtmlElement:
|
||||
link = el.attrib.get("href", "")
|
||||
|
||||
if not (el.tag == "a" and self._is_image_path(link)):
|
||||
return el
|
||||
|
||||
for _ in el.iter("img"): # if the <a> already has an <img> child
|
||||
return el
|
||||
|
||||
el.append(etree.Element("br"))
|
||||
el.append(etree.Element("img", src=link, width="256", height="256"))
|
||||
return el
|
||||
|
||||
|
||||
@staticmethod
|
||||
def _is_image_path(link: str) -> bool:
|
||||
return bool(re.match(
|
||||
r".+\.(jpg|jpeg|png|gif|bmp|webp|tiff|svg)$", link, re.IGNORECASE
|
||||
))
|
@@ -1,6 +0,0 @@
|
||||
# Copyright 2019 miruka
|
||||
# This file is part of harmonyqml, licensed under GPLv3.
|
||||
|
||||
from . import items
|
||||
from .list_model import ListModel
|
||||
from .list_model_map import ListModelMap
|
@@ -1,96 +0,0 @@
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from PyQt5.QtCore import QDateTime
|
||||
|
||||
from ..pyqt_future import PyQtFuture
|
||||
from .list_item import ListItem
|
||||
from .list_model import ListModel
|
||||
from .sort_filter_proxy import SortFilterProxy
|
||||
|
||||
|
||||
class Account(ListItem):
|
||||
_required_init_values = {"userId", "roomCategories"}
|
||||
_constant = {"userId", "roomCategories"}
|
||||
|
||||
userId: str = ""
|
||||
roomCategories: ListModel = ListModel()
|
||||
|
||||
|
||||
class RoomCategory(ListItem):
|
||||
_required_init_values = {"name", "rooms", "sortedRooms"}
|
||||
_constant = {"name", "rooms", "sortedRooms"}
|
||||
|
||||
name: str = ""
|
||||
rooms: ListModel = ListModel()
|
||||
sortedRooms: SortFilterProxy = SortFilterProxy(ListModel(), "", "")
|
||||
|
||||
|
||||
class Room(ListItem):
|
||||
_required_init_values = {"roomId", "displayName", "members",
|
||||
"sortedMembers"}
|
||||
_constant = {"roomId", "members", "sortedMembers"}
|
||||
|
||||
roomId: str = ""
|
||||
displayName: str = ""
|
||||
topic: Optional[str] = None
|
||||
lastEventDateTime: Optional[QDateTime] = None
|
||||
typingMembers: List[str] = []
|
||||
|
||||
members: ListModel = ListModel()
|
||||
sortedMembers: SortFilterProxy = SortFilterProxy(ListModel(), "", "")
|
||||
|
||||
inviter: Optional[Dict[str, str]] = None
|
||||
leftEvent: Optional[Dict[str, str]] = None
|
||||
|
||||
|
||||
class RoomMember(ListItem):
|
||||
_required_init_values = {"userId"}
|
||||
_constant = {"userId"}
|
||||
|
||||
userId: str = ""
|
||||
|
||||
|
||||
class RoomEvent(ListItem):
|
||||
_required_init_values = {"eventId", "type", "dict", "dateTime"}
|
||||
_constant = {"type"}
|
||||
|
||||
eventId: str = ""
|
||||
type: str = ""
|
||||
dict: Dict[str, Any] = {}
|
||||
dateTime: QDateTime = QDateTime()
|
||||
isLocalEcho: bool = False
|
||||
|
||||
|
||||
# ----------
|
||||
|
||||
class User(ListItem):
|
||||
_required_init_values = {"userId", "devices"}
|
||||
_constant = {"userId", "devices"}
|
||||
|
||||
# Use PyQtFutures because the info might or might not need a request
|
||||
# to be fetched, and we don't want to block the UI in any case.
|
||||
# QML's property binding ability is used on the PyQtFuture.value
|
||||
userId: str = ""
|
||||
displayName: Optional[PyQtFuture] = None
|
||||
avatarUrl: Optional[PyQtFuture] = None
|
||||
statusMessage: Optional[PyQtFuture] = None
|
||||
devices: ListModel = ListModel()
|
||||
|
||||
|
||||
class Trust(Enum):
|
||||
blacklisted = -1
|
||||
undecided = 0
|
||||
trusted = 1
|
||||
|
||||
|
||||
class Device(ListItem):
|
||||
_required_init_values = {"deviceId", "ed25519Key"}
|
||||
_constant = {"deviceId", "ed25519Key"}
|
||||
|
||||
deviceId: str = ""
|
||||
ed25519Key: str = ""
|
||||
displayName: Optional[str] = None
|
||||
trust: Trust = Trust.undecided
|
||||
lastSeenIp: Optional[str] = None
|
||||
lastSeenDate: Optional[QDateTime] = None
|
@@ -1,183 +0,0 @@
|
||||
# Copyright 2019 miruka
|
||||
# This file is part of harmonyqml, licensed under GPLv3.
|
||||
|
||||
import textwrap
|
||||
from typing import Any, Dict, List, Mapping, Set, Tuple, Union
|
||||
|
||||
from PyQt5.QtCore import QObject, pyqtProperty, pyqtSignal, pyqtSlot
|
||||
|
||||
PyqtType = Union[str, type]
|
||||
|
||||
|
||||
class _ListItemMeta(type(QObject)): # type: ignore
|
||||
__slots__ = ()
|
||||
|
||||
def __new__(mcs, name: str, bases: Tuple[type], attrs: Dict[str, Any]):
|
||||
def to_pyqt_type(type_) -> PyqtType:
|
||||
"Return an appropriate pyqtProperty type from an annotation."
|
||||
try:
|
||||
if issubclass(type_, (bool, int, float, str, type(None))):
|
||||
return type_
|
||||
if issubclass(type_, Mapping):
|
||||
return "QVariantMap"
|
||||
return "QVariant"
|
||||
except TypeError: # e.g. None passed
|
||||
return to_pyqt_type(type(type_))
|
||||
|
||||
# These special attributes must not be processed like properties
|
||||
special = {"_main_key", "_required_init_values", "_constant"}
|
||||
|
||||
# These properties won't be settable and will not have a notify signal
|
||||
constant: Set[str] = set(attrs.get("_constant") or set())
|
||||
|
||||
# pyqtProperty objects that were directly defined in the class
|
||||
direct_pyqt_props: Dict[str, pyqtProperty] = {
|
||||
name: obj for name, obj in attrs.items()
|
||||
if isinstance(obj, pyqtProperty)
|
||||
}
|
||||
|
||||
# {property_name: (its_pyqt_type, its_default_value)}
|
||||
props: Dict[str, Tuple[PyqtType, Any]] = {
|
||||
name: (to_pyqt_type(attrs.get("__annotations__", {}).get(name)),
|
||||
value)
|
||||
for name, value in attrs.items()
|
||||
|
||||
if not (name.startswith("__") or callable(value) or
|
||||
name in special)
|
||||
}
|
||||
|
||||
# Signals for the pyqtProperty notify arguments
|
||||
signals: Dict[str, pyqtSignal] = {
|
||||
f"{name}Changed": pyqtSignal(type_)
|
||||
for name, (type_, _) in props.items() if name not in constant
|
||||
}
|
||||
|
||||
# pyqtProperty() won't take None, so we make dicts of extra kwargs
|
||||
# to pass for each property
|
||||
pyqt_props_kwargs: Dict[str, Dict[str, Any]] = {
|
||||
name: {"constant": True} if name in constant else
|
||||
|
||||
{"notify": signals[f"{name}Changed"],
|
||||
|
||||
"fset": lambda self, value, n=name: (
|
||||
setattr(self, f"_{n}", value) or # type: ignore
|
||||
getattr(self, f"{n}Changed").emit(value),
|
||||
)}
|
||||
for name in props
|
||||
}
|
||||
|
||||
# The final pyqtProperty objects we create
|
||||
pyqt_props: Dict[str, pyqtProperty] = {
|
||||
name: pyqtProperty(
|
||||
type_,
|
||||
fget=lambda self, n=name: getattr(self, f"_{n}"),
|
||||
**pyqt_props_kwargs.get(name, {}),
|
||||
)
|
||||
for name, (type_, _) in props.items()
|
||||
}
|
||||
|
||||
attrs = {
|
||||
**attrs, # Original class attributes
|
||||
**signals,
|
||||
**direct_pyqt_props,
|
||||
**pyqt_props,
|
||||
|
||||
# Set the internal _properties as slots for memory savings
|
||||
"__slots__": tuple({f"_{prop}" for prop in props} & {"_main_key"}),
|
||||
|
||||
"_direct_props": list(direct_pyqt_props.keys()),
|
||||
"_props": props,
|
||||
|
||||
# The main key is either the attribute _main_key,
|
||||
# or the first defined property
|
||||
"_main_key": attrs.get("_main_key") or
|
||||
list(props.keys())[0] if props else None,
|
||||
|
||||
"_required_init_values": attrs.get("_required_init_values") or (),
|
||||
"_constant": constant,
|
||||
}
|
||||
return type.__new__(mcs, name, bases, attrs)
|
||||
|
||||
|
||||
class ListItem(QObject, metaclass=_ListItemMeta):
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
super().__init__()
|
||||
|
||||
method: str = "%s.__init__()" % type(self).__name__
|
||||
already_set: Set[str] = set()
|
||||
|
||||
required: Set[str] = set(self._required_init_values)
|
||||
required_num: int = len(required) + 1 # + 1 = self
|
||||
|
||||
args_num: int = len(self._props) + 1
|
||||
from_to: str = str(args_num) if required_num == args_num else \
|
||||
f"from {required_num} to {args_num}"
|
||||
|
||||
# Check that not too many positional arguments were passed
|
||||
if len(args) > len(self._props):
|
||||
raise TypeError(
|
||||
f"{method} takes {from_to} positional arguments but "
|
||||
f"{len(args) + 1} were given"
|
||||
)
|
||||
|
||||
# Set properties from provided positional arguments
|
||||
for prop, value in zip(self._props, args):
|
||||
setattr(self, f"_{prop}", self._set_parent(value))
|
||||
already_set.add(prop)
|
||||
|
||||
# Set properties from provided keyword arguments
|
||||
for prop, value in kwargs.items():
|
||||
if prop in already_set:
|
||||
raise TypeError(f"{method} got multiple values for "
|
||||
f"argument {prop!r}")
|
||||
if prop not in self._props:
|
||||
raise TypeError(f"{method} got an unexpected keyword "
|
||||
f"argument {prop!r}")
|
||||
setattr(self, f"_{prop}", self._set_parent(value))
|
||||
already_set.add(prop)
|
||||
|
||||
# Check for required init arguments not provided
|
||||
missing: Set[str] = required - already_set
|
||||
if missing:
|
||||
raise TypeError("%s missing %d required argument: %s" % (
|
||||
method, len(missing), ", ".join((repr(m) for m in missing))))
|
||||
|
||||
# Set default values for properties not provided in arguments
|
||||
for prop in set(self._props) - already_set:
|
||||
setattr(self, f"_{prop}", self._set_parent(self._props[prop][1]))
|
||||
|
||||
|
||||
def _set_parent(self, value: Any) -> Any:
|
||||
if isinstance(value, QObject):
|
||||
value.setParent(self)
|
||||
return value
|
||||
|
||||
|
||||
def __repr__(self) -> str:
|
||||
prop_strings = (
|
||||
"\033[{0};34m{1}\033[0,{0}m = \033[{0};32m{2}\033[0m".format(
|
||||
1 if p == self.mainKey else 0, # 1 = term bold
|
||||
p,
|
||||
repr(getattr(self, p))
|
||||
) for p in list(self._props.keys()) + self._direct_props
|
||||
)
|
||||
|
||||
return "\033[35m%s\033[0m(\n%s\n)" % (
|
||||
type(self).__name__,
|
||||
textwrap.indent(",\n".join(prop_strings), prefix=" " * 4)
|
||||
)
|
||||
|
||||
|
||||
@pyqtSlot(result=str)
|
||||
def repr(self) -> str:
|
||||
return self.__repr__()
|
||||
|
||||
|
||||
@pyqtProperty("QStringList", constant=True)
|
||||
def roles(self) -> List[str]:
|
||||
return list(self._props.keys()) + self._direct_props
|
||||
|
||||
|
||||
@pyqtProperty(str, constant=True)
|
||||
def mainKey(self) -> str:
|
||||
return self._main_key
|
@@ -1,443 +0,0 @@
|
||||
import logging
|
||||
import textwrap
|
||||
from typing import (
|
||||
Any, Callable, Dict, Iterable, List, Mapping, MutableSequence, Optional,
|
||||
Sequence, Set, Tuple, Union
|
||||
)
|
||||
|
||||
from PyQt5.QtCore import (
|
||||
QAbstractListModel, QModelIndex, QObject, Qt, pyqtProperty, pyqtSignal,
|
||||
pyqtSlot
|
||||
)
|
||||
|
||||
from .list_item import ListItem
|
||||
|
||||
Index = Union[int, str]
|
||||
NewItem = Union[ListItem, Mapping[str, Any], Sequence]
|
||||
|
||||
|
||||
class _GetFail:
|
||||
pass
|
||||
|
||||
|
||||
class _PopFail:
|
||||
pass
|
||||
|
||||
|
||||
class ListModel(QAbstractListModel):
|
||||
rolesSet = pyqtSignal()
|
||||
changed = pyqtSignal()
|
||||
countChanged = pyqtSignal(int)
|
||||
|
||||
def __init__(self,
|
||||
initial_data: Optional[List[NewItem]] = None,
|
||||
container: Callable[..., MutableSequence] = list,
|
||||
default_factory: Optional[Callable[[str], ListItem]] = None,
|
||||
parent: QObject = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._data: MutableSequence[ListItem] = container()
|
||||
|
||||
self.default_factory = default_factory
|
||||
|
||||
if initial_data:
|
||||
self.extend(initial_data)
|
||||
|
||||
|
||||
def __repr__(self) -> str:
|
||||
if not self._data:
|
||||
return "\033[35m%s\033[0m()" % type(self).__name__
|
||||
|
||||
return "\033[35m%s\033[0m(\n%s\n)" % (
|
||||
type(self).__name__,
|
||||
textwrap.indent(
|
||||
",\n".join((repr(item) for item in self._data)),
|
||||
prefix = " " * 4,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def __contains__(self, index: Index) -> bool:
|
||||
if isinstance(index, str):
|
||||
try:
|
||||
self.indexWhere(index)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
return index in self._data
|
||||
|
||||
|
||||
def __getitem__(self, index: Index) -> ListItem:
|
||||
return self.get(index)
|
||||
|
||||
|
||||
def __setitem__(self, index: Index, value: NewItem) -> None:
|
||||
self.set(index, value)
|
||||
|
||||
|
||||
def __delitem__(self, index: Index) -> None:
|
||||
self.remove(index)
|
||||
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._data)
|
||||
|
||||
|
||||
def __iter__(self) -> Iterable[NewItem]:
|
||||
return iter(self._data)
|
||||
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
return bool(self._data)
|
||||
|
||||
|
||||
@pyqtSlot(result=str)
|
||||
def repr(self) -> str:
|
||||
return self.__repr__()
|
||||
|
||||
|
||||
@pyqtProperty("QStringList", notify=rolesSet)
|
||||
def roles(self) -> Tuple[str, ...]:
|
||||
return self._data[0].roles if self._data else () # type: ignore
|
||||
|
||||
|
||||
@pyqtProperty("QVariant", notify=rolesSet)
|
||||
def mainKey(self) -> Optional[str]:
|
||||
return self._data[0].mainKey if self._data else None
|
||||
|
||||
|
||||
def roleNumbers(self) -> Dict[str, int]:
|
||||
return {name: Qt.UserRole + i
|
||||
for i, name in enumerate(self.roles, 1)} \
|
||||
if self._data else {}
|
||||
|
||||
|
||||
def roleNames(self) -> Dict[int, bytes]:
|
||||
return {Qt.UserRole + i: bytes(name, "utf-8")
|
||||
for i, name in enumerate(self.roles, 1)} \
|
||||
if self._data else {}
|
||||
|
||||
|
||||
def data(self, index: QModelIndex, role: int = Qt.DisplayRole) -> Any:
|
||||
if role <= Qt.UserRole:
|
||||
return None
|
||||
|
||||
return getattr(self._data[index.row()],
|
||||
str(self.roleNames()[role], "utf8"))
|
||||
|
||||
|
||||
def rowCount(self, _: QModelIndex = QModelIndex()) -> int:
|
||||
return len(self)
|
||||
|
||||
|
||||
def _convert_new_value(self, value: NewItem) -> ListItem:
|
||||
def convert() -> ListItem:
|
||||
if self._data and isinstance(value, Mapping):
|
||||
if not set(value.keys()) <= set(self.roles):
|
||||
raise ValueError(
|
||||
f"{value}: must have all these keys: {self.roles}"
|
||||
)
|
||||
|
||||
return type(self._data[0])(**value)
|
||||
|
||||
if not self._data and isinstance(value, Mapping):
|
||||
raise NotImplementedError("First item must be set from Python")
|
||||
|
||||
if self._data and isinstance(value, type(self._data[0])):
|
||||
return value
|
||||
|
||||
if not self._data and isinstance(value, ListItem):
|
||||
return value
|
||||
|
||||
raise TypeError("%r: must be mapping or %s" % (
|
||||
value,
|
||||
type(self._data[0]).__name__ if self._data else "ListItem"
|
||||
))
|
||||
|
||||
value = convert()
|
||||
value.setParent(self)
|
||||
return value
|
||||
|
||||
|
||||
@pyqtProperty(int, notify=countChanged)
|
||||
def count(self) -> int:
|
||||
return len(self)
|
||||
|
||||
|
||||
@pyqtSlot("QVariant", result=int)
|
||||
def indexWhere(self,
|
||||
main_key_is_value: Any,
|
||||
_can_use_default_factory: bool = True) -> int:
|
||||
|
||||
for i, item in enumerate(self._data):
|
||||
if getattr(item, self.mainKey) == main_key_is_value:
|
||||
return i
|
||||
|
||||
if _can_use_default_factory and self.default_factory:
|
||||
return self.append(self.default_factory(main_key_is_value))
|
||||
|
||||
raise ValueError(
|
||||
f"No item in model data with "
|
||||
f"property {self.mainKey} is set to {main_key_is_value!r}."
|
||||
)
|
||||
|
||||
|
||||
@pyqtSlot(int, result="QVariant")
|
||||
@pyqtSlot(str, result="QVariant")
|
||||
@pyqtSlot(int, "QVariant", result="QVariant")
|
||||
@pyqtSlot(str, "QVariant", result="QVariant")
|
||||
def get(self, index: Index, default: Any = _GetFail()) -> ListItem:
|
||||
try:
|
||||
i_index: int = \
|
||||
self.indexWhere(index, _can_use_default_factory=False) \
|
||||
if isinstance(index, str) else index
|
||||
|
||||
return self._data[i_index]
|
||||
|
||||
except (ValueError, IndexError):
|
||||
if isinstance(default, _GetFail):
|
||||
if self.default_factory and isinstance(index, str):
|
||||
item = self.default_factory(index)
|
||||
self.append(item)
|
||||
return item
|
||||
raise
|
||||
|
||||
return default
|
||||
|
||||
|
||||
@pyqtSlot(int, "QVariantMap", result=int)
|
||||
def insert(self, index: int, value: NewItem) -> int:
|
||||
value = self._convert_new_value(value)
|
||||
|
||||
try:
|
||||
present_index = self.indexWhere(
|
||||
main_key_is_value = getattr(value, self.mainKey),
|
||||
_can_use_default_factory = False
|
||||
)
|
||||
except (TypeError, ValueError): # TypeError = no items in model
|
||||
pass
|
||||
else:
|
||||
logging.warning(
|
||||
"Duplicate mainKey %r in model - present: %r, inserting: %r",
|
||||
self.mainKey,
|
||||
self[present_index],
|
||||
value
|
||||
)
|
||||
|
||||
self.beginInsertRows(QModelIndex(), index, index)
|
||||
|
||||
had_data = bool(self._data)
|
||||
self._data.insert(index, value)
|
||||
if not had_data:
|
||||
self.rolesSet.emit()
|
||||
|
||||
self.endInsertRows()
|
||||
|
||||
self.countChanged.emit(len(self))
|
||||
self.changed.emit()
|
||||
return index
|
||||
|
||||
|
||||
@pyqtSlot("QVariantMap", result=int)
|
||||
def append(self, value: NewItem) -> int:
|
||||
return self.insert(len(self), value)
|
||||
|
||||
|
||||
@pyqtSlot(list)
|
||||
def extend(self, values: Iterable[NewItem]) -> None:
|
||||
for val in values:
|
||||
self.append(val)
|
||||
|
||||
|
||||
@pyqtSlot(list)
|
||||
@pyqtSlot(list, bool)
|
||||
def updateAll(self, items: Sequence[NewItem], delete: bool = False
|
||||
) -> None:
|
||||
items_: List[ListItem] = [self._convert_new_value(i) for i in items]
|
||||
|
||||
if delete:
|
||||
present_item: ListItem
|
||||
for i, present_item in enumerate(self):
|
||||
present_item_key = getattr(present_item, self.mainKey)
|
||||
|
||||
# If this present item is in the update items, based on mainKey
|
||||
for update_item in items_:
|
||||
if present_item_key == getattr(update_item, self.mainKey):
|
||||
break
|
||||
else:
|
||||
del self[i]
|
||||
|
||||
for item in items_:
|
||||
self.upsert(
|
||||
where_main_key_is = getattr(item, item.mainKey),
|
||||
update_with = item
|
||||
)
|
||||
|
||||
|
||||
@pyqtSlot(int, "QVariantMap", result=int)
|
||||
@pyqtSlot(int, "QVariantMap", "QStringList", result=int)
|
||||
@pyqtSlot(str, "QVariantMap", result=int)
|
||||
@pyqtSlot(str, "QVariantMap", "QStringList", result=int)
|
||||
def updateItem(self,
|
||||
index: Index,
|
||||
value: NewItem,
|
||||
no_update: Sequence[str] = ()) -> int:
|
||||
value = self._convert_new_value(value)
|
||||
|
||||
i_index: int = self.indexWhere(index, _can_use_default_factory=False) \
|
||||
if isinstance(index, str) else index
|
||||
|
||||
to_update = self[i_index]
|
||||
|
||||
updated_roles: Set[int] = set()
|
||||
|
||||
for role_name, role_num in self.roleNumbers().items():
|
||||
if role_name not in no_update:
|
||||
old_value = getattr(to_update, role_name)
|
||||
new_value = getattr(value, role_name)
|
||||
|
||||
if old_value != new_value:
|
||||
try:
|
||||
setattr(to_update, role_name, new_value)
|
||||
except AttributeError: # constant/not settable
|
||||
pass
|
||||
else:
|
||||
updated_roles.add(role_num)
|
||||
|
||||
if updated_roles:
|
||||
qidx = QAbstractListModel.index(self, i_index, 0)
|
||||
self.dataChanged.emit(qidx, qidx, updated_roles)
|
||||
self.changed.emit()
|
||||
|
||||
return i_index
|
||||
|
||||
|
||||
@pyqtSlot(str, "QVariantMap")
|
||||
@pyqtSlot(str, "QVariantMap", int)
|
||||
@pyqtSlot(str, "QVariantMap", int, int)
|
||||
@pyqtSlot(str, "QVariantMap", int, int, "QStringList")
|
||||
def upsert(self,
|
||||
where_main_key_is: Any,
|
||||
update_with: NewItem,
|
||||
new_index_if_insert: Optional[int] = None,
|
||||
new_index_if_update: Optional[int] = None,
|
||||
no_update: Sequence[str] = ()) -> None:
|
||||
try:
|
||||
index = self.updateItem(
|
||||
where_main_key_is, update_with, no_update
|
||||
)
|
||||
except (IndexError, ValueError):
|
||||
self.insert(new_index_if_insert or len(self), update_with)
|
||||
else:
|
||||
if new_index_if_update:
|
||||
self.move(index, new_index_if_update)
|
||||
|
||||
|
||||
@pyqtSlot(int, list)
|
||||
@pyqtSlot(str, list)
|
||||
def set(self, index: Index, value: NewItem) -> None:
|
||||
i_index: int = self.indexWhere(index) \
|
||||
if isinstance(index, str) else index
|
||||
|
||||
qidx = QAbstractListModel.index(self, i_index, 0)
|
||||
value = self._convert_new_value(value)
|
||||
self._data[i_index] = value
|
||||
self.dataChanged.emit(qidx, qidx, self.roleNames())
|
||||
self.changed.emit()
|
||||
|
||||
|
||||
@pyqtSlot(int, str, "QVariant")
|
||||
@pyqtSlot(str, str, "QVariant")
|
||||
def setProperty(self, index: Index, prop: str, value: Any) -> None:
|
||||
i_index: int = self.indexWhere(index) \
|
||||
if isinstance(index, str) else index
|
||||
|
||||
if getattr(self[i_index], prop) != value:
|
||||
setattr(self[i_index], prop, value)
|
||||
qidx = QAbstractListModel.index(self, i_index, 0)
|
||||
self.dataChanged.emit(qidx, qidx, (self.roleNumbers()[prop],))
|
||||
self.changed.emit()
|
||||
|
||||
|
||||
@pyqtSlot(int, int)
|
||||
@pyqtSlot(int, int, int)
|
||||
@pyqtSlot(str, int)
|
||||
@pyqtSlot(str, int, int)
|
||||
def move(self, from_: Index, to: int, n: int = 1) -> None:
|
||||
# pylint: disable=invalid-name
|
||||
i_from: int = self.indexWhere(from_) \
|
||||
if isinstance(from_, str) else from_
|
||||
|
||||
qlast = i_from + n - 1
|
||||
|
||||
if (n <= 0) or (i_from == to) or (qlast == to) or \
|
||||
not (len(self) > qlast >= 0) or \
|
||||
not len(self) >= to >= 0:
|
||||
return
|
||||
|
||||
qidx = QModelIndex()
|
||||
qto = min(len(self), to + n if to > i_from else to)
|
||||
# print(f"self.beginMoveRows(qidx, {i_from}, {qlast}, qidx, {qto})")
|
||||
valid = self.beginMoveRows(qidx, i_from, qlast, qidx, qto)
|
||||
|
||||
if not valid:
|
||||
logging.warning("Invalid move operation - %r", locals())
|
||||
return
|
||||
|
||||
last = i_from + n
|
||||
cut = self._data[i_from:last]
|
||||
del self._data[i_from:last]
|
||||
self._data[to:to] = cut
|
||||
|
||||
self.endMoveRows()
|
||||
self.changed.emit()
|
||||
|
||||
|
||||
@pyqtSlot(int)
|
||||
@pyqtSlot(str)
|
||||
def remove(self, index: Index) -> None:
|
||||
i_index: int = self.indexWhere(index) \
|
||||
if isinstance(index, str) else index
|
||||
|
||||
self.beginRemoveRows(QModelIndex(), i_index, i_index)
|
||||
del self._data[i_index]
|
||||
self.endRemoveRows()
|
||||
|
||||
self.countChanged.emit(len(self))
|
||||
self.changed.emit()
|
||||
|
||||
|
||||
@pyqtSlot(int, result="QVariant")
|
||||
@pyqtSlot(str, result="QVariant")
|
||||
def pop(self, index: Index, default: Any = _PopFail()) -> ListItem:
|
||||
try:
|
||||
i_index: int = self.indexWhere(index) \
|
||||
if isinstance(index, str) else index
|
||||
item = self[i_index]
|
||||
|
||||
except (ValueError, IndexError):
|
||||
if isinstance(default, _PopFail):
|
||||
raise
|
||||
return default
|
||||
|
||||
self.beginRemoveRows(QModelIndex(), i_index, i_index)
|
||||
del self._data[i_index]
|
||||
self.endRemoveRows()
|
||||
|
||||
self.countChanged.emit(len(self))
|
||||
self.changed.emit()
|
||||
return item
|
||||
|
||||
|
||||
@pyqtSlot()
|
||||
def clear(self) -> None:
|
||||
if not self._data:
|
||||
return
|
||||
|
||||
# Reimplemented for performance reasons (begin/endRemoveRows)
|
||||
self.beginRemoveRows(QModelIndex(), 0, len(self))
|
||||
self._data.clear()
|
||||
self.endRemoveRows()
|
||||
|
||||
self.countChanged.emit(len(self))
|
||||
self.changed.emit()
|
@@ -1,58 +0,0 @@
|
||||
from typing import Any, DefaultDict
|
||||
|
||||
from PyQt5.QtCore import QObject, pyqtSlot
|
||||
|
||||
from .list_model import ListModel
|
||||
|
||||
|
||||
class ListModelMap(QObject):
|
||||
def __init__(self, *models_args, parent: QObject = None, **models_kwargs
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
models_kwargs["parent"] = self
|
||||
|
||||
# Set the parent to prevent item garbage-collection on the C++ side
|
||||
self.dict: DefaultDict[Any, ListModel] = \
|
||||
DefaultDict(
|
||||
lambda: ListModel(*models_args, **models_kwargs)
|
||||
)
|
||||
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return "%s(%r)" % (type(self).__name__, self.dict)
|
||||
|
||||
|
||||
def __getitem__(self, key) -> ListModel:
|
||||
return self.dict[key]
|
||||
|
||||
|
||||
def __setitem__(self, key, value: ListModel) -> None:
|
||||
value.setParent(self)
|
||||
self.dict[key] = value
|
||||
|
||||
|
||||
def __detitem__(self, key) -> None:
|
||||
del self.dict[key]
|
||||
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self.dict)
|
||||
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self.dict)
|
||||
|
||||
|
||||
@pyqtSlot(result=str)
|
||||
def repr(self) -> str:
|
||||
return self.__repr__()
|
||||
|
||||
|
||||
@pyqtSlot(str, result="QVariant")
|
||||
def get(self, key) -> ListModel:
|
||||
return self.dict[key]
|
||||
|
||||
|
||||
@pyqtSlot(str, result=bool)
|
||||
def has(self, key) -> bool:
|
||||
return key in self.dict
|
@@ -1,134 +0,0 @@
|
||||
from typing import Callable, Dict, Optional
|
||||
|
||||
from PyQt5.QtCore import (
|
||||
QModelIndex, QObject, QSortFilterProxyModel, Qt, pyqtProperty, pyqtSignal,
|
||||
pyqtSlot
|
||||
)
|
||||
|
||||
from .list_model import ListModel
|
||||
from .list_item import ListItem
|
||||
|
||||
SortCallable = Callable[["SortFilterProxy", ListItem, ListItem], bool]
|
||||
FilterCallable = Callable[["SortFilterProxy", ListItem], bool]
|
||||
|
||||
class SortFilterProxy(QSortFilterProxyModel):
|
||||
sortByRoleChanged = pyqtSignal()
|
||||
filterByRoleChanged = pyqtSignal()
|
||||
filterChanged = pyqtSignal()
|
||||
countChanged = pyqtSignal(int)
|
||||
|
||||
def __init__(self,
|
||||
source_model: ListModel,
|
||||
sort_by_role: str = "",
|
||||
filter_by_role: str = "",
|
||||
sort_func: Optional[SortCallable] = None,
|
||||
filter_func: Optional[FilterCallable] = None,
|
||||
reverse: bool = False,
|
||||
parent: QObject = None) -> None:
|
||||
|
||||
error = "{} and {}: only one can be set"
|
||||
if (sort_by_role and sort_func):
|
||||
raise TypeError(error.format("sort_by_role", "sort_func"))
|
||||
if (filter_by_role and filter_func):
|
||||
raise TypeError(error.format("filter_by_role", "filter_func"))
|
||||
|
||||
super().__init__(parent)
|
||||
self.setDynamicSortFilter(False)
|
||||
|
||||
self.setSourceModel(source_model)
|
||||
source_model.countChanged.connect(self.countChanged.emit)
|
||||
source_model.changed.connect(self._apply_sort)
|
||||
source_model.changed.connect(self.invalidateFilter)
|
||||
|
||||
self.sortByRole = sort_by_role
|
||||
self.filterByRole = filter_by_role
|
||||
self.sort_func = sort_func
|
||||
self.filter_func = filter_func
|
||||
self.reverse = reverse
|
||||
|
||||
self._filter = None
|
||||
|
||||
|
||||
@pyqtProperty(str, notify=filterChanged)
|
||||
def filter(self) -> str:
|
||||
return self._filter
|
||||
|
||||
|
||||
@filter.setter # type: ignore
|
||||
def filter(self, pattern: str) -> None:
|
||||
self._filter = pattern
|
||||
self.invalidateFilter()
|
||||
self.filterChanged.emit()
|
||||
self.countChanged.emit(self.rowCount())
|
||||
|
||||
|
||||
# Sorting/filtering methods override
|
||||
|
||||
def lessThan(self, index_left: QModelIndex, index_right: QModelIndex
|
||||
) -> bool:
|
||||
left = self.sourceModel()[index_left.row()]
|
||||
right = self.sourceModel()[index_right.row()]
|
||||
|
||||
if self.sort_func:
|
||||
return self.sort_func(self, left, right)
|
||||
|
||||
role = self.sortByRole
|
||||
try:
|
||||
return getattr(left, role) < getattr(right, role)
|
||||
except TypeError: # comparison between the two types not supported
|
||||
return False
|
||||
|
||||
|
||||
def filterAcceptsRow(self, row_index: int, _: QModelIndex) -> bool:
|
||||
item = self.sourceModel()[row_index]
|
||||
|
||||
if self.filter_func:
|
||||
return self.filter_func(self, item)
|
||||
|
||||
return self.filterMatches(getattr(item, self.filterByRole))
|
||||
|
||||
|
||||
# Implementations
|
||||
|
||||
|
||||
def _apply_sort(self) -> None:
|
||||
order = Qt.DescendingOrder if self.reverse else Qt.AscendingOrder
|
||||
self.sort(0, order)
|
||||
|
||||
|
||||
def filterMatches(self, string: str) -> bool:
|
||||
if not self.filter:
|
||||
return True
|
||||
|
||||
string = string.lower()
|
||||
return all(word in string for word in self.filter.lower().split())
|
||||
|
||||
|
||||
# The rest
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return \
|
||||
"%s(sortByRole=%r, filterByRole=%r, filter=%r, sourceModel=%s)" % (
|
||||
type(self).__name__,
|
||||
self.sortByRole,
|
||||
self.filterByRole,
|
||||
self.filter,
|
||||
"<%s at %s>" % (
|
||||
type(self.sourceModel()).__name__,
|
||||
hex(id(self.sourceModel())),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@pyqtSlot(result=str)
|
||||
def repr(self) -> str:
|
||||
return self.__repr__()
|
||||
|
||||
|
||||
@pyqtProperty(int, notify=countChanged)
|
||||
def count(self) -> int:
|
||||
return self.rowCount()
|
||||
|
||||
|
||||
def roleNames(self) -> Dict[int, bytes]:
|
||||
return self.sourceModel().roleNames()
|
@@ -1,149 +0,0 @@
|
||||
# Copyright 2019 miruka
|
||||
# This file is part of harmonyqml, licensed under GPLv3.
|
||||
|
||||
import logging
|
||||
import socket
|
||||
import ssl
|
||||
import time
|
||||
from threading import Lock
|
||||
from typing import Callable, Optional, Tuple
|
||||
from uuid import UUID
|
||||
|
||||
import nio
|
||||
|
||||
OptSock = Optional[ssl.SSLSocket]
|
||||
NioRequestFunc = Callable[..., Tuple[UUID, bytes]]
|
||||
|
||||
|
||||
class NioErrorResponse(Exception):
|
||||
def __init__(self, response: nio.ErrorResponse) -> None:
|
||||
self.response = response
|
||||
super().__init__(str(response))
|
||||
|
||||
|
||||
class RetrySleeper:
|
||||
def __init__(self) -> None:
|
||||
self.current_time: float = 0
|
||||
self.tries: int = 0
|
||||
|
||||
|
||||
def sleep(self, max_time: float) -> None:
|
||||
self.current_time = max(
|
||||
0, min((max_time / 10) * (2 ^ (self.tries - 1)), max_time)
|
||||
)
|
||||
time.sleep(self.current_time)
|
||||
self.tries += 1
|
||||
|
||||
|
||||
class NetworkManager:
|
||||
http_retry_codes = {408, 429, 500, 502, 503, 504, 507}
|
||||
|
||||
|
||||
def __init__(self, host: str, port: int, nio_client: nio.client.HttpClient
|
||||
) -> None:
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.nio = nio_client
|
||||
|
||||
self._ssl_context: ssl.SSLContext = ssl.create_default_context()
|
||||
self._ssl_session: Optional[ssl.SSLSession] = None
|
||||
self._lock: Lock = Lock()
|
||||
|
||||
|
||||
def _get_socket(self) -> ssl.SSLSocket:
|
||||
sock = self._ssl_context.wrap_socket( # type: ignore
|
||||
socket.create_connection((self.host, self.port), timeout=16),
|
||||
server_hostname = self.host,
|
||||
session = self._ssl_session,
|
||||
)
|
||||
self._ssl_session = self._ssl_session or sock.session
|
||||
return sock
|
||||
|
||||
|
||||
@staticmethod
|
||||
def _close_socket(sock: Optional[socket.socket]) -> None:
|
||||
if not sock:
|
||||
return
|
||||
|
||||
try:
|
||||
sock.shutdown(how=socket.SHUT_RDWR)
|
||||
except OSError: # Already closer by server
|
||||
pass
|
||||
sock.close()
|
||||
|
||||
|
||||
def http_disconnect(self) -> None:
|
||||
try:
|
||||
self.write(self.nio.disconnect())
|
||||
except (OSError, nio.ProtocolError):
|
||||
pass
|
||||
|
||||
|
||||
def read(self, with_sock: OptSock = None) -> nio.Response:
|
||||
sock = with_sock or self._get_socket()
|
||||
|
||||
response = None
|
||||
while not response:
|
||||
left_to_send = self.nio.data_to_send()
|
||||
if left_to_send:
|
||||
self.write(left_to_send, sock)
|
||||
|
||||
self.nio.receive(sock.recv(4096))
|
||||
response = self.nio.next_response()
|
||||
|
||||
if isinstance(response, nio.ErrorResponse):
|
||||
raise NioErrorResponse(response)
|
||||
|
||||
if not with_sock:
|
||||
self._close_socket(sock)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
def write(self, data: bytes, with_sock: OptSock = None) -> None:
|
||||
sock = with_sock or self._get_socket()
|
||||
sock.sendall(data)
|
||||
|
||||
if not with_sock:
|
||||
self._close_socket(sock)
|
||||
|
||||
|
||||
def talk(self,
|
||||
nio_func: NioRequestFunc,
|
||||
*args,
|
||||
**kwargs) -> nio.Response:
|
||||
|
||||
with self._lock:
|
||||
retry = RetrySleeper()
|
||||
|
||||
while True:
|
||||
sock = None
|
||||
|
||||
try:
|
||||
sock = self._get_socket()
|
||||
|
||||
if not self.nio.connection:
|
||||
# Establish HTTP protocol connection:
|
||||
self.write(self.nio.connect(), sock)
|
||||
|
||||
to_send = nio_func(*args, **kwargs)[1]
|
||||
self.write(to_send, sock)
|
||||
response = self.read(sock)
|
||||
|
||||
except (OSError, nio.RemoteTransportError) as err:
|
||||
self._close_socket(sock)
|
||||
self.http_disconnect()
|
||||
retry.sleep(max_time=2)
|
||||
|
||||
except NioErrorResponse as err:
|
||||
logging.error("Nio response error for %s: %s",
|
||||
nio_func.__name__, err)
|
||||
self._close_socket(sock)
|
||||
|
||||
if err.response.status_code not in self.http_retry_codes:
|
||||
raise
|
||||
|
||||
retry.sleep(max_time=10)
|
||||
|
||||
else:
|
||||
return response
|
@@ -1,164 +0,0 @@
|
||||
# Copyright 2019 miruka
|
||||
# This file is part of harmonyqml, licensed under GPLv3.
|
||||
|
||||
import functools
|
||||
import logging as log
|
||||
import sys
|
||||
import time
|
||||
import traceback
|
||||
from concurrent.futures import Executor, Future
|
||||
from typing import Any, Callable, Deque, Optional, Tuple, Union
|
||||
|
||||
from PyQt5.QtCore import QObject, pyqtProperty, pyqtSignal, pyqtSlot
|
||||
|
||||
|
||||
class PyQtFuture(QObject):
|
||||
gotResult = pyqtSignal("QVariant")
|
||||
|
||||
def __init__(self,
|
||||
future: Future,
|
||||
running_value: Any = None,
|
||||
parent: Optional[QObject] = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.future = future
|
||||
self.running_value = running_value
|
||||
self._result = None
|
||||
|
||||
self.future.add_done_callback(
|
||||
lambda future: self.gotResult.emit(future.result())
|
||||
)
|
||||
|
||||
|
||||
def __repr__(self) -> str:
|
||||
state = ("canceled" if self.cancelled else
|
||||
"running" if self.running else
|
||||
"finished")
|
||||
|
||||
return "%s(state=%s, value=%r)" % (
|
||||
type(self).__name__, state, self.value
|
||||
)
|
||||
|
||||
|
||||
def __lt__(self, other: "PyQtFuture") -> bool:
|
||||
# This is to allow sorting, e.g. from SortFilterProxy.lessThan()
|
||||
return self.value < other.value
|
||||
|
||||
|
||||
@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 self.running_value
|
||||
|
||||
|
||||
def add_done_callback(self, fn: Callable[[Future], None]) -> None:
|
||||
self.future.add_done_callback(fn)
|
||||
|
||||
|
||||
_Task = Tuple[Executor, Callable, Optional[tuple], Optional[dict]]
|
||||
_RUNNING: Deque[_Task] = Deque()
|
||||
_PENDING: Deque[_Task] = Deque()
|
||||
|
||||
|
||||
def futurize(max_running: Optional[int] = None,
|
||||
consider_args: bool = False,
|
||||
discard_if_max_running: bool = False,
|
||||
pyqt: bool = True,
|
||||
running_value: Any = None) -> Callable:
|
||||
|
||||
def decorator(func: Callable) -> Callable:
|
||||
|
||||
@functools.wraps(func)
|
||||
def wrapper(self, *args, **kws) -> Optional[PyQtFuture]:
|
||||
task: _Task = (
|
||||
self.pool,
|
||||
func,
|
||||
args if consider_args else None,
|
||||
kws if consider_args else None,
|
||||
)
|
||||
|
||||
def can_run_now() -> bool:
|
||||
if max_running is not None and \
|
||||
_RUNNING.count(task) >= max_running:
|
||||
log.debug("!! Max %d tasks of this kind running: %r",
|
||||
max_running, task[1:])
|
||||
return False
|
||||
|
||||
if not consider_args or not _PENDING:
|
||||
return True
|
||||
|
||||
log.debug(".. Pending: %r\n Queue: %r", task[1:], _PENDING)
|
||||
candidate_task = next((
|
||||
pending for pending in _PENDING
|
||||
if pending[0] == self.pool and pending[1] == func
|
||||
), None)
|
||||
|
||||
if candidate_task is None:
|
||||
log.debug(">> No other candidate, starting: %r", task[1:])
|
||||
return True
|
||||
|
||||
if candidate_task[2] == args and candidate_task[3] == kws:
|
||||
log.debug(">> Candidate is us: %r", candidate_task[1:])
|
||||
return True
|
||||
|
||||
log.debug("XX Other candidate: %r", candidate_task[1:])
|
||||
return False
|
||||
|
||||
if not can_run_now() and discard_if_max_running:
|
||||
log.debug("\\/ Discarding task: %r", task[1:])
|
||||
return None
|
||||
|
||||
def run_and_catch_errs():
|
||||
if not can_run_now():
|
||||
log.debug("~~ Can't start now: %r", task[1:])
|
||||
_PENDING.append(task)
|
||||
|
||||
while not can_run_now():
|
||||
time.sleep(0.05)
|
||||
|
||||
_RUNNING.append(task)
|
||||
log.debug("Starting: %r", task[1:])
|
||||
|
||||
# Without this, exceptions are silently ignored
|
||||
try:
|
||||
return func(self, *args, **kws)
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
log.error("Exiting thread/process due to exception.")
|
||||
sys.exit(1)
|
||||
finally:
|
||||
del _RUNNING[_RUNNING.index(task)]
|
||||
|
||||
future = self.pool.submit(run_and_catch_errs)
|
||||
return PyQtFuture(
|
||||
future=future, running_value=running_value, parent=self
|
||||
) if pyqt else future
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
@@ -1,490 +0,0 @@
|
||||
# Copyright 2019 miruka
|
||||
# This file is part of harmonyqml, licensed under GPLv3.
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from threading import Lock
|
||||
from typing import Any, Deque, Dict, List, Optional
|
||||
|
||||
from PyQt5.QtCore import QDateTime, QObject, pyqtBoundSignal, pyqtSignal
|
||||
|
||||
import nio
|
||||
from nio.rooms import MatrixRoom
|
||||
|
||||
from .backend import Backend
|
||||
from .client import Client
|
||||
from .model.items import (
|
||||
Account, Device, ListModel, Room, RoomCategory, RoomEvent, RoomMember,
|
||||
User
|
||||
)
|
||||
from .model.sort_filter_proxy import SortFilterProxy
|
||||
from .pyqt_future import futurize
|
||||
|
||||
Inviter = Optional[Dict[str, str]]
|
||||
LeftEvent = Optional[Dict[str, str]]
|
||||
|
||||
|
||||
class SignalManager(QObject):
|
||||
roomCategoryChanged = pyqtSignal(str, str, str, str)
|
||||
|
||||
_lock: Lock = Lock()
|
||||
|
||||
def __init__(self, backend: Backend) -> None:
|
||||
super().__init__(parent=backend)
|
||||
self.pool: ThreadPoolExecutor = ThreadPoolExecutor(max_workers=6)
|
||||
|
||||
self.backend = backend
|
||||
|
||||
self.last_room_events: Deque[str] = Deque(maxlen=1000)
|
||||
self._events_in_transfer: int = 0
|
||||
|
||||
self.backend.clients.clientAdded.connect(self.onClientAdded)
|
||||
self.backend.clients.clientDeleted.connect(self.onClientDeleted)
|
||||
|
||||
|
||||
def onClientAdded(self, client: Client) -> None:
|
||||
if client.userId in self.backend.accounts:
|
||||
return
|
||||
|
||||
# An user might already exist in the model, e.g. if another account
|
||||
# was in a room with the account that we just connected to
|
||||
self.backend.users.upsert(
|
||||
where_main_key_is = client.userId,
|
||||
update_with = User(
|
||||
userId = client.userId,
|
||||
displayName = self.backend.users[client.userId].displayName,
|
||||
# Devices are added later, we might need to upload keys before
|
||||
# but we want to show the accounts ASAP in the client side pane
|
||||
devices = ListModel(),
|
||||
)
|
||||
)
|
||||
|
||||
# Backend.accounts
|
||||
room_categories_kwargs: List[Dict[str, Any]] = [
|
||||
{"name": "Invites", "rooms": ListModel()},
|
||||
{"name": "Rooms", "rooms": ListModel()},
|
||||
{"name": "Left", "rooms": ListModel()},
|
||||
]
|
||||
|
||||
for i, _ in enumerate(room_categories_kwargs):
|
||||
proxy = SortFilterProxy(
|
||||
source_model = room_categories_kwargs[i]["rooms"],
|
||||
sort_by_role = "lastEventDateTime",
|
||||
filter_by_role = "displayName",
|
||||
reverse = True,
|
||||
)
|
||||
room_categories_kwargs[i]["sortedRooms"] = proxy
|
||||
|
||||
self.backend.accounts.append(Account(
|
||||
userId = client.userId,
|
||||
roomCategories = ListModel([
|
||||
RoomCategory(**kws) for kws in room_categories_kwargs
|
||||
]),
|
||||
))
|
||||
|
||||
# Upload our E2E keys to the matrix server if needed
|
||||
if not client.nio.olm_account_shared:
|
||||
client.uploadE2EKeys()
|
||||
|
||||
# Add all devices nio knows for this account
|
||||
store = client.nio.device_store
|
||||
|
||||
for user_id in store.users:
|
||||
user = self.backend.users.get(user_id, None)
|
||||
if not user:
|
||||
self.backend.users.append(
|
||||
User(userId=user_id, devices=ListModel())
|
||||
)
|
||||
|
||||
for device in store.active_user_devices(user_id):
|
||||
self.backend.users[client.userId].devices.upsert(
|
||||
where_main_key_is = device.id,
|
||||
update_with = Device(
|
||||
deviceId = device.id,
|
||||
ed25519Key = device.ed25519,
|
||||
trust = client.getDeviceTrust(device),
|
||||
)
|
||||
)
|
||||
|
||||
# Finally, connect all client signals
|
||||
self.connectClient(client)
|
||||
|
||||
|
||||
def onClientDeleted(self, user_id: str) -> None:
|
||||
del self.backend.accounts[user_id]
|
||||
|
||||
|
||||
def connectClient(self, client: Client) -> None:
|
||||
for name in dir(client):
|
||||
attr = getattr(client, name)
|
||||
|
||||
if isinstance(attr, pyqtBoundSignal):
|
||||
def onSignal(*args, name=name) -> None:
|
||||
func = getattr(self, f"on{name[0].upper()}{name[1:]}")
|
||||
func(client, *args)
|
||||
|
||||
attr.connect(onSignal)
|
||||
|
||||
|
||||
@staticmethod
|
||||
def _get_room_displayname(nio_room: MatrixRoom) -> Optional[str]:
|
||||
name = nio_room.name or nio_room.canonical_alias
|
||||
if name:
|
||||
return name
|
||||
|
||||
name = nio_room.group_name()
|
||||
return None if name == "Empty room?" else name
|
||||
|
||||
|
||||
def _add_users_from_nio_room(self, room: nio.rooms.MatrixRoom) -> None:
|
||||
for user in room.users.values():
|
||||
@futurize(running_value=user.display_name)
|
||||
def get_displayname(self, user) -> str:
|
||||
# pylint:disable=unused-argument
|
||||
return user.display_name
|
||||
|
||||
self.backend.users.upsert(
|
||||
where_main_key_is = user.user_id,
|
||||
update_with = User(
|
||||
userId = user.user_id,
|
||||
displayName = get_displayname(self, user),
|
||||
devices = ListModel()
|
||||
),
|
||||
no_update = ("devices",),
|
||||
)
|
||||
|
||||
|
||||
def _members_sort_func(self, _, left: RoomMember, right: RoomMember
|
||||
) -> bool:
|
||||
users = self.backend.users
|
||||
return users[left.userId].displayName < users[right.userId].displayName
|
||||
|
||||
|
||||
def _members_filter_func(self, proxy: SortFilterProxy, member: RoomMember
|
||||
) -> bool:
|
||||
users = self.backend.users
|
||||
return proxy.filterMatches(users[member.userId].displayName.value)
|
||||
|
||||
|
||||
def onRoomInvited(self,
|
||||
client: Client,
|
||||
room_id: str,
|
||||
inviter: Inviter = None) -> None:
|
||||
|
||||
nio_room = client.nio.invited_rooms[room_id]
|
||||
self._add_users_from_nio_room(nio_room)
|
||||
|
||||
categories = self.backend.accounts[client.userId].roomCategories
|
||||
|
||||
previous_room = categories["Rooms"].rooms.pop(room_id, None)
|
||||
previous_left = categories["Left"].rooms.pop(room_id, None)
|
||||
|
||||
members = ListModel()
|
||||
sorted_members = SortFilterProxy(
|
||||
source_model = members,
|
||||
sort_func = self._members_sort_func,
|
||||
filter_func = self._members_filter_func,
|
||||
)
|
||||
|
||||
categories["Invites"].rooms.upsert(
|
||||
where_main_key_is = room_id,
|
||||
update_with = Room(
|
||||
roomId = room_id,
|
||||
displayName = self._get_room_displayname(nio_room),
|
||||
topic = nio_room.topic,
|
||||
inviter = inviter,
|
||||
lastEventDateTime = QDateTime.currentDateTime(), # FIXME
|
||||
members = members,
|
||||
sortedMembers = sorted_members,
|
||||
),
|
||||
no_update = ("typingMembers", "members"),
|
||||
)
|
||||
categories["Invites"].rooms[room_id].members.updateAll([
|
||||
RoomMember(userId=user_id) for user_id in nio_room.users
|
||||
], delete=True)
|
||||
|
||||
signal = self.roomCategoryChanged
|
||||
if previous_room:
|
||||
signal.emit(client.userId, room_id, "Rooms", "Invites")
|
||||
elif previous_left:
|
||||
signal.emit(client.userId, room_id, "Left", "Invites")
|
||||
|
||||
|
||||
def onRoomJoined(self, client: Client, room_id: str) -> None:
|
||||
nio_room = client.nio.rooms[room_id]
|
||||
self._add_users_from_nio_room(nio_room)
|
||||
|
||||
categories = self.backend.accounts[client.userId].roomCategories
|
||||
|
||||
previous_invite = categories["Invites"].rooms.pop(room_id, None)
|
||||
previous_left = categories["Left"].rooms.pop(room_id, None)
|
||||
|
||||
members = ListModel()
|
||||
sorted_members = SortFilterProxy(
|
||||
source_model = members,
|
||||
sort_func = self._members_sort_func,
|
||||
filter_func = self._members_filter_func,
|
||||
)
|
||||
|
||||
categories["Rooms"].rooms.upsert(
|
||||
where_main_key_is = room_id,
|
||||
update_with = Room(
|
||||
roomId = room_id,
|
||||
displayName = self._get_room_displayname(nio_room),
|
||||
topic = nio_room.topic,
|
||||
members = members,
|
||||
sortedMembers = sorted_members,
|
||||
),
|
||||
no_update = ("typingMembers", "members", "sortedMembers",
|
||||
"lastEventDateTime"),
|
||||
)
|
||||
categories["Rooms"].rooms[room_id].members.updateAll([
|
||||
RoomMember(userId=user_id) for user_id in nio_room.users
|
||||
], delete=True)
|
||||
|
||||
signal = self.roomCategoryChanged
|
||||
if previous_invite:
|
||||
signal.emit(client.userId, room_id, "Invites", "Rooms")
|
||||
elif previous_left:
|
||||
signal.emit(client.userId, room_id, "Left", "Rooms")
|
||||
|
||||
|
||||
def onRoomLeft(self,
|
||||
client: Client,
|
||||
room_id: str,
|
||||
left_event: LeftEvent = None) -> None:
|
||||
categories = self.backend.accounts[client.userId].roomCategories
|
||||
|
||||
previous_room = categories["Rooms"].rooms.pop(room_id, None)
|
||||
previous_invite = categories["Invites"].rooms.pop(room_id, None)
|
||||
previous = previous_room or previous_invite or \
|
||||
categories["Left"].rooms.get(room_id, None)
|
||||
|
||||
left_time = left_event.get("server_timestamp") if left_event else None
|
||||
|
||||
members = ListModel()
|
||||
sorted_members = SortFilterProxy(
|
||||
source_model = members,
|
||||
sort_func = self._members_sort_func,
|
||||
filter_func = self._members_filter_func,
|
||||
)
|
||||
|
||||
categories["Left"].rooms.upsert(
|
||||
where_main_key_is = room_id,
|
||||
update_with = Room(
|
||||
roomId = room_id,
|
||||
displayName = previous.displayName if previous else None,
|
||||
topic = previous.topic if previous else None,
|
||||
leftEvent = left_event,
|
||||
lastEventDateTime = (
|
||||
QDateTime.fromMSecsSinceEpoch(left_time)
|
||||
if left_time else QDateTime.currentDateTime()
|
||||
),
|
||||
members = members,
|
||||
sortedMembers = sorted_members,
|
||||
),
|
||||
no_update = ("members", "sortedMembers", "lastEventDateTime"),
|
||||
)
|
||||
|
||||
signal = self.roomCategoryChanged
|
||||
if previous_room:
|
||||
signal.emit(client.userId, room_id, "Rooms", "Left")
|
||||
elif previous_invite:
|
||||
signal.emit(client.userId, room_id, "Invites", "Left")
|
||||
|
||||
|
||||
|
||||
def onRoomSyncPrevBatchTokenReceived(self,
|
||||
_: Client,
|
||||
room_id: str,
|
||||
token: str) -> None:
|
||||
|
||||
if room_id not in self.backend.past_tokens:
|
||||
self.backend.past_tokens[room_id] = token
|
||||
|
||||
|
||||
def onRoomPastPrevBatchTokenReceived(self,
|
||||
_: Client,
|
||||
room_id: str,
|
||||
token: str) -> None:
|
||||
|
||||
if self.backend.past_tokens[room_id] == token:
|
||||
self.backend.fully_loaded_rooms.add(room_id)
|
||||
|
||||
self.backend.past_tokens[room_id] = token
|
||||
|
||||
|
||||
def _set_room_last_event(self, user_id: str, room_id: str, event: RoomEvent
|
||||
) -> None:
|
||||
for categ in self.backend.accounts[user_id].roomCategories:
|
||||
if room_id in categ.rooms:
|
||||
|
||||
last = categ.rooms[room_id].lastEventDateTime
|
||||
if last and last > event.dateTime:
|
||||
continue
|
||||
|
||||
# Use setProperty to make sure to trigger model changed signals
|
||||
categ.rooms.setProperty(
|
||||
room_id, "lastEventDateTime", event.dateTime
|
||||
)
|
||||
|
||||
|
||||
def onRoomEventReceived(self,
|
||||
client: Client,
|
||||
room_id: str,
|
||||
etype: str,
|
||||
edict: Dict[str, Any]) -> None:
|
||||
|
||||
def process() -> Optional[RoomEvent]:
|
||||
# Prevent duplicate events in models due to multiple accounts
|
||||
if edict["event_id"] in self.last_room_events:
|
||||
return None
|
||||
|
||||
self.last_room_events.appendleft(edict["event_id"])
|
||||
|
||||
model = self.backend.roomEvents[room_id]
|
||||
date_time = QDateTime\
|
||||
.fromMSecsSinceEpoch(edict["server_timestamp"])
|
||||
new_event = RoomEvent(
|
||||
eventId = edict["event_id"],
|
||||
type = etype,
|
||||
dateTime = date_time,
|
||||
dict = edict,
|
||||
)
|
||||
|
||||
event_is_our_profile_changed = (
|
||||
etype == "RoomMemberEvent" and
|
||||
edict.get("sender") in self.backend.clients and
|
||||
((edict.get("content") or {}).get("membership") ==
|
||||
(edict.get("prev_content") or {}).get("membership"))
|
||||
)
|
||||
|
||||
if event_is_our_profile_changed:
|
||||
return None
|
||||
|
||||
if etype == "RoomCreateEvent":
|
||||
self.backend.fully_loaded_rooms.add(room_id)
|
||||
|
||||
if self._events_in_transfer:
|
||||
local_echoes_met: int = 0
|
||||
update_at: Optional[int] = None
|
||||
|
||||
# Find if any locally echoed event corresponds to new_event
|
||||
for i, event in enumerate(model):
|
||||
if not event.isLocalEcho:
|
||||
continue
|
||||
|
||||
sb = (event.dict.get("sender"), event.dict.get("body"))
|
||||
new_sb = (new_event.dict.get("sender"),
|
||||
new_event.dict.get("body"))
|
||||
|
||||
if sb == new_sb:
|
||||
# The oldest matching local echo shall be replaced
|
||||
update_at = max(update_at or 0, i)
|
||||
|
||||
local_echoes_met += 1
|
||||
if local_echoes_met >= self._events_in_transfer:
|
||||
break
|
||||
|
||||
if update_at is not None:
|
||||
model.updateItem(update_at, new_event)
|
||||
self._events_in_transfer -= 1
|
||||
return new_event
|
||||
|
||||
for i, event in enumerate(model):
|
||||
if event.isLocalEcho:
|
||||
continue
|
||||
|
||||
# Model is sorted from newest to oldest message
|
||||
if new_event.dateTime > event.dateTime:
|
||||
model.insert(i, new_event)
|
||||
return new_event
|
||||
|
||||
model.append(new_event)
|
||||
return new_event
|
||||
|
||||
with self._lock:
|
||||
new_event = process()
|
||||
if new_event:
|
||||
self._set_room_last_event(client.userId, room_id, new_event)
|
||||
|
||||
|
||||
def onRoomTypingMembersUpdated(self,
|
||||
client: Client,
|
||||
room_id: str,
|
||||
users: List[str]) -> None:
|
||||
categories = self.backend.accounts[client.userId].roomCategories
|
||||
for categ in categories:
|
||||
try:
|
||||
categ.rooms.setProperty(room_id, "typingMembers", users)
|
||||
break
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
def onMessageAboutToBeSent(self,
|
||||
client: Client,
|
||||
room_id: str,
|
||||
content: Dict[str, str]) -> None:
|
||||
|
||||
date_time = QDateTime.currentDateTime()
|
||||
|
||||
with self._lock:
|
||||
model = self.backend.roomEvents[room_id]
|
||||
nio_event = nio.events.RoomMessage.parse_event({
|
||||
"event_id": "",
|
||||
"sender": client.userId,
|
||||
"origin_server_ts": date_time.toMSecsSinceEpoch(),
|
||||
"content": content,
|
||||
})
|
||||
event = RoomEvent(
|
||||
eventId = f"localEcho.{self._events_in_transfer + 1}",
|
||||
type = type(nio_event).__name__,
|
||||
dict = nio_event.__dict__,
|
||||
dateTime = date_time,
|
||||
isLocalEcho = True,
|
||||
)
|
||||
model.insert(0, event)
|
||||
self._events_in_transfer += 1
|
||||
|
||||
self._set_room_last_event(client.userId, room_id, event)
|
||||
|
||||
|
||||
def onRoomAboutToBeForgotten(self, client: Client, room_id: str) -> None:
|
||||
categories = self.backend.accounts[client.userId].roomCategories
|
||||
|
||||
for categ in categories:
|
||||
categ.rooms.pop(room_id, None)
|
||||
|
||||
self.backend.roomEvents[room_id].clear()
|
||||
|
||||
|
||||
def onDeviceIsPresent(self,
|
||||
client: Client,
|
||||
user_id: str,
|
||||
device_id: str,
|
||||
ed25519_key: str) -> None:
|
||||
|
||||
nio_device = client.nio.device_store[user_id][device_id]
|
||||
|
||||
user = self.backend.users.get(user_id, None)
|
||||
if not user:
|
||||
self.backend.users.append(
|
||||
User(userId=user_id, devices=ListModel())
|
||||
)
|
||||
|
||||
self.backend.users[user_id].devices.upsert(
|
||||
where_main_key_is = device_id,
|
||||
update_with = Device(
|
||||
deviceId = device_id,
|
||||
ed25519Key = ed25519_key,
|
||||
trust = client.getDeviceTrust(nio_device),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def onDeviceIsDeleted(self, _: Client, user_id: str, device_id: str
|
||||
) -> None:
|
||||
try:
|
||||
del self.backend.users[user_id].devices[device_id]
|
||||
except ValueError:
|
||||
pass
|
@@ -1,45 +0,0 @@
|
||||
import QtQuick 2.7
|
||||
import "../Base"
|
||||
|
||||
Rectangle {
|
||||
property var name: null
|
||||
property var imageUrl: null
|
||||
property int dimension: HStyle.avatar.size
|
||||
property bool hidden: false
|
||||
|
||||
width: dimension
|
||||
height: hidden ? 1 : dimension
|
||||
implicitWidth: dimension
|
||||
implicitHeight: hidden ? 1 : dimension
|
||||
|
||||
opacity: hidden ? 0 : 1
|
||||
|
||||
color: name ?
|
||||
Qt.hsla(
|
||||
Backend.hueFromString(name),
|
||||
HStyle.avatar.background.saturation,
|
||||
HStyle.avatar.background.lightness,
|
||||
HStyle.avatar.background.alpha
|
||||
) :
|
||||
HStyle.avatar.background.unknown
|
||||
|
||||
HLabel {
|
||||
z: 1
|
||||
anchors.centerIn: parent
|
||||
visible: ! hidden
|
||||
|
||||
text: name ? name.charAt(0) : "?"
|
||||
color: HStyle.avatar.letter
|
||||
font.pixelSize: parent.height / 1.4
|
||||
}
|
||||
|
||||
HImage {
|
||||
z: 2
|
||||
anchors.fill: parent
|
||||
visible: ! hidden && imageUrl
|
||||
|
||||
Component.onCompleted: if (imageUrl) { source = imageUrl }
|
||||
fillMode: Image.PreserveAspectCrop
|
||||
sourceSize.width: dimension
|
||||
}
|
||||
}
|
@@ -1,138 +0,0 @@
|
||||
import QtQuick 2.7
|
||||
import QtQuick.Controls 2.2
|
||||
import QtQuick.Layouts 1.3
|
||||
|
||||
Button {
|
||||
property int horizontalMargin: 0
|
||||
property int verticalMargin: 0
|
||||
|
||||
property string iconName: ""
|
||||
property var iconDimension: null
|
||||
property var iconTransform: null
|
||||
property bool circle: false
|
||||
|
||||
property int fontSize: HStyle.fontSize.normal
|
||||
property color backgroundColor: HStyle.controls.button.background
|
||||
property alias overlayOpacity: buttonBackgroundOverlay.opacity
|
||||
property bool checkedLightens: false
|
||||
|
||||
property bool loading: false
|
||||
|
||||
property int contentWidth: 0
|
||||
|
||||
readonly property alias visibility: button.visible
|
||||
onVisibilityChanged: if (! visibility) { loading = false }
|
||||
|
||||
signal canceled
|
||||
signal clicked
|
||||
signal doubleClicked
|
||||
signal entered
|
||||
signal exited
|
||||
signal pressAndHold
|
||||
signal pressed
|
||||
signal released
|
||||
|
||||
function loadingUntilFutureDone(future) {
|
||||
loading = true
|
||||
future.onGotResult.connect(function() { loading = false })
|
||||
}
|
||||
|
||||
id: button
|
||||
|
||||
background: Rectangle {
|
||||
id: buttonBackground
|
||||
color: Qt.lighter(
|
||||
backgroundColor, checked ? (checkedLightens ? 1.3 : 0.7) : 1.0
|
||||
)
|
||||
radius: circle ? height : 0
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation { duration: HStyle.animationDuration / 2 }
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: buttonBackgroundOverlay
|
||||
anchors.fill: parent
|
||||
radius: parent.radius
|
||||
color: "black"
|
||||
opacity: 0
|
||||
|
||||
Behavior on opacity {
|
||||
NumberAnimation { duration: HStyle.animationDuration / 2 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: buttonContent
|
||||
|
||||
HRowLayout {
|
||||
id: contentLayout
|
||||
spacing: button.text && iconName ? 5 : 0
|
||||
Component.onCompleted: contentWidth = implicitWidth
|
||||
|
||||
HIcon {
|
||||
svgName: loading ? "hourglass" : iconName
|
||||
dimension: iconDimension || contentLayout.height
|
||||
transform: iconTransform
|
||||
|
||||
Layout.topMargin: verticalMargin
|
||||
Layout.bottomMargin: verticalMargin
|
||||
Layout.leftMargin: horizontalMargin
|
||||
Layout.rightMargin: horizontalMargin
|
||||
}
|
||||
|
||||
HLabel {
|
||||
text: button.text
|
||||
font.pixelSize: fontSize
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
|
||||
Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: loadingOverlay
|
||||
HRowLayout {
|
||||
HIcon {
|
||||
svgName: "hourglass"
|
||||
Layout.preferredWidth: contentWidth || -1
|
||||
Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
contentItem: Loader {
|
||||
sourceComponent:
|
||||
loading && ! iconName ? loadingOverlay : buttonContent
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
|
||||
onCanceled: button.canceled()
|
||||
onClicked: button.clicked()
|
||||
onDoubleClicked: button.doubleClicked()
|
||||
onEntered: {
|
||||
overlayOpacity = checked ? 0 : 0.15
|
||||
button.entered()
|
||||
}
|
||||
onExited: {
|
||||
overlayOpacity = 0
|
||||
button.exited()
|
||||
}
|
||||
onPressAndHold: button.pressAndHold()
|
||||
onPressed: {
|
||||
overlayOpacity += 0.15
|
||||
button.pressed()
|
||||
}
|
||||
onReleased: {
|
||||
if (checkable) { checked = ! checked }
|
||||
overlayOpacity = checked ? 0 : 0.15
|
||||
button.released()
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,11 +0,0 @@
|
||||
import QtQuick 2.7
|
||||
import QtQuick.Controls 2.2
|
||||
import QtQuick.Layouts 1.3
|
||||
|
||||
ColumnLayout {
|
||||
id: columnLayout
|
||||
spacing: 0
|
||||
|
||||
property int totalSpacing:
|
||||
spacing * Math.max(0, (columnLayout.visibleChildren.length - 1))
|
||||
}
|
@@ -1,10 +0,0 @@
|
||||
import QtQuick 2.7
|
||||
|
||||
HImage {
|
||||
property var svgName: null
|
||||
property int dimension: 20
|
||||
|
||||
source: "../../icons/" + (svgName || "none") + ".svg"
|
||||
sourceSize.width: svgName ? dimension : 0
|
||||
sourceSize.height: svgName ? dimension : 0
|
||||
}
|
@@ -1,8 +0,0 @@
|
||||
import QtQuick 2.7
|
||||
|
||||
Image {
|
||||
asynchronous: true
|
||||
cache: true
|
||||
mipmap: true
|
||||
fillMode: Image.PreserveAspectFit
|
||||
}
|
@@ -1,60 +0,0 @@
|
||||
import QtQuick 2.7
|
||||
import QtQuick.Layouts 1.3
|
||||
|
||||
HScalingBox {
|
||||
id: interfaceBox
|
||||
|
||||
property alias title: interfaceTitle.text
|
||||
property alias buttonModel: interfaceButtonsRepeater.model
|
||||
property var buttonCallbacks: []
|
||||
property string enterButtonTarget: ""
|
||||
|
||||
default property alias body: interfaceBody.children
|
||||
|
||||
function clickEnterButtonTarget() {
|
||||
for (var i = 0; i < buttonModel.length; i++) {
|
||||
var btn = interfaceButtonsRepeater.itemAt(i)
|
||||
if (btn.name === enterButtonTarget) { btn.clicked() }
|
||||
}
|
||||
}
|
||||
|
||||
HColumnLayout {
|
||||
anchors.fill: parent
|
||||
id: mainColumn
|
||||
|
||||
HRowLayout {
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
Layout.margins: interfaceBox.margins
|
||||
|
||||
HLabel {
|
||||
id: interfaceTitle
|
||||
font.pixelSize: HStyle.fontSize.big
|
||||
}
|
||||
}
|
||||
|
||||
HSpacer {}
|
||||
|
||||
HColumnLayout { id: interfaceBody }
|
||||
|
||||
HSpacer {}
|
||||
|
||||
HRowLayout {
|
||||
Repeater {
|
||||
id: interfaceButtonsRepeater
|
||||
model: []
|
||||
|
||||
HButton {
|
||||
property string name: modelData.name
|
||||
|
||||
id: button
|
||||
text: modelData.text
|
||||
iconName: modelData.iconName || ""
|
||||
onClicked: buttonCallbacks[modelData.name](button)
|
||||
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: HStyle.avatar.size
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,11 +0,0 @@
|
||||
import QtQuick.Controls 2.2
|
||||
|
||||
Label {
|
||||
font.family: HStyle.fontFamily.sans
|
||||
font.pixelSize: HStyle.fontSize.normal
|
||||
textFormat: Label.PlainText
|
||||
|
||||
color: HStyle.colors.foreground
|
||||
style: Label.Outline
|
||||
styleColor: HStyle.colors.textBorder
|
||||
}
|
@@ -1,24 +0,0 @@
|
||||
import QtQuick 2.7
|
||||
|
||||
ListView {
|
||||
property int duration: HStyle.animationDuration
|
||||
|
||||
add: Transition {
|
||||
NumberAnimation { properties: "x,y"; from: 100; duration: duration }
|
||||
}
|
||||
|
||||
move: Transition {
|
||||
NumberAnimation { properties: "x,y"; duration: duration }
|
||||
}
|
||||
|
||||
displaced: Transition {
|
||||
NumberAnimation { properties: "x,y"; duration: duration }
|
||||
}
|
||||
|
||||
remove: Transition {
|
||||
ParallelAnimation {
|
||||
NumberAnimation { property: "opacity"; to: 0; duration: duration }
|
||||
NumberAnimation { properties: "x,y"; to: 100; duration: duration }
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,34 +0,0 @@
|
||||
import QtQuick 2.7
|
||||
import QtQuick.Layouts 1.3
|
||||
import "../Base"
|
||||
|
||||
HRowLayout {
|
||||
property alias label: noticeLabel
|
||||
property alias text: noticeLabel.text
|
||||
property alias color: noticeLabel.color
|
||||
property alias font: noticeLabel.font
|
||||
property alias backgroundColor: noticeLabelBackground.color
|
||||
property alias radius: noticeLabelBackground.radius
|
||||
|
||||
HLabel {
|
||||
id: noticeLabel
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
wrapMode: Text.Wrap
|
||||
padding: 3
|
||||
leftPadding: 10
|
||||
rightPadding: 10
|
||||
|
||||
Layout.margins: 10
|
||||
Layout.alignment: Qt.AlignCenter
|
||||
Layout.maximumWidth:
|
||||
parent.width - Layout.leftMargin - Layout.rightMargin
|
||||
|
||||
opacity: width > Layout.leftMargin + Layout.rightMargin ? 1 : 0
|
||||
|
||||
background: Rectangle {
|
||||
id: noticeLabelBackground
|
||||
color: HStyle.box.background
|
||||
radius: HStyle.box.radius
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,6 +0,0 @@
|
||||
import QtQuick 2.7
|
||||
|
||||
Rectangle {
|
||||
id: rectangle
|
||||
color: HStyle.sidePane.background
|
||||
}
|
@@ -1,22 +0,0 @@
|
||||
import QtQuick 2.7
|
||||
|
||||
HLabel {
|
||||
id: label
|
||||
textFormat: Text.RichText
|
||||
|
||||
MouseArea {
|
||||
id: mouseArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
|
||||
onPositionChanged: function (event) {
|
||||
cursorShape = label.linkAt(event.x, event.y) ?
|
||||
Qt.PointingHandCursor : Qt.ArrowCursor
|
||||
}
|
||||
|
||||
onClicked: function(event) {
|
||||
var link = label.linkAt(event.x, event.y)
|
||||
if (link) { Qt.openUrlExternally(link) }
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,10 +0,0 @@
|
||||
import QtQuick 2.7
|
||||
import QtQuick.Layouts 1.3
|
||||
|
||||
RowLayout {
|
||||
id: rowLayout
|
||||
spacing: 0
|
||||
|
||||
property int totalSpacing:
|
||||
spacing * Math.max(0, (rowLayout.visibleChildren.length - 1))
|
||||
}
|
@@ -1,15 +0,0 @@
|
||||
import QtQuick 2.7
|
||||
|
||||
HRectangle {
|
||||
property real widthForHeight: 0.75
|
||||
property int baseHeight: 300
|
||||
property int startScalingUpAboveHeight: 1080
|
||||
|
||||
readonly property int baseWidth: baseHeight * widthForHeight
|
||||
readonly property int margins: baseHeight * 0.03
|
||||
|
||||
color: HStyle.box.background
|
||||
height: Math.min(parent.height, baseHeight)
|
||||
width: Math.min(parent.width, baseWidth)
|
||||
scale: Math.max(1, parent.height / startScalingUpAboveHeight)
|
||||
}
|
@@ -1,33 +0,0 @@
|
||||
import QtQuick 2.7
|
||||
import QtQuick.Controls 2.2
|
||||
|
||||
ScrollView {
|
||||
property alias backgroundColor: textAreaBackground.color
|
||||
property alias placeholderText: textArea.placeholderText
|
||||
property alias text: textArea.text
|
||||
property alias area: textArea
|
||||
|
||||
default property alias textAreaData: textArea.data
|
||||
|
||||
id: scrollView
|
||||
clip: true
|
||||
|
||||
TextArea {
|
||||
id: textArea
|
||||
readOnly: ! visible
|
||||
selectByMouse: true
|
||||
|
||||
wrapMode: TextEdit.Wrap
|
||||
font.family: HStyle.fontFamily.sans
|
||||
font.pixelSize: HStyle.fontSize.normal
|
||||
|
||||
color: HStyle.colors.foreground
|
||||
background: Rectangle {
|
||||
id: textAreaBackground
|
||||
color: HStyle.controls.textArea.background
|
||||
}
|
||||
|
||||
Keys.forwardTo: [scrollView]
|
||||
}
|
||||
}
|
||||
|
@@ -1,7 +0,0 @@
|
||||
import QtQuick 2.7
|
||||
import QtQuick.Layouts 1.3
|
||||
|
||||
Item {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
}
|
@@ -1,24 +0,0 @@
|
||||
import QtQuick 2.7
|
||||
import QtQuick.Controls 1.4 as Controls1
|
||||
|
||||
//https://doc.qt.io/qt-5/qml-qtquick-controls-splitview.html
|
||||
Controls1.SplitView {
|
||||
id: splitView
|
||||
|
||||
property bool anyHovered: false
|
||||
property bool anyPressed: false
|
||||
property bool anyResizing: false
|
||||
|
||||
property bool canAutoSize: true
|
||||
onAnyPressedChanged: canAutoSize = false
|
||||
|
||||
handleDelegate: Item {
|
||||
readonly property bool hovered: styleData.hovered
|
||||
readonly property bool pressed: styleData.pressed
|
||||
readonly property bool resizing: styleData.resizing
|
||||
|
||||
onHoveredChanged: splitView.anyHovered = hovered
|
||||
onPressedChanged: splitView.anyPressed = pressed
|
||||
onResizingChanged: splitView.anyResizing = resizing
|
||||
}
|
||||
}
|
@@ -1,11 +0,0 @@
|
||||
import QtQuick 2.7
|
||||
|
||||
HAvatar {
|
||||
HImage {
|
||||
id: status
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: parent.bottom
|
||||
source: "../../icons/status.svg"
|
||||
sourceSize.width: 12
|
||||
}
|
||||
}
|
@@ -1,139 +0,0 @@
|
||||
pragma Singleton
|
||||
import QtQuick 2.7
|
||||
|
||||
QtObject {
|
||||
id: style
|
||||
|
||||
property int animationDuration: 100
|
||||
|
||||
readonly property QtObject fontSize: QtObject {
|
||||
property int smallest: 6
|
||||
property int smaller: 8
|
||||
property int small: 12
|
||||
property int normal: 16
|
||||
property int big: 24
|
||||
property int bigger: 32
|
||||
property int biggest: 48
|
||||
}
|
||||
|
||||
readonly property QtObject fontFamily: QtObject {
|
||||
property string sans: "SFNS Display"
|
||||
property string serif: "Roboto Slab"
|
||||
property string mono: "Hack"
|
||||
}
|
||||
|
||||
property int radius: 5
|
||||
|
||||
readonly property QtObject colors: QtObject {
|
||||
property color background0: Qt.hsla(0, 0, 0.8, 0.5)
|
||||
property color background1: Qt.hsla(0, 0, 0.8, 0.7)
|
||||
property color foreground: "black"
|
||||
property color foregroundDim: Qt.hsla(0, 0, 0.2, 1)
|
||||
property color foregroundError: Qt.hsla(0.95, 0.64, 0.32, 1)
|
||||
property color textBorder: Qt.hsla(0, 0, 0, 0.07)
|
||||
}
|
||||
|
||||
readonly property QtObject controls: QtObject {
|
||||
readonly property QtObject button: QtObject {
|
||||
property color background: colors.background1
|
||||
}
|
||||
|
||||
readonly property QtObject textField: QtObject {
|
||||
property color background: colors.background1
|
||||
}
|
||||
|
||||
readonly property QtObject textArea: QtObject {
|
||||
property color background: colors.background1
|
||||
}
|
||||
}
|
||||
|
||||
readonly property QtObject sidePane: QtObject {
|
||||
property color background: colors.background1
|
||||
|
||||
readonly property QtObject settingsButton: QtObject {
|
||||
property color background: colors.background1
|
||||
}
|
||||
|
||||
readonly property QtObject filterRooms: QtObject {
|
||||
property color background: colors.background1
|
||||
}
|
||||
}
|
||||
|
||||
readonly property QtObject chat: QtObject {
|
||||
readonly property QtObject selectViewBar: QtObject {
|
||||
property color background: colors.background1
|
||||
}
|
||||
|
||||
readonly property QtObject roomHeader: QtObject {
|
||||
property color background: colors.background1
|
||||
}
|
||||
|
||||
readonly property QtObject roomEventList: QtObject {
|
||||
property color background: "transparent"
|
||||
}
|
||||
|
||||
readonly property QtObject message: QtObject {
|
||||
property color background: colors.background1
|
||||
property color body: colors.foreground
|
||||
property color date: colors.foregroundDim
|
||||
}
|
||||
|
||||
readonly property QtObject event: QtObject {
|
||||
property color background: colors.background1
|
||||
property real saturation: 0.22
|
||||
property real lightness: 0.24
|
||||
property color date: colors.foregroundDim
|
||||
}
|
||||
|
||||
readonly property QtObject daybreak: QtObject {
|
||||
property color background: colors.background1
|
||||
property color foreground: colors.foreground
|
||||
property int radius: style.radius
|
||||
}
|
||||
|
||||
readonly property QtObject inviteBanner: QtObject {
|
||||
property color background: colors.background1
|
||||
}
|
||||
|
||||
readonly property QtObject leftBanner: QtObject {
|
||||
property color background: colors.background1
|
||||
}
|
||||
|
||||
readonly property QtObject unknownDevices: QtObject {
|
||||
property color background: colors.background1
|
||||
}
|
||||
|
||||
readonly property QtObject typingMembers: QtObject {
|
||||
property color background: colors.background0
|
||||
}
|
||||
|
||||
readonly property QtObject sendBox: QtObject {
|
||||
property color background: colors.background1
|
||||
}
|
||||
}
|
||||
|
||||
readonly property QtObject box: QtObject {
|
||||
property color background: colors.background0
|
||||
property int radius: style.radius
|
||||
}
|
||||
|
||||
readonly property QtObject avatar: QtObject {
|
||||
property int size: 36
|
||||
property int radius: style.radius
|
||||
property color letter: "white"
|
||||
|
||||
readonly property QtObject background: QtObject {
|
||||
property real saturation: 0.22
|
||||
property real lightness: 0.5
|
||||
property real alpha: 1
|
||||
property color unknown: Qt.hsla(0, 0, 0.22, 1)
|
||||
}
|
||||
}
|
||||
|
||||
readonly property QtObject displayName: QtObject {
|
||||
property real saturation: 0.32
|
||||
property real lightness: 0.3
|
||||
}
|
||||
|
||||
property int bottomElementsHeight: 32
|
||||
}
|
@@ -1,17 +0,0 @@
|
||||
import QtQuick 2.7
|
||||
import QtQuick.Controls 2.2
|
||||
|
||||
TextField {
|
||||
property alias backgroundColor: textFieldBackground.color
|
||||
|
||||
font.family: HStyle.fontFamily.sans
|
||||
font.pixelSize: HStyle.fontSize.normal
|
||||
|
||||
color: HStyle.colors.foreground
|
||||
background: Rectangle {
|
||||
id: textFieldBackground
|
||||
color: HStyle.controls.textField.background
|
||||
}
|
||||
|
||||
selectByMouse: true
|
||||
}
|
@@ -1 +0,0 @@
|
||||
singleton HStyle 1.0 HStyle.qml
|
@@ -1,90 +0,0 @@
|
||||
import QtQuick 2.7
|
||||
import QtQuick.Layouts 1.3
|
||||
import "../../Base"
|
||||
|
||||
HRectangle {
|
||||
id: banner
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: HStyle.bottomElementsHeight
|
||||
|
||||
property alias avatar: bannerAvatar
|
||||
property alias icon: bannerIcon
|
||||
property alias labelText: bannerLabel.text
|
||||
property alias buttonModel: bannerRepeater.model
|
||||
property var buttonCallbacks: []
|
||||
|
||||
HRowLayout {
|
||||
id: bannerRow
|
||||
anchors.fill: parent
|
||||
|
||||
HAvatar {
|
||||
id: bannerAvatar
|
||||
dimension: banner.Layout.preferredHeight
|
||||
}
|
||||
|
||||
HIcon {
|
||||
id: bannerIcon
|
||||
dimension: bannerLabel.implicitHeight
|
||||
visible: Boolean(svgName)
|
||||
|
||||
Layout.leftMargin: 5
|
||||
}
|
||||
|
||||
HLabel {
|
||||
id: bannerLabel
|
||||
textFormat: Text.StyledText
|
||||
maximumLineCount: 1
|
||||
elide: Text.ElideRight
|
||||
|
||||
visible:
|
||||
bannerRow.width - bannerAvatar.width - bannerButtons.width > 30
|
||||
|
||||
Layout.maximumWidth:
|
||||
bannerRow.width -
|
||||
bannerAvatar.width - bannerButtons.width -
|
||||
Layout.leftMargin - Layout.rightMargin
|
||||
|
||||
Layout.leftMargin: 5
|
||||
Layout.rightMargin: Layout.leftMargin
|
||||
}
|
||||
|
||||
HSpacer {}
|
||||
|
||||
HRowLayout {
|
||||
id: bannerButtons
|
||||
|
||||
function getButtonsWidth() {
|
||||
var total = 0
|
||||
|
||||
for (var i = 0; i < bannerRepeater.count; i++) {
|
||||
total += bannerRepeater.itemAt(i).implicitWidth
|
||||
}
|
||||
|
||||
return total
|
||||
}
|
||||
|
||||
property bool compact:
|
||||
bannerRow.width <
|
||||
bannerAvatar.width +
|
||||
bannerLabel.implicitWidth +
|
||||
bannerLabel.Layout.leftMargin +
|
||||
bannerLabel.Layout.rightMargin +
|
||||
getButtonsWidth()
|
||||
|
||||
Repeater {
|
||||
id: bannerRepeater
|
||||
model: []
|
||||
|
||||
HButton {
|
||||
id: button
|
||||
text: modelData.text
|
||||
iconName: modelData.iconName
|
||||
onClicked: buttonCallbacks[modelData.name](button)
|
||||
|
||||
Layout.maximumWidth: bannerButtons.compact ? height : -1
|
||||
Layout.fillHeight: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,41 +0,0 @@
|
||||
import QtQuick 2.7
|
||||
import "../../Base"
|
||||
|
||||
Banner {
|
||||
property var inviter: null
|
||||
|
||||
color: HStyle.chat.inviteBanner.background
|
||||
|
||||
avatar.name: inviter ? inviter.displayname : ""
|
||||
//avatar.imageUrl: inviter ? inviter.avatar_url : ""
|
||||
|
||||
labelText:
|
||||
(inviter ?
|
||||
("<b>" + inviter.displayname + "</b>") : qsTr("Someone")) +
|
||||
" " + qsTr("invited you to join the room.")
|
||||
|
||||
buttonModel: [
|
||||
{
|
||||
name: "accept",
|
||||
text: qsTr("Accept"),
|
||||
iconName: "invite_accept",
|
||||
},
|
||||
{
|
||||
name: "decline",
|
||||
text: qsTr("Decline"),
|
||||
iconName: "invite_decline",
|
||||
}
|
||||
]
|
||||
|
||||
buttonCallbacks: {
|
||||
"accept": function(button) {
|
||||
button.loading = true
|
||||
Backend.clients.get(chatPage.userId).joinRoom(chatPage.roomId)
|
||||
},
|
||||
|
||||
"decline": function(button) {
|
||||
button.loading = true
|
||||
Backend.clients.get(chatPage.userId).leaveRoom(chatPage.roomId)
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,28 +0,0 @@
|
||||
import QtQuick 2.7
|
||||
import "../../Base"
|
||||
import "../utils.js" as ChatJS
|
||||
|
||||
Banner {
|
||||
property var leftEvent: null
|
||||
|
||||
color: HStyle.chat.leftBanner.background
|
||||
|
||||
avatar.name: ChatJS.getLeftBannerAvatarName(leftEvent, chatPage.userId)
|
||||
labelText: ChatJS.getLeftBannerText(leftEvent)
|
||||
|
||||
buttonModel: [
|
||||
{
|
||||
name: "forget",
|
||||
text: qsTr("Forget"),
|
||||
iconName: "forget_room",
|
||||
}
|
||||
]
|
||||
|
||||
buttonCallbacks: {
|
||||
"forget": function(button) {
|
||||
button.loading = true
|
||||
Backend.clients.get(chatPage.userId).forgetRoom(chatPage.roomId)
|
||||
pageStack.clear()
|
||||
},
|
||||
}
|
||||
}
|
@@ -1,25 +0,0 @@
|
||||
import QtQuick 2.7
|
||||
import "../../Base"
|
||||
import "../utils.js" as ChatJS
|
||||
|
||||
Banner {
|
||||
color: HStyle.chat.unknownDevices.background
|
||||
|
||||
avatar.visible: false
|
||||
icon.svgName: "unknown_devices_warning"
|
||||
labelText: "Unknown devices are present in this encrypted room."
|
||||
|
||||
buttonModel: [
|
||||
{
|
||||
name: "inspect",
|
||||
text: qsTr("Inspect"),
|
||||
iconName: "unknown_devices_inspect",
|
||||
}
|
||||
]
|
||||
|
||||
buttonCallbacks: {
|
||||
"inspect": function(button) {
|
||||
print("show")
|
||||
},
|
||||
}
|
||||
}
|
@@ -1,148 +0,0 @@
|
||||
import QtQuick 2.7
|
||||
import QtQuick.Layouts 1.3
|
||||
import "../Base"
|
||||
import "Banners"
|
||||
import "RoomEventList"
|
||||
import "RoomSidePane"
|
||||
|
||||
HColumnLayout {
|
||||
property string userId: ""
|
||||
property string category: ""
|
||||
property string roomId: ""
|
||||
|
||||
readonly property var roomInfo:
|
||||
Backend.accounts.get(userId)
|
||||
.roomCategories.get(category)
|
||||
.rooms.get(roomId)
|
||||
|
||||
readonly property var sender: Backend.users.get(userId)
|
||||
|
||||
readonly property bool hasUnknownDevices:
|
||||
category == "Rooms" ?
|
||||
Backend.clients.get(userId).roomHasUnknownDevices(roomId) : false
|
||||
|
||||
id: chatPage
|
||||
onFocusChanged: sendBox.setFocus()
|
||||
|
||||
Component.onCompleted: Backend.signals.roomCategoryChanged.connect(
|
||||
function(forUserId, forRoomId, previous, now) {
|
||||
if (chatPage && forUserId == userId && forRoomId == roomId) {
|
||||
chatPage.category = now
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
RoomHeader {
|
||||
id: roomHeader
|
||||
displayName: roomInfo.displayName
|
||||
topic: roomInfo.topic || ""
|
||||
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: HStyle.avatar.size
|
||||
}
|
||||
|
||||
|
||||
HSplitView {
|
||||
id: chatSplitView
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
|
||||
HColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
|
||||
RoomEventList {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
}
|
||||
|
||||
TypingMembersBar {}
|
||||
|
||||
InviteBanner {
|
||||
visible: category === "Invites"
|
||||
inviter: roomInfo.inviter
|
||||
}
|
||||
|
||||
UnknownDevicesBanner {
|
||||
visible: category == "Rooms" && hasUnknownDevices
|
||||
}
|
||||
|
||||
SendBox {
|
||||
id: sendBox
|
||||
visible: category == "Rooms" && ! hasUnknownDevices
|
||||
}
|
||||
|
||||
LeftBanner {
|
||||
visible: category === "Left"
|
||||
leftEvent: roomInfo.leftEvent
|
||||
}
|
||||
}
|
||||
|
||||
RoomSidePane {
|
||||
id: roomSidePane
|
||||
|
||||
activeView: roomHeader.activeButton
|
||||
property int oldWidth: width
|
||||
onActiveViewChanged:
|
||||
activeView ? restoreAnimation.start() : hideAnimation.start()
|
||||
|
||||
NumberAnimation {
|
||||
id: hideAnimation
|
||||
target: roomSidePane
|
||||
properties: "width"
|
||||
duration: HStyle.animationDuration
|
||||
from: target.width
|
||||
to: 0
|
||||
|
||||
onStarted: {
|
||||
target.oldWidth = target.width
|
||||
target.Layout.minimumWidth = 0
|
||||
}
|
||||
}
|
||||
|
||||
NumberAnimation {
|
||||
id: restoreAnimation
|
||||
target: roomSidePane
|
||||
properties: "width"
|
||||
duration: HStyle.animationDuration
|
||||
from: 0
|
||||
to: target.oldWidth
|
||||
|
||||
onStopped: target.Layout.minimumWidth = Qt.binding(
|
||||
function() { return HStyle.avatar.size }
|
||||
)
|
||||
}
|
||||
|
||||
collapsed: width < HStyle.avatar.size + 8
|
||||
|
||||
property bool wasSnapped: false
|
||||
property int referenceWidth: roomHeader.buttonsWidth
|
||||
onReferenceWidthChanged: {
|
||||
if (chatSplitView.canAutoSize || wasSnapped) {
|
||||
if (wasSnapped) { chatSplitView.canAutoSize = true }
|
||||
width = referenceWidth
|
||||
}
|
||||
}
|
||||
|
||||
property int currentWidth: width
|
||||
onCurrentWidthChanged: {
|
||||
if (referenceWidth != width &&
|
||||
referenceWidth - 15 < width &&
|
||||
width < referenceWidth + 15)
|
||||
{
|
||||
currentWidth = referenceWidth
|
||||
width = referenceWidth
|
||||
wasSnapped = true
|
||||
currentWidth = Qt.binding(
|
||||
function() { return roomSidePane.width }
|
||||
)
|
||||
} else {
|
||||
wasSnapped = false
|
||||
}
|
||||
}
|
||||
|
||||
width: referenceWidth // Initial width
|
||||
Layout.minimumWidth: HStyle.avatar.size
|
||||
Layout.maximumWidth: parent.width
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,9 +0,0 @@
|
||||
import QtQuick 2.7
|
||||
import "../../Base"
|
||||
|
||||
HNoticePage {
|
||||
text: dateTime.toLocaleDateString()
|
||||
color: HStyle.chat.daybreak.foreground
|
||||
backgroundColor: HStyle.chat.daybreak.background
|
||||
radius: HStyle.chat.daybreak.radius
|
||||
}
|
@@ -1,53 +0,0 @@
|
||||
import QtQuick 2.7
|
||||
import QtQuick.Layouts 1.3
|
||||
import "../../Base"
|
||||
import "../utils.js" as ChatJS
|
||||
|
||||
Row {
|
||||
id: eventContent
|
||||
spacing: standardSpacing / 2
|
||||
layoutDirection: isOwn ? Qt.RightToLeft : Qt.LeftToRight
|
||||
|
||||
width: Math.min(
|
||||
roomEventListView.width - avatar.width - eventContent.spacing,
|
||||
HStyle.fontSize.normal * 0.5 * 75, // 600 with 16px font
|
||||
contentLabel.implicitWidth
|
||||
)
|
||||
|
||||
HAvatar {
|
||||
id: avatar
|
||||
name: sender.displayName.value
|
||||
hidden: combine
|
||||
dimension: 28
|
||||
}
|
||||
|
||||
HLabel {
|
||||
width: parent.width
|
||||
|
||||
id: contentLabel
|
||||
text: "<font color='" +
|
||||
Qt.hsla(Backend.hueFromString(sender.displayName.value),
|
||||
HStyle.chat.event.saturation,
|
||||
HStyle.chat.event.lightness,
|
||||
1) +
|
||||
"'>" +
|
||||
sender.displayName.value + " " +
|
||||
ChatJS.getEventText(type, dict) +
|
||||
|
||||
" " +
|
||||
"<font size=" + HStyle.fontSize.small + "px " +
|
||||
"color=" + HStyle.chat.event.date + ">" +
|
||||
Qt.formatDateTime(dateTime, "hh:mm:ss") +
|
||||
"</font> " +
|
||||
"</font>"
|
||||
|
||||
textFormat: Text.RichText
|
||||
background: Rectangle {color: HStyle.chat.event.background}
|
||||
wrapMode: Text.Wrap
|
||||
|
||||
leftPadding: horizontalPadding
|
||||
rightPadding: horizontalPadding
|
||||
topPadding: verticalPadding
|
||||
bottomPadding: verticalPadding
|
||||
}
|
||||
}
|
@@ -1,80 +0,0 @@
|
||||
import QtQuick 2.7
|
||||
import QtQuick.Layouts 1.3
|
||||
import "../../Base"
|
||||
|
||||
Row {
|
||||
id: messageContent
|
||||
spacing: standardSpacing / 2
|
||||
layoutDirection: isOwn ? Qt.RightToLeft : Qt.LeftToRight
|
||||
|
||||
HAvatar {
|
||||
id: avatar
|
||||
hidden: combine
|
||||
name: sender.displayName.value
|
||||
dimension: 48
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
color: HStyle.chat.message.background
|
||||
|
||||
//width: nameLabel.implicitWidth
|
||||
width: Math.min(
|
||||
roomEventListView.width - avatar.width - messageContent.spacing,
|
||||
HStyle.fontSize.normal * 0.5 * 75, // 600 with 16px font
|
||||
Math.max(
|
||||
nameLabel.visible ? nameLabel.implicitWidth : 0,
|
||||
contentLabel.implicitWidth
|
||||
)
|
||||
)
|
||||
height: nameLabel.height + contentLabel.implicitHeight
|
||||
|
||||
Column {
|
||||
spacing: 0
|
||||
anchors.fill: parent
|
||||
|
||||
HLabel {
|
||||
height: combine ? 0 : implicitHeight
|
||||
width: parent.width
|
||||
visible: height > 0
|
||||
|
||||
id: nameLabel
|
||||
text: sender.displayName.value
|
||||
color: Qt.hsla(Backend.hueFromString(text),
|
||||
HStyle.displayName.saturation,
|
||||
HStyle.displayName.lightness,
|
||||
1)
|
||||
elide: Text.ElideRight
|
||||
maximumLineCount: 1
|
||||
horizontalAlignment: isOwn ? Text.AlignRight : Text.AlignLeft
|
||||
|
||||
leftPadding: horizontalPadding
|
||||
rightPadding: horizontalPadding
|
||||
topPadding: verticalPadding
|
||||
}
|
||||
|
||||
HRichLabel {
|
||||
width: parent.width
|
||||
|
||||
id: contentLabel
|
||||
text: (dict.formatted_body ?
|
||||
Backend.htmlFilter.filter(dict.formatted_body) :
|
||||
dict.body) +
|
||||
" <font size=" + HStyle.fontSize.small +
|
||||
"px color=" + HStyle.chat.message.date + ">" +
|
||||
Qt.formatDateTime(dateTime, "hh:mm:ss") +
|
||||
"</font>" +
|
||||
(isLocalEcho ?
|
||||
" <font size=" + HStyle.fontSize.small +
|
||||
"px>⏳</font>" : "")
|
||||
textFormat: Text.RichText
|
||||
color: HStyle.chat.message.body
|
||||
wrapMode: Text.Wrap
|
||||
|
||||
leftPadding: horizontalPadding
|
||||
rightPadding: horizontalPadding
|
||||
topPadding: nameLabel.visible ? 0 : verticalPadding
|
||||
bottomPadding: verticalPadding
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,88 +0,0 @@
|
||||
import QtQuick 2.7
|
||||
import QtQuick.Layouts 1.3
|
||||
import "../../Base"
|
||||
import "../utils.js" as ChatJS
|
||||
|
||||
Column {
|
||||
id: roomEventDelegate
|
||||
|
||||
function minsBetween(date1, date2) {
|
||||
return Math.round((((date2 - date1) % 86400000) % 3600000) / 60000)
|
||||
}
|
||||
|
||||
function getIsMessage(type_) { return type_.startsWith("RoomMessage") }
|
||||
|
||||
function getPreviousItem() {
|
||||
return index < roomEventListView.model.count - 1 ?
|
||||
roomEventListView.model.get(index + 1) : null
|
||||
}
|
||||
|
||||
property var previousItem: getPreviousItem()
|
||||
signal reloadPreviousItem()
|
||||
onReloadPreviousItem: previousItem = getPreviousItem()
|
||||
|
||||
readonly property bool isMessage: getIsMessage(type)
|
||||
|
||||
readonly property bool isUndecryptableEvent:
|
||||
type === "OlmEvent" || type === "MegolmEvent"
|
||||
|
||||
readonly property var sender: Backend.users.get(dict.sender)
|
||||
|
||||
readonly property bool isOwn:
|
||||
chatPage.userId === dict.sender
|
||||
|
||||
readonly property bool isFirstEvent: type == "RoomCreateEvent"
|
||||
|
||||
readonly property bool combine:
|
||||
previousItem &&
|
||||
! talkBreak &&
|
||||
! dayBreak &&
|
||||
getIsMessage(previousItem.type) === isMessage &&
|
||||
previousItem.dict.sender === dict.sender &&
|
||||
minsBetween(previousItem.dateTime, dateTime) <= 5
|
||||
|
||||
readonly property bool dayBreak:
|
||||
isFirstEvent ||
|
||||
previousItem &&
|
||||
dateTime.getDate() != previousItem.dateTime.getDate()
|
||||
|
||||
readonly property bool talkBreak:
|
||||
previousItem &&
|
||||
! dayBreak &&
|
||||
minsBetween(previousItem.dateTime, dateTime) >= 20
|
||||
|
||||
|
||||
property int standardSpacing: 16
|
||||
property int horizontalPadding: 6
|
||||
property int verticalPadding: 4
|
||||
|
||||
ListView.onAdd: {
|
||||
var nextDelegate = roomEventListView.contentItem.children[index]
|
||||
if (nextDelegate) { nextDelegate.reloadPreviousItem() }
|
||||
}
|
||||
|
||||
width: parent.width
|
||||
|
||||
topPadding:
|
||||
isFirstEvent ? 0 :
|
||||
dayBreak ? standardSpacing * 2 :
|
||||
talkBreak ? standardSpacing * 3 :
|
||||
combine ? standardSpacing / 4 :
|
||||
standardSpacing
|
||||
|
||||
Loader {
|
||||
source: dayBreak ? "Daybreak.qml" : ""
|
||||
width: roomEventDelegate.width
|
||||
}
|
||||
|
||||
Item {
|
||||
visible: dayBreak
|
||||
width: parent.width
|
||||
height: topPadding
|
||||
}
|
||||
|
||||
Loader {
|
||||
source: isMessage ? "MessageContent.qml" : "EventContent.qml"
|
||||
anchors.right: isOwn ? parent.right : undefined
|
||||
}
|
||||
}
|
@@ -1,43 +0,0 @@
|
||||
import QtQuick 2.7
|
||||
import "../../Base"
|
||||
|
||||
HRectangle {
|
||||
property int space: 8
|
||||
|
||||
color: HStyle.chat.roomEventList.background
|
||||
|
||||
HListView {
|
||||
id: roomEventListView
|
||||
delegate: RoomEventDelegate {}
|
||||
model: Backend.roomEvents.get(chatPage.roomId)
|
||||
clip: true
|
||||
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: space
|
||||
anchors.rightMargin: space
|
||||
|
||||
topMargin: space
|
||||
bottomMargin: space
|
||||
verticalLayoutDirection: ListView.BottomToTop
|
||||
|
||||
// Keep x scroll pages cached, to limit images having to be
|
||||
// reloaded from network.
|
||||
cacheBuffer: height * 6
|
||||
|
||||
// Declaring this "alias" provides the on... signal
|
||||
property real yPos: visibleArea.yPosition
|
||||
|
||||
onYPosChanged: {
|
||||
if (chatPage.category != "Invites" && yPos <= 0.1) {
|
||||
Backend.loadPastEvents(chatPage.roomId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HNoticePage {
|
||||
text: qsTr("Nothing to show here yet...")
|
||||
|
||||
visible: roomEventListView.model.count < 1
|
||||
anchors.fill: parent
|
||||
}
|
||||
}
|
@@ -1,102 +0,0 @@
|
||||
import QtQuick 2.7
|
||||
import QtQuick.Layouts 1.3
|
||||
import "../Base"
|
||||
|
||||
HRectangle {
|
||||
property string displayName: ""
|
||||
property string topic: ""
|
||||
|
||||
property alias buttonsImplicitWidth: viewButtons.implicitWidth
|
||||
property int buttonsWidth: viewButtons.Layout.preferredWidth
|
||||
property var activeButton: "members"
|
||||
|
||||
property bool collapseButtons: width < 400
|
||||
|
||||
id: roomHeader
|
||||
color: HStyle.chat.roomHeader.background
|
||||
|
||||
HRowLayout {
|
||||
id: row
|
||||
spacing: 8
|
||||
anchors.fill: parent
|
||||
|
||||
HAvatar {
|
||||
id: avatar
|
||||
name: displayName
|
||||
dimension: roomHeader.height
|
||||
Layout.alignment: Qt.AlignTop
|
||||
}
|
||||
|
||||
HLabel {
|
||||
id: roomName
|
||||
text: displayName
|
||||
font.pixelSize: HStyle.fontSize.big
|
||||
elide: Text.ElideRight
|
||||
maximumLineCount: 1
|
||||
|
||||
Layout.maximumWidth: Math.max(
|
||||
0,
|
||||
row.width - row.totalSpacing - avatar.width -
|
||||
viewButtons.width -
|
||||
(expandButton.visible ? expandButton.width : 0)
|
||||
)
|
||||
}
|
||||
|
||||
HLabel {
|
||||
id: roomTopic
|
||||
text: topic
|
||||
font.pixelSize: HStyle.fontSize.small
|
||||
elide: Text.ElideRight
|
||||
maximumLineCount: 1
|
||||
|
||||
Layout.maximumWidth: Math.max(
|
||||
0,
|
||||
row.width - row.totalSpacing - avatar.width -
|
||||
roomName.width - viewButtons.width -
|
||||
(expandButton.visible ? expandButton.width : 0)
|
||||
)
|
||||
}
|
||||
|
||||
HSpacer {}
|
||||
|
||||
Row {
|
||||
id: viewButtons
|
||||
Layout.preferredWidth: collapseButtons ? 0 : implicitWidth
|
||||
Layout.fillHeight: true
|
||||
|
||||
Repeater {
|
||||
model: [
|
||||
"members", "files", "notifications", "history", "settings"
|
||||
]
|
||||
HButton {
|
||||
iconName: "room_view_" + modelData
|
||||
iconDimension: 22
|
||||
autoExclusive: true
|
||||
checked: activeButton == modelData
|
||||
onClicked: activeButton = activeButton == modelData ?
|
||||
null : modelData
|
||||
}
|
||||
}
|
||||
|
||||
Behavior on Layout.preferredWidth {
|
||||
NumberAnimation {
|
||||
id: buttonsAnimation
|
||||
duration: HStyle.animationDuration
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HButton {
|
||||
id: expandButton
|
||||
z: 1
|
||||
anchors.right: parent.right
|
||||
opacity: collapseButtons ? 1 : 0
|
||||
visible: opacity > 0
|
||||
iconName: "reduced_room_buttons"
|
||||
|
||||
Behavior on opacity {
|
||||
NumberAnimation { duration: buttonsAnimation.duration * 2 }
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,37 +0,0 @@
|
||||
import QtQuick 2.7
|
||||
import QtQuick.Layouts 1.3
|
||||
import "../../Base"
|
||||
|
||||
MouseArea {
|
||||
id: memberDelegate
|
||||
width: memberList.width
|
||||
height: childrenRect.height
|
||||
|
||||
property var member: Backend.users.get(userId)
|
||||
|
||||
HRowLayout {
|
||||
width: parent.width
|
||||
spacing: memberList.spacing
|
||||
|
||||
HAvatar {
|
||||
id: memberAvatar
|
||||
name: member.displayName.value
|
||||
}
|
||||
|
||||
HColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
Layout.maximumWidth:
|
||||
parent.width - parent.totalSpacing - memberAvatar.width
|
||||
|
||||
HLabel {
|
||||
id: memberName
|
||||
text: member.displayName.value
|
||||
elide: Text.ElideRight
|
||||
maximumLineCount: 1
|
||||
verticalAlignment: Qt.AlignVCenter
|
||||
|
||||
Layout.maximumWidth: parent.width
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,49 +0,0 @@
|
||||
import QtQuick 2.7
|
||||
import QtQuick.Layouts 1.3
|
||||
import "../../Base"
|
||||
|
||||
HColumnLayout {
|
||||
property bool collapsed: false
|
||||
property int normalSpacing: collapsed ? 0 : 8
|
||||
|
||||
Behavior on normalSpacing {
|
||||
NumberAnimation { duration: HStyle.animationDuration }
|
||||
}
|
||||
|
||||
HListView {
|
||||
id: memberList
|
||||
|
||||
spacing: normalSpacing
|
||||
topMargin: normalSpacing
|
||||
bottomMargin: normalSpacing
|
||||
Layout.leftMargin: normalSpacing
|
||||
Layout.rightMargin: normalSpacing
|
||||
|
||||
model: chatPage.roomInfo.sortedMembers
|
||||
delegate: MemberDelegate {}
|
||||
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
|
||||
}
|
||||
|
||||
HTextField {
|
||||
id: filterField
|
||||
placeholderText: qsTr("Filter members")
|
||||
backgroundColor: HStyle.sidePane.filterRooms.background
|
||||
|
||||
// Without this, if the user types in the field, changes of room, then
|
||||
// comes back, the field will be empty but the filter still applied.
|
||||
Component.onCompleted:
|
||||
text = Backend.clients.get(chatPage.userId).getMemberFilter(
|
||||
chatPage.category, chatPage.roomId
|
||||
)
|
||||
|
||||
onTextChanged: Backend.clients.get(chatPage.userId).setMemberFilter(
|
||||
chatPage.category, chatPage.roomId, text
|
||||
)
|
||||
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: HStyle.bottomElementsHeight
|
||||
}
|
||||
}
|
@@ -1,15 +0,0 @@
|
||||
import QtQuick 2.7
|
||||
import QtQuick.Layouts 1.3
|
||||
import "../../Base"
|
||||
|
||||
HRectangle {
|
||||
id: roomSidePane
|
||||
|
||||
property bool collapsed: false
|
||||
property var activeView: null
|
||||
|
||||
MembersView {
|
||||
anchors.fill: parent
|
||||
collapsed: parent.collapsed
|
||||
}
|
||||
}
|
@@ -1,72 +0,0 @@
|
||||
import QtQuick 2.7
|
||||
import QtQuick.Layouts 1.3
|
||||
import "../Base"
|
||||
|
||||
HRectangle {
|
||||
function setFocus() { textArea.forceActiveFocus() }
|
||||
|
||||
id: root
|
||||
Layout.fillWidth: true
|
||||
Layout.minimumHeight: HStyle.bottomElementsHeight
|
||||
Layout.preferredHeight: textArea.implicitHeight
|
||||
// parent.height / 2 causes binding loop?
|
||||
Layout.maximumHeight: pageStack.height / 2
|
||||
color: HStyle.chat.sendBox.background
|
||||
|
||||
HRowLayout {
|
||||
anchors.fill: parent
|
||||
|
||||
HAvatar {
|
||||
id: avatar
|
||||
name: chatPage.sender.displayName.value
|
||||
dimension: root.Layout.minimumHeight
|
||||
}
|
||||
|
||||
HScrollableTextArea {
|
||||
Layout.fillHeight: true
|
||||
Layout.fillWidth: true
|
||||
|
||||
id: textArea
|
||||
placeholderText: qsTr("Type a message...")
|
||||
backgroundColor: "transparent"
|
||||
area.focus: true
|
||||
|
||||
property bool textChangedSinceLostFocus: false
|
||||
|
||||
function setTyping(typing) {
|
||||
Backend.clients.get(chatPage.userId)
|
||||
.setTypingState(chatPage.roomId, typing)
|
||||
}
|
||||
|
||||
onTextChanged: {
|
||||
setTyping(Boolean(text))
|
||||
textChangedSinceLostFocus = true
|
||||
}
|
||||
area.onEditingFinished: { // when lost focus
|
||||
if (text && textChangedSinceLostFocus) {
|
||||
setTyping(false)
|
||||
textChangedSinceLostFocus = false
|
||||
}
|
||||
}
|
||||
|
||||
Keys.onReturnPressed: {
|
||||
event.accepted = true
|
||||
|
||||
if (event.modifiers & Qt.ShiftModifier ||
|
||||
event.modifiers & Qt.ControlModifier ||
|
||||
event.modifiers & Qt.AltModifier) {
|
||||
textArea.insert(textArea.cursorPosition, "\n")
|
||||
return
|
||||
}
|
||||
|
||||
if (textArea.text === "") { return }
|
||||
Backend.clients.get(chatPage.userId)
|
||||
.sendMarkdown(chatPage.roomId, textArea.text)
|
||||
area.clear()
|
||||
}
|
||||
|
||||
// Numpad enter
|
||||
Keys.onEnterPressed: Keys.onReturnPressed(event)
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,23 +0,0 @@
|
||||
import QtQuick 2.7
|
||||
import QtQuick.Layouts 1.3
|
||||
import "../Base"
|
||||
import "utils.js" as ChatJS
|
||||
|
||||
HRectangle {
|
||||
property var typingMembers: chatPage.roomInfo.typingMembers
|
||||
|
||||
color: HStyle.chat.typingMembers.background
|
||||
|
||||
Layout.fillWidth: true
|
||||
Layout.minimumHeight: usersLabel.text ? usersLabel.implicitHeight : 0
|
||||
Layout.maximumHeight: Layout.minimumHeight
|
||||
|
||||
HLabel {
|
||||
id: usersLabel
|
||||
anchors.fill: parent
|
||||
|
||||
text: ChatJS.getTypingMembersText(typingMembers, chatPage.userId)
|
||||
elide: Text.ElideMiddle
|
||||
maximumLineCount: 1
|
||||
}
|
||||
}
|
@@ -1,210 +0,0 @@
|
||||
function getEventText(type, dict) {
|
||||
switch (type) {
|
||||
case "RoomCreateEvent":
|
||||
return (dict.federate ? "allowed" : "blocked") +
|
||||
" users on other matrix servers " +
|
||||
(dict.federate ? "to join" : "from joining") +
|
||||
" this room."
|
||||
break
|
||||
|
||||
case "RoomGuestAccessEvent":
|
||||
return (dict.guest_access === "can_join" ? "allowed " : "forbad") +
|
||||
"guests to join the room."
|
||||
break
|
||||
|
||||
case "RoomJoinRulesEvent":
|
||||
return "made the room " +
|
||||
(dict.join_rule === "public." ? "public" : "invite only.")
|
||||
break
|
||||
|
||||
case "RoomHistoryVisibilityEvent":
|
||||
return getHistoryVisibilityEventText(dict)
|
||||
break
|
||||
|
||||
case "PowerLevelsEvent":
|
||||
return "changed the room's permissions."
|
||||
|
||||
case "RoomMemberEvent":
|
||||
return getMemberEventText(dict)
|
||||
break
|
||||
|
||||
case "RoomAliasEvent":
|
||||
return "set the room's main address to " +
|
||||
dict.canonical_alias + "."
|
||||
break
|
||||
|
||||
case "RoomNameEvent":
|
||||
return "changed the room's name to \"" + dict.name + "\"."
|
||||
break
|
||||
|
||||
case "RoomTopicEvent":
|
||||
return "changed the room's topic to \"" + dict.topic + "\"."
|
||||
break
|
||||
|
||||
case "RoomEncryptionEvent":
|
||||
return "turned on encryption for this room."
|
||||
break
|
||||
|
||||
case "OlmEvent":
|
||||
case "MegolmEvent":
|
||||
return "hasn't sent your device the keys to decrypt this message."
|
||||
|
||||
default:
|
||||
console.log(type + "\n" + JSON.stringify(dict, null, 4) + "\n")
|
||||
return "did something this client does not understand."
|
||||
|
||||
//case "CallEvent": TODO
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function getHistoryVisibilityEventText(dict) {
|
||||
switch (dict.history_visibility) {
|
||||
case "shared":
|
||||
var end = "all room members."
|
||||
break
|
||||
|
||||
case "world_readable":
|
||||
var end = "any member or outsider."
|
||||
break
|
||||
|
||||
case "joined":
|
||||
var end = "all room members, since the point they joined."
|
||||
break
|
||||
|
||||
case "invited":
|
||||
var end = "all room members, since the point they were invited."
|
||||
break
|
||||
}
|
||||
|
||||
return "made future history visible to " + end
|
||||
}
|
||||
|
||||
|
||||
function getStateDisplayName(dict) {
|
||||
// The dict.content.displayname may be outdated, prefer
|
||||
// retrieving it fresh
|
||||
return Backend.users.get(dict.state_key).displayName.value
|
||||
}
|
||||
|
||||
|
||||
function getMemberEventText(dict) {
|
||||
var info = dict.content, prev = dict.prev_content
|
||||
|
||||
if (! prev || (info.membership != prev.membership)) {
|
||||
var reason = info.reason ? (" Reason: " + info.reason) : ""
|
||||
|
||||
switch (info.membership) {
|
||||
case "join":
|
||||
return prev && prev.membership === "invite" ?
|
||||
"accepted the invitation." : "joined the room."
|
||||
break
|
||||
|
||||
case "invite":
|
||||
return "invited " + getStateDisplayName(dict) + " to the room."
|
||||
break
|
||||
|
||||
case "leave":
|
||||
if (dict.state_key === dict.sender) {
|
||||
return (prev && prev.membership === "invite" ?
|
||||
"declined the invitation." : "left the room.") +
|
||||
reason
|
||||
}
|
||||
|
||||
var name = getStateDisplayName(dict)
|
||||
return (prev && prev.membership === "invite" ?
|
||||
"withdrew " + name + "'s invitation." :
|
||||
|
||||
prev && prev.membership == "ban" ?
|
||||
"unbanned " + name + " from the room." :
|
||||
|
||||
"kicked out " + name + " from the room.") +
|
||||
reason
|
||||
break
|
||||
|
||||
case "ban":
|
||||
var name = getStateDisplayName(dict)
|
||||
return "banned " + name + " from the room." + reason
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
var changed = []
|
||||
|
||||
if (prev && (info.avatar_url != prev.avatar_url)) {
|
||||
changed.push("profile picture")
|
||||
}
|
||||
|
||||
if (prev && (info.displayname != prev.displayname)) {
|
||||
changed.push("display name from \"" +
|
||||
(prev.displayname || dict.state_key) + '" to "' +
|
||||
(info.displayname || dict.state_key) + '"')
|
||||
}
|
||||
|
||||
if (changed.length > 0) {
|
||||
return "changed their " + changed.join(" and ") + "."
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
|
||||
function getLeftBannerText(leftEvent) {
|
||||
if (! leftEvent) {
|
||||
return "You are not member of this room."
|
||||
}
|
||||
|
||||
var info = leftEvent.content
|
||||
var prev = leftEvent.prev_content
|
||||
var reason = info.reason ? (" Reason: " + info.reason) : ""
|
||||
|
||||
if (leftEvent.state_key === leftEvent.sender) {
|
||||
return (prev && prev.membership === "invite" ?
|
||||
"You declined to join the room." : "You left the room.") +
|
||||
reason
|
||||
}
|
||||
|
||||
if (info.membership)
|
||||
|
||||
var name = Backend.users.get(leftEvent.sender).displayName.value
|
||||
|
||||
return "<b>" + name + "</b> " +
|
||||
(info.membership == "ban" ?
|
||||
"banned you from the room." :
|
||||
|
||||
prev && prev.membership === "invite" ?
|
||||
"canceled your invitation." :
|
||||
|
||||
prev && prev.membership == "ban" ?
|
||||
"unbanned you from the room." :
|
||||
|
||||
"kicked you out of the room.") +
|
||||
reason
|
||||
}
|
||||
|
||||
|
||||
function getLeftBannerAvatarName(leftEvent, accountId) {
|
||||
if (! leftEvent || leftEvent.state_key == leftEvent.sender) {
|
||||
return Backend.users.get(accountId).displayName.value
|
||||
}
|
||||
|
||||
return Backend.users.get(leftEvent.sender).displayName.value
|
||||
}
|
||||
|
||||
|
||||
function getTypingMembersText(users, ourAccountId) {
|
||||
var names = []
|
||||
|
||||
for (var i = 0; i < users.length; i++) {
|
||||
if (users[i] !== ourAccountId) {
|
||||
names.push(Backend.users.get(users[i]).displayName.value)
|
||||
}
|
||||
}
|
||||
|
||||
if (names.length < 1) { return "" }
|
||||
|
||||
return "🖋 " +
|
||||
[names.slice(0, -1).join(", "), names.slice(-1)[0]]
|
||||
.join(names.length < 2 ? "" : " and ") +
|
||||
(names.length > 1 ? " are" : " is") + " typing…"
|
||||
}
|
@@ -1,5 +0,0 @@
|
||||
import "../Base"
|
||||
|
||||
HNoticePage {
|
||||
text: "Select or add a room to start."
|
||||
}
|
@@ -1,43 +0,0 @@
|
||||
import QtQuick 2.7
|
||||
import QtQuick.Layouts 1.3
|
||||
import "../Base"
|
||||
|
||||
Item {
|
||||
property string loginWith: "username"
|
||||
property var client: null
|
||||
|
||||
HInterfaceBox {
|
||||
id: rememberBox
|
||||
title: "Sign in"
|
||||
anchors.centerIn: parent
|
||||
|
||||
enterButtonTarget: "yes"
|
||||
|
||||
buttonModel: [
|
||||
{ name: "yes", text: qsTr("Yes") },
|
||||
{ name: "no", text: qsTr("No") },
|
||||
]
|
||||
|
||||
buttonCallbacks: {
|
||||
"yes": function(button) {
|
||||
Backend.clients.remember(client)
|
||||
pageStack.showPage("Default")
|
||||
},
|
||||
"no": function(button) { pageStack.showPage("Default") },
|
||||
}
|
||||
|
||||
HLabel {
|
||||
text: qsTr(
|
||||
"Do you want to remember this account?\n\n" +
|
||||
"If yes, the " + loginWith + " and an access token will be " +
|
||||
"stored to automatically sign in on this device."
|
||||
)
|
||||
wrapMode: Text.Wrap
|
||||
|
||||
Layout.margins: rememberBox.margins
|
||||
Layout.maximumWidth: rememberBox.width - Layout.margins * 2
|
||||
}
|
||||
|
||||
HSpacer {}
|
||||
}
|
||||
}
|
@@ -1,83 +0,0 @@
|
||||
import QtQuick 2.7
|
||||
import QtQuick.Layouts 1.3
|
||||
import "../Base"
|
||||
|
||||
Item {
|
||||
property string loginWith: "username"
|
||||
onFocusChanged: identifierField.forceActiveFocus()
|
||||
|
||||
HInterfaceBox {
|
||||
id: signInBox
|
||||
title: "Sign in"
|
||||
anchors.centerIn: parent
|
||||
|
||||
enterButtonTarget: "login"
|
||||
|
||||
buttonModel: [
|
||||
{ name: "register", text: qsTr("Register") },
|
||||
{ name: "login", text: qsTr("Login") },
|
||||
{ name: "forgot", text: qsTr("Forgot?") }
|
||||
]
|
||||
|
||||
buttonCallbacks: {
|
||||
"register": function(button) {},
|
||||
|
||||
"login": function(button) {
|
||||
var future = Backend.clients.new(
|
||||
"matrix.org", identifierField.text, passwordField.text
|
||||
)
|
||||
button.loadingUntilFutureDone(future)
|
||||
future.onGotResult.connect(function(client) {
|
||||
pageStack.showPage(
|
||||
"RememberAccount",
|
||||
{"loginWith": loginWith, "client": client}
|
||||
)
|
||||
})
|
||||
},
|
||||
|
||||
"forgot": function(button) {}
|
||||
}
|
||||
|
||||
HRowLayout {
|
||||
spacing: signInBox.margins * 1.25
|
||||
Layout.margins: signInBox.margins
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
|
||||
Repeater {
|
||||
model: ["username", "email", "phone"]
|
||||
|
||||
HButton {
|
||||
iconName: modelData
|
||||
circle: true
|
||||
checked: loginWith == modelData
|
||||
autoExclusive: true
|
||||
checkedLightens: true
|
||||
onClicked: loginWith = modelData
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HTextField {
|
||||
id: identifierField
|
||||
placeholderText: qsTr(
|
||||
loginWith === "email" ? "Email" :
|
||||
loginWith === "phone" ? "Phone" :
|
||||
"Username"
|
||||
)
|
||||
onAccepted: signInBox.clickEnterButtonTarget()
|
||||
|
||||
Layout.fillWidth: true
|
||||
Layout.margins: signInBox.margins
|
||||
}
|
||||
|
||||
HTextField {
|
||||
id: passwordField
|
||||
placeholderText: qsTr("Password")
|
||||
echoMode: HTextField.Password
|
||||
onAccepted: signInBox.clickEnterButtonTarget()
|
||||
|
||||
Layout.fillWidth: true
|
||||
Layout.margins: signInBox.margins
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,80 +0,0 @@
|
||||
import QtQuick 2.7
|
||||
import QtQuick.Layouts 1.3
|
||||
import "../Base"
|
||||
|
||||
Column {
|
||||
id: accountDelegate
|
||||
width: parent.width
|
||||
|
||||
property var user: Backend.users.get(userId)
|
||||
|
||||
property string roomCategoriesListUserId: userId
|
||||
property bool expanded: true
|
||||
|
||||
HRowLayout {
|
||||
width: parent.width
|
||||
height: childrenRect.height
|
||||
id: row
|
||||
|
||||
HAvatar {
|
||||
id: avatar
|
||||
name: user.displayName.value
|
||||
}
|
||||
|
||||
HColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
|
||||
HLabel {
|
||||
id: accountLabel
|
||||
text: user.displayName.value
|
||||
elide: HLabel.ElideRight
|
||||
maximumLineCount: 1
|
||||
Layout.fillWidth: true
|
||||
leftPadding: 6
|
||||
rightPadding: leftPadding
|
||||
}
|
||||
|
||||
HTextField {
|
||||
id: statusEdit
|
||||
text: user.statusMessage || ""
|
||||
placeholderText: qsTr("Set status message")
|
||||
font.pixelSize: HStyle.fontSize.small
|
||||
background: null
|
||||
|
||||
padding: 0
|
||||
leftPadding: accountLabel.leftPadding
|
||||
rightPadding: leftPadding
|
||||
Layout.fillWidth: true
|
||||
|
||||
onEditingFinished: {
|
||||
//Backend.setStatusMessage(userId, text)
|
||||
pageStack.forceActiveFocus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ExpandButton {
|
||||
expandableItem: accountDelegate
|
||||
Layout.preferredHeight: row.height
|
||||
}
|
||||
}
|
||||
|
||||
RoomCategoriesList {
|
||||
id: roomCategoriesList
|
||||
interactive: false // no scrolling
|
||||
visible: height > 0
|
||||
width: parent.width
|
||||
height: childrenRect.height * (accountDelegate.expanded ? 1 : 0)
|
||||
clip: heightAnimation.running
|
||||
|
||||
userId: roomCategoriesListUserId
|
||||
|
||||
Behavior on height {
|
||||
NumberAnimation {
|
||||
id: heightAnimation;
|
||||
duration: HStyle.animationDuration
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,11 +0,0 @@
|
||||
import QtQuick 2.7
|
||||
import QtQuick.Layouts 1.3
|
||||
import "../Base"
|
||||
|
||||
HListView {
|
||||
id: accountList
|
||||
clip: true
|
||||
|
||||
model: Backend.accounts
|
||||
delegate: AccountDelegate {}
|
||||
}
|
@@ -1,22 +0,0 @@
|
||||
import QtQuick 2.7
|
||||
import QtQuick.Layouts 1.3
|
||||
import "../Base"
|
||||
|
||||
HButton {
|
||||
property var expandableItem: null
|
||||
|
||||
id: expandButton
|
||||
iconName: "expand"
|
||||
iconDimension: 16
|
||||
backgroundColor: "transparent"
|
||||
onClicked: expandableItem.expanded = ! expandableItem.expanded
|
||||
|
||||
iconTransform: Rotation {
|
||||
origin.x: expandButton.iconDimension / 2
|
||||
origin.y: expandButton.iconDimension / 2
|
||||
angle: expandableItem.expanded ? 90 : 180
|
||||
Behavior on angle {
|
||||
NumberAnimation { duration: HStyle.animationDuration }
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,25 +0,0 @@
|
||||
import QtQuick.Layouts 1.3
|
||||
import "../Base"
|
||||
|
||||
HRowLayout {
|
||||
id: toolBar
|
||||
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: HStyle.bottomElementsHeight
|
||||
|
||||
HButton {
|
||||
iconName: "settings"
|
||||
backgroundColor: HStyle.sidePane.settingsButton.background
|
||||
}
|
||||
|
||||
HTextField {
|
||||
id: filterField
|
||||
placeholderText: qsTr("Filter rooms")
|
||||
backgroundColor: HStyle.sidePane.filterRooms.background
|
||||
|
||||
onTextChanged: Backend.setRoomFilter(text)
|
||||
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: parent.height
|
||||
}
|
||||
}
|
@@ -1,11 +0,0 @@
|
||||
import QtQuick 2.7
|
||||
import QtQuick.Layouts 1.3
|
||||
import "../Base"
|
||||
|
||||
HListView {
|
||||
property string userId: ""
|
||||
|
||||
id: roomCategoriesList
|
||||
model: Backend.accounts.get(userId).roomCategories
|
||||
delegate: RoomCategoryDelegate {}
|
||||
}
|
@@ -1,60 +0,0 @@
|
||||
import QtQuick 2.7
|
||||
import QtQuick.Layouts 1.3
|
||||
import "../Base"
|
||||
|
||||
Column {
|
||||
id: roomCategoryDelegate
|
||||
width: roomCategoriesList.width
|
||||
|
||||
property int normalHeight: childrenRect.height // avoid binding loop
|
||||
|
||||
opacity: roomList.model.count > 0 ? 1 : 0
|
||||
height: normalHeight * opacity
|
||||
visible: opacity > 0
|
||||
|
||||
Behavior on opacity {
|
||||
NumberAnimation { duration: HStyle.animationDuration }
|
||||
}
|
||||
|
||||
property string roomListUserId: userId
|
||||
property bool expanded: true
|
||||
|
||||
HRowLayout {
|
||||
width: parent.width
|
||||
|
||||
HLabel {
|
||||
id: roomCategoryLabel
|
||||
text: name
|
||||
font.weight: Font.DemiBold
|
||||
elide: Text.ElideRight
|
||||
maximumLineCount: 1
|
||||
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
ExpandButton {
|
||||
expandableItem: roomCategoryDelegate
|
||||
iconDimension: 12
|
||||
}
|
||||
}
|
||||
|
||||
RoomList {
|
||||
id: roomList
|
||||
interactive: false // no scrolling
|
||||
visible: height > 0
|
||||
width: roomCategoriesList.width - accountList.Layout.leftMargin
|
||||
opacity: roomCategoryDelegate.expanded ? 1 : 0
|
||||
height: childrenRect.height * opacity
|
||||
clip: listHeightAnimation.running
|
||||
|
||||
userId: roomListUserId
|
||||
category: name
|
||||
|
||||
Behavior on opacity {
|
||||
NumberAnimation {
|
||||
id: listHeightAnimation
|
||||
duration: HStyle.animationDuration
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,61 +0,0 @@
|
||||
import QtQuick 2.7
|
||||
import QtQuick.Layouts 1.3
|
||||
import "../Base"
|
||||
import "utils.js" as SidePaneJS
|
||||
|
||||
MouseArea {
|
||||
id: roomDelegate
|
||||
width: roomList.width
|
||||
height: childrenRect.height
|
||||
|
||||
onClicked: pageStack.showRoom(roomList.userId, roomList.category, roomId)
|
||||
|
||||
HRowLayout {
|
||||
width: parent.width
|
||||
spacing: roomList.spacing
|
||||
|
||||
HAvatar {
|
||||
id: roomAvatar
|
||||
name: displayName
|
||||
}
|
||||
|
||||
HColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
Layout.maximumWidth:
|
||||
parent.width - parent.totalSpacing - roomAvatar.width
|
||||
|
||||
HLabel {
|
||||
id: roomLabel
|
||||
text: displayName ? displayName : "<i>Empty room</i>"
|
||||
textFormat: Text.StyledText
|
||||
elide: Text.ElideRight
|
||||
maximumLineCount: 1
|
||||
verticalAlignment: Qt.AlignVCenter
|
||||
|
||||
Layout.maximumWidth: parent.width
|
||||
}
|
||||
|
||||
HLabel {
|
||||
function getText() {
|
||||
return SidePaneJS.getLastRoomEventText(
|
||||
roomId, roomList.userId
|
||||
)
|
||||
}
|
||||
|
||||
property var lastEvTime: lastEventDateTime
|
||||
onLastEvTimeChanged: subtitleLabel.text = getText()
|
||||
|
||||
id: subtitleLabel
|
||||
visible: text !== ""
|
||||
text: getText()
|
||||
textFormat: Text.StyledText
|
||||
|
||||
font.pixelSize: HStyle.fontSize.small
|
||||
elide: Text.ElideRight
|
||||
maximumLineCount: 1
|
||||
|
||||
Layout.maximumWidth: parent.width
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,14 +0,0 @@
|
||||
import QtQuick 2.7
|
||||
import QtQuick.Layouts 1.3
|
||||
import "../Base"
|
||||
|
||||
HListView {
|
||||
property string userId: ""
|
||||
property string category: ""
|
||||
|
||||
id: roomList
|
||||
spacing: accountList.spacing
|
||||
model:
|
||||
Backend.accounts.get(userId).roomCategories.get(category).sortedRooms
|
||||
delegate: RoomDelegate {}
|
||||
}
|
@@ -1,30 +0,0 @@
|
||||
import QtQuick 2.7
|
||||
import QtQuick.Layouts 1.3
|
||||
import "../Base"
|
||||
|
||||
HRectangle {
|
||||
id: sidePane
|
||||
|
||||
property int normalSpacing: 8
|
||||
property bool collapsed: false
|
||||
|
||||
HColumnLayout {
|
||||
anchors.fill: parent
|
||||
|
||||
AccountList {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
|
||||
spacing: collapsed ? 0 : normalSpacing
|
||||
topMargin: spacing
|
||||
bottomMargin: spacing
|
||||
Layout.leftMargin: spacing
|
||||
|
||||
Behavior on spacing {
|
||||
NumberAnimation { duration: HStyle.animationDuration }
|
||||
}
|
||||
}
|
||||
|
||||
PaneToolBar {}
|
||||
}
|
||||
}
|
@@ -1,29 +0,0 @@
|
||||
.import "../Chat/utils.js" as ChatJS
|
||||
|
||||
|
||||
function getLastRoomEventText(roomId, accountId) {
|
||||
var eventsModel = Backend.roomEvents.get(roomId)
|
||||
if (eventsModel.count < 1) { return "" }
|
||||
var ev = eventsModel.get(0)
|
||||
|
||||
var name = Backend.users.get(ev.dict.sender).displayName.value
|
||||
|
||||
var undecryptable = ev.type === "OlmEvent" || ev.type === "MegolmEvent"
|
||||
|
||||
if (undecryptable || ev.type.startsWith("RoomMessage")) {
|
||||
var color = Qt.hsla(Backend.hueFromString(name), 0.32, 0.3, 1)
|
||||
|
||||
return "<font color='" + color + "'>" +
|
||||
name +
|
||||
":</font> " +
|
||||
(undecryptable ?
|
||||
"<font color='darkred'>" + qsTr("Undecryptable") + "<font>" :
|
||||
ev.dict.body)
|
||||
} else {
|
||||
return "<font color='" + (undecryptable ? "darkred" : "#444") + "'>" +
|
||||
name +
|
||||
" " +
|
||||
ChatJS.getEventText(ev.type, ev.dict) +
|
||||
"</font>"
|
||||
}
|
||||
}
|
@@ -1,103 +0,0 @@
|
||||
import QtQuick 2.7
|
||||
import QtQuick.Controls 2.2
|
||||
import QtQuick.Layouts 1.3
|
||||
import QtQuick.Window 2.7
|
||||
import "Base"
|
||||
import "SidePane"
|
||||
|
||||
Item {
|
||||
id: mainUI
|
||||
|
||||
HImage {
|
||||
id: mainUIBackground
|
||||
fillMode: Image.PreserveAspectCrop
|
||||
source: "../images/login_background.jpg"
|
||||
sourceSize.width: Screen.width
|
||||
sourceSize.height: Screen.height
|
||||
anchors.fill: parent
|
||||
}
|
||||
|
||||
property bool accountsLoggedIn: Backend.clients.count > 0
|
||||
|
||||
HSplitView {
|
||||
id: uiSplitView
|
||||
anchors.fill: parent
|
||||
|
||||
SidePane {
|
||||
id: sidePane
|
||||
visible: accountsLoggedIn
|
||||
collapsed: width < Layout.minimumWidth + normalSpacing
|
||||
|
||||
property int parentWidth: parent.width
|
||||
property int collapseBelow: 120
|
||||
|
||||
function set_width() {
|
||||
width = parent.width * 0.3 < collapseBelow ?
|
||||
Layout.minimumWidth : Math.min(parent.width * 0.3, 300)
|
||||
}
|
||||
|
||||
onParentWidthChanged: if (uiSplitView.canAutoSize) { set_width() }
|
||||
|
||||
width: set_width() // Initial width
|
||||
Layout.minimumWidth: HStyle.avatar.size
|
||||
Layout.maximumWidth: parent.width
|
||||
|
||||
Behavior on width {
|
||||
NumberAnimation {
|
||||
// Don't slow down the user manually resizing
|
||||
duration:
|
||||
(uiSplitView.canAutoSize &&
|
||||
parent.width * 0.3 < sidePane.collapseBelow * 1.2) ?
|
||||
HStyle.animationDuration : 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
StackView {
|
||||
id: pageStack
|
||||
|
||||
property bool initialPageSet: false
|
||||
|
||||
function showPage(name, properties) {
|
||||
pageStack.replace("Pages/" + name + ".qml", properties || {})
|
||||
}
|
||||
|
||||
function showRoom(userId, category, roomId) {
|
||||
pageStack.replace(
|
||||
"Chat/Chat.qml",
|
||||
{ userId: userId, category: category, roomId: roomId }
|
||||
)
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
if (pageStack.initialPageSet) { return }
|
||||
pageStack.initialPageSet = true
|
||||
showPage(accountsLoggedIn ? "Default" : "SignIn")
|
||||
if (accountsLoggedIn) { initialRoomTimer.start() }
|
||||
}
|
||||
|
||||
Timer {
|
||||
// TODO: remove this, debug
|
||||
id: initialRoomTimer
|
||||
interval: appWindow.reloadedTimes > 0 ? 0 : 5000
|
||||
repeat: false
|
||||
onTriggered: pageStack.showRoom(
|
||||
"@test_mary:matrix.org",
|
||||
"Rooms",
|
||||
"!TSXGsbBbdwsdylIOJZ:matrix.org"
|
||||
)
|
||||
}
|
||||
|
||||
onCurrentItemChanged: if (currentItem) {
|
||||
currentItem.forceActiveFocus()
|
||||
}
|
||||
|
||||
// Buggy
|
||||
replaceExit: null
|
||||
popExit: null
|
||||
pushExit: null
|
||||
}
|
||||
|
||||
Keys.onEscapePressed: Backend.pdb() // TODO: only if debug mode True
|
||||
}
|
||||
}
|
@@ -1,20 +0,0 @@
|
||||
import QtQuick 2.7
|
||||
import QtQuick.Controls 2.2
|
||||
import QtQuick.Window 2.7
|
||||
|
||||
ApplicationWindow {
|
||||
id: appWindow
|
||||
visible: true
|
||||
width: Math.min(Screen.width, 1152)
|
||||
height: Math.min(Screen.height, 768)
|
||||
|
||||
onClosing: Backend.clients.removeAll()
|
||||
|
||||
property int reloadedTimes: 0
|
||||
|
||||
Loader {
|
||||
anchors.fill: parent
|
||||
source: "UI.qml"
|
||||
objectName: "UILoader"
|
||||
}
|
||||
}
|
@@ -1,81 +0,0 @@
|
||||
# Copyright 2019 miruka
|
||||
# This file is part of harmonyqml, licensed under GPLv3.
|
||||
|
||||
import signal
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Generator
|
||||
|
||||
from PyQt5.QtCore import QObject, QTimer
|
||||
from PyQt5.QtQml import QQmlApplicationEngine
|
||||
|
||||
|
||||
class Engine(QQmlApplicationEngine):
|
||||
def __init__(self, debug: bool = False) -> None:
|
||||
# Connect UNXI signals to properly exit program
|
||||
self._original_signal_handlers: Dict[int, Any] = {}
|
||||
|
||||
for signame in ("INT" , "HUP", "QUIT", "TERM"):
|
||||
sig = signal.Signals[f"SIG{signame}"] # pylint: disable=no-member
|
||||
self._original_signal_handlers[sig] = signal.getsignal(sig)
|
||||
signal.signal(sig, self.onExitSignal)
|
||||
|
||||
# Make SIGINT (ctrl-c) work
|
||||
self._sigint_timer = QTimer()
|
||||
self._sigint_timer.timeout.connect(lambda: None)
|
||||
self._sigint_timer.start(100)
|
||||
|
||||
super().__init__()
|
||||
self.app_dir = Path(__file__).resolve().parent
|
||||
|
||||
from .backend.backend import Backend
|
||||
self.backend = Backend(self)
|
||||
self.rootContext().setContextProperty("Backend", self.backend)
|
||||
|
||||
# Setup UI live-reloading when a file is edited
|
||||
if debug:
|
||||
from PyQt5.QtCore import QFileSystemWatcher
|
||||
self._watcher = QFileSystemWatcher()
|
||||
self._watcher.directoryChanged.connect(lambda _: self.reloadQml())
|
||||
self._watcher.addPath(str(self.app_dir))
|
||||
|
||||
for _dir in list(self._recursive_dirs_in(self.app_dir)):
|
||||
self._watcher.addPath(str(_dir))
|
||||
|
||||
|
||||
def onExitSignal(self, *_) -> None:
|
||||
for sig, handler in self._original_signal_handlers.items():
|
||||
signal.signal(sig, handler)
|
||||
|
||||
self._original_signal_handlers.clear()
|
||||
self.closeWindow()
|
||||
|
||||
|
||||
def _recursive_dirs_in(self, path: Path) -> Generator[Path, None, None]:
|
||||
for item in path.iterdir():
|
||||
if item.is_dir() and item.name != "__pycache__":
|
||||
yield item
|
||||
yield from self._recursive_dirs_in(item)
|
||||
|
||||
|
||||
def showWindow(self) -> None:
|
||||
self.load(str(self.app_dir / "components" / "Window.qml"))
|
||||
|
||||
|
||||
def closeWindow(self) -> None:
|
||||
try:
|
||||
self.rootObjects()[0].close()
|
||||
except IndexError:
|
||||
pass
|
||||
|
||||
|
||||
def reloadQml(self) -> None:
|
||||
loader = self.rootObjects()[0].findChild(QObject, "UILoader")
|
||||
source = loader.property("source")
|
||||
loader.setProperty("source", None)
|
||||
self.clearComponentCache()
|
||||
|
||||
window = self.rootObjects()[0]
|
||||
reloaded_times = window.property("reloadedTimes")
|
||||
window.setProperty("reloadedTimes", reloaded_times + 1)
|
||||
|
||||
loader.setProperty("source", source)
|
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M12 12.713l-11.985-9.713h23.971l-11.986 9.713zm-5.425-1.822l-6.575-5.329v12.501l6.575-7.172zm10.85 0l6.575 7.172v-12.501l-6.575 5.329zm-1.557 1.261l-3.868 3.135-3.868-3.135-8.11 8.848h23.956l-8.11-8.848z"/></svg>
|
Before Width: | Height: | Size: 304 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M5 3l3.057-3 11.943 12-11.943 12-3.057-3 9-9z"/></svg>
|
Before Width: | Height: | Size: 146 B |
@@ -1,51 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
version="1.1"
|
||||
id="svg4"
|
||||
sodipodi:docname="forget_room.svg"
|
||||
inkscape:version="">
|
||||
<metadata
|
||||
id="metadata10">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<defs
|
||||
id="defs8" />
|
||||
<sodipodi:namedview
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1"
|
||||
objecttolerance="10"
|
||||
gridtolerance="10"
|
||||
guidetolerance="10"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:window-width="640"
|
||||
inkscape:window-height="480"
|
||||
id="namedview6"
|
||||
showgrid="false"
|
||||
inkscape:zoom="9.8333333"
|
||||
inkscape:cx="0.81355932"
|
||||
inkscape:cy="12.610169"
|
||||
inkscape:current-layer="svg4" />
|
||||
<path
|
||||
d="M3 6v18h18v-18h-18zm5 14c0 .552-.448 1-1 1s-1-.448-1-1v-10c0-.552.448-1 1-1s1 .448 1 1v10zm5 0c0 .552-.448 1-1 1s-1-.448-1-1v-10c0-.552.448-1 1-1s1 .448 1 1v10zm5 0c0 .552-.448 1-1 1s-1-.448-1-1v-10c0-.552.448-1 1-1s1 .448 1 1v10zm4-18v2h-20v-2h5.711c.9 0 1.631-1.099 1.631-2h5.315c0 .901.73 2 1.631 2h5.712z"
|
||||
id="path2"
|
||||
style="fill:#ab0937;fill-opacity:1" />
|
||||
</svg>
|
Before Width: | Height: | Size: 1.7 KiB |
@@ -1,7 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Svg Vector Icons : http://www.onlinewebfonts.com/icon -->
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 1000 1000" enable-background="new 0 0 1000 1000" xml:space="preserve">
|
||||
<metadata> Svg Vector Icons : http://www.onlinewebfonts.com/icon </metadata>
|
||||
<g><path d="M926,68v-8.3c0-27.4-22.4-49.8-49.8-49.8H123.7C96.4,10,74,32.4,74,59.8V68c0,27.4,22.4,49.8,49.8,49.8H143c9.9,0,9.1,9.2,9.1,13.9c0,55.3,22.7,119.2,40,160c34.8,81.9,114.1,149,179.2,196.4c11.9,8.7,9,16.7,2.1,22.2c-63.1,50.6-146.1,115.2-181.3,198c-17.1,40.2-39.3,102.7-40,157.4c-0.1,5.6,2.2,16.5-11.2,16.5h-17.1C96.4,882.2,74,904.6,74,932v8.3c0,27.4,22.4,49.8,49.8,49.8h752.5c27.4,0,49.8-22.4,49.8-49.8V932c0-27.4-22.4-49.8-49.8-49.8h-10.8c-19.8,0-17.8-14.8-17.6-22.5c1.4-51.1-13.6-109.1-35.6-152.7c-48.9-97-125.9-158.7-173.4-194.4c-10.3-7.7-11.3-17.3,0-25.1C687.8,453.8,763.4,390,812.3,293c23.4-46.5,38.9-109.3,35.1-162.6c-0.3-4.3-0.6-12.6,10.6-12.6h18.3C903.6,117.8,926,95.4,926,68z M772.6,273c-45.5,90.3-118.4,154.8-181.9,193.8c-3.8,2.3-11.1,8.1-11.1,24.3v19.3c0,17.1,7.8,20.9,11.8,23.4c63.4,39,135.8,103.4,181.1,193.3c20.9,41.6,31.8,91.8,30.7,131.8c-0.2,8.3,3,23.4-18.7,23.4H214.9c-19,0-18.3-9.5-18.2-14.7c0.3-36.4,12.9-86.8,36.3-141.8c40.6-95.5,125.9-155.1,182.7-190.7c2.5-1.6,7.4-4.4,7.4-19.5v-28.9c0-15.6-7.5-21.7-11.4-24.2c-56.7-35.8-139-94.9-178.7-188.1c-23.7-55.6-36.2-106.5-36.3-142.9c0-4.8,0.6-13.6,11.2-13.6h582.9c11.8,0,11.6,8.9,12,13.6C806.1,172.7,795.2,228,772.6,273z"/><path d="M488.4,563.6l-194,242.7c-6.4,8-3.3,14.6,7,14.6h397.2c10.3,0,13.4-6.6,7-14.6l-194-242.7C505.2,555.6,494.8,555.6,488.4,563.6z"/><path d="M486.8,450.8c7.3,7.2,19.2,7.2,26.4,0l97.6-97.3c7.3-7.2,4.8-13.2-5.5-13.2H394.7c-10.3,0-12.7,5.9-5.5,13.2L486.8,450.8z"/></g>
|
||||
</svg>
|
Before Width: | Height: | Size: 1.9 KiB |
@@ -1,51 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
version="1.1"
|
||||
id="svg4"
|
||||
sodipodi:docname="invite_accept.svg"
|
||||
inkscape:version="">
|
||||
<metadata
|
||||
id="metadata10">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<defs
|
||||
id="defs8" />
|
||||
<sodipodi:namedview
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1"
|
||||
objecttolerance="10"
|
||||
gridtolerance="10"
|
||||
guidetolerance="10"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:window-width="640"
|
||||
inkscape:window-height="480"
|
||||
id="namedview6"
|
||||
showgrid="false"
|
||||
inkscape:zoom="9.8333333"
|
||||
inkscape:cx="-28.271186"
|
||||
inkscape:cy="12"
|
||||
inkscape:current-layer="svg4" />
|
||||
<path
|
||||
d="M9 21.035l-9-8.638 2.791-2.87 6.156 5.874 12.21-12.436 2.843 2.817z"
|
||||
id="path2"
|
||||
style="fill:#0d8967;fill-opacity:1" />
|
||||
</svg>
|
Before Width: | Height: | Size: 1.4 KiB |
@@ -1,51 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
version="1.1"
|
||||
id="svg14"
|
||||
sodipodi:docname="invite_decline.svg"
|
||||
inkscape:version="">
|
||||
<metadata
|
||||
id="metadata20">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<defs
|
||||
id="defs18" />
|
||||
<sodipodi:namedview
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1"
|
||||
objecttolerance="10"
|
||||
gridtolerance="10"
|
||||
guidetolerance="10"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:window-width="640"
|
||||
inkscape:window-height="480"
|
||||
id="namedview16"
|
||||
showgrid="false"
|
||||
inkscape:zoom="9.8333333"
|
||||
inkscape:cx="6.9152542"
|
||||
inkscape:cy="17.084746"
|
||||
inkscape:current-layer="svg14" />
|
||||
<path
|
||||
d="M23 20.168l-8.185-8.187 8.185-8.174-2.832-2.807-8.182 8.179-8.176-8.179-2.81 2.81 8.186 8.196-8.186 8.184 2.81 2.81 8.203-8.192 8.18 8.192z"
|
||||
id="path12"
|
||||
style="fill:#ab0938;fill-opacity:1" />
|
||||
</svg>
|
Before Width: | Height: | Size: 1.5 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M16 9v-4l8 7-8 7v-4h-8v-6h8zm-2 10v-.083c-1.178.685-2.542 1.083-4 1.083-4.411 0-8-3.589-8-8s3.589-8 8-8c1.458 0 2.822.398 4 1.083v-2.245c-1.226-.536-2.577-.838-4-.838-5.522 0-10 4.477-10 10s4.478 10 10 10c1.423 0 2.774-.302 4-.838v-2.162z"/></svg>
|
Before Width: | Height: | Size: 339 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="0" height="0" viewBox="0 0 0 0"></svg>
|
Before Width: | Height: | Size: 86 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M18.48 22.926l-1.193.658c-6.979 3.621-19.082-17.494-12.279-21.484l1.145-.637 3.714 6.467-1.139.632c-2.067 1.245 2.76 9.707 4.879 8.545l1.162-.642 3.711 6.461zm-9.808-22.926l-1.68.975 3.714 6.466 1.681-.975-3.715-6.466zm8.613 14.997l-1.68.975 3.714 6.467 1.681-.975-3.715-6.467z"/></svg>
|
Before Width: | Height: | Size: 378 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M12 2c5.514 0 10 4.486 10 10s-4.486 10-10 10-10-4.486-10-10 4.486-10 10-10zm0-2c-6.627 0-12 5.373-12 12s5.373 12 12 12 12-5.373 12-12-5.373-12-12-12z"/></svg>
|
Before Width: | Height: | Size: 250 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M24 6h-24v-4h24v4zm0 4h-24v4h24v-4zm0 8h-24v4h24v-4z"/></svg>
|
Before Width: | Height: | Size: 153 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M6 12c0 1.657-1.343 3-3 3s-3-1.343-3-3 1.343-3 3-3 3 1.343 3 3zm9 0c0 1.657-1.343 3-3 3s-3-1.343-3-3 1.343-3 3-3 3 1.343 3 3zm9 0c0 1.657-1.343 3-3 3s-3-1.343-3-3 1.343-3 3-3 3 1.343 3 3z"/></svg>
|
Before Width: | Height: | Size: 288 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M22 13v-13h-20v24h8.409c4.857 0 3.335-8 3.335-8 3.009.745 8.256.419 8.256-3zm-4-7h-12v-1h12v1zm0 3h-12v-1h12v1zm0 3h-12v-1h12v1zm-2.091 6.223c2.047.478 4.805-.279 6.091-1.179-1.494 1.998-5.23 5.708-7.432 6.881 1.156-1.168 1.563-4.234 1.341-5.702z"/></svg>
|
Before Width: | Height: | Size: 347 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M24 12c0 6.627-5.373 12-12 12s-12-5.373-12-12h2c0 5.514 4.486 10 10 10s10-4.486 10-10-4.486-10-10-10c-2.777 0-5.287 1.141-7.099 2.977l2.061 2.061-6.962 1.354 1.305-7.013 2.179 2.18c2.172-2.196 5.182-3.559 8.516-3.559 6.627 0 12 5.373 12 12zm-13-6v8h7v-2h-5v-6h-2z"/></svg>
|
Before Width: | Height: | Size: 364 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M20.822 18.096c-3.439-.794-6.64-1.49-5.09-4.418 4.72-8.912 1.251-13.678-3.732-13.678-5.082 0-8.464 4.949-3.732 13.678 1.597 2.945-1.725 3.641-5.09 4.418-3.073.71-3.188 2.236-3.178 4.904l.004 1h23.99l.004-.969c.012-2.688-.092-4.222-3.176-4.935z"/></svg>
|
Before Width: | Height: | Size: 344 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M15.137 3.945c-.644-.374-1.042-1.07-1.041-1.82v-.003c.001-1.172-.938-2.122-2.096-2.122s-2.097.95-2.097 2.122v.003c.001.751-.396 1.446-1.041 1.82-4.667 2.712-1.985 11.715-6.862 13.306v1.749h20v-1.749c-4.877-1.591-2.195-10.594-6.863-13.306zm-3.137-2.945c.552 0 1 .449 1 1 0 .552-.448 1-1 1s-1-.448-1-1c0-.551.448-1 1-1zm3 20c0 1.598-1.392 3-2.971 3s-3.029-1.402-3.029-3h6z"/></svg>
|
Before Width: | Height: | Size: 471 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M6 18h-2v5h-2v-5h-2v-3h6v3zm-2-17h-2v12h2v-12zm11 7h-6v3h2v12h2v-12h2v-3zm-2-7h-2v5h2v-5zm11 14h-6v3h2v5h2v-5h2v-3zm-2-14h-2v12h2v-12z"/></svg>
|
Before Width: | Height: | Size: 235 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M23.822 20.88l-6.353-6.354c.93-1.465 1.467-3.2 1.467-5.059.001-5.219-4.247-9.467-9.468-9.467s-9.468 4.248-9.468 9.468c0 5.221 4.247 9.469 9.468 9.469 1.768 0 3.421-.487 4.839-1.333l6.396 6.396 3.119-3.12zm-20.294-11.412c0-3.273 2.665-5.938 5.939-5.938 3.275 0 5.94 2.664 5.94 5.938 0 3.275-2.665 5.939-5.94 5.939-3.274 0-5.939-2.664-5.939-5.939z"/></svg>
|
Before Width: | Height: | Size: 446 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M9.963 8.261c-.566-.585-.536-1.503.047-2.07l5.948-5.768c.291-.281.664-.423 1.035-.423.376 0 .75.146 1.035.44l-8.065 7.821zm-9.778 14.696c-.123.118-.185.277-.185.436 0 .333.271.607.607.607.152 0 .305-.057.423-.171l.999-.972-.845-.872-.999.972zm8.44-11.234l-3.419 3.314c-1.837 1.781-2.774 3.507-3.64 5.916l1.509 1.559c2.434-.79 4.187-1.673 6.024-3.455l3.418-3.315-3.892-4.019zm9.97-10.212l-8.806 8.54 4.436 4.579 8.806-8.538c.645-.626.969-1.458.969-2.291 0-2.784-3.373-4.261-5.405-2.29z"/></svg>
|
Before Width: | Height: | Size: 585 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M24 13.616v-3.232c-1.651-.587-2.694-.752-3.219-2.019v-.001c-.527-1.271.1-2.134.847-3.707l-2.285-2.285c-1.561.742-2.433 1.375-3.707.847h-.001c-1.269-.526-1.435-1.576-2.019-3.219h-3.232c-.582 1.635-.749 2.692-2.019 3.219h-.001c-1.271.528-2.132-.098-3.707-.847l-2.285 2.285c.745 1.568 1.375 2.434.847 3.707-.527 1.271-1.584 1.438-3.219 2.02v3.232c1.632.58 2.692.749 3.219 2.019.53 1.282-.114 2.166-.847 3.707l2.285 2.286c1.562-.743 2.434-1.375 3.707-.847h.001c1.27.526 1.436 1.579 2.019 3.219h3.232c.582-1.636.75-2.69 2.027-3.222h.001c1.262-.524 2.12.101 3.698.851l2.285-2.286c-.744-1.563-1.375-2.433-.848-3.706.527-1.271 1.588-1.44 3.221-2.021zm-12 2.384c-2.209 0-4-1.791-4-4s1.791-4 4-4 4 1.791 4 4-1.791 4-4 4z"/></svg>
|
Before Width: | Height: | Size: 811 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><circle cx="12" cy="12" r="12"/></svg>
|
Before Width: | Height: | Size: 121 B |
@@ -1,51 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
version="1.1"
|
||||
id="svg4"
|
||||
sodipodi:docname="unknown_devices_inspect.svg"
|
||||
inkscape:version="">
|
||||
<metadata
|
||||
id="metadata10">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<defs
|
||||
id="defs8" />
|
||||
<sodipodi:namedview
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1"
|
||||
objecttolerance="10"
|
||||
gridtolerance="10"
|
||||
guidetolerance="10"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:window-width="640"
|
||||
inkscape:window-height="480"
|
||||
id="namedview6"
|
||||
showgrid="false"
|
||||
inkscape:zoom="1.8756036"
|
||||
inkscape:cx="-105.68924"
|
||||
inkscape:cy="18.344365"
|
||||
inkscape:current-layer="svg4" />
|
||||
<path
|
||||
d="M23.822 20.88l-6.353-6.354c.93-1.465 1.467-3.2 1.467-5.059.001-5.219-4.247-9.467-9.468-9.467s-9.468 4.248-9.468 9.468c0 5.221 4.247 9.469 9.468 9.469 1.768 0 3.421-.487 4.839-1.333l6.396 6.396 3.119-3.12zm-20.294-11.412c0-3.273 2.665-5.938 5.939-5.938 3.275 0 5.94 2.664 5.94 5.938 0 3.275-2.665 5.939-5.94 5.939-3.274 0-5.939-2.664-5.939-5.939z"
|
||||
id="path2"
|
||||
style="fill:#9a8308;fill-opacity:1" />
|
||||
</svg>
|
Before Width: | Height: | Size: 1.7 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M18 10v-4c0-3.313-2.687-6-6-6s-6 2.687-6 6v4h-3v14h18v-14h-3zm-5 7.723v2.277h-2v-2.277c-.595-.347-1-.984-1-1.723 0-1.104.896-2 2-2s2 .896 2 2c0 .738-.404 1.376-1 1.723zm-5-7.723v-4c0-2.206 1.794-4 4-4 2.205 0 4 1.794 4 4v4h-8z"/></svg>
|
Before Width: | Height: | Size: 327 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M20.822 18.096c-3.439-.794-6.64-1.49-5.09-4.418 4.72-8.912 1.251-13.678-3.732-13.678-5.082 0-8.464 4.949-3.732 13.678 1.597 2.945-1.725 3.641-5.09 4.418-3.073.71-3.188 2.236-3.178 4.904l.004 1h23.99l.004-.969c.012-2.688-.092-4.222-3.176-4.935z"/></svg>
|
Before Width: | Height: | Size: 344 B |
Before Width: | Height: | Size: 1.8 MiB |