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

12
src/__about__.py Normal file
View File

@@ -0,0 +1,12 @@
"""<SHORTDESC>"""
__pkg_name__ = "harmonyqml"
__pretty_name__ = "Harmony QML"
__version__ = "0.1.0"
__status__ = "Development"
# __status__ = "Production"
__author__ = "miruka"
__email__ = "miruka@disroot.org"
__license__ = "GPLv3"

1
src/__init__.py Normal file
View File

@@ -0,0 +1 @@
from .app import APP

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

83
src/app.py Normal file
View File

@@ -0,0 +1,83 @@
import asyncio
from concurrent.futures import Future
from pathlib import Path
from threading import Thread
from typing import Any, Coroutine, Dict, List, Optional, Sequence
from uuid import uuid4
from appdirs import AppDirs
from . import __about__
from .events.system import CoroutineDone, AppExitRequested
class App:
def __init__(self) -> None:
self.appdirs = AppDirs(appname=__about__.__pkg_name__, roaming=True)
self.backend = None
self.loop = asyncio.get_event_loop()
self.loop_thread = Thread(target=self._loop_starter)
self.loop_thread.start()
def start(self, cli_flags: Sequence[str] = ()) -> bool:
debug = False
if "-d" in cli_flags or "--debug" in cli_flags:
self._run_in_loop(self._exit_on_app_file_change())
debug = True
from .backend import Backend
self.backend = Backend(app=self) # type: ignore
return debug
async def _exit_on_app_file_change(self) -> None:
from watchgod import awatch
async for _ in awatch(Path(__file__).resolve().parent):
AppExitRequested(231)
def _loop_starter(self) -> None:
asyncio.set_event_loop(self.loop)
self.loop.run_forever()
def _run_in_loop(self, coro: Coroutine) -> Future:
return asyncio.run_coroutine_threadsafe(coro, self.loop)
def call_backend_coro(self,
name: str,
args: Optional[List[str]] = None,
kwargs: Optional[Dict[str, Any]] = None) -> str:
# To be used from QML
coro = getattr(self.backend, name)(*args or [], **kwargs or {})
uuid = str(uuid4())
self._run_in_loop(coro).add_done_callback(
lambda future: CoroutineDone(uuid=uuid, result=future.result())
)
return uuid
def pdb(self, additional_data: Sequence = ()) -> None:
# pylint: disable=all
ad = additional_data
ba = self.backend
cl = self.backend.clients # type: ignore
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
pdb.set_trace()
APP = App()

122
src/backend.py Normal file
View File

@@ -0,0 +1,122 @@
import asyncio
import json
from pathlib import Path
from typing import Dict, Optional, Tuple
from atomicfile import AtomicFile
from .app import App
from .matrix_client import MatrixClient
SavedAccounts = Dict[str, Dict[str, str]]
CONFIG_LOCK = asyncio.Lock()
class Backend:
def __init__(self, app: App) -> None:
self.app = app
self.clients: Dict[str, MatrixClient] = {}
def __repr__(self) -> str:
return f"{type(self).__name__}(clients={self.clients!r})"
# Clients management
async def login_client(self,
user: str,
password: str,
device_id: Optional[str] = None,
homeserver: str = "https://matrix.org") -> None:
client = MatrixClient(
user=user, homeserver=homeserver, device_id=device_id
)
await client.login(password)
self.clients[client.user_id] = client
async def resume_client(self,
user_id: str,
token: str,
device_id: str,
homeserver: str = "https://matrix.org") -> None:
client = MatrixClient(
user=user_id, homeserver=homeserver, device_id=device_id
)
await client.resume(user_id=user_id, token=token, device_id=device_id)
self.clients[client.user_id] = client
async def logout_client(self, user_id: str) -> None:
client = self.clients.pop(user_id, None)
if client:
await client.close()
async def logout_all_clients(self) -> None:
await asyncio.gather(*(
self.logout_client(user_id) for user_id in self.clients.copy()
))
# Saved account operations - TODO: Use aiofiles?
@property
def saved_accounts_path(self) -> Path:
return Path(self.app.appdirs.user_config_dir) / "accounts.json"
@property
def saved_accounts(self) -> SavedAccounts:
try:
return json.loads(self.saved_accounts_path.read_text())
except (json.JSONDecodeError, FileNotFoundError):
return {}
async def has_saved_accounts(self) -> bool:
return bool(self.saved_accounts)
async def load_saved_accounts(self) -> Tuple[str, ...]:
async def resume(user_id: str, info: Dict[str, str]) -> str:
await self.resume_client(
user_id = user_id,
token = info["token"],
device_id = info["device_id"],
homeserver = info["homeserver"],
)
return user_id
return await asyncio.gather(*(
resume(uid, info) for uid, info in self.saved_accounts.items()
))
async def save_account(self, client: MatrixClient) -> None:
await self._write_config({
**self.saved_accounts,
client.userId: {
"hostname": client.nio.host,
"token": client.nio.access_token,
"device_id": client.nio.device_id,
}
})
async def forget_account(self, user_id: str) -> None:
await self._write_config({
uid: info
for uid, info in self.saved_accounts.items() if uid != user_id
})
async def _write_config(self, accounts: SavedAccounts) -> None:
js = json.dumps(accounts, indent=4, ensure_ascii=False, sort_keys=True)
with CONFIG_LOCK:
self.saved_accounts_path.parent.mkdir(parents=True, exist_ok=True)
with AtomicFile(self.saved_accounts_path, "w") as new:
new.write(js)

0
src/events/__init__.py Normal file
View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

23
src/events/event.py Normal file
View File

@@ -0,0 +1,23 @@
from enum import Enum
from dataclasses import dataclass
import pyotherside
class AutoStrEnum(Enum):
@staticmethod
def _generate_next_value_(name, *_):
return name
@dataclass
class Event:
def __post_init__(self) -> None:
# CPython >= 3.6 or any Python >= 3.7 needed for correct dict order
args = [
# pylint: disable=no-member
getattr(self, field)
for field in self.__dataclass_fields__ # type: ignore
]
pyotherside.send(type(self).__name__, *args)

36
src/events/rooms.py Normal file
View File

@@ -0,0 +1,36 @@
from datetime import datetime
from typing import Dict, Optional
from dataclasses import dataclass, field
from .event import Event
@dataclass
class RoomUpdated(Event):
room_id: str = field()
display_name: Optional[str] = None
avatar_url: Optional[str] = None
topic: Optional[str] = None
last_event_date: Optional[datetime] = None
inviter: Optional[Dict[str, str]] = None
left_event: Optional[Dict[str, str]] = None
@dataclass
class RoomDeleted(Event):
room_id: str = field()
@dataclass
class RoomMemberUpdated(Event):
room_id: str = field()
user_id: str = field()
typing: bool = field()
@dataclass
class RoomMemberDeleted(Event):
room_id: str = field()
user_id: str = field()

View File

@@ -0,0 +1,8 @@
from datetime import datetime
from typing import Dict, Optional
from dataclasses import dataclass, field
from .event import Event

16
src/events/system.py Normal file
View File

@@ -0,0 +1,16 @@
from typing import Any
from dataclasses import dataclass, field
from .event import Event
@dataclass
class AppExitRequested(Event):
exit_code: int = 0
@dataclass
class CoroutineDone(Event):
uuid: str = field()
result: Any = None

52
src/events/users.py Normal file
View File

@@ -0,0 +1,52 @@
from datetime import datetime
from enum import Enum
from typing import Optional
from dataclasses import dataclass, field
from .event import Event
# Logged-in accounts
@dataclass
class AccountUpdated(Event):
user_id: str = field()
@dataclass
class AccountDeleted(Event):
user_id: str = field()
# Accounts and room members details
@dataclass
class UserUpdated(Event):
user_id: str = field()
display_name: Optional[str] = None
avatar_url: Optional[str] = None
# Devices
class Trust(Enum):
blacklisted = -1
undecided = 0
trusted = 1
@dataclass
class DeviceUpdated(Event):
user_id: str = field()
device_id: str = field()
ed25519_key: str = field()
trust: Trust = Trust.undecided
display_name: Optional[str] = None
last_seen_ip: Optional[str] = None
last_seen_date: Optional[datetime] = None
@dataclass
class DeviceDeleted(Event):
user_id: str = field()
device_id: str = field()

1
src/icons/email.svg Normal file
View File

@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 304 B

1
src/icons/expand.svg Normal file
View File

@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 146 B

51
src/icons/forget_room.svg Normal file
View File

@@ -0,0 +1,51 @@
<?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>

After

Width:  |  Height:  |  Size: 1.7 KiB

7
src/icons/hourglass.svg Normal file
View File

@@ -0,0 +1,7 @@
<?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>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -0,0 +1,51 @@
<?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>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,51 @@
<?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>

After

Width:  |  Height:  |  Size: 1.5 KiB

1
src/icons/join.svg Normal file
View File

@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 339 B

1
src/icons/none.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="0" height="0" viewBox="0 0 0 0"></svg>

After

Width:  |  Height:  |  Size: 86 B

1
src/icons/phone.svg Normal file
View File

@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 378 B

View File

@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 250 B

View File

@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 153 B

View File

@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 288 B

View File

@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 347 B

View File

@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 364 B

View File

@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 344 B

View File

@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 471 B

View File

@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 235 B

1
src/icons/search.svg Normal file
View File

@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 446 B

1
src/icons/set_status.svg Normal file
View File

@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 585 B

1
src/icons/settings.svg Normal file
View File

@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 811 B

1
src/icons/status.svg Normal file
View File

@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 121 B

View File

@@ -0,0 +1,51 @@
<?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>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 327 B

1
src/icons/username.svg Normal file
View File

@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 344 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

22
src/matrix_client.py Normal file
View File

@@ -0,0 +1,22 @@
from typing import Optional
import nio
class MatrixClient(nio.AsyncClient):
def __init__(self,
user: str,
homeserver: str = "https://matrix.org",
device_id: Optional[str] = None) -> None:
super().__init__(homeserver=homeserver, user=user, device_id=device_id)
def __repr__(self) -> str:
return "%s(user_id=%r, homeserver=%r, device_id=%r)" % (
type(self).__name__, self.user_id, self.homeserver, self.device_id
)
async def resume(self, user_id: str, token: str, device_id: str) -> None:
self.receive_response(nio.LoginResponse(user_id, device_id, token))

45
src/qml/Base/HAvatar.qml Normal file
View File

@@ -0,0 +1,45 @@
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
}
}

138
src/qml/Base/HButton.qml Normal file
View File

@@ -0,0 +1,138 @@
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

@@ -0,0 +1,11 @@
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))
}

10
src/qml/Base/HIcon.qml Normal file
View File

@@ -0,0 +1,10 @@
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
}

8
src/qml/Base/HImage.qml Normal file
View File

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

View File

@@ -0,0 +1,60 @@
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
}
}
}
}
}

11
src/qml/Base/HLabel.qml Normal file
View File

@@ -0,0 +1,11 @@
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

@@ -0,0 +1,57 @@
import QtQuick 2.7
ListModel {
// To initialize a HListModel with items,
// use `Component.onCompleted: extend([{"foo": 1, "bar": 2}, ...])`
id: listModel
function extend(new_items) {
for (var i = 0; i < new_items.length; i++) {
listModel.append(new_items[i])
}
}
function getIndices(where_role, is, max) { // max: undefined or int
var results = []
for (var i = 0; i < listModel.count; i++) {
if (listModel.get(i)[where_role] == is) {
results.push(i)
if (max && results.length >= max) {
break
}
}
}
return results
}
function getWhere(where_role, is, max) {
var indices = getIndices(where_role, is, max)
var results = []
for (var i = 0; i < indices.length; i++) {
results.push(listModel.get(indices[i]))
}
return results
}
function upsert(where_role, is, new_item) {
// new_item can contain only the keys we're interested in updating
var indices = getIndices(where_role, is, 1)
if (indices.length == 0) {
listModel.append(new_item)
} else {
listModel.set(indices[0], new_item)
}
}
function pop(index) {
var item = listModel.get(index)
listModel.remove(index)
return item
}
}

View File

@@ -0,0 +1,24 @@
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

@@ -0,0 +1,34 @@
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

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

View File

@@ -0,0 +1,22 @@
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

@@ -0,0 +1,10 @@
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

@@ -0,0 +1,15 @@
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

@@ -0,0 +1,33 @@
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]
}
}

7
src/qml/Base/HSpacer.qml Normal file
View File

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

View File

@@ -0,0 +1,24 @@
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

@@ -0,0 +1,11 @@
import QtQuick 2.7
HAvatar {
HImage {
id: status
anchors.right: parent.right
anchors.bottom: parent.bottom
source: "../../icons/status.svg"
sourceSize.width: 12
}
}

139
src/qml/Base/HStyle.qml Normal file
View File

@@ -0,0 +1,139 @@
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

@@ -0,0 +1,17 @@
import QtQuick 2.7
import QtQuick.Controls 2.2
TextField {
property alias backgroundColor: textFieldBackground.color
font.family: HStyle.fontFamily.sans
font.pixelSize: HStyle.fontSize.normal
color: HStyle.colors.foreground
background: Rectangle {
id: textFieldBackground
color: HStyle.controls.textField.background
}
selectByMouse: true
}

1
src/qml/Base/qmldir Normal file
View File

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

View File

@@ -0,0 +1,90 @@
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

@@ -0,0 +1,41 @@
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

@@ -0,0 +1,28 @@
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

@@ -0,0 +1,25 @@
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")
},
}
}

148
src/qml/Chat/Chat.qml Normal file
View File

@@ -0,0 +1,148 @@
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

@@ -0,0 +1,9 @@
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

@@ -0,0 +1,53 @@
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

@@ -0,0 +1,80 @@
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

@@ -0,0 +1,88 @@
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

@@ -0,0 +1,43 @@
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
}
}

102
src/qml/Chat/RoomHeader.qml Normal file
View File

@@ -0,0 +1,102 @@
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

@@ -0,0 +1,37 @@
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

@@ -0,0 +1,49 @@
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

@@ -0,0 +1,15 @@
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
}
}

72
src/qml/Chat/SendBox.qml Normal file
View File

@@ -0,0 +1,72 @@
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

@@ -0,0 +1,23 @@
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
}
}

210
src/qml/Chat/utils.js Normal file
View File

@@ -0,0 +1,210 @@
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

@@ -0,0 +1,2 @@
// FIXME: Obsolete method, but need Qt 5.12+ for standard JS modules import
Qt.include("system.js")

View File

@@ -0,0 +1,8 @@
function onAppExitRequested(exit_code) {
Qt.exit(exit_code)
}
function onCoroutineDone(uuid, result) {
py.pendingCoroutines[uuid](result)
delete pendingCoroutines[uuid]
}

10
src/qml/LoadingScreen.qml Normal file
View File

@@ -0,0 +1,10 @@
import QtQuick 2.7
import QtQuick.Controls 2.2
Rectangle {
color: "lightgray"
BusyIndicator {
anchors.centerIn: parent
}
}

View File

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

View File

@@ -0,0 +1,43 @@
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 {}
}
}

83
src/qml/Pages/SignIn.qml Normal file
View File

@@ -0,0 +1,83 @@
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
}
}
}

38
src/qml/Python.qml Normal file
View File

@@ -0,0 +1,38 @@
import QtQuick 2.7
import QtQuick.Controls 2.2
import io.thp.pyotherside 1.5
import "EventHandlers/includes.js" as EventHandlers
Python {
id: py
signal ready(bool accountsToLoad)
property var pendingCoroutines: ({})
function callCoro(name, args, kwargs, callback) {
call("APP.call_backend_coro", [name, args, kwargs], function(uuid){
pendingCoroutines[uuid] = callback || function() {}
})
}
Component.onCompleted: {
for (var func in EventHandlers) {
if (EventHandlers.hasOwnProperty(func)) {
setHandler(func.replace(/^on/, ""), EventHandlers[func])
}
}
addImportPath("../..")
importNames("src", ["APP"], function() {
call("APP.start", [Qt.application.arguments], function(debug_on) {
window.debug = debug_on
callCoro("has_saved_accounts", [], {}, function(has) {
print(has)
py.ready(has)
})
})
})
}
}

View File

@@ -0,0 +1,80 @@
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

@@ -0,0 +1,11 @@
import QtQuick 2.7
import QtQuick.Layouts 1.3
import "../Base"
HListView {
id: accountList
clip: true
model: Backend.accounts
delegate: AccountDelegate {}
}

View File

@@ -0,0 +1,22 @@
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

@@ -0,0 +1,25 @@
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

@@ -0,0 +1,11 @@
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

@@ -0,0 +1,60 @@
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

@@ -0,0 +1,61 @@
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

@@ -0,0 +1,14 @@
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

@@ -0,0 +1,30 @@
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 {}
}
}

29
src/qml/SidePane/utils.js Normal file
View File

@@ -0,0 +1,29 @@
.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>"
}
}

106
src/qml/UI.qml Normal file
View File

@@ -0,0 +1,106 @@
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
property bool accountsLoggedIn: Backend.clients.count > 0
HImage {
id: mainUIBackground
fillMode: Image.PreserveAspectCrop
source: "../images/login_background.jpg"
sourceSize.width: Screen.width
sourceSize.height: Screen.height
anchors.fill: parent
asynchronous: false
}
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
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 }
)
}
Connections {
target: py
onReady: function(accountsToLoad) {
pageStack.showPage(accountsToLoad ? "Default" : "SignIn")
if (accountsToLoad) {
py.callCoro("load_saved_accounts")
// initialRoomTimer.start()
}
}
}
Timer {
// TODO: remove this, debug
id: initialRoomTimer
interval: 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: if (window.debug) { py.call("APP.pdb") }
}
}

Some files were not shown because too many files have changed in this diff Show More