Start rewriting backend with pyotherside+asyncio

This commit is contained in:
miruka
2019-06-27 02:31:03 -04:00
parent f530f51937
commit 3344debbbf
128 changed files with 715 additions and 2941 deletions

View File

@@ -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"

View File

@@ -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_())

View File

@@ -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()

View File

@@ -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__)

View File

@@ -1,4 +0,0 @@
# Copyright 2019 miruka
# This file is part of harmonyqml, licensed under GPLv3.
from .backend import Backend

View File

@@ -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()

View File

@@ -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

View File

@@ -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)

View File

@@ -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
))

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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()

View File

@@ -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

View File

@@ -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()

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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
}
}

View File

@@ -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()
}
}
}

View File

@@ -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))
}

View File

@@ -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
}

View File

@@ -1,8 +0,0 @@
import QtQuick 2.7
Image {
asynchronous: true
cache: true
mipmap: true
fillMode: Image.PreserveAspectFit
}

View File

@@ -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
}
}
}
}
}

View File

@@ -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
}

View File

@@ -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 }
}
}
}

View File

@@ -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
}
}
}

View File

@@ -1,6 +0,0 @@
import QtQuick 2.7
Rectangle {
id: rectangle
color: HStyle.sidePane.background
}

View File

@@ -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) }
}
}
}

View File

@@ -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))
}

View File

@@ -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)
}

View File

@@ -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]
}
}

View File

@@ -1,7 +0,0 @@
import QtQuick 2.7
import QtQuick.Layouts 1.3
Item {
Layout.fillWidth: true
Layout.fillHeight: true
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -1 +0,0 @@
singleton HStyle 1.0 HStyle.qml

View File

@@ -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
}
}
}
}
}

View File

@@ -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)
}
}
}

View File

@@ -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()
},
}
}

View File

@@ -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")
},
}
}

View File

@@ -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
}
}
}

View File

@@ -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
}

View File

@@ -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) +
"&nbsp;&nbsp;" +
"<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
}
}

View File

@@ -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) +
"&nbsp;&nbsp;<font size=" + HStyle.fontSize.small +
"px color=" + HStyle.chat.message.date + ">" +
Qt.formatDateTime(dateTime, "hh:mm:ss") +
"</font>" +
(isLocalEcho ?
"&nbsp;<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
}
}
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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 }
}
}
}

View File

@@ -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
}
}
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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)
}
}
}

View File

@@ -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
}
}

View File

@@ -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…"
}

View File

@@ -1,5 +0,0 @@
import "../Base"
HNoticePage {
text: "Select or add a room to start."
}

View File

@@ -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 {}
}
}

View File

@@ -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
}
}
}

View File

@@ -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
}
}
}
}

View File

@@ -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 {}
}

View File

@@ -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 }
}
}
}

View File

@@ -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
}
}

View File

@@ -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 {}
}

View File

@@ -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
}
}
}
}

View File

@@ -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
}
}
}
}

View File

@@ -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 {}
}

View File

@@ -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 {}
}
}

View File

@@ -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>"
}
}

View File

@@ -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
}
}

View File

@@ -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"
}
}

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB