Start rewriting backend with pyotherside+asyncio
12
.gitignore
vendored
|
@ -1,12 +0,0 @@
|
|||
__pycache__
|
||||
.mypy_cache
|
||||
build
|
||||
dist
|
||||
*.egg-info
|
||||
*.pyc
|
||||
*.qmlc
|
||||
*.jsc
|
||||
|
||||
.pylintrc
|
||||
|
||||
tmp-*
|
165
COPYING.LESSER
Normal file
|
@ -0,0 +1,165 @@
|
|||
GNU LESSER GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
|
||||
This version of the GNU Lesser General Public License incorporates
|
||||
the terms and conditions of version 3 of the GNU General Public
|
||||
License, supplemented by the additional permissions listed below.
|
||||
|
||||
0. Additional Definitions.
|
||||
|
||||
As used herein, "this License" refers to version 3 of the GNU Lesser
|
||||
General Public License, and the "GNU GPL" refers to version 3 of the GNU
|
||||
General Public License.
|
||||
|
||||
"The Library" refers to a covered work governed by this License,
|
||||
other than an Application or a Combined Work as defined below.
|
||||
|
||||
An "Application" is any work that makes use of an interface provided
|
||||
by the Library, but which is not otherwise based on the Library.
|
||||
Defining a subclass of a class defined by the Library is deemed a mode
|
||||
of using an interface provided by the Library.
|
||||
|
||||
A "Combined Work" is a work produced by combining or linking an
|
||||
Application with the Library. The particular version of the Library
|
||||
with which the Combined Work was made is also called the "Linked
|
||||
Version".
|
||||
|
||||
The "Minimal Corresponding Source" for a Combined Work means the
|
||||
Corresponding Source for the Combined Work, excluding any source code
|
||||
for portions of the Combined Work that, considered in isolation, are
|
||||
based on the Application, and not on the Linked Version.
|
||||
|
||||
The "Corresponding Application Code" for a Combined Work means the
|
||||
object code and/or source code for the Application, including any data
|
||||
and utility programs needed for reproducing the Combined Work from the
|
||||
Application, but excluding the System Libraries of the Combined Work.
|
||||
|
||||
1. Exception to Section 3 of the GNU GPL.
|
||||
|
||||
You may convey a covered work under sections 3 and 4 of this License
|
||||
without being bound by section 3 of the GNU GPL.
|
||||
|
||||
2. Conveying Modified Versions.
|
||||
|
||||
If you modify a copy of the Library, and, in your modifications, a
|
||||
facility refers to a function or data to be supplied by an Application
|
||||
that uses the facility (other than as an argument passed when the
|
||||
facility is invoked), then you may convey a copy of the modified
|
||||
version:
|
||||
|
||||
a) under this License, provided that you make a good faith effort to
|
||||
ensure that, in the event an Application does not supply the
|
||||
function or data, the facility still operates, and performs
|
||||
whatever part of its purpose remains meaningful, or
|
||||
|
||||
b) under the GNU GPL, with none of the additional permissions of
|
||||
this License applicable to that copy.
|
||||
|
||||
3. Object Code Incorporating Material from Library Header Files.
|
||||
|
||||
The object code form of an Application may incorporate material from
|
||||
a header file that is part of the Library. You may convey such object
|
||||
code under terms of your choice, provided that, if the incorporated
|
||||
material is not limited to numerical parameters, data structure
|
||||
layouts and accessors, or small macros, inline functions and templates
|
||||
(ten or fewer lines in length), you do both of the following:
|
||||
|
||||
a) Give prominent notice with each copy of the object code that the
|
||||
Library is used in it and that the Library and its use are
|
||||
covered by this License.
|
||||
|
||||
b) Accompany the object code with a copy of the GNU GPL and this license
|
||||
document.
|
||||
|
||||
4. Combined Works.
|
||||
|
||||
You may convey a Combined Work under terms of your choice that,
|
||||
taken together, effectively do not restrict modification of the
|
||||
portions of the Library contained in the Combined Work and reverse
|
||||
engineering for debugging such modifications, if you also do each of
|
||||
the following:
|
||||
|
||||
a) Give prominent notice with each copy of the Combined Work that
|
||||
the Library is used in it and that the Library and its use are
|
||||
covered by this License.
|
||||
|
||||
b) Accompany the Combined Work with a copy of the GNU GPL and this license
|
||||
document.
|
||||
|
||||
c) For a Combined Work that displays copyright notices during
|
||||
execution, include the copyright notice for the Library among
|
||||
these notices, as well as a reference directing the user to the
|
||||
copies of the GNU GPL and this license document.
|
||||
|
||||
d) Do one of the following:
|
||||
|
||||
0) Convey the Minimal Corresponding Source under the terms of this
|
||||
License, and the Corresponding Application Code in a form
|
||||
suitable for, and under terms that permit, the user to
|
||||
recombine or relink the Application with a modified version of
|
||||
the Linked Version to produce a modified Combined Work, in the
|
||||
manner specified by section 6 of the GNU GPL for conveying
|
||||
Corresponding Source.
|
||||
|
||||
1) Use a suitable shared library mechanism for linking with the
|
||||
Library. A suitable mechanism is one that (a) uses at run time
|
||||
a copy of the Library already present on the user's computer
|
||||
system, and (b) will operate properly with a modified version
|
||||
of the Library that is interface-compatible with the Linked
|
||||
Version.
|
||||
|
||||
e) Provide Installation Information, but only if you would otherwise
|
||||
be required to provide such information under section 6 of the
|
||||
GNU GPL, and only to the extent that such information is
|
||||
necessary to install and execute a modified version of the
|
||||
Combined Work produced by recombining or relinking the
|
||||
Application with a modified version of the Linked Version. (If
|
||||
you use option 4d0, the Installation Information must accompany
|
||||
the Minimal Corresponding Source and Corresponding Application
|
||||
Code. If you use option 4d1, you must provide the Installation
|
||||
Information in the manner specified by section 6 of the GNU GPL
|
||||
for conveying Corresponding Source.)
|
||||
|
||||
5. Combined Libraries.
|
||||
|
||||
You may place library facilities that are a work based on the
|
||||
Library side by side in a single library together with other library
|
||||
facilities that are not Applications and are not covered by this
|
||||
License, and convey such a combined library under terms of your
|
||||
choice, if you do both of the following:
|
||||
|
||||
a) Accompany the combined library with a copy of the same work based
|
||||
on the Library, uncombined with any other library facilities,
|
||||
conveyed under the terms of this License.
|
||||
|
||||
b) Give prominent notice with the combined library that part of it
|
||||
is a work based on the Library, and explaining where to find the
|
||||
accompanying uncombined form of the same work.
|
||||
|
||||
6. Revised Versions of the GNU Lesser General Public License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions
|
||||
of the GNU Lesser General Public License from time to time. Such new
|
||||
versions will be similar in spirit to the present version, but may
|
||||
differ in detail to address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Library as you received it specifies that a certain numbered version
|
||||
of the GNU Lesser General Public License "or any later version"
|
||||
applies to it, you have the option of following the terms and
|
||||
conditions either of that published version or of any later version
|
||||
published by the Free Software Foundation. If the Library as you
|
||||
received it does not specify a version number of the GNU Lesser
|
||||
General Public License, you may choose any version of the GNU Lesser
|
||||
General Public License ever published by the Free Software Foundation.
|
||||
|
||||
If the Library as you received it specifies that a proxy can decide
|
||||
whether future versions of the GNU Lesser General Public License shall
|
||||
apply, that proxy's public statement of acceptance of any version is
|
||||
permanent authorization for you to choose that version for the
|
||||
Library.
|
33
Makefile
|
@ -1,10 +1,6 @@
|
|||
# Copyright 2019 miruka
|
||||
# This file is part of harmonyqml, licensed under GPLv3.
|
||||
|
||||
PKG_DIR = harmonyqml
|
||||
PKG_DIR = src
|
||||
|
||||
PYTHON = python3
|
||||
PIP = pip3
|
||||
PYLINT = pylint
|
||||
MYPY = mypy
|
||||
VULTURE = vulture
|
||||
|
@ -12,8 +8,6 @@ BANDIT = bandit
|
|||
PYCYLE = pycycle
|
||||
CLOC = cloc
|
||||
|
||||
ARCHIVE_FORMATS = gztar
|
||||
INSTALL_FLAGS = --user --editable
|
||||
PYLINT_FLAGS = --output-format colorized
|
||||
MYPY_FLAGS = --ignore-missing-imports
|
||||
VULTURE_FLAGS = --min-confidence 70
|
||||
|
@ -23,11 +17,7 @@ CLOC_FLAGS = --ignore-whitespace
|
|||
|
||||
LINE = "\033[35m―――――――――――――――――――――――――――――――――――――――――――――――――――――――\033[0m"
|
||||
|
||||
|
||||
.PHONY: all clean dist install upload test
|
||||
|
||||
|
||||
all: clean dist install
|
||||
.PHONY: clean test
|
||||
|
||||
clean:
|
||||
find . -name '__pycache__' -exec rm -Rfv {} +
|
||||
|
@ -35,23 +25,6 @@ clean:
|
|||
find . -name '*.qmlc' -exec rm -Rfv {} +
|
||||
find . -name '*.jsc' -exec rm -Rfv {} +
|
||||
find . -name '*.egg-info' -exec rm -Rfv {} +
|
||||
rm -Rfv build dist
|
||||
|
||||
dist: clean
|
||||
@echo
|
||||
${PYTHON} setup.py sdist --format ${ARCHIVE_FORMATS}
|
||||
@echo
|
||||
${PYTHON} setup.py bdist_wheel
|
||||
|
||||
install: clean
|
||||
@echo
|
||||
${PIP} install ${INSTALL_FLAGS} .
|
||||
|
||||
|
||||
upload: dist
|
||||
@echo
|
||||
twine upload dist/*
|
||||
|
||||
|
||||
test:
|
||||
@echo
|
||||
|
@ -73,7 +46,7 @@ test:
|
|||
@echo
|
||||
@echo pylint ${LINE}
|
||||
@echo
|
||||
- ${PYLINT} ${PYLINT_FLAGS} ${PKG_DIR} *.py
|
||||
- ${PYLINT} ${PYLINT_FLAGS} ${PKG_DIR}
|
||||
@echo
|
||||
@echo cloc ${LINE}
|
||||
@echo
|
||||
|
|
92
TODO.md
|
@ -1,92 +0,0 @@
|
|||
- Refactoring
|
||||
- Migrate more JS functions to their own files / Implement in Python instead
|
||||
- Don't bake in size properties for components
|
||||
|
||||
- Bug fixes
|
||||
- dataclass-like `default_factory` for ListItem
|
||||
- Prevent briefly seeing login screen if there are accounts to
|
||||
resumeSession for but they take time to appear
|
||||
- 100% CPU usage when hitting top edge to trigger messages loading
|
||||
- Sending `![A picture](https://picsum.photos/256/256)` → not clickable?
|
||||
- Icons, images and HStyle singleton aren't reloaded
|
||||
- `MessageDelegate.qml:63: TypeError: 'reloadPreviousItem' not a function`
|
||||
- RoomEventsList scrolling when resizing the window
|
||||
|
||||
- UI
|
||||
- Invite to room
|
||||
- Accounts delegates background
|
||||
- SidePane delegates hover effect
|
||||
- Server selection
|
||||
- Register/Forgot? for SignIn dialog
|
||||
- Scaling
|
||||
- See [Text.fontSizeMode](https://doc.qt.io/qt-5/qml-qtquick-text.html#fontSizeMode-prop)
|
||||
- Add room
|
||||
- Leave room
|
||||
- Forget room warning popup
|
||||
- Prevent using the SendBox if no permission (power levels)
|
||||
- Spinner when loading past room events, images or clicking buttons
|
||||
- Better theming/styling system
|
||||
- See about <https://doc.qt.io/qt-5/qtquickcontrols2-configuration.html>
|
||||
- Settings page
|
||||
- Multiaccount aliases
|
||||
- Message/text selection
|
||||
|
||||
- Major features
|
||||
- E2E
|
||||
- Device verification
|
||||
- Edit/delete own devices
|
||||
- Request room keys from own other devices
|
||||
- Auto-trust accounts within the same client
|
||||
- Import/export keys
|
||||
- Uploads
|
||||
- QQuickImageProvider
|
||||
- Read receipts
|
||||
- Status message and presence
|
||||
- Links preview
|
||||
|
||||
- Client improvements
|
||||
- Filtering rooms: search more than display names?
|
||||
- nio.MatrixRoom has `typing_users`, no need to handle it on our own
|
||||
- Initial sync filter and lazy load, see weechat-matrix `_handle_login()`
|
||||
- See also `handle_response()`'s `keys_query` request
|
||||
- HTTP/2
|
||||
- `retry_after_ms` when rate-limited
|
||||
- Direct chats category
|
||||
- On sync, check messages API, if a limited sync timeline was received
|
||||
- Markdown: don't turn #things (no space) and `thing\n---` into title,
|
||||
disable `__` syntax for bold/italic
|
||||
- Push instead of replacing in stack view (remove getMemberFilter when done)
|
||||
- Make links in room subtitle clickable, formatting?
|
||||
- `<pre>` scrollbar on overflow
|
||||
- Handle cases where an avatar char is # or @ (#alias room, @user\_id)
|
||||
- When inviting someone to direct chat, room is "Empty room" until accepted,
|
||||
it should be the peer's display name instead.
|
||||
- Keep an accounts order
|
||||
- See `Qt.callLater()` potential usages
|
||||
- Banner name color instead of bold
|
||||
- Animate RoomEventDelegate DayBreak apparition
|
||||
|
||||
- Missing nio support
|
||||
- MatrixRoom invited members list
|
||||
- Invite events are missing their timestamps (needed for sorting)
|
||||
- Left room events after client reboot
|
||||
- `org.matrix.room.preview_urls` event
|
||||
- `m.room.aliases` event
|
||||
- Support "Empty room (was ...)" after peer left
|
||||
|
||||
- Waiting for approval/release
|
||||
- nio avatars
|
||||
- olm/olm-devel 0.3.1 in void repos
|
||||
|
||||
- Distribution
|
||||
- Review setup.py, add dependencies
|
||||
- README.md
|
||||
- Use PyInstaller or pyqtdeploy
|
||||
- Test command:
|
||||
```
|
||||
pyinstaller --onefile --windowed --name harmonyqml \
|
||||
--add-data 'harmonyqml/components:harmonyqml/components' \
|
||||
--additional-hooks-dir . \
|
||||
--upx-dir ~/opt/upx-3.95-amd64_linux \
|
||||
run.py
|
||||
```
|
|
@ -1,21 +0,0 @@
|
|||
# Copyright 2019 miruka
|
||||
# This file is part of harmonyqml, licensed under GPLv3.
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
# The disk cache is responsible for multiple display bugs when running
|
||||
# the app for the first time/when cache needs to be recompiled, on top
|
||||
# of litering the source folders with .qmlc files.
|
||||
os.environ["QML_DISABLE_DISK_CACHE"] = "1"
|
||||
|
||||
|
||||
def run() -> None:
|
||||
from .app import Application
|
||||
app = Application(sys.argv)
|
||||
|
||||
from .engine import Engine
|
||||
engine = Engine(debug=app.debug)
|
||||
engine.showWindow()
|
||||
|
||||
sys.exit(app.exec_())
|
|
@ -1,8 +0,0 @@
|
|||
# Copyright 2019 miruka
|
||||
# This file is part of harmonyqml, licensed under GPLv3.
|
||||
|
||||
"Run app when this package is executed from 'python -m <pkgname>'."
|
||||
|
||||
from . import run
|
||||
|
||||
run()
|
|
@ -1,22 +0,0 @@
|
|||
# Copyright 2019 miruka
|
||||
# This file is part of harmonyqml, licensed under GPLv3.
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
from PyQt5.QtGui import QGuiApplication
|
||||
|
||||
from . import __about__
|
||||
|
||||
|
||||
class Application(QGuiApplication):
|
||||
def __init__(self, args: Optional[List[str]] = None) -> None:
|
||||
self.debug = False
|
||||
|
||||
if args and "--debug" in args:
|
||||
del args[args.index("--debug")]
|
||||
self.debug = True
|
||||
|
||||
super().__init__(args or [])
|
||||
|
||||
self.setApplicationName(__about__.__pkg_name__)
|
||||
self.setApplicationDisplayName(__about__.__pretty_name__)
|
|
@ -1,4 +0,0 @@
|
|||
# Copyright 2019 miruka
|
||||
# This file is part of harmonyqml, licensed under GPLv3.
|
||||
|
||||
from .backend import Backend
|
|
@ -1,168 +0,0 @@
|
|||
# Copyright 2019 miruka
|
||||
# This file is part of harmonyqml, licensed under GPLv3.
|
||||
|
||||
import os
|
||||
import random
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import Deque, Dict, Optional, Sequence, Set, Tuple
|
||||
|
||||
from atomicfile import AtomicFile
|
||||
from PyQt5.QtCore import QObject, QStandardPaths, pyqtProperty, pyqtSlot
|
||||
|
||||
from .html_filter import HtmlFilter
|
||||
from .model import ListModel, ListModelMap
|
||||
from .model.items import User
|
||||
from .network_manager import NioErrorResponse
|
||||
from .pyqt_future import futurize
|
||||
|
||||
|
||||
class Backend(QObject):
|
||||
def __init__(self, parent: QObject) -> None:
|
||||
super().__init__(parent)
|
||||
self.pool: ThreadPoolExecutor = ThreadPoolExecutor(max_workers=6)
|
||||
|
||||
self.past_tokens: Dict[str, str] = {}
|
||||
self.fully_loaded_rooms: Set[str] = set()
|
||||
|
||||
self._html_filter: HtmlFilter = HtmlFilter(self)
|
||||
|
||||
from .client_manager import ClientManager
|
||||
self._client_manager: ClientManager = ClientManager(self)
|
||||
|
||||
self._accounts: ListModel = ListModel(parent=parent)
|
||||
|
||||
self._room_events: ListModelMap = ListModelMap(
|
||||
container = Deque,
|
||||
parent = self
|
||||
)
|
||||
|
||||
self._users: ListModel = ListModel(
|
||||
default_factory = self._query_user,
|
||||
parent = self
|
||||
)
|
||||
|
||||
from .signal_manager import SignalManager
|
||||
self._signal_manager: SignalManager = SignalManager(self)
|
||||
|
||||
self.clients.configLoad()
|
||||
|
||||
|
||||
@pyqtProperty("QVariant", constant=True)
|
||||
def htmlFilter(self):
|
||||
return self._html_filter
|
||||
|
||||
@pyqtProperty("QVariant", constant=True)
|
||||
def clients(self):
|
||||
return self._client_manager
|
||||
|
||||
@pyqtProperty("QVariant", constant=True)
|
||||
def accounts(self):
|
||||
return self._accounts
|
||||
|
||||
@pyqtProperty("QVariant", constant=True)
|
||||
def roomEvents(self):
|
||||
return self._room_events
|
||||
|
||||
@pyqtProperty("QVariant", constant=True)
|
||||
def users(self):
|
||||
return self._users
|
||||
|
||||
@pyqtProperty("QVariant", constant=True)
|
||||
def signals(self):
|
||||
return self._signal_manager
|
||||
|
||||
|
||||
def _query_user(self, user_id: str) -> User:
|
||||
client = random.choice(tuple(self.clients.values())) # nosec
|
||||
|
||||
@futurize(running_value=user_id)
|
||||
def get_displayname(self) -> str:
|
||||
try:
|
||||
response = client.net.talk(client.nio.get_displayname, user_id)
|
||||
return response.displayname or user_id
|
||||
except NioErrorResponse:
|
||||
return user_id
|
||||
|
||||
return User(
|
||||
userId = user_id,
|
||||
displayName = get_displayname(self),
|
||||
devices = ListModel(),
|
||||
)
|
||||
|
||||
|
||||
@pyqtSlot(str, result=float)
|
||||
def hueFromString(self, string: str) -> float:
|
||||
# pylint:disable=no-self-use
|
||||
return sum((ord(char) * 99 for char in string)) % 360 / 360
|
||||
|
||||
|
||||
@pyqtSlot(str)
|
||||
@pyqtSlot(str, int)
|
||||
def loadPastEvents(self, room_id: str, limit: int = 100) -> None:
|
||||
if not room_id in self.past_tokens:
|
||||
return # Initial sync not done yet
|
||||
|
||||
if room_id in self.fully_loaded_rooms:
|
||||
return
|
||||
|
||||
for client in self.clients.values():
|
||||
if room_id in client.nio.rooms:
|
||||
client.loadPastEvents(
|
||||
room_id, self.past_tokens[room_id], limit
|
||||
)
|
||||
break
|
||||
|
||||
|
||||
@pyqtSlot(str)
|
||||
def setRoomFilter(self, pattern: str) -> None:
|
||||
for account in self.accounts:
|
||||
for categ in account.roomCategories:
|
||||
categ.sortedRooms.filter = pattern
|
||||
|
||||
|
||||
@staticmethod
|
||||
def getDir(standard_dir: QStandardPaths.StandardLocation) -> str:
|
||||
path = QStandardPaths.writableLocation(standard_dir)
|
||||
os.makedirs(path, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def getFile(self,
|
||||
standard_dir: QStandardPaths.StandardLocation,
|
||||
relative_file_path: str,
|
||||
initial_content: Optional[str] = None) -> str:
|
||||
|
||||
relative_file_path = relative_file_path.replace("/", os.sep)
|
||||
|
||||
path = QStandardPaths.locate(standard_dir, relative_file_path)
|
||||
if path:
|
||||
return path
|
||||
|
||||
path = os.path.join(self.getDir(standard_dir), relative_file_path)
|
||||
|
||||
if initial_content is not None:
|
||||
with AtomicFile(path, "w") as new:
|
||||
new.write(initial_content)
|
||||
|
||||
return path
|
||||
|
||||
|
||||
@pyqtSlot()
|
||||
@pyqtSlot(list)
|
||||
def pdb(self, additional_data: Sequence = ()) -> None:
|
||||
# pylint: disable=all
|
||||
ad = additional_data
|
||||
cl = self.clients
|
||||
ac = self.accounts
|
||||
re = self.roomEvents
|
||||
us = self.users
|
||||
|
||||
tcl = lambda user: cl[f"@test_{user}:matrix.org"]
|
||||
|
||||
import json
|
||||
jd = lambda obj: print(json.dumps(obj, indent=4, ensure_ascii=False))
|
||||
|
||||
import pdb
|
||||
from PyQt5.QtCore import pyqtRemoveInputHook
|
||||
pyqtRemoveInputHook()
|
||||
pdb.set_trace()
|
|
@ -1,357 +0,0 @@
|
|||
# Copyright 2019 miruka
|
||||
# This file is part of harmonyqml, licensed under GPLv3.
|
||||
|
||||
import logging as log
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from threading import Event
|
||||
from typing import DefaultDict, Tuple
|
||||
|
||||
from PyQt5.QtCore import (
|
||||
QObject, QStandardPaths, pyqtProperty, pyqtSignal, pyqtSlot
|
||||
)
|
||||
|
||||
import nio
|
||||
|
||||
from .model.items import Trust
|
||||
from .network_manager import NetworkManager
|
||||
from .pyqt_future import PyQtFuture, futurize
|
||||
|
||||
|
||||
class Client(QObject):
|
||||
roomInvited = pyqtSignal([str, dict], [str])
|
||||
roomJoined = pyqtSignal(str)
|
||||
roomLeft = pyqtSignal([str, dict], [str])
|
||||
|
||||
roomAboutToBeForgotten = pyqtSignal(str)
|
||||
|
||||
roomSyncPrevBatchTokenReceived = pyqtSignal(str, str)
|
||||
roomPastPrevBatchTokenReceived = pyqtSignal(str, str)
|
||||
roomEventReceived = pyqtSignal(str, str, dict)
|
||||
roomTypingMembersUpdated = pyqtSignal(str, list)
|
||||
|
||||
messageAboutToBeSent = pyqtSignal(str, dict)
|
||||
|
||||
deviceIsPresent = pyqtSignal(str, str, str)
|
||||
deviceIsDeleted = pyqtSignal(str, str)
|
||||
|
||||
|
||||
def __init__(self,
|
||||
manager,
|
||||
hostname: str,
|
||||
username: str,
|
||||
device_id: str = "") -> None:
|
||||
super().__init__(manager)
|
||||
self.manager = manager
|
||||
|
||||
host, *port = hostname.split(":")
|
||||
self.host: str = host
|
||||
self.port: int = int(port[0]) if port else 443
|
||||
|
||||
self.pool: ThreadPoolExecutor = ThreadPoolExecutor(6)
|
||||
|
||||
store_path = self.manager.backend.getDir(
|
||||
QStandardPaths.AppDataLocation
|
||||
)
|
||||
|
||||
self.nio: nio.client.HttpClient = nio.client.HttpClient(
|
||||
self.host, username, device_id, store_path=store_path
|
||||
)
|
||||
|
||||
# Since nio clients can't handle more than one talk operation
|
||||
# at a time, this one is used exclusively to poll the sync API
|
||||
self.nio_sync: nio.client.HttpClient = nio.client.HttpClient(
|
||||
self.host, username, device_id, store_path=store_path
|
||||
)
|
||||
|
||||
self.net = NetworkManager(self.host, self.port, self.nio)
|
||||
self.net_sync = NetworkManager(self.host, self.port, self.nio_sync)
|
||||
|
||||
self._stop_sync: Event = Event()
|
||||
|
||||
# {room_id: (was_typing, at_timestamp_secs)}
|
||||
self._last_typing_set: DefaultDict[str, Tuple[bool, float]] = \
|
||||
DefaultDict(lambda: (False, 0))
|
||||
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return "%s(host=%r, port=%r, user_id=%r)" % \
|
||||
(type(self).__name__, self.host, self.port, self.userId)
|
||||
|
||||
|
||||
@pyqtProperty(str, constant=True)
|
||||
def userId(self) -> str:
|
||||
return self.nio.user_id
|
||||
|
||||
|
||||
@futurize(max_running=1, discard_if_max_running=True, pyqt=False)
|
||||
def uploadE2EKeys(self) -> None:
|
||||
self.net.talk(self.nio.keys_upload)
|
||||
|
||||
|
||||
def queryE2EKeys(self) -> None:
|
||||
self._on_query_e2e_keys(self.net.talk(self.nio.keys_query))
|
||||
|
||||
|
||||
def _on_query_e2e_keys(self, response: nio.KeysQueryResponse) -> None:
|
||||
for user_id, device_dict in response.device_keys.items():
|
||||
for device_id, payload in device_dict.items():
|
||||
if device_id == self.nio.device_id:
|
||||
continue
|
||||
|
||||
ed25519_key = payload["keys"][f"ed25519:{device_id}"]
|
||||
self.deviceIsPresent.emit(user_id, device_id, ed25519_key)
|
||||
|
||||
for device_id, device in self.nio.device_store[user_id].items():
|
||||
if device.deleted:
|
||||
self.deviceIsDeleted.emit(user_id, device_id)
|
||||
|
||||
|
||||
def claimE2EKeysForRoom(self, room_id: str) -> None:
|
||||
self.net.talk(self.nio.keys_claim, room_id)
|
||||
|
||||
|
||||
def shareRoomE2ESession(self,
|
||||
room_id: str,
|
||||
ignore_missing_sessions: bool = False) -> None:
|
||||
self.net.talk(
|
||||
self.nio.share_group_session,
|
||||
room_id = room_id,
|
||||
ignore_missing_sessions = ignore_missing_sessions,
|
||||
)
|
||||
|
||||
|
||||
def getDeviceTrust(self, device: nio.crypto.OlmDevice) -> Trust:
|
||||
olm = self.nio.olm
|
||||
return (
|
||||
Trust.trusted if olm.is_device_verified(device) else
|
||||
Trust.blacklisted if olm.is_device_blacklisted(device) else
|
||||
Trust.undecided
|
||||
)
|
||||
|
||||
|
||||
@pyqtSlot(str, result="QVariant")
|
||||
@pyqtSlot(str, str, result="QVariant")
|
||||
@futurize()
|
||||
def login(self, password: str, device_name: str = "") -> "Client":
|
||||
# Main nio client will receive the response here
|
||||
response = self.net.talk(self.nio.login, password, device_name)
|
||||
# Now, receive it with the sync nio client too:
|
||||
self.nio_sync.receive_response(response)
|
||||
return self
|
||||
|
||||
|
||||
@pyqtSlot(str, str, str, result="QVariant")
|
||||
@futurize()
|
||||
def resumeSession(self, user_id: str, token: str, device_id: str
|
||||
) -> "Client":
|
||||
response = nio.LoginResponse(user_id, device_id, token)
|
||||
self.nio.receive_response(response)
|
||||
self.nio_sync.receive_response(response)
|
||||
return self
|
||||
|
||||
|
||||
@pyqtSlot(result="QVariant")
|
||||
@futurize()
|
||||
def logout(self) -> "Client":
|
||||
self._stop_sync.set()
|
||||
self.net.http_disconnect()
|
||||
self.net_sync.http_disconnect()
|
||||
return self
|
||||
|
||||
|
||||
@futurize(pyqt=False)
|
||||
def startSyncing(self) -> None:
|
||||
while True:
|
||||
try:
|
||||
response = self.net_sync.talk(self.nio_sync.sync, timeout=8000)
|
||||
except nio.LocalProtocolError: # logout occured
|
||||
pass
|
||||
else:
|
||||
self._on_sync(response)
|
||||
|
||||
if self._stop_sync.is_set():
|
||||
self._stop_sync.clear()
|
||||
break
|
||||
|
||||
|
||||
def _on_sync(self, response: nio.SyncResponse) -> None:
|
||||
self.nio.receive_response(response)
|
||||
|
||||
if self.nio.should_upload_keys:
|
||||
self.uploadE2EKeys()
|
||||
|
||||
if self.nio.should_query_keys:
|
||||
self.queryE2EKeys()
|
||||
|
||||
for room_id, room_info in response.rooms.invite.items():
|
||||
for ev in room_info.invite_state:
|
||||
member_ev = isinstance(ev, nio.InviteMemberEvent)
|
||||
|
||||
if member_ev and ev.content["membership"] == "join":
|
||||
self.roomInvited.emit(room_id, ev.content)
|
||||
break
|
||||
else:
|
||||
self.roomInvited[str].emit(room_id)
|
||||
|
||||
for room_id, room_info in response.rooms.join.items():
|
||||
self.roomJoined.emit(room_id)
|
||||
|
||||
self.roomSyncPrevBatchTokenReceived.emit(
|
||||
room_id, room_info.timeline.prev_batch
|
||||
)
|
||||
|
||||
for ev in room_info.timeline.events:
|
||||
self.roomEventReceived.emit(
|
||||
room_id, type(ev).__name__, ev.__dict__
|
||||
)
|
||||
|
||||
for ev in room_info.ephemeral:
|
||||
if isinstance(ev, nio.TypingNoticeEvent):
|
||||
self.roomTypingMembersUpdated.emit(room_id, ev.users)
|
||||
else:
|
||||
print("ephemeral event: ", ev)
|
||||
|
||||
for room_id, room_info in response.rooms.leave.items():
|
||||
for ev in room_info.timeline.events:
|
||||
member_ev = isinstance(ev, nio.RoomMemberEvent)
|
||||
|
||||
if member_ev and ev.content["membership"] in ("leave", "ban"):
|
||||
self.roomLeft.emit(room_id, ev.__dict__)
|
||||
break
|
||||
else:
|
||||
self.roomLeft[str].emit(room_id)
|
||||
|
||||
|
||||
@futurize(max_running=1, discard_if_max_running=True)
|
||||
def loadPastEvents(self, room_id: str, start_token: str, limit: int = 100
|
||||
) -> None:
|
||||
# From QML, use Backend.loastPastEvents instead
|
||||
|
||||
self._on_past_events(
|
||||
room_id,
|
||||
self.net.talk(
|
||||
self.nio.room_messages, room_id, start=start_token, limit=limit
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _on_past_events(self, room_id: str, response: nio.RoomMessagesResponse
|
||||
) -> None:
|
||||
self.roomPastPrevBatchTokenReceived.emit(room_id, response.end)
|
||||
|
||||
for ev in response.chunk:
|
||||
self.roomEventReceived.emit(
|
||||
room_id, type(ev).__name__, ev.__dict__
|
||||
)
|
||||
|
||||
|
||||
@pyqtSlot(str, bool)
|
||||
@futurize(max_running=1, discard_if_max_running=True)
|
||||
def setTypingState(self, room_id: str, typing: bool) -> None:
|
||||
set_for_secs = 5
|
||||
last_set, last_time = self._last_typing_set[room_id]
|
||||
|
||||
if not typing and last_set is False:
|
||||
return
|
||||
|
||||
if typing and time.time() - last_time < set_for_secs - 1:
|
||||
return
|
||||
|
||||
self._last_typing_set[room_id] = (typing, time.time())
|
||||
|
||||
self.net.talk(
|
||||
self.nio.room_typing,
|
||||
room_id = room_id,
|
||||
typing_state = typing,
|
||||
timeout = set_for_secs * 1000,
|
||||
)
|
||||
|
||||
|
||||
@pyqtSlot(str, str)
|
||||
def sendMarkdown(self, room_id: str, text: str) -> PyQtFuture:
|
||||
html = self.manager.backend.htmlFilter.fromMarkdown(text)
|
||||
content = {
|
||||
"body": text,
|
||||
"formatted_body": html,
|
||||
"format": "org.matrix.custom.html",
|
||||
"msgtype": "m.text",
|
||||
}
|
||||
self.messageAboutToBeSent.emit(room_id, content)
|
||||
|
||||
# If the thread pool workers are all occupied, and @futurize
|
||||
# wrapped sendMarkdown, the messageAboutToBeSent signal neccessary
|
||||
# for local echoes would not be sent until a thread is free.
|
||||
#
|
||||
# send() only takes the room_id argument explicitely because
|
||||
# of consider_args=True: This means the max number of messages being
|
||||
# sent at a time is one per room at a time.
|
||||
@futurize(max_running=1, consider_args=True)
|
||||
def send(self, room_id: str) -> PyQtFuture:
|
||||
talk = lambda: self.net.talk(
|
||||
self.nio.room_send,
|
||||
room_id = room_id,
|
||||
message_type = "m.room.message",
|
||||
content = content,
|
||||
)
|
||||
|
||||
try:
|
||||
log.debug("Try sending message %r to %r", content, room_id)
|
||||
return talk()
|
||||
except nio.GroupEncryptionError as err:
|
||||
log.warning(err)
|
||||
try:
|
||||
self.shareRoomE2ESession(room_id)
|
||||
except nio.EncryptionError as err:
|
||||
log.warning(err)
|
||||
self.claimE2EKeysForRoom(room_id)
|
||||
self.shareRoomE2ESession(room_id,
|
||||
ignore_missing_sessions=True)
|
||||
|
||||
log.debug("Final try to send %r to %r", content, room_id)
|
||||
return talk()
|
||||
|
||||
return send(self, room_id)
|
||||
|
||||
|
||||
@pyqtSlot(str, result="QVariant")
|
||||
@futurize()
|
||||
def joinRoom(self, room_id: str) -> None:
|
||||
return self.net.talk(self.nio.join, room_id=room_id)
|
||||
|
||||
|
||||
@pyqtSlot(str, result="QVariant")
|
||||
@futurize()
|
||||
def leaveRoom(self, room_id: str) -> None:
|
||||
return self.net.talk(self.nio.room_leave, room_id=room_id)
|
||||
|
||||
|
||||
@pyqtSlot(str, result="QVariant")
|
||||
@futurize()
|
||||
def forgetRoom(self, room_id: str) -> None:
|
||||
self.roomAboutToBeForgotten.emit(room_id)
|
||||
response = self.net.talk(self.nio.room_forget, room_id=room_id)
|
||||
self.nio.invalidate_outbound_session(room_id)
|
||||
return response
|
||||
|
||||
|
||||
@pyqtSlot(str, result=bool)
|
||||
def roomHasUnknownDevices(self, room_id: str) -> bool:
|
||||
return self.nio.room_contains_unverified(room_id)
|
||||
|
||||
|
||||
@pyqtSlot(str, str, result=str)
|
||||
def getMemberFilter(self, room_category: str, room_id: str) -> str:
|
||||
return self.manager.backend.accounts[self.userId]\
|
||||
.roomCategories[room_category]\
|
||||
.rooms[room_id]\
|
||||
.sortedMembers.filter
|
||||
|
||||
|
||||
@pyqtSlot(str, str, str)
|
||||
def setMemberFilter(self, room_category: str, room_id: str, pattern: str
|
||||
) -> None:
|
||||
self.manager.backend.accounts[self.userId]\
|
||||
.roomCategories[room_category]\
|
||||
.rooms[room_id]\
|
||||
.sortedMembers.filter = pattern
|
|
@ -1,155 +0,0 @@
|
|||
# Copyright 2018 miruka
|
||||
# This file is part of harmonyqt, licensed under GPLv3.
|
||||
|
||||
import json
|
||||
import platform
|
||||
import threading
|
||||
from collections.abc import Mapping
|
||||
from typing import Dict
|
||||
|
||||
from atomicfile import AtomicFile
|
||||
from PyQt5.QtCore import (
|
||||
QObject, QStandardPaths, pyqtProperty, pyqtSignal, pyqtSlot
|
||||
)
|
||||
|
||||
from harmonyqml import __about__
|
||||
|
||||
from .backend import Backend
|
||||
from .client import Client
|
||||
|
||||
AccountConfig = Dict[str, Dict[str, str]]
|
||||
|
||||
_CONFIG_LOCK = threading.Lock()
|
||||
|
||||
|
||||
class _ClientManagerMeta(type(QObject), type(Mapping)): # type: ignore
|
||||
pass
|
||||
|
||||
|
||||
class ClientManager(QObject, Mapping, metaclass=_ClientManagerMeta):
|
||||
clientAdded = pyqtSignal(Client)
|
||||
clientDeleted = pyqtSignal(str)
|
||||
clientCountChanged = pyqtSignal(int)
|
||||
|
||||
|
||||
def __init__(self, backend: Backend) -> None:
|
||||
super().__init__(backend)
|
||||
self.backend = backend
|
||||
self._clients: Dict[str, Client] = {}
|
||||
|
||||
func = lambda: self.clientCountChanged.emit(len(self))
|
||||
self.clientAdded.connect(func)
|
||||
self.clientDeleted.connect(func)
|
||||
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"{type(self).__name__}(clients={self._clients!r})"
|
||||
|
||||
|
||||
def __getitem__(self, user_id: str) -> Client:
|
||||
return self.get(user_id)
|
||||
|
||||
|
||||
def __len__(self) -> int:
|
||||
return self.count
|
||||
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self._clients)
|
||||
|
||||
|
||||
@pyqtSlot(str, result="QVariant")
|
||||
def get(self, key: str) -> Client:
|
||||
return self._clients[key]
|
||||
|
||||
|
||||
@pyqtProperty(int, notify=clientCountChanged)
|
||||
def count(self):
|
||||
return len(self._clients)
|
||||
|
||||
|
||||
@pyqtSlot()
|
||||
def configLoad(self) -> None:
|
||||
for user_id, info in self.configAccounts().items():
|
||||
client = Client(self, info["hostname"], user_id)
|
||||
client.resumeSession(user_id, info["token"], info["device_id"])\
|
||||
.add_done_callback(lambda _, c=client: self._on_connected(c))
|
||||
|
||||
|
||||
@pyqtSlot(str, str, str, result="QVariant")
|
||||
@pyqtSlot(str, str, str, str, result="QVariant")
|
||||
def new(self, hostname: str, username: str, password: str,
|
||||
device_id: str = "") -> None:
|
||||
|
||||
client = Client(self, hostname, username, device_id)
|
||||
future = client.login(password, self.defaultDeviceName)
|
||||
future.add_done_callback(lambda _: self._on_connected(client))
|
||||
return future
|
||||
|
||||
|
||||
def _on_connected(self, client: Client) -> None:
|
||||
self._clients[client.userId] = client
|
||||
self.clientAdded.emit(client)
|
||||
client.startSyncing()
|
||||
|
||||
|
||||
@pyqtSlot(str)
|
||||
def remove(self, user_id: str) -> None:
|
||||
client = self._clients.pop(user_id, None)
|
||||
if client:
|
||||
self.clientDeleted.emit(user_id)
|
||||
client.logout()
|
||||
|
||||
|
||||
@pyqtSlot()
|
||||
def removeAll(self) -> None:
|
||||
for user_id in self._clients.copy():
|
||||
self.remove(user_id)
|
||||
|
||||
|
||||
@pyqtProperty(str, constant=True)
|
||||
def defaultDeviceName(self) -> str: # pylint: disable=no-self-use
|
||||
os_ = f" on {platform.system()}".rstrip()
|
||||
os_ = f"{os_} {platform.release()}".rstrip() if os_ != " on" else ""
|
||||
return f"{__about__.__pretty_name__}{os_}"
|
||||
|
||||
|
||||
# Config file operations
|
||||
|
||||
def getAccountConfigPath(self) -> str:
|
||||
return self.backend.getFile(
|
||||
QStandardPaths.AppConfigLocation, "accounts.json", "[]"
|
||||
)
|
||||
|
||||
|
||||
def configAccounts(self) -> AccountConfig:
|
||||
with open(self.getAccountConfigPath(), "r") as file:
|
||||
return json.loads(file.read().strip()) or {}
|
||||
|
||||
|
||||
@pyqtSlot("QVariant")
|
||||
def remember(self, client: Client) -> None:
|
||||
self._write_config({
|
||||
**self.configAccounts(),
|
||||
**{client.userId: {
|
||||
"hostname": client.nio.host,
|
||||
"token": client.nio.access_token,
|
||||
"device_id": client.nio.device_id,
|
||||
}}
|
||||
})
|
||||
|
||||
|
||||
@pyqtSlot(str)
|
||||
def forget(self, user_id: str) -> None:
|
||||
self._write_config({
|
||||
uid: info
|
||||
for uid, info in self.configAccounts().items() if uid != user_id
|
||||
})
|
||||
|
||||
|
||||
def _write_config(self, accounts: AccountConfig) -> None:
|
||||
js = json.dumps(accounts, indent=4, ensure_ascii=False, sort_keys=True)
|
||||
|
||||
with _CONFIG_LOCK:
|
||||
with AtomicFile(self.getAccountConfigPath(), "w") as new:
|
||||
new.write(js)
|
|
@ -1,154 +0,0 @@
|
|||
# Copyright 2019 miruka
|
||||
# This file is part of harmonyqml, licensed under GPLv3.
|
||||
|
||||
import re
|
||||
|
||||
import mistune
|
||||
from lxml.html import HtmlElement, etree # nosec
|
||||
from PyQt5.QtCore import QObject, pyqtProperty, pyqtSlot
|
||||
|
||||
import html_sanitizer.sanitizer as sanitizer
|
||||
|
||||
|
||||
class HtmlFilter(QObject):
|
||||
link_regexes = [re.compile(r, re.IGNORECASE) for r in [
|
||||
(r"(?P<body>.+://(?P<host>[a-z0-9._-]+)(?:/[/\-_.,a-z0-9%&?;=~]*)?"
|
||||
r"(?:\([/\-_.,a-z0-9%&?;=~]*\))?)"),
|
||||
r"mailto:(?P<body>[a-z0-9._-]+@(?P<host>[a-z0-9_.-]+[a-z]))",
|
||||
r"tel:(?P<body>[0-9+-]+)(?P<host>)",
|
||||
r"(?P<body>magnet:\?xt=urn:[a-z0-9]+:.+)(?P<host>)",
|
||||
]]
|
||||
|
||||
|
||||
def __init__(self, parent: QObject) -> None:
|
||||
super().__init__(parent)
|
||||
self._sanitizer = sanitizer.Sanitizer(self.sanitizer_settings)
|
||||
|
||||
# The whitespace remover doesn't take <pre> into account
|
||||
sanitizer.normalize_overall_whitespace = lambda html: html
|
||||
sanitizer.normalize_whitespace_in_text_or_tail = lambda el: el
|
||||
|
||||
# hard_wrap: convert all \n to <br> without required two spaces
|
||||
self._markdown_to_html = mistune.Markdown(hard_wrap=True)
|
||||
|
||||
|
||||
@pyqtSlot(str, result=str)
|
||||
def fromMarkdown(self, text: str) -> str:
|
||||
return self.filter(self._markdown_to_html(text))
|
||||
|
||||
|
||||
@pyqtSlot(str, result=str)
|
||||
def filter(self, html: str) -> str:
|
||||
html = self._sanitizer.sanitize(html)
|
||||
tree = etree.fromstring(html, parser=etree.HTMLParser())
|
||||
|
||||
if tree is None:
|
||||
return ""
|
||||
|
||||
for el in tree.iter("img"):
|
||||
el = self._wrap_img_in_a(el)
|
||||
|
||||
for el in tree.iter("a"):
|
||||
el = self._append_img_to_a(el)
|
||||
|
||||
result = b"".join((etree.tostring(el, encoding="utf-8")
|
||||
for el in tree[0].iterchildren()))
|
||||
|
||||
return str(result, "utf-8")
|
||||
|
||||
|
||||
@pyqtProperty("QVariantMap")
|
||||
def sanitizer_settings(self) -> dict:
|
||||
# https://matrix.org/docs/spec/client_server/latest.html#m-room-message-msgtypes
|
||||
return {
|
||||
"tags": {
|
||||
# TODO: mx-reply, audio, video
|
||||
"font", "h1", "h2", "h3", "h4", "h5", "h6",
|
||||
"blockquote", "p", "a", "ul", "ol", "sup", "sub", "li",
|
||||
"b", "i", "s", "u", "code", "hr", "br",
|
||||
"table", "thead", "tbody", "tr", "th", "td",
|
||||
"pre", "img",
|
||||
},
|
||||
"attributes": {
|
||||
# TODO: translate font attrs to qt html subset
|
||||
"font": {"data-mx-bg-color", "data-mx-color"},
|
||||
"a": {"href"},
|
||||
"img": {"width", "height", "alt", "title", "src"},
|
||||
"ol": {"start"},
|
||||
"code": {"class"},
|
||||
},
|
||||
"empty": {"hr", "br", "img"},
|
||||
"separate": {
|
||||
"a", "p", "li", "table", "tr", "th", "td", "br", "hr"
|
||||
},
|
||||
"whitespace": {},
|
||||
"add_nofollow": False,
|
||||
"autolink": { # FIXME: arg dict not working
|
||||
"link_regexes": self.link_regexes,
|
||||
"avoid_hosts": [],
|
||||
},
|
||||
"sanitize_href": lambda href: href,
|
||||
"element_preprocessors": [
|
||||
sanitizer.bold_span_to_strong,
|
||||
sanitizer.italic_span_to_em,
|
||||
sanitizer.tag_replacer("strong", "b"),
|
||||
sanitizer.tag_replacer("em", "i"),
|
||||
sanitizer.tag_replacer("strike", "s"),
|
||||
sanitizer.tag_replacer("del", "s"),
|
||||
sanitizer.tag_replacer("span", "font"),
|
||||
self._remove_empty_font,
|
||||
sanitizer.tag_replacer("form", "p"),
|
||||
sanitizer.tag_replacer("div", "p"),
|
||||
sanitizer.tag_replacer("caption", "p"),
|
||||
sanitizer.target_blank_noopener,
|
||||
],
|
||||
"element_postprocessors": [],
|
||||
"is_mergeable": lambda e1, e2: e1.attrib == e2.attrib,
|
||||
}
|
||||
|
||||
|
||||
def _remove_empty_font(self, el: HtmlElement) -> HtmlElement:
|
||||
if el.tag != "font":
|
||||
return el
|
||||
|
||||
if not self.sanitizer_settings["attributes"]["font"] & set(el.keys()):
|
||||
el.clear()
|
||||
|
||||
return el
|
||||
|
||||
|
||||
def _wrap_img_in_a(self, el: HtmlElement) -> HtmlElement:
|
||||
link = el.attrib.get("src", "")
|
||||
width = el.attrib.get("width", "256")
|
||||
height = el.attrib.get("height", "256")
|
||||
|
||||
if el.getparent().tag == "a" or el.tag != "img" or \
|
||||
not self._is_image_path(link):
|
||||
return el
|
||||
|
||||
el.tag = "a"
|
||||
el.attrib.clear()
|
||||
el.attrib["href"] = link
|
||||
el.append(etree.Element("img", src=link, width=width, height=height))
|
||||
return el
|
||||
|
||||
|
||||
def _append_img_to_a(self, el: HtmlElement) -> HtmlElement:
|
||||
link = el.attrib.get("href", "")
|
||||
|
||||
if not (el.tag == "a" and self._is_image_path(link)):
|
||||
return el
|
||||
|
||||
for _ in el.iter("img"): # if the <a> already has an <img> child
|
||||
return el
|
||||
|
||||
el.append(etree.Element("br"))
|
||||
el.append(etree.Element("img", src=link, width="256", height="256"))
|
||||
return el
|
||||
|
||||
|
||||
@staticmethod
|
||||
def _is_image_path(link: str) -> bool:
|
||||
return bool(re.match(
|
||||
r".+\.(jpg|jpeg|png|gif|bmp|webp|tiff|svg)$", link, re.IGNORECASE
|
||||
))
|
|
@ -1,6 +0,0 @@
|
|||
# Copyright 2019 miruka
|
||||
# This file is part of harmonyqml, licensed under GPLv3.
|
||||
|
||||
from . import items
|
||||
from .list_model import ListModel
|
||||
from .list_model_map import ListModelMap
|
|
@ -1,96 +0,0 @@
|
|||
from enum import Enum
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from PyQt5.QtCore import QDateTime
|
||||
|
||||
from ..pyqt_future import PyQtFuture
|
||||
from .list_item import ListItem
|
||||
from .list_model import ListModel
|
||||
from .sort_filter_proxy import SortFilterProxy
|
||||
|
||||
|
||||
class Account(ListItem):
|
||||
_required_init_values = {"userId", "roomCategories"}
|
||||
_constant = {"userId", "roomCategories"}
|
||||
|
||||
userId: str = ""
|
||||
roomCategories: ListModel = ListModel()
|
||||
|
||||
|
||||
class RoomCategory(ListItem):
|
||||
_required_init_values = {"name", "rooms", "sortedRooms"}
|
||||
_constant = {"name", "rooms", "sortedRooms"}
|
||||
|
||||
name: str = ""
|
||||
rooms: ListModel = ListModel()
|
||||
sortedRooms: SortFilterProxy = SortFilterProxy(ListModel(), "", "")
|
||||
|
||||
|
||||
class Room(ListItem):
|
||||
_required_init_values = {"roomId", "displayName", "members",
|
||||
"sortedMembers"}
|
||||
_constant = {"roomId", "members", "sortedMembers"}
|
||||
|
||||
roomId: str = ""
|
||||
displayName: str = ""
|
||||
topic: Optional[str] = None
|
||||
lastEventDateTime: Optional[QDateTime] = None
|
||||
typingMembers: List[str] = []
|
||||
|
||||
members: ListModel = ListModel()
|
||||
sortedMembers: SortFilterProxy = SortFilterProxy(ListModel(), "", "")
|
||||
|
||||
inviter: Optional[Dict[str, str]] = None
|
||||
leftEvent: Optional[Dict[str, str]] = None
|
||||
|
||||
|
||||
class RoomMember(ListItem):
|
||||
_required_init_values = {"userId"}
|
||||
_constant = {"userId"}
|
||||
|
||||
userId: str = ""
|
||||
|
||||
|
||||
class RoomEvent(ListItem):
|
||||
_required_init_values = {"eventId", "type", "dict", "dateTime"}
|
||||
_constant = {"type"}
|
||||
|
||||
eventId: str = ""
|
||||
type: str = ""
|
||||
dict: Dict[str, Any] = {}
|
||||
dateTime: QDateTime = QDateTime()
|
||||
isLocalEcho: bool = False
|
||||
|
||||
|
||||
# ----------
|
||||
|
||||
class User(ListItem):
|
||||
_required_init_values = {"userId", "devices"}
|
||||
_constant = {"userId", "devices"}
|
||||
|
||||
# Use PyQtFutures because the info might or might not need a request
|
||||
# to be fetched, and we don't want to block the UI in any case.
|
||||
# QML's property binding ability is used on the PyQtFuture.value
|
||||
userId: str = ""
|
||||
displayName: Optional[PyQtFuture] = None
|
||||
avatarUrl: Optional[PyQtFuture] = None
|
||||
statusMessage: Optional[PyQtFuture] = None
|
||||
devices: ListModel = ListModel()
|
||||
|
||||
|
||||
class Trust(Enum):
|
||||
blacklisted = -1
|
||||
undecided = 0
|
||||
trusted = 1
|
||||
|
||||
|
||||
class Device(ListItem):
|
||||
_required_init_values = {"deviceId", "ed25519Key"}
|
||||
_constant = {"deviceId", "ed25519Key"}
|
||||
|
||||
deviceId: str = ""
|
||||
ed25519Key: str = ""
|
||||
displayName: Optional[str] = None
|
||||
trust: Trust = Trust.undecided
|
||||
lastSeenIp: Optional[str] = None
|
||||
lastSeenDate: Optional[QDateTime] = None
|
|
@ -1,183 +0,0 @@
|
|||
# Copyright 2019 miruka
|
||||
# This file is part of harmonyqml, licensed under GPLv3.
|
||||
|
||||
import textwrap
|
||||
from typing import Any, Dict, List, Mapping, Set, Tuple, Union
|
||||
|
||||
from PyQt5.QtCore import QObject, pyqtProperty, pyqtSignal, pyqtSlot
|
||||
|
||||
PyqtType = Union[str, type]
|
||||
|
||||
|
||||
class _ListItemMeta(type(QObject)): # type: ignore
|
||||
__slots__ = ()
|
||||
|
||||
def __new__(mcs, name: str, bases: Tuple[type], attrs: Dict[str, Any]):
|
||||
def to_pyqt_type(type_) -> PyqtType:
|
||||
"Return an appropriate pyqtProperty type from an annotation."
|
||||
try:
|
||||
if issubclass(type_, (bool, int, float, str, type(None))):
|
||||
return type_
|
||||
if issubclass(type_, Mapping):
|
||||
return "QVariantMap"
|
||||
return "QVariant"
|
||||
except TypeError: # e.g. None passed
|
||||
return to_pyqt_type(type(type_))
|
||||
|
||||
# These special attributes must not be processed like properties
|
||||
special = {"_main_key", "_required_init_values", "_constant"}
|
||||
|
||||
# These properties won't be settable and will not have a notify signal
|
||||
constant: Set[str] = set(attrs.get("_constant") or set())
|
||||
|
||||
# pyqtProperty objects that were directly defined in the class
|
||||
direct_pyqt_props: Dict[str, pyqtProperty] = {
|
||||
name: obj for name, obj in attrs.items()
|
||||
if isinstance(obj, pyqtProperty)
|
||||
}
|
||||
|
||||
# {property_name: (its_pyqt_type, its_default_value)}
|
||||
props: Dict[str, Tuple[PyqtType, Any]] = {
|
||||
name: (to_pyqt_type(attrs.get("__annotations__", {}).get(name)),
|
||||
value)
|
||||
for name, value in attrs.items()
|
||||
|
||||
if not (name.startswith("__") or callable(value) or
|
||||
name in special)
|
||||
}
|
||||
|
||||
# Signals for the pyqtProperty notify arguments
|
||||
signals: Dict[str, pyqtSignal] = {
|
||||
f"{name}Changed": pyqtSignal(type_)
|
||||
for name, (type_, _) in props.items() if name not in constant
|
||||
}
|
||||
|
||||
# pyqtProperty() won't take None, so we make dicts of extra kwargs
|
||||
# to pass for each property
|
||||
pyqt_props_kwargs: Dict[str, Dict[str, Any]] = {
|
||||
name: {"constant": True} if name in constant else
|
||||
|
||||
{"notify": signals[f"{name}Changed"],
|
||||
|
||||
"fset": lambda self, value, n=name: (
|
||||
setattr(self, f"_{n}", value) or # type: ignore
|
||||
getattr(self, f"{n}Changed").emit(value),
|
||||
)}
|
||||
for name in props
|
||||
}
|
||||
|
||||
# The final pyqtProperty objects we create
|
||||
pyqt_props: Dict[str, pyqtProperty] = {
|
||||
name: pyqtProperty(
|
||||
type_,
|
||||
fget=lambda self, n=name: getattr(self, f"_{n}"),
|
||||
**pyqt_props_kwargs.get(name, {}),
|
||||
)
|
||||
for name, (type_, _) in props.items()
|
||||
}
|
||||
|
||||
attrs = {
|
||||
**attrs, # Original class attributes
|
||||
**signals,
|
||||
**direct_pyqt_props,
|
||||
**pyqt_props,
|
||||
|
||||
# Set the internal _properties as slots for memory savings
|
||||
"__slots__": tuple({f"_{prop}" for prop in props} & {"_main_key"}),
|
||||
|
||||
"_direct_props": list(direct_pyqt_props.keys()),
|
||||
"_props": props,
|
||||
|
||||
# The main key is either the attribute _main_key,
|
||||
# or the first defined property
|
||||
"_main_key": attrs.get("_main_key") or
|
||||
list(props.keys())[0] if props else None,
|
||||
|
||||
"_required_init_values": attrs.get("_required_init_values") or (),
|
||||
"_constant": constant,
|
||||
}
|
||||
return type.__new__(mcs, name, bases, attrs)
|
||||
|
||||
|
||||
class ListItem(QObject, metaclass=_ListItemMeta):
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
super().__init__()
|
||||
|
||||
method: str = "%s.__init__()" % type(self).__name__
|
||||
already_set: Set[str] = set()
|
||||
|
||||
required: Set[str] = set(self._required_init_values)
|
||||
required_num: int = len(required) + 1 # + 1 = self
|
||||
|
||||
args_num: int = len(self._props) + 1
|
||||
from_to: str = str(args_num) if required_num == args_num else \
|
||||
f"from {required_num} to {args_num}"
|
||||
|
||||
# Check that not too many positional arguments were passed
|
||||
if len(args) > len(self._props):
|
||||
raise TypeError(
|
||||
f"{method} takes {from_to} positional arguments but "
|
||||
f"{len(args) + 1} were given"
|
||||
)
|
||||
|
||||
# Set properties from provided positional arguments
|
||||
for prop, value in zip(self._props, args):
|
||||
setattr(self, f"_{prop}", self._set_parent(value))
|
||||
already_set.add(prop)
|
||||
|
||||
# Set properties from provided keyword arguments
|
||||
for prop, value in kwargs.items():
|
||||
if prop in already_set:
|
||||
raise TypeError(f"{method} got multiple values for "
|
||||
f"argument {prop!r}")
|
||||
if prop not in self._props:
|
||||
raise TypeError(f"{method} got an unexpected keyword "
|
||||
f"argument {prop!r}")
|
||||
setattr(self, f"_{prop}", self._set_parent(value))
|
||||
already_set.add(prop)
|
||||
|
||||
# Check for required init arguments not provided
|
||||
missing: Set[str] = required - already_set
|
||||
if missing:
|
||||
raise TypeError("%s missing %d required argument: %s" % (
|
||||
method, len(missing), ", ".join((repr(m) for m in missing))))
|
||||
|
||||
# Set default values for properties not provided in arguments
|
||||
for prop in set(self._props) - already_set:
|
||||
setattr(self, f"_{prop}", self._set_parent(self._props[prop][1]))
|
||||
|
||||
|
||||
def _set_parent(self, value: Any) -> Any:
|
||||
if isinstance(value, QObject):
|
||||
value.setParent(self)
|
||||
return value
|
||||
|
||||
|
||||
def __repr__(self) -> str:
|
||||
prop_strings = (
|
||||
"\033[{0};34m{1}\033[0,{0}m = \033[{0};32m{2}\033[0m".format(
|
||||
1 if p == self.mainKey else 0, # 1 = term bold
|
||||
p,
|
||||
repr(getattr(self, p))
|
||||
) for p in list(self._props.keys()) + self._direct_props
|
||||
)
|
||||
|
||||
return "\033[35m%s\033[0m(\n%s\n)" % (
|
||||
type(self).__name__,
|
||||
textwrap.indent(",\n".join(prop_strings), prefix=" " * 4)
|
||||
)
|
||||
|
||||
|
||||
@pyqtSlot(result=str)
|
||||
def repr(self) -> str:
|
||||
return self.__repr__()
|
||||
|
||||
|
||||
@pyqtProperty("QStringList", constant=True)
|
||||
def roles(self) -> List[str]:
|
||||
return list(self._props.keys()) + self._direct_props
|
||||
|
||||
|
||||
@pyqtProperty(str, constant=True)
|
||||
def mainKey(self) -> str:
|
||||
return self._main_key
|
|
@ -1,443 +0,0 @@
|
|||
import logging
|
||||
import textwrap
|
||||
from typing import (
|
||||
Any, Callable, Dict, Iterable, List, Mapping, MutableSequence, Optional,
|
||||
Sequence, Set, Tuple, Union
|
||||
)
|
||||
|
||||
from PyQt5.QtCore import (
|
||||
QAbstractListModel, QModelIndex, QObject, Qt, pyqtProperty, pyqtSignal,
|
||||
pyqtSlot
|
||||
)
|
||||
|
||||
from .list_item import ListItem
|
||||
|
||||
Index = Union[int, str]
|
||||
NewItem = Union[ListItem, Mapping[str, Any], Sequence]
|
||||
|
||||
|
||||
class _GetFail:
|
||||
pass
|
||||
|
||||
|
||||
class _PopFail:
|
||||
pass
|
||||
|
||||
|
||||
class ListModel(QAbstractListModel):
|
||||
rolesSet = pyqtSignal()
|
||||
changed = pyqtSignal()
|
||||
countChanged = pyqtSignal(int)
|
||||
|
||||
def __init__(self,
|
||||
initial_data: Optional[List[NewItem]] = None,
|
||||
container: Callable[..., MutableSequence] = list,
|
||||
default_factory: Optional[Callable[[str], ListItem]] = None,
|
||||
parent: QObject = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._data: MutableSequence[ListItem] = container()
|
||||
|
||||
self.default_factory = default_factory
|
||||
|
||||
if initial_data:
|
||||
self.extend(initial_data)
|
||||
|
||||
|
||||
def __repr__(self) -> str:
|
||||
if not self._data:
|
||||
return "\033[35m%s\033[0m()" % type(self).__name__
|
||||
|
||||
return "\033[35m%s\033[0m(\n%s\n)" % (
|
||||
type(self).__name__,
|
||||
textwrap.indent(
|
||||
",\n".join((repr(item) for item in self._data)),
|
||||
prefix = " " * 4,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def __contains__(self, index: Index) -> bool:
|
||||
if isinstance(index, str):
|
||||
try:
|
||||
self.indexWhere(index)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
return index in self._data
|
||||
|
||||
|
||||
def __getitem__(self, index: Index) -> ListItem:
|
||||
return self.get(index)
|
||||
|
||||
|
||||
def __setitem__(self, index: Index, value: NewItem) -> None:
|
||||
self.set(index, value)
|
||||
|
||||
|
||||
def __delitem__(self, index: Index) -> None:
|
||||
self.remove(index)
|
||||
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._data)
|
||||
|
||||
|
||||
def __iter__(self) -> Iterable[NewItem]:
|
||||
return iter(self._data)
|
||||
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
return bool(self._data)
|
||||
|
||||
|
||||
@pyqtSlot(result=str)
|
||||
def repr(self) -> str:
|
||||
return self.__repr__()
|
||||
|
||||
|
||||
@pyqtProperty("QStringList", notify=rolesSet)
|
||||
def roles(self) -> Tuple[str, ...]:
|
||||
return self._data[0].roles if self._data else () # type: ignore
|
||||
|
||||
|
||||
@pyqtProperty("QVariant", notify=rolesSet)
|
||||
def mainKey(self) -> Optional[str]:
|
||||
return self._data[0].mainKey if self._data else None
|
||||
|
||||
|
||||
def roleNumbers(self) -> Dict[str, int]:
|
||||
return {name: Qt.UserRole + i
|
||||
for i, name in enumerate(self.roles, 1)} \
|
||||
if self._data else {}
|
||||
|
||||
|
||||
def roleNames(self) -> Dict[int, bytes]:
|
||||
return {Qt.UserRole + i: bytes(name, "utf-8")
|
||||
for i, name in enumerate(self.roles, 1)} \
|
||||
if self._data else {}
|
||||
|
||||
|
||||
def data(self, index: QModelIndex, role: int = Qt.DisplayRole) -> Any:
|
||||
if role <= Qt.UserRole:
|
||||
return None
|
||||
|
||||
return getattr(self._data[index.row()],
|
||||
str(self.roleNames()[role], "utf8"))
|
||||
|
||||
|
||||
def rowCount(self, _: QModelIndex = QModelIndex()) -> int:
|
||||
return len(self)
|
||||
|
||||
|
||||
def _convert_new_value(self, value: NewItem) -> ListItem:
|
||||
def convert() -> ListItem:
|
||||
if self._data and isinstance(value, Mapping):
|
||||
if not set(value.keys()) <= set(self.roles):
|
||||
raise ValueError(
|
||||
f"{value}: must have all these keys: {self.roles}"
|
||||
)
|
||||
|
||||
return type(self._data[0])(**value)
|
||||
|
||||
if not self._data and isinstance(value, Mapping):
|
||||
raise NotImplementedError("First item must be set from Python")
|
||||
|
||||
if self._data and isinstance(value, type(self._data[0])):
|
||||
return value
|
||||
|
||||
if not self._data and isinstance(value, ListItem):
|
||||
return value
|
||||
|
||||
raise TypeError("%r: must be mapping or %s" % (
|
||||
value,
|
||||
type(self._data[0]).__name__ if self._data else "ListItem"
|
||||
))
|
||||
|
||||
value = convert()
|
||||
value.setParent(self)
|
||||
return value
|
||||
|
||||
|
||||
@pyqtProperty(int, notify=countChanged)
|
||||
def count(self) -> int:
|
||||
return len(self)
|
||||
|
||||
|
||||
@pyqtSlot("QVariant", result=int)
|
||||
def indexWhere(self,
|
||||
main_key_is_value: Any,
|
||||
_can_use_default_factory: bool = True) -> int:
|
||||
|
||||
for i, item in enumerate(self._data):
|
||||
if getattr(item, self.mainKey) == main_key_is_value:
|
||||
return i
|
||||
|
||||
if _can_use_default_factory and self.default_factory:
|
||||
return self.append(self.default_factory(main_key_is_value))
|
||||
|
||||
raise ValueError(
|
||||
f"No item in model data with "
|
||||
f"property {self.mainKey} is set to {main_key_is_value!r}."
|
||||
)
|
||||
|
||||
|
||||
@pyqtSlot(int, result="QVariant")
|
||||
@pyqtSlot(str, result="QVariant")
|
||||
@pyqtSlot(int, "QVariant", result="QVariant")
|
||||
@pyqtSlot(str, "QVariant", result="QVariant")
|
||||
def get(self, index: Index, default: Any = _GetFail()) -> ListItem:
|
||||
try:
|
||||
i_index: int = \
|
||||
self.indexWhere(index, _can_use_default_factory=False) \
|
||||
if isinstance(index, str) else index
|
||||
|
||||
return self._data[i_index]
|
||||
|
||||
except (ValueError, IndexError):
|
||||
if isinstance(default, _GetFail):
|
||||
if self.default_factory and isinstance(index, str):
|
||||
item = self.default_factory(index)
|
||||
self.append(item)
|
||||
return item
|
||||
raise
|
||||
|
||||
return default
|
||||
|
||||
|
||||
@pyqtSlot(int, "QVariantMap", result=int)
|
||||
def insert(self, index: int, value: NewItem) -> int:
|
||||
value = self._convert_new_value(value)
|
||||
|
||||
try:
|
||||
present_index = self.indexWhere(
|
||||
main_key_is_value = getattr(value, self.mainKey),
|
||||
_can_use_default_factory = False
|
||||
)
|
||||
except (TypeError, ValueError): # TypeError = no items in model
|
||||
pass
|
||||
else:
|
||||
logging.warning(
|
||||
"Duplicate mainKey %r in model - present: %r, inserting: %r",
|
||||
self.mainKey,
|
||||
self[present_index],
|
||||
value
|
||||
)
|
||||
|
||||
self.beginInsertRows(QModelIndex(), index, index)
|
||||
|
||||
had_data = bool(self._data)
|
||||
self._data.insert(index, value)
|
||||
if not had_data:
|
||||
self.rolesSet.emit()
|
||||
|
||||
self.endInsertRows()
|
||||
|
||||
self.countChanged.emit(len(self))
|
||||
self.changed.emit()
|
||||
return index
|
||||
|
||||
|
||||
@pyqtSlot("QVariantMap", result=int)
|
||||
def append(self, value: NewItem) -> int:
|
||||
return self.insert(len(self), value)
|
||||
|
||||
|
||||
@pyqtSlot(list)
|
||||
def extend(self, values: Iterable[NewItem]) -> None:
|
||||
for val in values:
|
||||
self.append(val)
|
||||
|
||||
|
||||
@pyqtSlot(list)
|
||||
@pyqtSlot(list, bool)
|
||||
def updateAll(self, items: Sequence[NewItem], delete: bool = False
|
||||
) -> None:
|
||||
items_: List[ListItem] = [self._convert_new_value(i) for i in items]
|
||||
|
||||
if delete:
|
||||
present_item: ListItem
|
||||
for i, present_item in enumerate(self):
|
||||
present_item_key = getattr(present_item, self.mainKey)
|
||||
|
||||
# If this present item is in the update items, based on mainKey
|
||||
for update_item in items_:
|
||||
if present_item_key == getattr(update_item, self.mainKey):
|
||||
break
|
||||
else:
|
||||
del self[i]
|
||||
|
||||
for item in items_:
|
||||
self.upsert(
|
||||
where_main_key_is = getattr(item, item.mainKey),
|
||||
update_with = item
|
||||
)
|
||||
|
||||
|
||||
@pyqtSlot(int, "QVariantMap", result=int)
|
||||
@pyqtSlot(int, "QVariantMap", "QStringList", result=int)
|
||||
@pyqtSlot(str, "QVariantMap", result=int)
|
||||
@pyqtSlot(str, "QVariantMap", "QStringList", result=int)
|
||||
def updateItem(self,
|
||||
index: Index,
|
||||
value: NewItem,
|
||||
no_update: Sequence[str] = ()) -> int:
|
||||
value = self._convert_new_value(value)
|
||||
|
||||
i_index: int = self.indexWhere(index, _can_use_default_factory=False) \
|
||||
if isinstance(index, str) else index
|
||||
|
||||
to_update = self[i_index]
|
||||
|
||||
updated_roles: Set[int] = set()
|
||||
|
||||
for role_name, role_num in self.roleNumbers().items():
|
||||
if role_name not in no_update:
|
||||
old_value = getattr(to_update, role_name)
|
||||
new_value = getattr(value, role_name)
|
||||
|
||||
if old_value != new_value:
|
||||
try:
|
||||
setattr(to_update, role_name, new_value)
|
||||
except AttributeError: # constant/not settable
|
||||
pass
|
||||
else:
|
||||
updated_roles.add(role_num)
|
||||
|
||||
if updated_roles:
|
||||
qidx = QAbstractListModel.index(self, i_index, 0)
|
||||
self.dataChanged.emit(qidx, qidx, updated_roles)
|
||||
self.changed.emit()
|
||||
|
||||
return i_index
|
||||
|
||||
|
||||
@pyqtSlot(str, "QVariantMap")
|
||||
@pyqtSlot(str, "QVariantMap", int)
|
||||
@pyqtSlot(str, "QVariantMap", int, int)
|
||||
@pyqtSlot(str, "QVariantMap", int, int, "QStringList")
|
||||
def upsert(self,
|
||||
where_main_key_is: Any,
|
||||
update_with: NewItem,
|
||||
new_index_if_insert: Optional[int] = None,
|
||||
new_index_if_update: Optional[int] = None,
|
||||
no_update: Sequence[str] = ()) -> None:
|
||||
try:
|
||||
index = self.updateItem(
|
||||
where_main_key_is, update_with, no_update
|
||||
)
|
||||
except (IndexError, ValueError):
|
||||
self.insert(new_index_if_insert or len(self), update_with)
|
||||
else:
|
||||
if new_index_if_update:
|
||||
self.move(index, new_index_if_update)
|
||||
|
||||
|
||||
@pyqtSlot(int, list)
|
||||
@pyqtSlot(str, list)
|
||||
def set(self, index: Index, value: NewItem) -> None:
|
||||
i_index: int = self.indexWhere(index) \
|
||||
if isinstance(index, str) else index
|
||||
|
||||
qidx = QAbstractListModel.index(self, i_index, 0)
|
||||
value = self._convert_new_value(value)
|
||||
self._data[i_index] = value
|
||||
self.dataChanged.emit(qidx, qidx, self.roleNames())
|
||||
self.changed.emit()
|
||||
|
||||
|
||||
@pyqtSlot(int, str, "QVariant")
|
||||
@pyqtSlot(str, str, "QVariant")
|
||||
def setProperty(self, index: Index, prop: str, value: Any) -> None:
|
||||
i_index: int = self.indexWhere(index) \
|
||||
if isinstance(index, str) else index
|
||||
|
||||
if getattr(self[i_index], prop) != value:
|
||||
setattr(self[i_index], prop, value)
|
||||
qidx = QAbstractListModel.index(self, i_index, 0)
|
||||
self.dataChanged.emit(qidx, qidx, (self.roleNumbers()[prop],))
|
||||
self.changed.emit()
|
||||
|
||||
|
||||
@pyqtSlot(int, int)
|
||||
@pyqtSlot(int, int, int)
|
||||
@pyqtSlot(str, int)
|
||||
@pyqtSlot(str, int, int)
|
||||
def move(self, from_: Index, to: int, n: int = 1) -> None:
|
||||
# pylint: disable=invalid-name
|
||||
i_from: int = self.indexWhere(from_) \
|
||||
if isinstance(from_, str) else from_
|
||||
|
||||
qlast = i_from + n - 1
|
||||
|
||||
if (n <= 0) or (i_from == to) or (qlast == to) or \
|
||||
not (len(self) > qlast >= 0) or \
|
||||
not len(self) >= to >= 0:
|
||||
return
|
||||
|
||||
qidx = QModelIndex()
|
||||
qto = min(len(self), to + n if to > i_from else to)
|
||||
# print(f"self.beginMoveRows(qidx, {i_from}, {qlast}, qidx, {qto})")
|
||||
valid = self.beginMoveRows(qidx, i_from, qlast, qidx, qto)
|
||||
|
||||
if not valid:
|
||||
logging.warning("Invalid move operation - %r", locals())
|
||||
return
|
||||
|
||||
last = i_from + n
|
||||
cut = self._data[i_from:last]
|
||||
del self._data[i_from:last]
|
||||
self._data[to:to] = cut
|
||||
|
||||
self.endMoveRows()
|
||||
self.changed.emit()
|
||||
|
||||
|
||||
@pyqtSlot(int)
|
||||
@pyqtSlot(str)
|
||||
def remove(self, index: Index) -> None:
|
||||
i_index: int = self.indexWhere(index) \
|
||||
if isinstance(index, str) else index
|
||||
|
||||
self.beginRemoveRows(QModelIndex(), i_index, i_index)
|
||||
del self._data[i_index]
|
||||
self.endRemoveRows()
|
||||
|
||||
self.countChanged.emit(len(self))
|
||||
self.changed.emit()
|
||||
|
||||
|
||||
@pyqtSlot(int, result="QVariant")
|
||||
@pyqtSlot(str, result="QVariant")
|
||||
def pop(self, index: Index, default: Any = _PopFail()) -> ListItem:
|
||||
try:
|
||||
i_index: int = self.indexWhere(index) \
|
||||
if isinstance(index, str) else index
|
||||
item = self[i_index]
|
||||
|
||||
except (ValueError, IndexError):
|
||||
if isinstance(default, _PopFail):
|
||||
raise
|
||||
return default
|
||||
|
||||
self.beginRemoveRows(QModelIndex(), i_index, i_index)
|
||||
del self._data[i_index]
|
||||
self.endRemoveRows()
|
||||
|
||||
self.countChanged.emit(len(self))
|
||||
self.changed.emit()
|
||||
return item
|
||||
|
||||
|
||||
@pyqtSlot()
|
||||
def clear(self) -> None:
|
||||
if not self._data:
|
||||
return
|
||||
|
||||
# Reimplemented for performance reasons (begin/endRemoveRows)
|
||||
self.beginRemoveRows(QModelIndex(), 0, len(self))
|
||||
self._data.clear()
|
||||
self.endRemoveRows()
|
||||
|
||||
self.countChanged.emit(len(self))
|
||||
self.changed.emit()
|
|
@ -1,58 +0,0 @@
|
|||
from typing import Any, DefaultDict
|
||||
|
||||
from PyQt5.QtCore import QObject, pyqtSlot
|
||||
|
||||
from .list_model import ListModel
|
||||
|
||||
|
||||
class ListModelMap(QObject):
|
||||
def __init__(self, *models_args, parent: QObject = None, **models_kwargs
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
models_kwargs["parent"] = self
|
||||
|
||||
# Set the parent to prevent item garbage-collection on the C++ side
|
||||
self.dict: DefaultDict[Any, ListModel] = \
|
||||
DefaultDict(
|
||||
lambda: ListModel(*models_args, **models_kwargs)
|
||||
)
|
||||
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return "%s(%r)" % (type(self).__name__, self.dict)
|
||||
|
||||
|
||||
def __getitem__(self, key) -> ListModel:
|
||||
return self.dict[key]
|
||||
|
||||
|
||||
def __setitem__(self, key, value: ListModel) -> None:
|
||||
value.setParent(self)
|
||||
self.dict[key] = value
|
||||
|
||||
|
||||
def __detitem__(self, key) -> None:
|
||||
del self.dict[key]
|
||||
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self.dict)
|
||||
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self.dict)
|
||||
|
||||
|
||||
@pyqtSlot(result=str)
|
||||
def repr(self) -> str:
|
||||
return self.__repr__()
|
||||
|
||||
|
||||
@pyqtSlot(str, result="QVariant")
|
||||
def get(self, key) -> ListModel:
|
||||
return self.dict[key]
|
||||
|
||||
|
||||
@pyqtSlot(str, result=bool)
|
||||
def has(self, key) -> bool:
|
||||
return key in self.dict
|
|
@ -1,134 +0,0 @@
|
|||
from typing import Callable, Dict, Optional
|
||||
|
||||
from PyQt5.QtCore import (
|
||||
QModelIndex, QObject, QSortFilterProxyModel, Qt, pyqtProperty, pyqtSignal,
|
||||
pyqtSlot
|
||||
)
|
||||
|
||||
from .list_model import ListModel
|
||||
from .list_item import ListItem
|
||||
|
||||
SortCallable = Callable[["SortFilterProxy", ListItem, ListItem], bool]
|
||||
FilterCallable = Callable[["SortFilterProxy", ListItem], bool]
|
||||
|
||||
class SortFilterProxy(QSortFilterProxyModel):
|
||||
sortByRoleChanged = pyqtSignal()
|
||||
filterByRoleChanged = pyqtSignal()
|
||||
filterChanged = pyqtSignal()
|
||||
countChanged = pyqtSignal(int)
|
||||
|
||||
def __init__(self,
|
||||
source_model: ListModel,
|
||||
sort_by_role: str = "",
|
||||
filter_by_role: str = "",
|
||||
sort_func: Optional[SortCallable] = None,
|
||||
filter_func: Optional[FilterCallable] = None,
|
||||
reverse: bool = False,
|
||||
parent: QObject = None) -> None:
|
||||
|
||||
error = "{} and {}: only one can be set"
|
||||
if (sort_by_role and sort_func):
|
||||
raise TypeError(error.format("sort_by_role", "sort_func"))
|
||||
if (filter_by_role and filter_func):
|
||||
raise TypeError(error.format("filter_by_role", "filter_func"))
|
||||
|
||||
super().__init__(parent)
|
||||
self.setDynamicSortFilter(False)
|
||||
|
||||
self.setSourceModel(source_model)
|
||||
source_model.countChanged.connect(self.countChanged.emit)
|
||||
source_model.changed.connect(self._apply_sort)
|
||||
source_model.changed.connect(self.invalidateFilter)
|
||||
|
||||
self.sortByRole = sort_by_role
|
||||
self.filterByRole = filter_by_role
|
||||
self.sort_func = sort_func
|
||||
self.filter_func = filter_func
|
||||
self.reverse = reverse
|
||||
|
||||
self._filter = None
|
||||
|
||||
|
||||
@pyqtProperty(str, notify=filterChanged)
|
||||
def filter(self) -> str:
|
||||
return self._filter
|
||||
|
||||
|
||||
@filter.setter # type: ignore
|
||||
def filter(self, pattern: str) -> None:
|
||||
self._filter = pattern
|
||||
self.invalidateFilter()
|
||||
self.filterChanged.emit()
|
||||
self.countChanged.emit(self.rowCount())
|
||||
|
||||
|
||||
# Sorting/filtering methods override
|
||||
|
||||
def lessThan(self, index_left: QModelIndex, index_right: QModelIndex
|
||||
) -> bool:
|
||||
left = self.sourceModel()[index_left.row()]
|
||||
right = self.sourceModel()[index_right.row()]
|
||||
|
||||
if self.sort_func:
|
||||
return self.sort_func(self, left, right)
|
||||
|
||||
role = self.sortByRole
|
||||
try:
|
||||
return getattr(left, role) < getattr(right, role)
|
||||
except TypeError: # comparison between the two types not supported
|
||||
return False
|
||||
|
||||
|
||||
def filterAcceptsRow(self, row_index: int, _: QModelIndex) -> bool:
|
||||
item = self.sourceModel()[row_index]
|
||||
|
||||
if self.filter_func:
|
||||
return self.filter_func(self, item)
|
||||
|
||||
return self.filterMatches(getattr(item, self.filterByRole))
|
||||
|
||||
|
||||
# Implementations
|
||||
|
||||
|
||||
def _apply_sort(self) -> None:
|
||||
order = Qt.DescendingOrder if self.reverse else Qt.AscendingOrder
|
||||
self.sort(0, order)
|
||||
|
||||
|
||||
def filterMatches(self, string: str) -> bool:
|
||||
if not self.filter:
|
||||
return True
|
||||
|
||||
string = string.lower()
|
||||
return all(word in string for word in self.filter.lower().split())
|
||||
|
||||
|
||||
# The rest
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return \
|
||||
"%s(sortByRole=%r, filterByRole=%r, filter=%r, sourceModel=%s)" % (
|
||||
type(self).__name__,
|
||||
self.sortByRole,
|
||||
self.filterByRole,
|
||||
self.filter,
|
||||
"<%s at %s>" % (
|
||||
type(self.sourceModel()).__name__,
|
||||
hex(id(self.sourceModel())),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@pyqtSlot(result=str)
|
||||
def repr(self) -> str:
|
||||
return self.__repr__()
|
||||
|
||||
|
||||
@pyqtProperty(int, notify=countChanged)
|
||||
def count(self) -> int:
|
||||
return self.rowCount()
|
||||
|
||||
|
||||
def roleNames(self) -> Dict[int, bytes]:
|
||||
return self.sourceModel().roleNames()
|
|
@ -1,149 +0,0 @@
|
|||
# Copyright 2019 miruka
|
||||
# This file is part of harmonyqml, licensed under GPLv3.
|
||||
|
||||
import logging
|
||||
import socket
|
||||
import ssl
|
||||
import time
|
||||
from threading import Lock
|
||||
from typing import Callable, Optional, Tuple
|
||||
from uuid import UUID
|
||||
|
||||
import nio
|
||||
|
||||
OptSock = Optional[ssl.SSLSocket]
|
||||
NioRequestFunc = Callable[..., Tuple[UUID, bytes]]
|
||||
|
||||
|
||||
class NioErrorResponse(Exception):
|
||||
def __init__(self, response: nio.ErrorResponse) -> None:
|
||||
self.response = response
|
||||
super().__init__(str(response))
|
||||
|
||||
|
||||
class RetrySleeper:
|
||||
def __init__(self) -> None:
|
||||
self.current_time: float = 0
|
||||
self.tries: int = 0
|
||||
|
||||
|
||||
def sleep(self, max_time: float) -> None:
|
||||
self.current_time = max(
|
||||
0, min((max_time / 10) * (2 ^ (self.tries - 1)), max_time)
|
||||
)
|
||||
time.sleep(self.current_time)
|
||||
self.tries += 1
|
||||
|
||||
|
||||
class NetworkManager:
|
||||
http_retry_codes = {408, 429, 500, 502, 503, 504, 507}
|
||||
|
||||
|
||||
def __init__(self, host: str, port: int, nio_client: nio.client.HttpClient
|
||||
) -> None:
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.nio = nio_client
|
||||
|
||||
self._ssl_context: ssl.SSLContext = ssl.create_default_context()
|
||||
self._ssl_session: Optional[ssl.SSLSession] = None
|
||||
self._lock: Lock = Lock()
|
||||
|
||||
|
||||
def _get_socket(self) -> ssl.SSLSocket:
|
||||
sock = self._ssl_context.wrap_socket( # type: ignore
|
||||
socket.create_connection((self.host, self.port), timeout=16),
|
||||
server_hostname = self.host,
|
||||
session = self._ssl_session,
|
||||
)
|
||||
self._ssl_session = self._ssl_session or sock.session
|
||||
return sock
|
||||
|
||||
|
||||
@staticmethod
|
||||
def _close_socket(sock: Optional[socket.socket]) -> None:
|
||||
if not sock:
|
||||
return
|
||||
|
||||
try:
|
||||
sock.shutdown(how=socket.SHUT_RDWR)
|
||||
except OSError: # Already closer by server
|
||||
pass
|
||||
sock.close()
|
||||
|
||||
|
||||
def http_disconnect(self) -> None:
|
||||
try:
|
||||
self.write(self.nio.disconnect())
|
||||
except (OSError, nio.ProtocolError):
|
||||
pass
|
||||
|
||||
|
||||
def read(self, with_sock: OptSock = None) -> nio.Response:
|
||||
sock = with_sock or self._get_socket()
|
||||
|
||||
response = None
|
||||
while not response:
|
||||
left_to_send = self.nio.data_to_send()
|
||||
if left_to_send:
|
||||
self.write(left_to_send, sock)
|
||||
|
||||
self.nio.receive(sock.recv(4096))
|
||||
response = self.nio.next_response()
|
||||
|
||||
if isinstance(response, nio.ErrorResponse):
|
||||
raise NioErrorResponse(response)
|
||||
|
||||
if not with_sock:
|
||||
self._close_socket(sock)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
def write(self, data: bytes, with_sock: OptSock = None) -> None:
|
||||
sock = with_sock or self._get_socket()
|
||||
sock.sendall(data)
|
||||
|
||||
if not with_sock:
|
||||
self._close_socket(sock)
|
||||
|
||||
|
||||
def talk(self,
|
||||
nio_func: NioRequestFunc,
|
||||
*args,
|
||||
**kwargs) -> nio.Response:
|
||||
|
||||
with self._lock:
|
||||
retry = RetrySleeper()
|
||||
|
||||
while True:
|
||||
sock = None
|
||||
|
||||
try:
|
||||
sock = self._get_socket()
|
||||
|
||||
if not self.nio.connection:
|
||||
# Establish HTTP protocol connection:
|
||||
self.write(self.nio.connect(), sock)
|
||||
|
||||
to_send = nio_func(*args, **kwargs)[1]
|
||||
self.write(to_send, sock)
|
||||
response = self.read(sock)
|
||||
|
||||
except (OSError, nio.RemoteTransportError) as err:
|
||||
self._close_socket(sock)
|
||||
self.http_disconnect()
|
||||
retry.sleep(max_time=2)
|
||||
|
||||
except NioErrorResponse as err:
|
||||
logging.error("Nio response error for %s: %s",
|
||||
nio_func.__name__, err)
|
||||
self._close_socket(sock)
|
||||
|
||||
if err.response.status_code not in self.http_retry_codes:
|
||||
raise
|
||||
|
||||
retry.sleep(max_time=10)
|
||||
|
||||
else:
|
||||
return response
|
|
@ -1,164 +0,0 @@
|
|||
# Copyright 2019 miruka
|
||||
# This file is part of harmonyqml, licensed under GPLv3.
|
||||
|
||||
import functools
|
||||
import logging as log
|
||||
import sys
|
||||
import time
|
||||
import traceback
|
||||
from concurrent.futures import Executor, Future
|
||||
from typing import Any, Callable, Deque, Optional, Tuple, Union
|
||||
|
||||
from PyQt5.QtCore import QObject, pyqtProperty, pyqtSignal, pyqtSlot
|
||||
|
||||
|
||||
class PyQtFuture(QObject):
|
||||
gotResult = pyqtSignal("QVariant")
|
||||
|
||||
def __init__(self,
|
||||
future: Future,
|
||||
running_value: Any = None,
|
||||
parent: Optional[QObject] = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.future = future
|
||||
self.running_value = running_value
|
||||
self._result = None
|
||||
|
||||
self.future.add_done_callback(
|
||||
lambda future: self.gotResult.emit(future.result())
|
||||
)
|
||||
|
||||
|
||||
def __repr__(self) -> str:
|
||||
state = ("canceled" if self.cancelled else
|
||||
"running" if self.running else
|
||||
"finished")
|
||||
|
||||
return "%s(state=%s, value=%r)" % (
|
||||
type(self).__name__, state, self.value
|
||||
)
|
||||
|
||||
|
||||
def __lt__(self, other: "PyQtFuture") -> bool:
|
||||
# This is to allow sorting, e.g. from SortFilterProxy.lessThan()
|
||||
return self.value < other.value
|
||||
|
||||
|
||||
@pyqtSlot()
|
||||
def cancel(self):
|
||||
self.future.cancel()
|
||||
|
||||
|
||||
@pyqtProperty(bool)
|
||||
def cancelled(self):
|
||||
return self.future.cancelled()
|
||||
|
||||
|
||||
@pyqtProperty(bool)
|
||||
def running(self):
|
||||
return self.future.running()
|
||||
|
||||
|
||||
@pyqtProperty(bool)
|
||||
def done(self):
|
||||
return self.future.done()
|
||||
|
||||
|
||||
@pyqtSlot(result="QVariant")
|
||||
@pyqtSlot(int, result="QVariant")
|
||||
@pyqtSlot(float, result="QVariant")
|
||||
def result(self, timeout: Optional[Union[int, float]] = None):
|
||||
return self.future.result(timeout)
|
||||
|
||||
|
||||
@pyqtProperty("QVariant", notify=gotResult)
|
||||
def value(self):
|
||||
return self.future.result() if self.done else self.running_value
|
||||
|
||||
|
||||
def add_done_callback(self, fn: Callable[[Future], None]) -> None:
|
||||
self.future.add_done_callback(fn)
|
||||
|
||||
|
||||
_Task = Tuple[Executor, Callable, Optional[tuple], Optional[dict]]
|
||||
_RUNNING: Deque[_Task] = Deque()
|
||||
_PENDING: Deque[_Task] = Deque()
|
||||
|
||||
|
||||
def futurize(max_running: Optional[int] = None,
|
||||
consider_args: bool = False,
|
||||
discard_if_max_running: bool = False,
|
||||
pyqt: bool = True,
|
||||
running_value: Any = None) -> Callable:
|
||||
|
||||
def decorator(func: Callable) -> Callable:
|
||||
|
||||
@functools.wraps(func)
|
||||
def wrapper(self, *args, **kws) -> Optional[PyQtFuture]:
|
||||
task: _Task = (
|
||||
self.pool,
|
||||
func,
|
||||
args if consider_args else None,
|
||||
kws if consider_args else None,
|
||||
)
|
||||
|
||||
def can_run_now() -> bool:
|
||||
if max_running is not None and \
|
||||
_RUNNING.count(task) >= max_running:
|
||||
log.debug("!! Max %d tasks of this kind running: %r",
|
||||
max_running, task[1:])
|
||||
return False
|
||||
|
||||
if not consider_args or not _PENDING:
|
||||
return True
|
||||
|
||||
log.debug(".. Pending: %r\n Queue: %r", task[1:], _PENDING)
|
||||
candidate_task = next((
|
||||
pending for pending in _PENDING
|
||||
if pending[0] == self.pool and pending[1] == func
|
||||
), None)
|
||||
|
||||
if candidate_task is None:
|
||||
log.debug(">> No other candidate, starting: %r", task[1:])
|
||||
return True
|
||||
|
||||
if candidate_task[2] == args and candidate_task[3] == kws:
|
||||
log.debug(">> Candidate is us: %r", candidate_task[1:])
|
||||
return True
|
||||
|
||||
log.debug("XX Other candidate: %r", candidate_task[1:])
|
||||
return False
|
||||
|
||||
if not can_run_now() and discard_if_max_running:
|
||||
log.debug("\\/ Discarding task: %r", task[1:])
|
||||
return None
|
||||
|
||||
def run_and_catch_errs():
|
||||
if not can_run_now():
|
||||
log.debug("~~ Can't start now: %r", task[1:])
|
||||
_PENDING.append(task)
|
||||
|
||||
while not can_run_now():
|
||||
time.sleep(0.05)
|
||||
|
||||
_RUNNING.append(task)
|
||||
log.debug("Starting: %r", task[1:])
|
||||
|
||||
# Without this, exceptions are silently ignored
|
||||
try:
|
||||
return func(self, *args, **kws)
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
log.error("Exiting thread/process due to exception.")
|
||||
sys.exit(1)
|
||||
finally:
|
||||
del _RUNNING[_RUNNING.index(task)]
|
||||
|
||||
future = self.pool.submit(run_and_catch_errs)
|
||||
return PyQtFuture(
|
||||
future=future, running_value=running_value, parent=self
|
||||
) if pyqt else future
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
|
@ -1,490 +0,0 @@
|
|||
# Copyright 2019 miruka
|
||||
# This file is part of harmonyqml, licensed under GPLv3.
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from threading import Lock
|
||||
from typing import Any, Deque, Dict, List, Optional
|
||||
|
||||
from PyQt5.QtCore import QDateTime, QObject, pyqtBoundSignal, pyqtSignal
|
||||
|
||||
import nio
|
||||
from nio.rooms import MatrixRoom
|
||||
|
||||
from .backend import Backend
|
||||
from .client import Client
|
||||
from .model.items import (
|
||||
Account, Device, ListModel, Room, RoomCategory, RoomEvent, RoomMember,
|
||||
User
|
||||
)
|
||||
from .model.sort_filter_proxy import SortFilterProxy
|
||||
from .pyqt_future import futurize
|
||||
|
||||
Inviter = Optional[Dict[str, str]]
|
||||
LeftEvent = Optional[Dict[str, str]]
|
||||
|
||||
|
||||
class SignalManager(QObject):
|
||||
roomCategoryChanged = pyqtSignal(str, str, str, str)
|
||||
|
||||
_lock: Lock = Lock()
|
||||
|
||||
def __init__(self, backend: Backend) -> None:
|
||||
super().__init__(parent=backend)
|
||||
self.pool: ThreadPoolExecutor = ThreadPoolExecutor(max_workers=6)
|
||||
|
||||
self.backend = backend
|
||||
|
||||
self.last_room_events: Deque[str] = Deque(maxlen=1000)
|
||||
self._events_in_transfer: int = 0
|
||||
|
||||
self.backend.clients.clientAdded.connect(self.onClientAdded)
|
||||
self.backend.clients.clientDeleted.connect(self.onClientDeleted)
|
||||
|
||||
|
||||
def onClientAdded(self, client: Client) -> None:
|
||||
if client.userId in self.backend.accounts:
|
||||
return
|
||||
|
||||
# An user might already exist in the model, e.g. if another account
|
||||
# was in a room with the account that we just connected to
|
||||
self.backend.users.upsert(
|
||||
where_main_key_is = client.userId,
|
||||
update_with = User(
|
||||
userId = client.userId,
|
||||
displayName = self.backend.users[client.userId].displayName,
|
||||
# Devices are added later, we might need to upload keys before
|
||||
# but we want to show the accounts ASAP in the client side pane
|
||||
devices = ListModel(),
|
||||
)
|
||||
)
|
||||
|
||||
# Backend.accounts
|
||||
room_categories_kwargs: List[Dict[str, Any]] = [
|
||||
{"name": "Invites", "rooms": ListModel()},
|
||||
{"name": "Rooms", "rooms": ListModel()},
|
||||
{"name": "Left", "rooms": ListModel()},
|
||||
]
|
||||
|
||||
for i, _ in enumerate(room_categories_kwargs):
|
||||
proxy = SortFilterProxy(
|
||||
source_model = room_categories_kwargs[i]["rooms"],
|
||||
sort_by_role = "lastEventDateTime",
|
||||
filter_by_role = "displayName",
|
||||
reverse = True,
|
||||
)
|
||||
room_categories_kwargs[i]["sortedRooms"] = proxy
|
||||
|
||||
self.backend.accounts.append(Account(
|
||||
userId = client.userId,
|
||||
roomCategories = ListModel([
|
||||
RoomCategory(**kws) for kws in room_categories_kwargs
|
||||
]),
|
||||
))
|
||||
|
||||
# Upload our E2E keys to the matrix server if needed
|
||||
if not client.nio.olm_account_shared:
|
||||
client.uploadE2EKeys()
|
||||
|
||||
# Add all devices nio knows for this account
|
||||
store = client.nio.device_store
|
||||
|
||||
for user_id in store.users:
|
||||
user = self.backend.users.get(user_id, None)
|
||||
if not user:
|
||||
self.backend.users.append(
|
||||
User(userId=user_id, devices=ListModel())
|
||||
)
|
||||
|
||||
for device in store.active_user_devices(user_id):
|
||||
self.backend.users[client.userId].devices.upsert(
|
||||
where_main_key_is = device.id,
|
||||
update_with = Device(
|
||||
deviceId = device.id,
|
||||
ed25519Key = device.ed25519,
|
||||
trust = client.getDeviceTrust(device),
|
||||
)
|
||||
)
|
||||
|
||||
# Finally, connect all client signals
|
||||
self.connectClient(client)
|
||||
|
||||
|
||||
def onClientDeleted(self, user_id: str) -> None:
|
||||
del self.backend.accounts[user_id]
|
||||
|
||||
|
||||
def connectClient(self, client: Client) -> None:
|
||||
for name in dir(client):
|
||||
attr = getattr(client, name)
|
||||
|
||||
if isinstance(attr, pyqtBoundSignal):
|
||||
def onSignal(*args, name=name) -> None:
|
||||
func = getattr(self, f"on{name[0].upper()}{name[1:]}")
|
||||
func(client, *args)
|
||||
|
||||
attr.connect(onSignal)
|
||||
|
||||
|
||||
@staticmethod
|
||||
def _get_room_displayname(nio_room: MatrixRoom) -> Optional[str]:
|
||||
name = nio_room.name or nio_room.canonical_alias
|
||||
if name:
|
||||
return name
|
||||
|
||||
name = nio_room.group_name()
|
||||
return None if name == "Empty room?" else name
|
||||
|
||||
|
||||
def _add_users_from_nio_room(self, room: nio.rooms.MatrixRoom) -> None:
|
||||
for user in room.users.values():
|
||||
@futurize(running_value=user.display_name)
|
||||
def get_displayname(self, user) -> str:
|
||||
# pylint:disable=unused-argument
|
||||
return user.display_name
|
||||
|
||||
self.backend.users.upsert(
|
||||
where_main_key_is = user.user_id,
|
||||
update_with = User(
|
||||
userId = user.user_id,
|
||||
displayName = get_displayname(self, user),
|
||||
devices = ListModel()
|
||||
),
|
||||
no_update = ("devices",),
|
||||
)
|
||||
|
||||
|
||||
def _members_sort_func(self, _, left: RoomMember, right: RoomMember
|
||||
) -> bool:
|
||||
users = self.backend.users
|
||||
return users[left.userId].displayName < users[right.userId].displayName
|
||||
|
||||
|
||||
def _members_filter_func(self, proxy: SortFilterProxy, member: RoomMember
|
||||
) -> bool:
|
||||
users = self.backend.users
|
||||
return proxy.filterMatches(users[member.userId].displayName.value)
|
||||
|
||||
|
||||
def onRoomInvited(self,
|
||||
client: Client,
|
||||
room_id: str,
|
||||
inviter: Inviter = None) -> None:
|
||||
|
||||
nio_room = client.nio.invited_rooms[room_id]
|
||||
self._add_users_from_nio_room(nio_room)
|
||||
|
||||
categories = self.backend.accounts[client.userId].roomCategories
|
||||
|
||||
previous_room = categories["Rooms"].rooms.pop(room_id, None)
|
||||
previous_left = categories["Left"].rooms.pop(room_id, None)
|
||||
|
||||
members = ListModel()
|
||||
sorted_members = SortFilterProxy(
|
||||
source_model = members,
|
||||
sort_func = self._members_sort_func,
|
||||
filter_func = self._members_filter_func,
|
||||
)
|
||||
|
||||
categories["Invites"].rooms.upsert(
|
||||
where_main_key_is = room_id,
|
||||
update_with = Room(
|
||||
roomId = room_id,
|
||||
displayName = self._get_room_displayname(nio_room),
|
||||
topic = nio_room.topic,
|
||||
inviter = inviter,
|
||||
lastEventDateTime = QDateTime.currentDateTime(), # FIXME
|
||||
members = members,
|
||||
sortedMembers = sorted_members,
|
||||
),
|
||||
no_update = ("typingMembers", "members"),
|
||||
)
|
||||
categories["Invites"].rooms[room_id].members.updateAll([
|
||||
RoomMember(userId=user_id) for user_id in nio_room.users
|
||||
], delete=True)
|
||||
|
||||
signal = self.roomCategoryChanged
|
||||
if previous_room:
|
||||
signal.emit(client.userId, room_id, "Rooms", "Invites")
|
||||
elif previous_left:
|
||||
signal.emit(client.userId, room_id, "Left", "Invites")
|
||||
|
||||
|
||||
def onRoomJoined(self, client: Client, room_id: str) -> None:
|
||||
nio_room = client.nio.rooms[room_id]
|
||||
self._add_users_from_nio_room(nio_room)
|
||||
|
||||
categories = self.backend.accounts[client.userId].roomCategories
|
||||
|
||||
previous_invite = categories["Invites"].rooms.pop(room_id, None)
|
||||
previous_left = categories["Left"].rooms.pop(room_id, None)
|
||||
|
||||
members = ListModel()
|
||||
sorted_members = SortFilterProxy(
|
||||
source_model = members,
|
||||
sort_func = self._members_sort_func,
|
||||
filter_func = self._members_filter_func,
|
||||
)
|
||||
|
||||
categories["Rooms"].rooms.upsert(
|
||||
where_main_key_is = room_id,
|
||||
update_with = Room(
|
||||
roomId = room_id,
|
||||
displayName = self._get_room_displayname(nio_room),
|
||||
topic = nio_room.topic,
|
||||
members = members,
|
||||
sortedMembers = sorted_members,
|
||||
),
|
||||
no_update = ("typingMembers", "members", "sortedMembers",
|
||||
"lastEventDateTime"),
|
||||
)
|
||||
categories["Rooms"].rooms[room_id].members.updateAll([
|
||||
RoomMember(userId=user_id) for user_id in nio_room.users
|
||||
], delete=True)
|
||||
|
||||
signal = self.roomCategoryChanged
|
||||
if previous_invite:
|
||||
signal.emit(client.userId, room_id, "Invites", "Rooms")
|
||||
elif previous_left:
|
||||
signal.emit(client.userId, room_id, "Left", "Rooms")
|
||||
|
||||
|
||||
def onRoomLeft(self,
|
||||
client: Client,
|
||||
room_id: str,
|
||||
left_event: LeftEvent = None) -> None:
|
||||
categories = self.backend.accounts[client.userId].roomCategories
|
||||
|
||||
previous_room = categories["Rooms"].rooms.pop(room_id, None)
|
||||
previous_invite = categories["Invites"].rooms.pop(room_id, None)
|
||||
previous = previous_room or previous_invite or \
|
||||
categories["Left"].rooms.get(room_id, None)
|
||||
|
||||
left_time = left_event.get("server_timestamp") if left_event else None
|
||||
|
||||
members = ListModel()
|
||||
sorted_members = SortFilterProxy(
|
||||
source_model = members,
|
||||
sort_func = self._members_sort_func,
|
||||
filter_func = self._members_filter_func,
|
||||
)
|
||||
|
||||
categories["Left"].rooms.upsert(
|
||||
where_main_key_is = room_id,
|
||||
update_with = Room(
|
||||
roomId = room_id,
|
||||
displayName = previous.displayName if previous else None,
|
||||
topic = previous.topic if previous else None,
|
||||
leftEvent = left_event,
|
||||
lastEventDateTime = (
|
||||
QDateTime.fromMSecsSinceEpoch(left_time)
|
||||
if left_time else QDateTime.currentDateTime()
|
||||
),
|
||||
members = members,
|
||||
sortedMembers = sorted_members,
|
||||
),
|
||||
no_update = ("members", "sortedMembers", "lastEventDateTime"),
|
||||
)
|
||||
|
||||
signal = self.roomCategoryChanged
|
||||
if previous_room:
|
||||
signal.emit(client.userId, room_id, "Rooms", "Left")
|
||||
elif previous_invite:
|
||||
signal.emit(client.userId, room_id, "Invites", "Left")
|
||||
|
||||
|
||||
|
||||
def onRoomSyncPrevBatchTokenReceived(self,
|
||||
_: Client,
|
||||
room_id: str,
|
||||
token: str) -> None:
|
||||
|
||||
if room_id not in self.backend.past_tokens:
|
||||
self.backend.past_tokens[room_id] = token
|
||||
|
||||
|
||||
def onRoomPastPrevBatchTokenReceived(self,
|
||||
_: Client,
|
||||
room_id: str,
|
||||
token: str) -> None:
|
||||
|
||||
if self.backend.past_tokens[room_id] == token:
|
||||
self.backend.fully_loaded_rooms.add(room_id)
|
||||
|
||||
self.backend.past_tokens[room_id] = token
|
||||
|
||||
|
||||
def _set_room_last_event(self, user_id: str, room_id: str, event: RoomEvent
|
||||
) -> None:
|
||||
for categ in self.backend.accounts[user_id].roomCategories:
|
||||
if room_id in categ.rooms:
|
||||
|
||||
last = categ.rooms[room_id].lastEventDateTime
|
||||
if last and last > event.dateTime:
|
||||
continue
|
||||
|
||||
# Use setProperty to make sure to trigger model changed signals
|
||||
categ.rooms.setProperty(
|
||||
room_id, "lastEventDateTime", event.dateTime
|
||||
)
|
||||
|
||||
|
||||
def onRoomEventReceived(self,
|
||||
client: Client,
|
||||
room_id: str,
|
||||
etype: str,
|
||||
edict: Dict[str, Any]) -> None:
|
||||
|
||||
def process() -> Optional[RoomEvent]:
|
||||
# Prevent duplicate events in models due to multiple accounts
|
||||
if edict["event_id"] in self.last_room_events:
|
||||
return None
|
||||
|
||||
self.last_room_events.appendleft(edict["event_id"])
|
||||
|
||||
model = self.backend.roomEvents[room_id]
|
||||
date_time = QDateTime\
|
||||
.fromMSecsSinceEpoch(edict["server_timestamp"])
|
||||
new_event = RoomEvent(
|
||||
eventId = edict["event_id"],
|
||||
type = etype,
|
||||
dateTime = date_time,
|
||||
dict = edict,
|
||||
)
|
||||
|
||||
event_is_our_profile_changed = (
|
||||
etype == "RoomMemberEvent" and
|
||||
edict.get("sender") in self.backend.clients and
|
||||
((edict.get("content") or {}).get("membership") ==
|
||||
(edict.get("prev_content") or {}).get("membership"))
|
||||
)
|
||||
|
||||
if event_is_our_profile_changed:
|
||||
return None
|
||||
|
||||
if etype == "RoomCreateEvent":
|
||||
self.backend.fully_loaded_rooms.add(room_id)
|
||||
|
||||
if self._events_in_transfer:
|
||||
local_echoes_met: int = 0
|
||||
update_at: Optional[int] = None
|
||||
|
||||
# Find if any locally echoed event corresponds to new_event
|
||||
for i, event in enumerate(model):
|
||||
if not event.isLocalEcho:
|
||||
continue
|
||||
|
||||
sb = (event.dict.get("sender"), event.dict.get("body"))
|
||||
new_sb = (new_event.dict.get("sender"),
|
||||
new_event.dict.get("body"))
|
||||
|
||||
if sb == new_sb:
|
||||
# The oldest matching local echo shall be replaced
|
||||
update_at = max(update_at or 0, i)
|
||||
|
||||
local_echoes_met += 1
|
||||
if local_echoes_met >= self._events_in_transfer:
|
||||
break
|
||||
|
||||
if update_at is not None:
|
||||
model.updateItem(update_at, new_event)
|
||||
self._events_in_transfer -= 1
|
||||
return new_event
|
||||
|
||||
for i, event in enumerate(model):
|
||||
if event.isLocalEcho:
|
||||
continue
|
||||
|
||||
# Model is sorted from newest to oldest message
|
||||
if new_event.dateTime > event.dateTime:
|
||||
model.insert(i, new_event)
|
||||
return new_event
|
||||
|
||||
model.append(new_event)
|
||||
return new_event
|
||||
|
||||
with self._lock:
|
||||
new_event = process()
|
||||
if new_event:
|
||||
self._set_room_last_event(client.userId, room_id, new_event)
|
||||
|
||||
|
||||
def onRoomTypingMembersUpdated(self,
|
||||
client: Client,
|
||||
room_id: str,
|
||||
users: List[str]) -> None:
|
||||
categories = self.backend.accounts[client.userId].roomCategories
|
||||
for categ in categories:
|
||||
try:
|
||||
categ.rooms.setProperty(room_id, "typingMembers", users)
|
||||
break
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
def onMessageAboutToBeSent(self,
|
||||
client: Client,
|
||||
room_id: str,
|
||||
content: Dict[str, str]) -> None:
|
||||
|
||||
date_time = QDateTime.currentDateTime()
|
||||
|
||||
with self._lock:
|
||||
model = self.backend.roomEvents[room_id]
|
||||
nio_event = nio.events.RoomMessage.parse_event({
|
||||
"event_id": "",
|
||||
"sender": client.userId,
|
||||
"origin_server_ts": date_time.toMSecsSinceEpoch(),
|
||||
"content": content,
|
||||
})
|
||||
event = RoomEvent(
|
||||
eventId = f"localEcho.{self._events_in_transfer + 1}",
|
||||
type = type(nio_event).__name__,
|
||||
dict = nio_event.__dict__,
|
||||
dateTime = date_time,
|
||||
isLocalEcho = True,
|
||||
)
|
||||
model.insert(0, event)
|
||||
self._events_in_transfer += 1
|
||||
|
||||
self._set_room_last_event(client.userId, room_id, event)
|
||||
|
||||
|
||||
def onRoomAboutToBeForgotten(self, client: Client, room_id: str) -> None:
|
||||
categories = self.backend.accounts[client.userId].roomCategories
|
||||
|
||||
for categ in categories:
|
||||
categ.rooms.pop(room_id, None)
|
||||
|
||||
self.backend.roomEvents[room_id].clear()
|
||||
|
||||
|
||||
def onDeviceIsPresent(self,
|
||||
client: Client,
|
||||
user_id: str,
|
||||
device_id: str,
|
||||
ed25519_key: str) -> None:
|
||||
|
||||
nio_device = client.nio.device_store[user_id][device_id]
|
||||
|
||||
user = self.backend.users.get(user_id, None)
|
||||
if not user:
|
||||
self.backend.users.append(
|
||||
User(userId=user_id, devices=ListModel())
|
||||
)
|
||||
|
||||
self.backend.users[user_id].devices.upsert(
|
||||
where_main_key_is = device_id,
|
||||
update_with = Device(
|
||||
deviceId = device_id,
|
||||
ed25519Key = ed25519_key,
|
||||
trust = client.getDeviceTrust(nio_device),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def onDeviceIsDeleted(self, _: Client, user_id: str, device_id: str
|
||||
) -> None:
|
||||
try:
|
||||
del self.backend.users[user_id].devices[device_id]
|
||||
except ValueError:
|
||||
pass
|
|
@ -1,20 +0,0 @@
|
|||
import QtQuick 2.7
|
||||
import QtQuick.Controls 2.2
|
||||
import QtQuick.Window 2.7
|
||||
|
||||
ApplicationWindow {
|
||||
id: appWindow
|
||||
visible: true
|
||||
width: Math.min(Screen.width, 1152)
|
||||
height: Math.min(Screen.height, 768)
|
||||
|
||||
onClosing: Backend.clients.removeAll()
|
||||
|
||||
property int reloadedTimes: 0
|
||||
|
||||
Loader {
|
||||
anchors.fill: parent
|
||||
source: "UI.qml"
|
||||
objectName: "UILoader"
|
||||
}
|
||||
}
|
|
@ -1,81 +0,0 @@
|
|||
# Copyright 2019 miruka
|
||||
# This file is part of harmonyqml, licensed under GPLv3.
|
||||
|
||||
import signal
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Generator
|
||||
|
||||
from PyQt5.QtCore import QObject, QTimer
|
||||
from PyQt5.QtQml import QQmlApplicationEngine
|
||||
|
||||
|
||||
class Engine(QQmlApplicationEngine):
|
||||
def __init__(self, debug: bool = False) -> None:
|
||||
# Connect UNXI signals to properly exit program
|
||||
self._original_signal_handlers: Dict[int, Any] = {}
|
||||
|
||||
for signame in ("INT" , "HUP", "QUIT", "TERM"):
|
||||
sig = signal.Signals[f"SIG{signame}"] # pylint: disable=no-member
|
||||
self._original_signal_handlers[sig] = signal.getsignal(sig)
|
||||
signal.signal(sig, self.onExitSignal)
|
||||
|
||||
# Make SIGINT (ctrl-c) work
|
||||
self._sigint_timer = QTimer()
|
||||
self._sigint_timer.timeout.connect(lambda: None)
|
||||
self._sigint_timer.start(100)
|
||||
|
||||
super().__init__()
|
||||
self.app_dir = Path(__file__).resolve().parent
|
||||
|
||||
from .backend.backend import Backend
|
||||
self.backend = Backend(self)
|
||||
self.rootContext().setContextProperty("Backend", self.backend)
|
||||
|
||||
# Setup UI live-reloading when a file is edited
|
||||
if debug:
|
||||
from PyQt5.QtCore import QFileSystemWatcher
|
||||
self._watcher = QFileSystemWatcher()
|
||||
self._watcher.directoryChanged.connect(lambda _: self.reloadQml())
|
||||
self._watcher.addPath(str(self.app_dir))
|
||||
|
||||
for _dir in list(self._recursive_dirs_in(self.app_dir)):
|
||||
self._watcher.addPath(str(_dir))
|
||||
|
||||
|
||||
def onExitSignal(self, *_) -> None:
|
||||
for sig, handler in self._original_signal_handlers.items():
|
||||
signal.signal(sig, handler)
|
||||
|
||||
self._original_signal_handlers.clear()
|
||||
self.closeWindow()
|
||||
|
||||
|
||||
def _recursive_dirs_in(self, path: Path) -> Generator[Path, None, None]:
|
||||
for item in path.iterdir():
|
||||
if item.is_dir() and item.name != "__pycache__":
|
||||
yield item
|
||||
yield from self._recursive_dirs_in(item)
|
||||
|
||||
|
||||
def showWindow(self) -> None:
|
||||
self.load(str(self.app_dir / "components" / "Window.qml"))
|
||||
|
||||
|
||||
def closeWindow(self) -> None:
|
||||
try:
|
||||
self.rootObjects()[0].close()
|
||||
except IndexError:
|
||||
pass
|
||||
|
||||
|
||||
def reloadQml(self) -> None:
|
||||
loader = self.rootObjects()[0].findChild(QObject, "UILoader")
|
||||
source = loader.property("source")
|
||||
loader.setProperty("source", None)
|
||||
self.clearComponentCache()
|
||||
|
||||
window = self.rootObjects()[0]
|
||||
reloaded_times = window.property("reloadedTimes")
|
||||
window.setProperty("reloadedTimes", reloaded_times + 1)
|
||||
|
||||
loader.setProperty("source", source)
|
8
run_live_reload.sh
Executable file
|
@ -0,0 +1,8 @@
|
|||
#!/usr/bin/env sh
|
||||
|
||||
while true; do
|
||||
clear
|
||||
qml src/qml/Window.qml -- --debug
|
||||
exit_code="$?"
|
||||
if [ "$exit_code" != 231 ]; then break; fi
|
||||
done
|
75
setup.py
|
@ -1,75 +0,0 @@
|
|||
# Copyright 2019 miruka
|
||||
# This file is part of harmonyqml, licensed under GPLv3.
|
||||
|
||||
"harmonyqml setuptools file"
|
||||
|
||||
from setuptools import setup, find_packages
|
||||
|
||||
from harmonyqml import __about__
|
||||
|
||||
|
||||
def get_readme():
|
||||
with open("README.md", "r") as readme:
|
||||
return readme.read()
|
||||
|
||||
|
||||
setup(
|
||||
name = __about__.__pkg_name__,
|
||||
version = __about__.__version__,
|
||||
|
||||
author = __about__.__author__,
|
||||
author_email = __about__.__email__,
|
||||
license = __about__.__license__,
|
||||
|
||||
description = __about__.__doc__,
|
||||
long_description = get_readme(),
|
||||
long_description_content_type = "text/markdown",
|
||||
|
||||
python_requires = ">=3.6, <4",
|
||||
install_requires = [
|
||||
"dataclasses;python_version<'3.7'",
|
||||
"docopt",
|
||||
],
|
||||
|
||||
include_package_data = True,
|
||||
packages = find_packages(),
|
||||
# package_data = {__about__.__pkg_name__: ["*.yaml"]},
|
||||
entry_points = {
|
||||
"console_scripts": [
|
||||
f"{__about__.__pkg_name__}={__about__.__pkg_name__}.cli:main"
|
||||
]
|
||||
},
|
||||
|
||||
keywords = "<KEYWORDS>",
|
||||
url = "https://github.com/mirukan/harmonyqml",
|
||||
|
||||
classifiers=[
|
||||
"Development Status :: 3 - Alpha",
|
||||
# "Development Status :: 4 - Beta",
|
||||
# "Development Status :: 5 - Production/Stable",
|
||||
|
||||
"Intended Audience :: Developers",
|
||||
"Intended Audience :: End Users/Desktop",
|
||||
|
||||
"Environment :: Console",
|
||||
# "Environment :: Console :: Curses",
|
||||
# "Environment :: Plugins",
|
||||
# "Environment :: X11 Applications",
|
||||
# "Environment :: X11 Applications :: Qt",
|
||||
|
||||
# "Topic :: Utilities",
|
||||
# grep '^Topic' ~/docs/web/pypi-classifiers.txt
|
||||
|
||||
("License :: OSI Approved :: "
|
||||
"GNU General Public License v3 or later (GPLv3+)"),
|
||||
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3 :: Only",
|
||||
"Programming Language :: Python :: 3.6",
|
||||
"Programming Language :: Python :: 3.7",
|
||||
|
||||
"Natural Language :: English",
|
||||
|
||||
"Operating System :: POSIX",
|
||||
]
|
||||
)
|
|
@ -1,6 +1,3 @@
|
|||
# Copyright 2019 miruka
|
||||
# This file is part of harmonyqml, licensed under GPLv3.
|
||||
|
||||
"""<SHORTDESC>"""
|
||||
|
||||
__pkg_name__ = "harmonyqml"
|
1
src/__init__.py
Normal file
|
@ -0,0 +1 @@
|
|||
from .app import APP
|
BIN
src/__pycache__/__about__.cpython-36.pyc
Normal file
BIN
src/__pycache__/__init__.cpython-36.pyc
Normal file
BIN
src/__pycache__/app.cpython-36.pyc
Normal file
BIN
src/__pycache__/backend.cpython-36.pyc
Normal file
BIN
src/__pycache__/matrix_client.cpython-36.pyc
Normal file
83
src/app.py
Normal 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
|
@ -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
BIN
src/events/__pycache__/__init__.cpython-36.pyc
Normal file
BIN
src/events/__pycache__/event.cpython-36.pyc
Normal file
BIN
src/events/__pycache__/system.cpython-36.pyc
Normal file
23
src/events/event.py
Normal 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
|
@ -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()
|
8
src/events/rooms_timeline.py
Normal 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
|
@ -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
|
@ -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()
|
Before Width: | Height: | Size: 304 B After Width: | Height: | Size: 304 B |
Before Width: | Height: | Size: 146 B After Width: | Height: | Size: 146 B |
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.9 KiB |
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
Before Width: | Height: | Size: 339 B After Width: | Height: | Size: 339 B |
Before Width: | Height: | Size: 86 B After Width: | Height: | Size: 86 B |
Before Width: | Height: | Size: 378 B After Width: | Height: | Size: 378 B |
Before Width: | Height: | Size: 250 B After Width: | Height: | Size: 250 B |
Before Width: | Height: | Size: 153 B After Width: | Height: | Size: 153 B |
Before Width: | Height: | Size: 288 B After Width: | Height: | Size: 288 B |
Before Width: | Height: | Size: 347 B After Width: | Height: | Size: 347 B |
Before Width: | Height: | Size: 364 B After Width: | Height: | Size: 364 B |
Before Width: | Height: | Size: 344 B After Width: | Height: | Size: 344 B |
Before Width: | Height: | Size: 471 B After Width: | Height: | Size: 471 B |
Before Width: | Height: | Size: 235 B After Width: | Height: | Size: 235 B |
Before Width: | Height: | Size: 446 B After Width: | Height: | Size: 446 B |
Before Width: | Height: | Size: 585 B After Width: | Height: | Size: 585 B |
Before Width: | Height: | Size: 811 B After Width: | Height: | Size: 811 B |
Before Width: | Height: | Size: 121 B After Width: | Height: | Size: 121 B |
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
Before Width: | Height: | Size: 327 B After Width: | Height: | Size: 327 B |
Before Width: | Height: | Size: 344 B After Width: | Height: | Size: 344 B |
Before Width: | Height: | Size: 1.8 MiB After Width: | Height: | Size: 1.8 MiB |
22
src/matrix_client.py
Normal 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))
|
57
src/qml/Base/HListModel.qml
Normal 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
|
||||
}
|
||||
}
|