diff --git a/autoreload.py b/autoreload.py index f3e8116e..72ab777b 100755 --- a/autoreload.py +++ b/autoreload.py @@ -22,42 +22,42 @@ ROOT = Path(__file__).parent class Watcher(DefaultWatcher): - def accept_change(self, entry: os.DirEntry) -> bool: - path = Path(entry.path) + def accept_change(self, entry: os.DirEntry) -> bool: + path = Path(entry.path) - for bad in ("src/config", "src/themes"): - if path.is_relative_to(ROOT / bad): - return False + for bad in ("src/config", "src/themes"): + if path.is_relative_to(ROOT / bad): + return False - for good in ("src", "submodules"): - if path.is_relative_to(ROOT / good): - return True + for good in ("src", "submodules"): + if path.is_relative_to(ROOT / good): + return True - return False + return False - def should_watch_dir(self, entry: os.DirEntry) -> bool: - return super().should_watch_dir(entry) and self.accept_change(entry) + def should_watch_dir(self, entry: os.DirEntry) -> bool: + return super().should_watch_dir(entry) and self.accept_change(entry) - def should_watch_file(self, entry: os.DirEntry) -> bool: - return super().should_watch_file(entry) and self.accept_change(entry) + def should_watch_file(self, entry: os.DirEntry) -> bool: + return super().should_watch_file(entry) and self.accept_change(entry) def cmd(*parts) -> subprocess.CompletedProcess: - return subprocess.run(parts, cwd=ROOT, check=True) + return subprocess.run(parts, cwd=ROOT, check=True) def run_app(args=sys.argv[1:]) -> None: - print("\n\x1b[36m", "─" * term_size().columns, "\x1b[0m\n", sep="") + print("\n\x1b[36m", "─" * term_size().columns, "\x1b[0m\n", sep="") - with suppress(KeyboardInterrupt): - cmd("qmake", "moment.pro", "CONFIG+=dev") - cmd("make") - cmd("./moment", "-name", "dev", *args) + with suppress(KeyboardInterrupt): + cmd("qmake", "moment.pro", "CONFIG+=dev") + cmd("make") + cmd("./moment", "-name", "dev", *args) if __name__ == "__main__": - if len(sys.argv) > 2 and sys.argv[1] in ("-h", "--help"): - print(__doc__) - else: - (ROOT / "Makefile").exists() and cmd("make", "clean") - run_process(ROOT, run_app, callback=print, watcher_cls=Watcher) + if len(sys.argv) > 2 and sys.argv[1] in ("-h", "--help"): + print(__doc__) + else: + (ROOT / "Makefile").exists() and cmd("make", "clean") + run_process(ROOT, run_app, callback=print, watcher_cls=Watcher) diff --git a/packaging/flatpak/collector.py b/packaging/flatpak/collector.py index 153d4af7..d1cd0fd2 100644 --- a/packaging/flatpak/collector.py +++ b/packaging/flatpak/collector.py @@ -2,31 +2,31 @@ import json import yaml with open("moment.flatpak.base.yaml") as f: - base = yaml.load(f, Loader=yaml.FullLoader) + base = yaml.load(f, Loader=yaml.FullLoader) with open("flatpak-pip.json") as f: - modules = json.load(f)["modules"] + modules = json.load(f)["modules"] # set some modules in front as dependencies and dropping matrix-nio # which is declared separately front = [] back = [] for m in modules: - n = m["name"] - if n.startswith("python3-") and \ - n[len("python3-"):] in ["cffi", "importlib-metadata", "multidict", "pytest-runner", "setuptools-scm"]: - front.append(m) - else: - back.append(m) + n = m["name"] + if n.startswith("python3-") and \ + n[len("python3-"):] in ["cffi", "importlib-metadata", "multidict", "pytest-runner", "setuptools-scm"]: + front.append(m) + else: + back.append(m) # replace placeholder with modules phold = None for i in range(len(base["modules"])): - if base["modules"][i]["name"] == "PLACEHOLDER PYTHON DEPENDENCIES": - phold = i - break + if base["modules"][i]["name"] == "PLACEHOLDER PYTHON DEPENDENCIES": + phold = i + break base["modules"] = base["modules"][:i] + front + back + base["modules"][i+1:] with open("moment.flatpak.yaml", "w") as f: - f.write(yaml.dump(base, sort_keys=False, indent=2)) + f.write(yaml.dump(base, sort_keys=False, indent=2)) diff --git a/packaging/update-metainfo-releases.py b/packaging/update-metainfo-releases.py index f0655f57..27b53458 100755 --- a/packaging/update-metainfo-releases.py +++ b/packaging/update-metainfo-releases.py @@ -4,29 +4,29 @@ import html import re from pathlib import Path -root = Path(__file__).resolve().parent.parent +root = Path(__file__).resolve().parent.parent title_pattern = re.compile(r"## (\d+\.\d+\.\d+) \((\d{4}-\d\d-\d\d)\)") release_lines = [" "] for line in (root / "docs" / "CHANGELOG.md").read_text().splitlines(): - match = title_pattern.match(line) + match = title_pattern.match(line) - if match: - args = (html.escape(match.group(1)), html.escape(match.group(2))) - release_lines.append(' ' % args) + if match: + args = (html.escape(match.group(1)), html.escape(match.group(2))) + release_lines.append(' ' % args) -appdata = root / "packaging" / "moment.metainfo.xml" +appdata = root / "packaging" / "moment.metainfo.xml" in_releases = False final_lines = [] for line in appdata.read_text().splitlines(): - if line == " ": - in_releases = True - final_lines += release_lines - elif line == " ": - in_releases = False + if line == " ": + in_releases = True + final_lines += release_lines + elif line == " ": + in_releases = False - if not in_releases: - final_lines.append(line) + if not in_releases: + final_lines.append(line) appdata.write_text("\n".join(final_lines)) diff --git a/src/backend/__init__.py b/src/backend/__init__.py index 4de73963..e30c65c1 100644 --- a/src/backend/__init__.py +++ b/src/backend/__init__.py @@ -13,7 +13,7 @@ documentation in the following modules first: - `nio_callbacks` """ -__app_name__ = "moment" +__app_name__ = "moment" __display_name__ = "Moment" __reverse_dns__ = "xyz.mx-moment" -__version__ = "0.7.3" +__version__ = "0.7.3" diff --git a/src/backend/backend.py b/src/backend/backend.py index f36d5d26..8ecfa893 100644 --- a/src/backend/backend.py +++ b/src/backend/backend.py @@ -36,577 +36,577 @@ from .models.model_store import ModelStore from .presence import Presence from .sso_server import SSOServer from .user_files import ( - Accounts, History, NewTheme, Pre070Settings, Settings, Theme, UIState, + Accounts, History, NewTheme, Pre070Settings, Settings, Theme, UIState, ) # Logging configuration log.getLogger().setLevel(log.INFO) if hasattr(nio, 'log'): - nio.logger_group.level = nio.log.logbook.WARNING - nio.log.logbook.StreamHandler(sys.stderr).push_application() + nio.logger_group.level = nio.log.logbook.WARNING + nio.log.logbook.StreamHandler(sys.stderr).push_application() else: - log.getLogger('nio').setLevel(log.WARNING) + log.getLogger('nio').setLevel(log.WARNING) class Backend: - """Manage matrix clients and provide other useful general methods. + """Manage matrix clients and provide other useful general methods. - Attributes: - saved_accounts: User config file for saved matrix account. + Attributes: + saved_accounts: User config file for saved matrix account. - settings: User config file for general UI and backend settings. + settings: User config file for general UI and backend settings. - ui_state: User data file for saving/restoring QML UI state. + ui_state: User data file for saving/restoring QML UI state. - history: User data file for saving/restoring text typed into QML - components. + history: User data file for saving/restoring text typed into QML + components. - models: A mapping containing our data models that are - synchronized between the Python backend and the QML UI. - The models should only ever be modified from the backend. + models: A mapping containing our data models that are + synchronized between the Python backend and the QML UI. + The models should only ever be modified from the backend. - If a non-existent key is accessed, it is created and an - associated `Model` and returned. + If a non-existent key is accessed, it is created and an + associated `Model` and returned. - The mapping keys are the `Model`'s synchronization ID, - which a strings or tuple of strings. + The mapping keys are the `Model`'s synchronization ID, + which a strings or tuple of strings. - Currently used sync ID throughout the code are: + Currently used sync ID throughout the code are: - - `"accounts"`: logged-in accounts; + - `"accounts"`: logged-in accounts; - - `("", "pushrules")`: push rules configured for our - account `user_id`. + - `("", "pushrules")`: push rules configured for our + account `user_id`. - - `("", "rooms")`: rooms our account `user_id` is part of; + - `("", "rooms")`: rooms our account `user_id` is part of; - - `("", "transfers")`: ongoing or failed file - uploads/downloads for our account `user_id`; + - `("", "transfers")`: ongoing or failed file + uploads/downloads for our account `user_id`; - - `("", "", "members")`: members in the room - `room_id` that our account `user_id` is part of; + - `("", "", "members")`: members in the room + `room_id` that our account `user_id` is part of; - - `("", "", "events")`: state events and messages - in the room `room_id` that our account `user_id` is part of. + - `("", "", "events")`: state events and messages + in the room `room_id` that our account `user_id` is part of. - Special models: + Special models: - - `"all_rooms"`: See `models.special_models.AllRooms` docstring + - `"all_rooms"`: See `models.special_models.AllRooms` docstring - - `"matching_accounts"` - See `models.special_models.MatchingAccounts` docstring + - `"matching_accounts"` + See `models.special_models.MatchingAccounts` docstring - - `("", "", "filtered_members")`: - See `models.special_models.FilteredMembers` docstring + - `("", "", "filtered_members")`: + See `models.special_models.FilteredMembers` docstring - - `("filtered_homeservers")`: - See `models.special_models.FilteredHomeservers` docstring + - `("filtered_homeservers")`: + See `models.special_models.FilteredHomeservers` docstring - clients: A `{user_id: MatrixClient}` dict for the logged-in clients - we managed. Every client is logged to one matrix account. + clients: A `{user_id: MatrixClient}` dict for the logged-in clients + we managed. Every client is logged to one matrix account. - media_cache: A matrix media cache for downloaded files. + media_cache: A matrix media cache for downloaded files. - presences: A `{user_id: Presence}` dict for storing presence info about - matrix users registered on Moment. + presences: A `{user_id: Presence}` dict for storing presence info about + matrix users registered on Moment. - mxc_events: A dict storing media `Event` model items for any account - that have the same mxc URI - """ + mxc_events: A dict storing media `Event` model items for any account + that have the same mxc URI + """ - def __init__(self) -> None: - self.appdirs = AppDirs(appname=__app_name__, roaming=True) + def __init__(self) -> None: + self.appdirs = AppDirs(appname=__app_name__, roaming=True) - self.models = ModelStore() + self.models = ModelStore() - self.saved_accounts = Accounts(self) - self.settings = Settings(self) - self.ui_state = UIState(self) - self.history = History(self) - self.theme = Theme(self, self.settings.General.theme) - # self.new_theme = NewTheme(self, self.settings.General.new_theme) - self.new_theme = NewTheme(self, ".new.py") # TODO - Pre070Settings(self) + self.saved_accounts = Accounts(self) + self.settings = Settings(self) + self.ui_state = UIState(self) + self.history = History(self) + self.theme = Theme(self, self.settings.General.theme) + # self.new_theme = NewTheme(self, self.settings.General.new_theme) + self.new_theme = NewTheme(self, ".new.py") # TODO + Pre070Settings(self) - self.clients: Dict[str, MatrixClient] = {} + self.clients: Dict[str, MatrixClient] = {} - self._sso_server: Optional[SSOServer] = None - self._sso_server_task: Optional[asyncio.Future] = None + self._sso_server: Optional[SSOServer] = None + self._sso_server_task: Optional[asyncio.Future] = None - self.profile_cache: Dict[str, nio.ProfileGetResponse] = {} - self.get_profile_locks: DefaultDict[str, asyncio.Lock] = \ - DefaultDict(asyncio.Lock) # {user_id: lock} + self.profile_cache: Dict[str, nio.ProfileGetResponse] = {} + self.get_profile_locks: DefaultDict[str, asyncio.Lock] = \ + DefaultDict(asyncio.Lock) # {user_id: lock} - self.send_locks: DefaultDict[str, asyncio.Lock] = \ - DefaultDict(asyncio.Lock) # {room_id: lock} + self.send_locks: DefaultDict[str, asyncio.Lock] = \ + DefaultDict(asyncio.Lock) # {room_id: lock} - cache_dir = Path( - os.environ.get("MOMENT_CACHE_DIR") or self.appdirs.user_cache_dir, - ) + cache_dir = Path( + os.environ.get("MOMENT_CACHE_DIR") or self.appdirs.user_cache_dir, + ) - self.media_cache: MediaCache = MediaCache(self, cache_dir) + self.media_cache: MediaCache = MediaCache(self, cache_dir) - self.presences: Dict[str, Presence] = {} + self.presences: Dict[str, Presence] = {} - self.concurrent_get_presence_limit = asyncio.BoundedSemaphore(8) + self.concurrent_get_presence_limit = asyncio.BoundedSemaphore(8) - self.mxc_events: DefaultDict[str, List[Event]] = DefaultDict(list) + self.mxc_events: DefaultDict[str, List[Event]] = DefaultDict(list) - self.notification_avatar_cache: Dict[str, Path] = {} # {mxc: path} - self.notifications_working: bool = True - self.audio_working: bool = True + self.notification_avatar_cache: Dict[str, Path] = {} # {mxc: path} + self.notifications_working: bool = True + self.audio_working: bool = True - def __repr__(self) -> str: - return f"{type(self).__name__}(clients={self.clients!r})" + def __repr__(self) -> str: + return f"{type(self).__name__}(clients={self.clients!r})" - # Clients management + # Clients management - async def server_info(self, homeserver: str) -> Tuple[str, List[str]]: - """Return server's real URL and supported login flows. + async def server_info(self, homeserver: str) -> Tuple[str, List[str]]: + """Return server's real URL and supported login flows. - Retrieving the real URL uses the `.well-known` API. - Possible login methods include `m.login.password` or `m.login.sso`. - """ + Retrieving the real URL uses the `.well-known` API. + Possible login methods include `m.login.password` or `m.login.sso`. + """ - if not re.match(r"https?://", homeserver): - homeserver = f"http://{homeserver}" + if not re.match(r"https?://", homeserver): + homeserver = f"http://{homeserver}" - client = MatrixClient(self, homeserver=homeserver) - http_re = re.compile("^http://") - is_local = urlparse(client.homeserver).netloc.split(":")[0] in ( - "localhost", "127.0.0.1", "::1", - ) + client = MatrixClient(self, homeserver=homeserver) + http_re = re.compile("^http://") + is_local = urlparse(client.homeserver).netloc.split(":")[0] in ( + "localhost", "127.0.0.1", "::1", + ) - try: - client.homeserver = (await client.discovery_info()).homeserver_url - except MatrixError: - # This is either already the real URL, or an invalid URL. - pass + try: + client.homeserver = (await client.discovery_info()).homeserver_url + except MatrixError: + # This is either already the real URL, or an invalid URL. + pass - try: - try: - login_response = await client.login_info() - except (asyncio.TimeoutError, MatrixError): - # Maybe we still have a http URL and server only supports https - client.homeserver = http_re.sub("https://", client.homeserver) - login_response = await client.login_info() + try: + try: + login_response = await client.login_info() + except (asyncio.TimeoutError, MatrixError): + # Maybe we still have a http URL and server only supports https + client.homeserver = http_re.sub("https://", client.homeserver) + login_response = await client.login_info() - # If we still have a http URL and server redirected to https - if login_response.transport_response.real_url.scheme == "https": - client.homeserver = http_re.sub("https://", client.homeserver) + # If we still have a http URL and server redirected to https + if login_response.transport_response.real_url.scheme == "https": + client.homeserver = http_re.sub("https://", client.homeserver) - # If we still have a http URL and server accept both http and https - if http_re.match(client.homeserver) and not is_local: - original = client.homeserver - client.homeserver = http_re.sub("https://", client.homeserver) + # If we still have a http URL and server accept both http and https + if http_re.match(client.homeserver) and not is_local: + original = client.homeserver + client.homeserver = http_re.sub("https://", client.homeserver) - try: - await asyncio.wait_for(client.login_info(), timeout=6) - except (asyncio.TimeoutError, MatrixError): - client.homeserver = original + try: + await asyncio.wait_for(client.login_info(), timeout=6) + except (asyncio.TimeoutError, MatrixError): + client.homeserver = original - return (client.homeserver, login_response.flows) - finally: - await client.close() + return (client.homeserver, login_response.flows) + finally: + await client.close() - async def password_auth( - self, user: str, password: str, homeserver: str, - ) -> str: - """Create & register a `MatrixClient`, login using the password - and return the user ID we get. - """ + async def password_auth( + self, user: str, password: str, homeserver: str, + ) -> str: + """Create & register a `MatrixClient`, login using the password + and return the user ID we get. + """ - client = MatrixClient(self, user=user, homeserver=homeserver) - return await self._do_login(client, password=password) + client = MatrixClient(self, user=user, homeserver=homeserver) + return await self._do_login(client, password=password) - async def start_sso_auth(self, homeserver: str) -> str: - """Start SSO server and return URL to open in the user's browser. + async def start_sso_auth(self, homeserver: str) -> str: + """Start SSO server and return URL to open in the user's browser. - See the `sso_server.SSOServer` class documentation. - Once the returned URL has been opened in the user's browser - (done from QML), `MatrixClient.continue_sso_auth()` should be called. - """ + See the `sso_server.SSOServer` class documentation. + Once the returned URL has been opened in the user's browser + (done from QML), `MatrixClient.continue_sso_auth()` should be called. + """ - server = SSOServer(homeserver) - self._sso_server = server - self._sso_server_task = asyncio.ensure_future(server.wait_for_token()) - return server.url_to_open + server = SSOServer(homeserver) + self._sso_server = server + self._sso_server_task = asyncio.ensure_future(server.wait_for_token()) + return server.url_to_open - async def continue_sso_auth(self) -> str: - """Wait for the started SSO server to get a token, then login. + async def continue_sso_auth(self) -> str: + """Wait for the started SSO server to get a token, then login. - `MatrixClient.start_sso_auth()` must be called first. - Creates and register a `MatrixClient` for logging in. - Returns the user ID we get from logging in. - """ + `MatrixClient.start_sso_auth()` must be called first. + Creates and register a `MatrixClient` for logging in. + Returns the user ID we get from logging in. + """ - if not self._sso_server or not self._sso_server_task: - raise RuntimeError("Must call Backend.start_sso_auth() first") + if not self._sso_server or not self._sso_server_task: + raise RuntimeError("Must call Backend.start_sso_auth() first") - await self._sso_server_task - homeserver = self._sso_server.for_homeserver - token = self._sso_server_task.result() - self._sso_server_task = None - self._sso_server = None + await self._sso_server_task + homeserver = self._sso_server.for_homeserver + token = self._sso_server_task.result() + self._sso_server_task = None + self._sso_server = None - client = MatrixClient(self, homeserver=homeserver) - return await self._do_login(client, token=token) + client = MatrixClient(self, homeserver=homeserver) + return await self._do_login(client, token=token) - async def _do_login(self, client: MatrixClient, **login_kwargs) -> str: - """Login on a client. If successful, register it and return user ID.""" + async def _do_login(self, client: MatrixClient, **login_kwargs) -> str: + """Login on a client. If successful, register it and return user ID.""" - try: - await client.login(**login_kwargs) - except MatrixError: - await client.close() - raise + try: + await client.login(**login_kwargs) + except MatrixError: + await client.close() + raise - if client.user_id in self.clients: - await client.logout() - return client.user_id + if client.user_id in self.clients: + await client.logout() + return client.user_id - self.clients[client.user_id] = client - return client.user_id + self.clients[client.user_id] = client + return client.user_id - async def resume_client( - self, - user_id: str, - token: str, - device_id: str, - homeserver: str, - state: str = "online", - status_msg: str = "", - ) -> None: - """Create and register a `MatrixClient` with known account details.""" + async def resume_client( + self, + user_id: str, + token: str, + device_id: str, + homeserver: str, + state: str = "online", + status_msg: str = "", + ) -> None: + """Create and register a `MatrixClient` with known account details.""" - client = MatrixClient( - self, user=user_id, homeserver=homeserver, device_id=device_id, - ) + client = MatrixClient( + self, user=user_id, homeserver=homeserver, device_id=device_id, + ) - self.clients[user_id] = client + self.clients[user_id] = client - await client.resume(user_id, token, device_id, state, status_msg) + await client.resume(user_id, token, device_id, state, status_msg) - async def load_saved_accounts(self) -> List[str]: - """Call `resume_client` for all saved accounts in user config.""" + async def load_saved_accounts(self) -> List[str]: + """Call `resume_client` for all saved accounts in user config.""" - async def resume(user_id: str, info: Dict[str, Any]) -> str: - # Get or create account model - self.models["accounts"].setdefault( - user_id, Account(user_id, info.get("order", -1)), - ) + async def resume(user_id: str, info: Dict[str, Any]) -> str: + # Get or create account model + self.models["accounts"].setdefault( + user_id, Account(user_id, info.get("order", -1)), + ) - await self.resume_client( - user_id = user_id, - token = info["token"], - device_id = info["device_id"], - homeserver = info["homeserver"], - state = info.get("presence", "online"), - status_msg = info.get("status_msg", ""), - ) + await self.resume_client( + user_id = user_id, + token = info["token"], + device_id = info["device_id"], + homeserver = info["homeserver"], + state = info.get("presence", "online"), + status_msg = info.get("status_msg", ""), + ) - return user_id + return user_id - return await asyncio.gather(*( - resume(user_id, info) - for user_id, info in self.saved_accounts.items() - if info.get("enabled", True) - )) + return await asyncio.gather(*( + resume(user_id, info) + for user_id, info in self.saved_accounts.items() + if info.get("enabled", True) + )) - async def logout_client(self, user_id: str) -> None: - """Log a `MatrixClient` out and unregister it from our models.""" + async def logout_client(self, user_id: str) -> None: + """Log a `MatrixClient` out and unregister it from our models.""" - client = self.clients.pop(user_id, None) + client = self.clients.pop(user_id, None) - if client: - try: - await client.logout() - except MatrixInvalidAccessToken: - pass + if client: + try: + await client.logout() + except MatrixInvalidAccessToken: + pass - self.models["accounts"].pop(user_id, None) - self.models["matching_accounts"].pop(user_id, None) - self.models[user_id, "transfers"].clear() + self.models["accounts"].pop(user_id, None) + self.models["matching_accounts"].pop(user_id, None) + self.models[user_id, "transfers"].clear() - for room_id in self.models[user_id, "rooms"]: - self.models["all_rooms"].pop(room_id, None) - self.models[user_id, room_id, "members"].clear() - self.models[user_id, room_id, "events"].clear() - self.models[user_id, room_id, "filtered_members"].clear() + for room_id in self.models[user_id, "rooms"]: + self.models["all_rooms"].pop(room_id, None) + self.models[user_id, room_id, "members"].clear() + self.models[user_id, room_id, "events"].clear() + self.models[user_id, room_id, "filtered_members"].clear() - self.models[user_id, "rooms"].clear() + self.models[user_id, "rooms"].clear() - await self.saved_accounts.forget(user_id) + await self.saved_accounts.forget(user_id) - async def terminate_clients(self) -> None: - """Call every `MatrixClient`'s `terminate()` method.""" + async def terminate_clients(self) -> None: + """Call every `MatrixClient`'s `terminate()` method.""" - log.info("Setting clients offline...") - tasks = [client.terminate() for client in self.clients.values()] - await asyncio.gather(*tasks) + log.info("Setting clients offline...") + tasks = [client.terminate() for client in self.clients.values()] + await asyncio.gather(*tasks) - async def get_client(self, user_id: str, _debug_info=None) -> MatrixClient: - """Wait until a `MatrixClient` is registered in model and return it.""" + async def get_client(self, user_id: str, _debug_info=None) -> MatrixClient: + """Wait until a `MatrixClient` is registered in model and return it.""" - failures = 0 + failures = 0 - while True: - if user_id in self.clients: - return self.clients[user_id] + while True: + if user_id in self.clients: + return self.clients[user_id] - if failures and failures % 100 == 0: # every 10s except first time - log.warning( - "Client %r not found after %ds, _debug_info:\n%r", - user_id, failures / 10, _debug_info, - ) + if failures and failures % 100 == 0: # every 10s except first time + log.warning( + "Client %r not found after %ds, _debug_info:\n%r", + user_id, failures / 10, _debug_info, + ) - await asyncio.sleep(0.1) - failures += 1 + await asyncio.sleep(0.1) + failures += 1 - # Multi-client Matrix functions + # Multi-client Matrix functions - async def update_room_read_marker( - self, room_id: str, event_id: str, - ) -> None: - """Update room's read marker to an event for all accounts part of it. - """ + async def update_room_read_marker( + self, room_id: str, event_id: str, + ) -> None: + """Update room's read marker to an event for all accounts part of it. + """ - async def update(client: MatrixClient) -> None: - room = self.models[client.user_id, "rooms"].get(room_id) - account = self.models["accounts"][client.user_id] + async def update(client: MatrixClient) -> None: + room = self.models[client.user_id, "rooms"].get(room_id) + account = self.models["accounts"][client.user_id] - if room: - room.set_fields(unreads=0, highlights=0, local_unreads=False) - await client.update_account_unread_counts() + if room: + room.set_fields(unreads=0, highlights=0, local_unreads=False) + await client.update_account_unread_counts() - if account.presence not in [ - Presence.State.invisible, Presence.State.offline, - ]: - await client.update_receipt_marker(room_id, event_id) + if account.presence not in [ + Presence.State.invisible, Presence.State.offline, + ]: + await client.update_receipt_marker(room_id, event_id) - await asyncio.gather(*[update(c) for c in self.clients.values()]) + await asyncio.gather(*[update(c) for c in self.clients.values()]) - async def verify_device( - self, user_id: str, device_id: str, ed25519_key: str, - ) -> None: - """Mark a device as verified on all our accounts.""" + async def verify_device( + self, user_id: str, device_id: str, ed25519_key: str, + ) -> None: + """Mark a device as verified on all our accounts.""" - for client in self.clients.values(): - try: - device = client.device_store[user_id][device_id] - except KeyError: - continue + for client in self.clients.values(): + try: + device = client.device_store[user_id][device_id] + except KeyError: + continue - if device.ed25519 == ed25519_key: - client.verify_device(device) + if device.ed25519 == ed25519_key: + client.verify_device(device) - async def blacklist_device( - self, user_id: str, device_id: str, ed25519_key: str, - ) -> None: - """Mark a device as blacklisted on all our accounts.""" + async def blacklist_device( + self, user_id: str, device_id: str, ed25519_key: str, + ) -> None: + """Mark a device as blacklisted on all our accounts.""" - for client in self.clients.values(): - try: - # This won't include the client's current device, as expected - device = client.device_store[user_id][device_id] - except KeyError: - continue + for client in self.clients.values(): + try: + # This won't include the client's current device, as expected + device = client.device_store[user_id][device_id] + except KeyError: + continue - if device.ed25519 == ed25519_key: - client.blacklist_device(device) + if device.ed25519 == ed25519_key: + client.blacklist_device(device) - # General functions + # General functions - async def get_config_dir(self) -> Path: - return Path( - os.environ.get("MOMENT_CONFIG_DIR") or - self.appdirs.user_config_dir, - ) + async def get_config_dir(self) -> Path: + return Path( + os.environ.get("MOMENT_CONFIG_DIR") or + self.appdirs.user_config_dir, + ) - async def get_theme_dir(self) -> Path: - path = Path( - os.environ.get("MOMENT_DATA_DIR") or self.appdirs.user_data_dir, - ) / "themes" - path.mkdir(parents=True, exist_ok=True) - return path + async def get_theme_dir(self) -> Path: + path = Path( + os.environ.get("MOMENT_DATA_DIR") or self.appdirs.user_data_dir, + ) / "themes" + path.mkdir(parents=True, exist_ok=True) + return path - async def get_settings(self) -> Tuple[dict, UIState, History, str, dict]: - """Return parsed user config files for QML.""" - return ( - self.settings.qml_data, - self.ui_state.qml_data, - self.history.qml_data, - self.theme.qml_data, - self.new_theme.qml_data, - ) + async def get_settings(self) -> Tuple[dict, UIState, History, str, dict]: + """Return parsed user config files for QML.""" + return ( + self.settings.qml_data, + self.ui_state.qml_data, + self.history.qml_data, + self.theme.qml_data, + self.new_theme.qml_data, + ) - async def set_string_filter( - self, model_id: Union[SyncId, List[str]], value: str, - ) -> None: - """Set a FieldStringFilter (or derived class) model's filter property. + async def set_string_filter( + self, model_id: Union[SyncId, List[str]], value: str, + ) -> None: + """Set a FieldStringFilter (or derived class) model's filter property. - This should only be called from QML. - """ + This should only be called from QML. + """ - if isinstance(model_id, list): # QML can't pass tuples - model_id = tuple(model_id) + if isinstance(model_id, list): # QML can't pass tuples + model_id = tuple(model_id) - model = Model.proxies[model_id] + model = Model.proxies[model_id] - if not isinstance(model, FieldStringFilter): - raise TypeError("model_id must point to a FieldStringFilter") + if not isinstance(model, FieldStringFilter): + raise TypeError("model_id must point to a FieldStringFilter") - model.filter = value + model.filter = value - async def set_account_collapse(self, user_id: str, collapse: bool) -> None: - """Call `set_account_collapse()` on the `all_rooms` model. + async def set_account_collapse(self, user_id: str, collapse: bool) -> None: + """Call `set_account_collapse()` on the `all_rooms` model. - This should only be called from QML. - """ - self.models["all_rooms"].set_account_collapse(user_id, collapse) + This should only be called from QML. + """ + self.models["all_rooms"].set_account_collapse(user_id, collapse) - async def _ping_homeserver( - self, session: aiohttp.ClientSession, homeserver_url: str, - ) -> None: - """Ping a homeserver present in our model and set its `ping` field.""" + async def _ping_homeserver( + self, session: aiohttp.ClientSession, homeserver_url: str, + ) -> None: + """Ping a homeserver present in our model and set its `ping` field.""" - item = self.models["homeservers"][homeserver_url] - times = [] + item = self.models["homeservers"][homeserver_url] + times = [] - for i in range(16): - start = time.time() + for i in range(16): + start = time.time() - try: - await session.get(f"{homeserver_url}/_matrix/client/versions") - except aiohttp.ClientError as err: - log.warning("Failed pinging %s: %r", homeserver_url, err) - item.status = PingStatus.Failed - return + try: + await session.get(f"{homeserver_url}/_matrix/client/versions") + except aiohttp.ClientError as err: + log.warning("Failed pinging %s: %r", homeserver_url, err) + item.status = PingStatus.Failed + return - times.append(round((time.time() - start) * 1000)) - - if i == 7 or i == 15: - item.set_fields( - ping=sum(times) // len(times), status=PingStatus.Done, - ) - - async def fetch_homeservers(self) -> None: - """Retrieve a list of public homeservers and add them to our model.""" - - @client_session # need to trigger this decorator for creation - async def have_session_be_created(*_): - pass - - # We just want that client's aiohttp session, that way we don't have - # to depend ourselves on aiohttp + aiohttp-socks - proxy = self.settings.General.proxy - client = nio.AsyncClient(homeserver="", proxy=proxy) - await have_session_be_created(client) - - - session = client.client_session - # aiohttp only has "timeout" in 3.7.0+ - timeout = getattr(session, "timeout", session._timeout) - session = type(session)( - raise_for_status = True, - timeout = type(timeout)(total=20), - connector = session.connector, - ) - - api_list = "https://joinmatrix.org/servers.json" - try: - response = await session.get(api_list) - except: - await session.close() - print("Unable to fetch", api_list) - return - - coros = [] - - for server in (await response.json()): - homeserver_url = "https://" + server["domain"] - - if not server["open"]: # ignore closed servers - continue - - self.models["homeservers"][homeserver_url] = Homeserver( - id = homeserver_url, - name = server["name"], - site_url = server["domain"], - country = server["jurisdiction"], - stability = 0, - downtimes_ms = 0, - # austin's list doesn't have stability/downtime - ) - - coros.append(self._ping_homeserver(session, homeserver_url)) - - await asyncio.gather(*coros) - await session.close() - - - async def desktop_notify( - self, title: str, body: str = "", image: Union[Path, str] = "", - ) -> None: - # XXX: images on windows must be .ICO - - try: - plyer.notification.notify( - title = title, - message = body, - app_name = __app_name__, - app_icon = str(image), - timeout = 10, - toast = False, - ) - self.notifications_working = True - except Exception: # noqa - if self.notifications_working: - trace = traceback.format_exc().rstrip() - log.error("Sending desktop notification failed\n%s", trace) - self.notifications_working = False - - - async def sound_notify(self) -> None: - path = self.settings.Notifications.default_sound - path = str(Path(path).expanduser()) - - if path == "default.wav": - path = "src/sounds/default.wav" - - try: - content = pyotherside.qrc_get_file_contents(path) - except ValueError: - sa = simpleaudio.WaveObject.from_wave_file(path) - else: - wave_read = wave.open(io.BytesIO(content)) - sa = simpleaudio.WaveObject.from_wave_read(wave_read) - - try: - sa.play() - self.audio_working = True - except Exception as e: # noqa - if self.audio_working: - trace = traceback.format_exc().rstrip() - log.error("Playing audio failed\n%s", trace) - self.audio_working = False + times.append(round((time.time() - start) * 1000)) + + if i == 7 or i == 15: + item.set_fields( + ping=sum(times) // len(times), status=PingStatus.Done, + ) + + async def fetch_homeservers(self) -> None: + """Retrieve a list of public homeservers and add them to our model.""" + + @client_session # need to trigger this decorator for creation + async def have_session_be_created(*_): + pass + + # We just want that client's aiohttp session, that way we don't have + # to depend ourselves on aiohttp + aiohttp-socks + proxy = self.settings.General.proxy + client = nio.AsyncClient(homeserver="", proxy=proxy) + await have_session_be_created(client) + + + session = client.client_session + # aiohttp only has "timeout" in 3.7.0+ + timeout = getattr(session, "timeout", session._timeout) + session = type(session)( + raise_for_status = True, + timeout = type(timeout)(total=20), + connector = session.connector, + ) + + api_list = "https://joinmatrix.org/servers.json" + try: + response = await session.get(api_list) + except: + await session.close() + print("Unable to fetch", api_list) + return + + coros = [] + + for server in (await response.json()): + homeserver_url = "https://" + server["domain"] + + if not server["open"]: # ignore closed servers + continue + + self.models["homeservers"][homeserver_url] = Homeserver( + id = homeserver_url, + name = server["name"], + site_url = server["domain"], + country = server["jurisdiction"], + stability = 0, + downtimes_ms = 0, + # austin's list doesn't have stability/downtime + ) + + coros.append(self._ping_homeserver(session, homeserver_url)) + + await asyncio.gather(*coros) + await session.close() + + + async def desktop_notify( + self, title: str, body: str = "", image: Union[Path, str] = "", + ) -> None: + # XXX: images on windows must be .ICO + + try: + plyer.notification.notify( + title = title, + message = body, + app_name = __app_name__, + app_icon = str(image), + timeout = 10, + toast = False, + ) + self.notifications_working = True + except Exception: # noqa + if self.notifications_working: + trace = traceback.format_exc().rstrip() + log.error("Sending desktop notification failed\n%s", trace) + self.notifications_working = False + + + async def sound_notify(self) -> None: + path = self.settings.Notifications.default_sound + path = str(Path(path).expanduser()) + + if path == "default.wav": + path = "src/sounds/default.wav" + + try: + content = pyotherside.qrc_get_file_contents(path) + except ValueError: + sa = simpleaudio.WaveObject.from_wave_file(path) + else: + wave_read = wave.open(io.BytesIO(content)) + sa = simpleaudio.WaveObject.from_wave_read(wave_read) + + try: + sa.play() + self.audio_working = True + except Exception as e: # noqa + if self.audio_working: + trace = traceback.format_exc().rstrip() + log.error("Playing audio failed\n%s", trace) + self.audio_working = False diff --git a/src/backend/color.py b/src/backend/color.py index 0d903b71..bfc28e5f 100644 --- a/src/backend/color.py +++ b/src/backend/color.py @@ -17,442 +17,442 @@ ColorTuple = Tuple[float, float, float, float] @dataclass(repr=False) class Color: - """A color manipulable in HSLuv, HSL, RGB, hexadecimal and by SVG name. + """A color manipulable in HSLuv, HSL, RGB, hexadecimal and by SVG name. - The `Color` object constructor accepts hexadecimal string - ("#RGB", "#RRGGBB" or "#RRGGBBAA") or another `Color` to copy. + The `Color` object constructor accepts hexadecimal string + ("#RGB", "#RRGGBB" or "#RRGGBBAA") or another `Color` to copy. - Attributes representing the color in HSLuv, HSL, RGB, hexadecimal and - SVG name formats can be accessed and modified on these `Color` objects. + Attributes representing the color in HSLuv, HSL, RGB, hexadecimal and + SVG name formats can be accessed and modified on these `Color` objects. - The `hsluv()`/`hsluva()`, `hsl()`/`hsla()` and `rgb()`/`rgba()` - functions in this module are provided to create an object by specifying - a color in other formats. + The `hsluv()`/`hsluva()`, `hsl()`/`hsla()` and `rgb()`/`rgba()` + functions in this module are provided to create an object by specifying + a color in other formats. - Copies of objects with modified attributes can be created with the - with the `Color.but()`, `Color.plus()` and `Copy.times()` methods. + Copies of objects with modified attributes can be created with the + with the `Color.but()`, `Color.plus()` and `Copy.times()` methods. - If the `hue` is outside of the normal 0-359 range, the number is - interpreted as `hue % 360`, e.g. `360` is `0`, `460` is `100`, - or `-20` is `340`. - """ + If the `hue` is outside of the normal 0-359 range, the number is + interpreted as `hue % 360`, e.g. `360` is `0`, `460` is `100`, + or `-20` is `340`. + """ - # The saturation and luv are properties due to the need for a setter - # capping the value between 0-100, as hsluv handles numbers outside - # this range incorrectly. + # The saturation and luv are properties due to the need for a setter + # capping the value between 0-100, as hsluv handles numbers outside + # this range incorrectly. - color_or_hex: InitVar[str] = "#00000000" - hue: float = field(init=False, default=0) - _saturation: float = field(init=False, default=0) - _luv: float = field(init=False, default=0) - alpha: float = field(init=False, default=1) + color_or_hex: InitVar[str] = "#00000000" + hue: float = field(init=False, default=0) + _saturation: float = field(init=False, default=0) + _luv: float = field(init=False, default=0) + alpha: float = field(init=False, default=1) - def __post_init__(self, color_or_hex: Union["Color", str]) -> None: - if isinstance(color_or_hex, Color): - hsluva = color_or_hex.hsluva - self.hue, self.saturation, self.luv, self.alpha = hsluva - else: - self.hex = color_or_hex + def __post_init__(self, color_or_hex: Union["Color", str]) -> None: + if isinstance(color_or_hex, Color): + hsluva = color_or_hex.hsluva + self.hue, self.saturation, self.luv, self.alpha = hsluva + else: + self.hex = color_or_hex - # HSLuv + # HSLuv - @property - def hsluva(self) -> ColorTuple: - return (self.hue, self.saturation, self.luv, self.alpha) + @property + def hsluva(self) -> ColorTuple: + return (self.hue, self.saturation, self.luv, self.alpha) - @hsluva.setter - def hsluva(self, value: ColorTuple) -> None: - self.hue, self.saturation, self.luv, self.alpha = value + @hsluva.setter + def hsluva(self, value: ColorTuple) -> None: + self.hue, self.saturation, self.luv, self.alpha = value - @property - def saturation(self) -> float: - return self._saturation + @property + def saturation(self) -> float: + return self._saturation - @saturation.setter - def saturation(self, value: float) -> None: - self._saturation = max(0, min(100, value)) + @saturation.setter + def saturation(self, value: float) -> None: + self._saturation = max(0, min(100, value)) - @property - def luv(self) -> float: - return self._luv + @property + def luv(self) -> float: + return self._luv - @luv.setter - def luv(self, value: float) -> None: - self._luv = max(0, min(100, value)) + @luv.setter + def luv(self, value: float) -> None: + self._luv = max(0, min(100, value)) - # HSL + # HSL - @property - def hsla(self) -> ColorTuple: - r, g, b = (self.red / 255, self.green / 255, self.blue / 255) - h, l, s = colorsys.rgb_to_hls(r, g, b) - return (h * 360, s * 100, l * 100, self.alpha) + @property + def hsla(self) -> ColorTuple: + r, g, b = (self.red / 255, self.green / 255, self.blue / 255) + h, l, s = colorsys.rgb_to_hls(r, g, b) + return (h * 360, s * 100, l * 100, self.alpha) - @hsla.setter - def hsla(self, value: ColorTuple) -> None: - h, s, l = (value[0] / 360, value[1] / 100, value[2] / 100) # noqa - r, g, b = colorsys.hls_to_rgb(h, l, s) - self.rgba = (r * 255, g * 255, b * 255, value[3]) + @hsla.setter + def hsla(self, value: ColorTuple) -> None: + h, s, l = (value[0] / 360, value[1] / 100, value[2] / 100) # noqa + r, g, b = colorsys.hls_to_rgb(h, l, s) + self.rgba = (r * 255, g * 255, b * 255, value[3]) - @property - def light(self) -> float: - return self.hsla[2] + @property + def light(self) -> float: + return self.hsla[2] - @light.setter - def light(self, value: float) -> None: - self.hsla = (self.hue, self.saturation, value, self.alpha) + @light.setter + def light(self, value: float) -> None: + self.hsla = (self.hue, self.saturation, value, self.alpha) - # RGB + # RGB - @property - def rgba(self) -> ColorTuple: - r, g, b = hsluv_to_rgb(self.hsluva) - return r * 255, g * 255, b * 255, self.alpha + @property + def rgba(self) -> ColorTuple: + r, g, b = hsluv_to_rgb(self.hsluva) + return r * 255, g * 255, b * 255, self.alpha - @rgba.setter - def rgba(self, value: ColorTuple) -> None: - r, g, b = (value[0] / 255, value[1] / 255, value[2] / 255) - self.hsluva = rgb_to_hsluv((r, g, b)) + (self.alpha,) + @rgba.setter + def rgba(self, value: ColorTuple) -> None: + r, g, b = (value[0] / 255, value[1] / 255, value[2] / 255) + self.hsluva = rgb_to_hsluv((r, g, b)) + (self.alpha,) - @property - def red(self) -> float: - return self.rgba[0] + @property + def red(self) -> float: + return self.rgba[0] - @red.setter - def red(self, value: float) -> None: - self.rgba = (value, self.green, self.blue, self.alpha) + @red.setter + def red(self, value: float) -> None: + self.rgba = (value, self.green, self.blue, self.alpha) - @property - def green(self) -> float: - return self.rgba[1] + @property + def green(self) -> float: + return self.rgba[1] - @green.setter - def green(self, value: float) -> None: - self.rgba = (self.red, value, self.blue, self.alpha) + @green.setter + def green(self, value: float) -> None: + self.rgba = (self.red, value, self.blue, self.alpha) - @property - def blue(self) -> float: - return self.rgba[2] + @property + def blue(self) -> float: + return self.rgba[2] - @blue.setter - def blue(self, value: float) -> None: - self.rgba = (self.red, self.green, value, self.alpha) + @blue.setter + def blue(self, value: float) -> None: + self.rgba = (self.red, self.green, value, self.alpha) - # Hexadecimal + # Hexadecimal - @property - def hex(self) -> str: - rgb = hsluv_to_hex(self.hsluva) - alpha = builtins.hex(int(self.alpha * 255))[2:] - alpha = f"0{alpha}" if len(alpha) == 1 else alpha - return f"{alpha if self.alpha < 1 else ''}{rgb}".lower() + @property + def hex(self) -> str: + rgb = hsluv_to_hex(self.hsluva) + alpha = builtins.hex(int(self.alpha * 255))[2:] + alpha = f"0{alpha}" if len(alpha) == 1 else alpha + return f"{alpha if self.alpha < 1 else ''}{rgb}".lower() - @hex.setter - def hex(self, value: str) -> None: - if len(value) == 4: - template = "#{r}{r}{g}{g}{b}{b}" - value = template.format(r=value[1], g=value[2], b=value[3]) + @hex.setter + def hex(self, value: str) -> None: + if len(value) == 4: + template = "#{r}{r}{g}{g}{b}{b}" + value = template.format(r=value[1], g=value[2], b=value[3]) - alpha = int(value[-2:] if len(value) == 9 else "ff", 16) / 255 + alpha = int(value[-2:] if len(value) == 9 else "ff", 16) / 255 - self.hsluva = hex_to_hsluv(value) + (alpha,) + self.hsluva = hex_to_hsluv(value) + (alpha,) - # name color + # name color - @property - def name(self) -> Optional[str]: - try: - return SVGColor(self.hex).name - except ValueError: - return None + @property + def name(self) -> Optional[str]: + try: + return SVGColor(self.hex).name + except ValueError: + return None - @name.setter - def name(self, value: str) -> None: - self.hex = SVGColor[value.lower()].value.hex + @name.setter + def name(self, value: str) -> None: + self.hex = SVGColor[value.lower()].value.hex - # Other methods + # Other methods - def __repr__(self) -> str: - r, g, b = int(self.red), int(self.green), int(self.blue) - h, s, luv = int(self.hue), int(self.saturation), int(self.luv) - l = int(self.light) # noqa - a = self.alpha - block = f"\x1b[38;2;{r};{g};{b}m█████\x1b[0m" - sep = "\x1b[1;33m/\x1b[0m" - end = f" {sep} {self.name}" if self.name else "" - # Need a terminal with true color support to render the block! - return ( - f"{block} hsluva({h}, {s}, {luv}, {a}) {sep} " - f"hsla({h}, {s}, {l}, {a}) {sep} rgba({r}, {g}, {b}, {a}) {sep} " - f"{self.hex}{end}" - ) + def __repr__(self) -> str: + r, g, b = int(self.red), int(self.green), int(self.blue) + h, s, luv = int(self.hue), int(self.saturation), int(self.luv) + l = int(self.light) # noqa + a = self.alpha + block = f"\x1b[38;2;{r};{g};{b}m█████\x1b[0m" + sep = "\x1b[1;33m/\x1b[0m" + end = f" {sep} {self.name}" if self.name else "" + # Need a terminal with true color support to render the block! + return ( + f"{block} hsluva({h}, {s}, {luv}, {a}) {sep} " + f"hsla({h}, {s}, {l}, {a}) {sep} rgba({r}, {g}, {b}, {a}) {sep} " + f"{self.hex}{end}" + ) - def but( - self, - hue: Optional[float] = None, - saturation: Optional[float] = None, - luv: Optional[float] = None, - alpha: Optional[float] = None, - *, - hsluva: Optional[ColorTuple] = None, - hsla: Optional[ColorTuple] = None, - rgba: Optional[ColorTuple] = None, - hex: Optional[str] = None, - name: Optional[str] = None, - light: Optional[float] = None, - red: Optional[float] = None, - green: Optional[float] = None, - blue: Optional[float] = None, - ) -> "Color": - """Return a copy of this `Color` with overriden attributes. + def but( + self, + hue: Optional[float] = None, + saturation: Optional[float] = None, + luv: Optional[float] = None, + alpha: Optional[float] = None, + *, + hsluva: Optional[ColorTuple] = None, + hsla: Optional[ColorTuple] = None, + rgba: Optional[ColorTuple] = None, + hex: Optional[str] = None, + name: Optional[str] = None, + light: Optional[float] = None, + red: Optional[float] = None, + green: Optional[float] = None, + blue: Optional[float] = None, + ) -> "Color": + """Return a copy of this `Color` with overriden attributes. - Example: - >>> first = Color(100, 50, 50) - >>> second = c.but(hue=20, saturation=100) - >>> second.hsluva - (20, 50, 100, 1) - """ + Example: + >>> first = Color(100, 50, 50) + >>> second = c.but(hue=20, saturation=100) + >>> second.hsluva + (20, 50, 100, 1) + """ - new = copy(self) + new = copy(self) - for arg, value in locals().items(): - if arg not in ("new", "self") and value is not None: - setattr(new, arg, value) + for arg, value in locals().items(): + if arg not in ("new", "self") and value is not None: + setattr(new, arg, value) - return new + return new - def plus( - self, - hue: Optional[float] = None, - saturation: Optional[float] = None, - luv: Optional[float] = None, - alpha: Optional[float] = None, - *, - light: Optional[float] = None, - red: Optional[float] = None, - green: Optional[float] = None, - blue: Optional[float] = None, - ) -> "Color": - """Return a copy of this `Color` with values added to attributes. + def plus( + self, + hue: Optional[float] = None, + saturation: Optional[float] = None, + luv: Optional[float] = None, + alpha: Optional[float] = None, + *, + light: Optional[float] = None, + red: Optional[float] = None, + green: Optional[float] = None, + blue: Optional[float] = None, + ) -> "Color": + """Return a copy of this `Color` with values added to attributes. - Example: - >>> first = Color(100, 50, 50) - >>> second = c.plus(hue=10, saturation=-20) - >>> second.hsluva - (110, 30, 50, 1) - """ + Example: + >>> first = Color(100, 50, 50) + >>> second = c.plus(hue=10, saturation=-20) + >>> second.hsluva + (110, 30, 50, 1) + """ - new = copy(self) + new = copy(self) - for arg, value in locals().items(): - if arg not in ("new", "self") and value is not None: - setattr(new, arg, getattr(self, arg) + value) + for arg, value in locals().items(): + if arg not in ("new", "self") and value is not None: + setattr(new, arg, getattr(self, arg) + value) - return new + return new - def times( - self, - hue: Optional[float] = None, - saturation: Optional[float] = None, - luv: Optional[float] = None, - alpha: Optional[float] = None, - *, - light: Optional[float] = None, - red: Optional[float] = None, - green: Optional[float] = None, - blue: Optional[float] = None, - ) -> "Color": - """Return a copy of this `Color` with multiplied attributes. + def times( + self, + hue: Optional[float] = None, + saturation: Optional[float] = None, + luv: Optional[float] = None, + alpha: Optional[float] = None, + *, + light: Optional[float] = None, + red: Optional[float] = None, + green: Optional[float] = None, + blue: Optional[float] = None, + ) -> "Color": + """Return a copy of this `Color` with multiplied attributes. - Example: - >>> first = Color(100, 50, 50, 0.8) - >>> second = c.times(luv=2, alpha=0.5) - >>> second.hsluva - (100, 50, 100, 0.4) - """ + Example: + >>> first = Color(100, 50, 50, 0.8) + >>> second = c.times(luv=2, alpha=0.5) + >>> second.hsluva + (100, 50, 100, 0.4) + """ - new = copy(self) + new = copy(self) - for arg, value in locals().items(): - if arg not in ("new", "self") and value is not None: - setattr(new, arg, getattr(self, arg) * value) + for arg, value in locals().items(): + if arg not in ("new", "self") and value is not None: + setattr(new, arg, getattr(self, arg) * value) - return new + return new class SVGColor(Enum): - """Standard SVG/HTML/CSS colors, with the addition of `transparent`.""" + """Standard SVG/HTML/CSS colors, with the addition of `transparent`.""" - aliceblue = Color("#f0f8ff") - antiquewhite = Color("#faebd7") - aqua = Color("#00ffff") - aquamarine = Color("#7fffd4") - azure = Color("#f0ffff") - beige = Color("#f5f5dc") - bisque = Color("#ffe4c4") - black = Color("#000000") - blanchedalmond = Color("#ffebcd") - blue = Color("#0000ff") - blueviolet = Color("#8a2be2") - brown = Color("#a52a2a") - burlywood = Color("#deb887") - cadetblue = Color("#5f9ea0") - chartreuse = Color("#7fff00") - chocolate = Color("#d2691e") - coral = Color("#ff7f50") - cornflowerblue = Color("#6495ed") - cornsilk = Color("#fff8dc") - crimson = Color("#dc143c") - cyan = Color("#00ffff") - darkblue = Color("#00008b") - darkcyan = Color("#008b8b") - darkgoldenrod = Color("#b8860b") - darkgray = Color("#a9a9a9") - darkgreen = Color("#006400") - darkgrey = Color("#a9a9a9") - darkkhaki = Color("#bdb76b") - darkmagenta = Color("#8b008b") - darkolivegreen = Color("#556b2f") - darkorange = Color("#ff8c00") - darkorchid = Color("#9932cc") - darkred = Color("#8b0000") - darksalmon = Color("#e9967a") - darkseagreen = Color("#8fbc8f") - darkslateblue = Color("#483d8b") - darkslategray = Color("#2f4f4f") - darkslategrey = Color("#2f4f4f") - darkturquoise = Color("#00ced1") - darkviolet = Color("#9400d3") - deeppink = Color("#ff1493") - deepskyblue = Color("#00bfff") - dimgray = Color("#696969") - dimgrey = Color("#696969") - dodgerblue = Color("#1e90ff") - firebrick = Color("#b22222") - floralwhite = Color("#fffaf0") - forestgreen = Color("#228b22") - fuchsia = Color("#ff00ff") - gainsboro = Color("#dcdcdc") - ghostwhite = Color("#f8f8ff") - gold = Color("#ffd700") - goldenrod = Color("#daa520") - gray = Color("#808080") - green = Color("#008000") - greenyellow = Color("#adff2f") - grey = Color("#808080") - honeydew = Color("#f0fff0") - hotpink = Color("#ff69b4") - indianred = Color("#cd5c5c") - indigo = Color("#4b0082") - ivory = Color("#fffff0") - khaki = Color("#f0e68c") - lavender = Color("#e6e6fa") - lavenderblush = Color("#fff0f5") - lawngreen = Color("#7cfc00") - lemonchiffon = Color("#fffacd") - lightblue = Color("#add8e6") - lightcoral = Color("#f08080") - lightcyan = Color("#e0ffff") - lightgoldenrodyellow = Color("#fafad2") - lightgray = Color("#d3d3d3") - lightgreen = Color("#90ee90") - lightgrey = Color("#d3d3d3") - lightpink = Color("#ffb6c1") - lightsalmon = Color("#ffa07a") - lightseagreen = Color("#20b2aa") - lightskyblue = Color("#87cefa") - lightslategray = Color("#778899") - lightslategrey = Color("#778899") - lightsteelblue = Color("#b0c4de") - lightyellow = Color("#ffffe0") - lime = Color("#00ff00") - limegreen = Color("#32cd32") - linen = Color("#faf0e6") - magenta = Color("#ff00ff") - maroon = Color("#800000") - mediumaquamarine = Color("#66cdaa") - mediumblue = Color("#0000cd") - mediumorchid = Color("#ba55d3") - mediumpurple = Color("#9370db") - mediumseagreen = Color("#3cb371") - mediumslateblue = Color("#7b68ee") - mediumspringgreen = Color("#00fa9a") - mediumturquoise = Color("#48d1cc") - mediumvioletred = Color("#c71585") - midnightblue = Color("#191970") - mintcream = Color("#f5fffa") - mistyrose = Color("#ffe4e1") - moccasin = Color("#ffe4b5") - navajowhite = Color("#ffdead") - navy = Color("#000080") - oldlace = Color("#fdf5e6") - olive = Color("#808000") - olivedrab = Color("#6b8e23") - orange = Color("#ffa500") - orangered = Color("#ff4500") - orchid = Color("#da70d6") - palegoldenrod = Color("#eee8aa") - palegreen = Color("#98fb98") - paleturquoise = Color("#afeeee") - palevioletred = Color("#db7093") - papayawhip = Color("#ffefd5") - peachpuff = Color("#ffdab9") - peru = Color("#cd853f") - pink = Color("#ffc0cb") - plum = Color("#dda0dd") - powderblue = Color("#b0e0e6") - purple = Color("#800080") - rebeccapurple = Color("#663399") - red = Color("#ff0000") - rosybrown = Color("#bc8f8f") - royalblue = Color("#4169e1") - saddlebrown = Color("#8b4513") - salmon = Color("#fa8072") - sandybrown = Color("#f4a460") - seagreen = Color("#2e8b57") - seashell = Color("#fff5ee") - sienna = Color("#a0522d") - silver = Color("#c0c0c0") - skyblue = Color("#87ceeb") - slateblue = Color("#6a5acd") - slategray = Color("#708090") - slategrey = Color("#708090") - snow = Color("#fffafa") - springgreen = Color("#00ff7f") - steelblue = Color("#4682b4") - tan = Color("#d2b48c") - teal = Color("#008080") - thistle = Color("#d8bfd8") - tomato = Color("#ff6347") - transparent = Color("#00000000") # not standard but exists in QML - turquoise = Color("#40e0d0") - violet = Color("#ee82ee") - wheat = Color("#f5deb3") - white = Color("#ffffff") - whitesmoke = Color("#f5f5f5") - yellow = Color("#ffff00") - yellowgreen = Color("#9acd32") + aliceblue = Color("#f0f8ff") + antiquewhite = Color("#faebd7") + aqua = Color("#00ffff") + aquamarine = Color("#7fffd4") + azure = Color("#f0ffff") + beige = Color("#f5f5dc") + bisque = Color("#ffe4c4") + black = Color("#000000") + blanchedalmond = Color("#ffebcd") + blue = Color("#0000ff") + blueviolet = Color("#8a2be2") + brown = Color("#a52a2a") + burlywood = Color("#deb887") + cadetblue = Color("#5f9ea0") + chartreuse = Color("#7fff00") + chocolate = Color("#d2691e") + coral = Color("#ff7f50") + cornflowerblue = Color("#6495ed") + cornsilk = Color("#fff8dc") + crimson = Color("#dc143c") + cyan = Color("#00ffff") + darkblue = Color("#00008b") + darkcyan = Color("#008b8b") + darkgoldenrod = Color("#b8860b") + darkgray = Color("#a9a9a9") + darkgreen = Color("#006400") + darkgrey = Color("#a9a9a9") + darkkhaki = Color("#bdb76b") + darkmagenta = Color("#8b008b") + darkolivegreen = Color("#556b2f") + darkorange = Color("#ff8c00") + darkorchid = Color("#9932cc") + darkred = Color("#8b0000") + darksalmon = Color("#e9967a") + darkseagreen = Color("#8fbc8f") + darkslateblue = Color("#483d8b") + darkslategray = Color("#2f4f4f") + darkslategrey = Color("#2f4f4f") + darkturquoise = Color("#00ced1") + darkviolet = Color("#9400d3") + deeppink = Color("#ff1493") + deepskyblue = Color("#00bfff") + dimgray = Color("#696969") + dimgrey = Color("#696969") + dodgerblue = Color("#1e90ff") + firebrick = Color("#b22222") + floralwhite = Color("#fffaf0") + forestgreen = Color("#228b22") + fuchsia = Color("#ff00ff") + gainsboro = Color("#dcdcdc") + ghostwhite = Color("#f8f8ff") + gold = Color("#ffd700") + goldenrod = Color("#daa520") + gray = Color("#808080") + green = Color("#008000") + greenyellow = Color("#adff2f") + grey = Color("#808080") + honeydew = Color("#f0fff0") + hotpink = Color("#ff69b4") + indianred = Color("#cd5c5c") + indigo = Color("#4b0082") + ivory = Color("#fffff0") + khaki = Color("#f0e68c") + lavender = Color("#e6e6fa") + lavenderblush = Color("#fff0f5") + lawngreen = Color("#7cfc00") + lemonchiffon = Color("#fffacd") + lightblue = Color("#add8e6") + lightcoral = Color("#f08080") + lightcyan = Color("#e0ffff") + lightgoldenrodyellow = Color("#fafad2") + lightgray = Color("#d3d3d3") + lightgreen = Color("#90ee90") + lightgrey = Color("#d3d3d3") + lightpink = Color("#ffb6c1") + lightsalmon = Color("#ffa07a") + lightseagreen = Color("#20b2aa") + lightskyblue = Color("#87cefa") + lightslategray = Color("#778899") + lightslategrey = Color("#778899") + lightsteelblue = Color("#b0c4de") + lightyellow = Color("#ffffe0") + lime = Color("#00ff00") + limegreen = Color("#32cd32") + linen = Color("#faf0e6") + magenta = Color("#ff00ff") + maroon = Color("#800000") + mediumaquamarine = Color("#66cdaa") + mediumblue = Color("#0000cd") + mediumorchid = Color("#ba55d3") + mediumpurple = Color("#9370db") + mediumseagreen = Color("#3cb371") + mediumslateblue = Color("#7b68ee") + mediumspringgreen = Color("#00fa9a") + mediumturquoise = Color("#48d1cc") + mediumvioletred = Color("#c71585") + midnightblue = Color("#191970") + mintcream = Color("#f5fffa") + mistyrose = Color("#ffe4e1") + moccasin = Color("#ffe4b5") + navajowhite = Color("#ffdead") + navy = Color("#000080") + oldlace = Color("#fdf5e6") + olive = Color("#808000") + olivedrab = Color("#6b8e23") + orange = Color("#ffa500") + orangered = Color("#ff4500") + orchid = Color("#da70d6") + palegoldenrod = Color("#eee8aa") + palegreen = Color("#98fb98") + paleturquoise = Color("#afeeee") + palevioletred = Color("#db7093") + papayawhip = Color("#ffefd5") + peachpuff = Color("#ffdab9") + peru = Color("#cd853f") + pink = Color("#ffc0cb") + plum = Color("#dda0dd") + powderblue = Color("#b0e0e6") + purple = Color("#800080") + rebeccapurple = Color("#663399") + red = Color("#ff0000") + rosybrown = Color("#bc8f8f") + royalblue = Color("#4169e1") + saddlebrown = Color("#8b4513") + salmon = Color("#fa8072") + sandybrown = Color("#f4a460") + seagreen = Color("#2e8b57") + seashell = Color("#fff5ee") + sienna = Color("#a0522d") + silver = Color("#c0c0c0") + skyblue = Color("#87ceeb") + slateblue = Color("#6a5acd") + slategray = Color("#708090") + slategrey = Color("#708090") + snow = Color("#fffafa") + springgreen = Color("#00ff7f") + steelblue = Color("#4682b4") + tan = Color("#d2b48c") + teal = Color("#008080") + thistle = Color("#d8bfd8") + tomato = Color("#ff6347") + transparent = Color("#00000000") # not standard but exists in QML + turquoise = Color("#40e0d0") + violet = Color("#ee82ee") + wheat = Color("#f5deb3") + white = Color("#ffffff") + whitesmoke = Color("#f5f5f5") + yellow = Color("#ffff00") + yellowgreen = Color("#9acd32") def hsluva( - hue: float = 0, saturation: float = 0, luv: float = 0, alpha: float = 1, + hue: float = 0, saturation: float = 0, luv: float = 0, alpha: float = 1, ) -> Color: - """Return a `Color` from `(0-359, 0-100, 0-100, 0-1)` HSLuv arguments.""" - return Color().but(hue, saturation, luv, alpha) + """Return a `Color` from `(0-359, 0-100, 0-100, 0-1)` HSLuv arguments.""" + return Color().but(hue, saturation, luv, alpha) def hsla( - hue: float = 0, saturation: float = 0, light: float = 0, alpha: float = 1, + hue: float = 0, saturation: float = 0, light: float = 0, alpha: float = 1, ) -> Color: - """Return a `Color` from `(0-359, 0-100, 0-100, 0-1)` HSL arguments.""" - return Color().but(hue, saturation, light=light, alpha=alpha) + """Return a `Color` from `(0-359, 0-100, 0-100, 0-1)` HSL arguments.""" + return Color().but(hue, saturation, light=light, alpha=alpha) def rgba( - red: float = 0, green: float = 0, blue: float = 0, alpha: float = 1, + red: float = 0, green: float = 0, blue: float = 0, alpha: float = 1, ) -> Color: - """Return a `Color` from `(0-255, 0-255, 0-255, 0-1)` RGB arguments.""" - return Color().but(red=red, green=green, blue=blue, alpha=alpha) + """Return a `Color` from `(0-255, 0-255, 0-255, 0-1)` RGB arguments.""" + return Color().but(red=red, green=green, blue=blue, alpha=alpha) # Aliases diff --git a/src/backend/errors.py b/src/backend/errors.py index 9ec6ea7d..19603363 100644 --- a/src/backend/errors.py +++ b/src/backend/errors.py @@ -12,117 +12,117 @@ import nio @dataclass class MatrixError(Exception): - """An error returned by a Matrix server.""" + """An error returned by a Matrix server.""" - http_code: int = 400 - m_code: Optional[str] = None - message: Optional[str] = None - content: str = "" + http_code: int = 400 + m_code: Optional[str] = None + message: Optional[str] = None + content: str = "" - @classmethod - async def from_nio(cls, response: nio.ErrorResponse) -> "MatrixError": - """Return a `MatrixError` subclass from a nio `ErrorResponse`.""" + @classmethod + async def from_nio(cls, response: nio.ErrorResponse) -> "MatrixError": + """Return a `MatrixError` subclass from a nio `ErrorResponse`.""" - http_code = response.transport_response.status - m_code = response.status_code - message = response.message - content = await response.transport_response.text() + http_code = response.transport_response.status + m_code = response.status_code + message = response.message + content = await response.transport_response.text() - for subcls in cls.__subclasses__(): - if subcls.m_code and subcls.m_code == m_code: - return subcls(http_code, m_code, message, content) + for subcls in cls.__subclasses__(): + if subcls.m_code and subcls.m_code == m_code: + return subcls(http_code, m_code, message, content) - # If error doesn't have a M_CODE, look for a generic http error class - for subcls in cls.__subclasses__(): - if not subcls.m_code and subcls.http_code == http_code: - return subcls(http_code, m_code, message, content) + # If error doesn't have a M_CODE, look for a generic http error class + for subcls in cls.__subclasses__(): + if not subcls.m_code and subcls.http_code == http_code: + return subcls(http_code, m_code, message, content) - return cls(http_code, m_code, message, content) + return cls(http_code, m_code, message, content) @dataclass class MatrixUnrecognized(MatrixError): - http_code: int = 400 - m_code: str = "M_UNRECOGNIZED" + http_code: int = 400 + m_code: str = "M_UNRECOGNIZED" @dataclass class MatrixInvalidAccessToken(MatrixError): - http_code: int = 401 - m_code: str = "M_UNKNOWN_TOKEN" + http_code: int = 401 + m_code: str = "M_UNKNOWN_TOKEN" @dataclass class MatrixUnauthorized(MatrixError): - http_code: int = 401 - m_code: str = "M_UNAUTHORIZED" + http_code: int = 401 + m_code: str = "M_UNAUTHORIZED" @dataclass class MatrixForbidden(MatrixError): - http_code: int = 403 - m_code: str = "M_FORBIDDEN" + http_code: int = 403 + m_code: str = "M_FORBIDDEN" @dataclass class MatrixBadJson(MatrixError): - http_code: int = 403 - m_code: str = "M_BAD_JSON" + http_code: int = 403 + m_code: str = "M_BAD_JSON" @dataclass class MatrixNotJson(MatrixError): - http_code: int = 403 - m_code: str = "M_NOT_JSON" + http_code: int = 403 + m_code: str = "M_NOT_JSON" @dataclass class MatrixUserDeactivated(MatrixError): - http_code: int = 403 - m_code: str = "M_USER_DEACTIVATED" + http_code: int = 403 + m_code: str = "M_USER_DEACTIVATED" @dataclass class MatrixNotFound(MatrixError): - http_code: int = 404 - m_code: str = "M_NOT_FOUND" + http_code: int = 404 + m_code: str = "M_NOT_FOUND" @dataclass class MatrixTooLarge(MatrixError): - http_code: int = 413 - m_code: str = "M_TOO_LARGE" + http_code: int = 413 + m_code: str = "M_TOO_LARGE" @dataclass class MatrixBadGateway(MatrixError): - http_code: int = 502 - m_code: Optional[str] = None + http_code: int = 502 + m_code: Optional[str] = None # Client errors @dataclass class InvalidUserId(Exception): - user_id: str = field() + user_id: str = field() @dataclass class InvalidUserInContext(Exception): - user_id: str = field() + user_id: str = field() @dataclass class UserFromOtherServerDisallowed(Exception): - user_id: str = field() + user_id: str = field() @dataclass class UneededThumbnail(Exception): - pass + pass @dataclass class BadMimeType(Exception): - wanted: str = field() - got: str = field() + wanted: str = field() + got: str = field() diff --git a/src/backend/html_markdown.py b/src/backend/html_markdown.py index a159ea6f..596f5ff2 100644 --- a/src/backend/html_markdown.py +++ b/src/backend/html_markdown.py @@ -19,503 +19,503 @@ from .color import SVGColor def parse_colour(inline, m, state): - colour = m.group(1) - text = m.group(2) - return "colour", colour, text + colour = m.group(1) + text = m.group(2) + return "colour", colour, text def render_html_colour(colour, text): - return f'{text}' + return f'{text}' def plugin_matrix(md): - # test string: r"(x) (x) \b>(x) b>(x) (\(z) (foo\)xyz)" - colour = ( - r"^<(.+?)>" # capture the colour in `` - r"\((.+?)" # capture text in `(text` - r"(?(x) (x) \b>(x) b>(x) (\(z) (foo\)xyz)" + colour = ( + r"^<(.+?)>" # capture the colour in `` + r"\((.+?)" # capture text in `(text` + r"(?(hi) matches the - # inline_html rule instead of the colour rule. - md.inline.rules.insert(1, "colour") - md.inline.register_rule("colour", colour, parse_colour) + # Mark colour as high priority as otherwise e.g. (hi) matches the + # inline_html rule instead of the colour rule. + md.inline.rules.insert(1, "colour") + md.inline.register_rule("colour", colour, parse_colour) - if md.renderer.NAME == "html": - md.renderer.register("colour", render_html_colour) + if md.renderer.NAME == "html": + md.renderer.register("colour", render_html_colour) class HTMLProcessor: - """Provide HTML filtering and conversion from Markdown. - - Filtering sanitizes HTML and ensures it complies both with the Matrix - specification: - https://matrix.org/docs/spec/client_server/latest#m-room-message-msgtypes - and the supported Qt HTML subset for usage in QML: - https://doc.qt.io/qt-5/richtext-html-subset.html - - Some methods take an `outgoing` argument, specifying if the HTML is - intended to be sent to matrix servers or used locally in our application. - - For local usage, extra transformations are applied: - - - Wrap text lines starting with a `>` in `` with a `quote` class. - This allows them to be styled appropriately from QML. - - Some methods take an `inline` argument, which return text appropriate - for UI elements restricted to display a single line, e.g. the room - last message subtitles in QML or notifications. - In inline filtered HTML, block tags are stripped or substituted and - newlines are turned into ⏎ symbols (U+23CE). - """ - - inline_tags = { - "span", "font", "a", "sup", "sub", "b", "i", "s", "u", "code", - "mx-reply", - } - - block_tags = { - "h1", "h2", "h3", "h4", "h5", "h6", "blockquote", - "p", "ul", "ol", "li", "hr", "br", "img", - "table", "thead", "tbody", "tr", "th", "td", "pre", - "mx-reply", - } - - opaque_id = r"[a-zA-Z\d._-]+?" - user_id_localpart = r"[\x21-\x39\x3B-\x7E]+?" - - user_id_regex = re.compile( - rf"(?P@{user_id_localpart}:(?P[a-zA-Z\d.:-]*[a-zA-Z\d]))", - ) - room_id_regex = re.compile( - rf"(?P!{opaque_id}:(?P[a-zA-Z\d.:-]*[a-zA-Z\d]))", - ) - room_alias_regex = re.compile( - r"(?=^|\W)(?P#\S+?:(?P[a-zA-Z\d.:-]*[a-zA-Z\d]))", - ) - - link_regexes = [re.compile(r, re.IGNORECASE) - if isinstance(r, str) else r for r in [ - # Normal :// URLs - (r"(?P[a-z\d]+://(?P[a-z\d._-]+(?:\:\d+)?)" - r"(?:/[/\-.,\w#%&?:;=~!$*+^@']*)?(?:\([/\-_.,a-z\d#%&?;=~]*\))?)"), - - # mailto: and tel: - r"mailto:(?P[a-z0-9._-]+@(?P[a-z0-9.:-]*[a-z\d]))", - r"tel:(?P[0-9+-]+)(?P)", - - # magnet: - r"(?Pmagnet:\?xt=urn:[a-z0-9]+:.+)(?P)", - - user_id_regex, room_id_regex, room_alias_regex, - ]] - - matrix_to_regex = re.compile(r"^https?://matrix.to/#/", re.IGNORECASE) - - link_is_matrix_to_regex = re.compile( - r"https?://matrix.to/#/\S+", re.IGNORECASE, - ) - link_is_user_id_regex = re.compile( - r"https?://matrix.to/#/@\S+", re.IGNORECASE, - ) - link_is_room_id_regex = re.compile( - r"https?://matrix.to/#/!\S+", re.IGNORECASE, - ) - link_is_room_alias_regex = re.compile( - r"https?://matrix.to/#/#\S+", re.IGNORECASE, - ) - link_is_message_id_regex = re.compile( - r"https?://matrix.to/#/[!#]\S+/\$\S+", re.IGNORECASE, - ) - - inline_quote_regex = re.compile(r"(^|⏎|>)(\s*>[^⏎\n]*)", re.MULTILINE) - - quote_regex = re.compile( - r"(^||

|
||)" - r"(\s*>.*?)" - r"(||
||
|$)", - re.MULTILINE, - ) - - extra_newlines_regex = re.compile(r"\n(\n*)") - - - def __init__(self) -> None: - # The whitespace remover doesn't take

 into account
-        sanitizer.normalize_overall_whitespace = lambda html, *args, **kw: html
-        sanitizer.normalize_whitespace_in_text_or_tail = \
-            lambda el, *args, **kw: el
-
-        # hard_wrap: convert all \n to 
without required two spaces - # escape: escape HTML characters in the input string, e.g. tags - self._markdown_to_html = mistune.create_markdown( - hard_wrap = True, - escape = True, - renderer = "html", - plugins = ['strikethrough', plugin_matrix], - ) - - - def mentions_in_html(self, html: str) -> List[Tuple[str, str]]: - """Return list of (text, href) tuples for all mention links in html.""" - - if not html.strip(): - return [] - - return [ - (a_tag.text, href) - for a_tag, _, href, _ in lxml.html.iterlinks(html) - if a_tag.text and - self.link_is_matrix_to_regex.match(unquote(href.strip())) - ] - - - def from_markdown( - self, - text: str, - inline: bool = False, - outgoing: bool = False, - display_name_mentions: Optional[Dict[str, str]] = None, - ) -> str: - """Return filtered HTML from Markdown text.""" - - return self.filter( - self._markdown_to_html(text), - inline, - outgoing, - display_name_mentions, - ) - - - def filter( - self, - html: str, - inline: bool = False, - outgoing: bool = False, - display_name_mentions: Optional[Dict[str, str]] = None, - ) -> str: - """Filter and return HTML.""" - - mentions = display_name_mentions - - sanit = Sanitizer(self.sanitize_settings(inline, outgoing, mentions)) - html = sanit.sanitize(html).rstrip("\n") - - if not html.strip(): - return html - - tree = etree.fromstring( - html, parser=etree.HTMLParser(encoding="utf-8"), - ) - - for a_tag in tree.iterdescendants("a"): - self._mentions_to_matrix_to_links(a_tag, mentions, outgoing) - - if not outgoing: - self._matrix_to_links_add_classes(a_tag) - - html = etree.tostring(tree, encoding="utf-8", method="html").decode() - html = sanit.sanitize(html).rstrip("\n") - - if outgoing: - return html - - # Client-side modifications - - html = self.quote_regex.sub(r'\1\2\3', html) - - if not inline: - return html - - return self.inline_quote_regex.sub( - r'\1\2', html, - ) - - - def sanitize_settings( - self, - inline: bool = False, - outgoing: bool = False, - display_name_mentions: Optional[Dict[str, str]] = None, - ) -> dict: - """Return an html_sanitizer configuration.""" - - # https://matrix.org/docs/spec/client_server/latest#m-room-message-msgtypes - - inline_tags = self.inline_tags - all_tags = inline_tags | self.block_tags - - inlines_attributes = { - "font": {"color"}, - "a": {"href", "class", "data-mention"}, - "code": {"class"}, - } - attributes = {**inlines_attributes, **{ - "ol": {"start"}, - "hr": {"width"}, - "span": {"data-mx-color"}, - "img": { - "data-mx-emote", "src", "alt", "title", "width", "height", - }, - }} - - username_link_regexes = [re.compile(r) for r in [ - rf"(?{re.escape(name or user_id)})(?!\w)(?P)" - for user_id, name in (display_name_mentions or {}).items() - ]] - - return { - "tags": inline_tags if inline else all_tags, - "attributes": inlines_attributes if inline else attributes, - "empty": {} if inline else {"hr", "br", "img"}, - "separate": {"a"} if inline else { - "a", "p", "li", "table", "tr", "th", "td", "br", "hr", "img", - }, - "whitespace": {}, - "keep_typographic_whitespace": True, - "add_nofollow": False, - "autolink": { - "link_regexes": - self.link_regexes + username_link_regexes, # type: ignore - "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("form", "p"), - sanitizer.tag_replacer("div", "p"), - sanitizer.tag_replacer("caption", "p"), - sanitizer.target_blank_noopener, - - self._span_color_to_font if not outgoing else lambda el: el, - - self._img_to_a, - self._remove_extra_newlines, - self._newlines_to_return_symbol if inline else lambda el: el, - self._reply_to_inline if inline else lambda el: el, - ], - "element_postprocessors": [ - self._font_color_to_span if outgoing else lambda el: el, - self._hr_to_dashes if not outgoing else lambda el: el, - ], - "is_mergeable": lambda e1, e2: e1.attrib == e2.attrib, - } - - - @staticmethod - def _span_color_to_font(el: HtmlElement) -> HtmlElement: - """Convert HTML ``.""" + """Provide HTML filtering and conversion from Markdown. + + Filtering sanitizes HTML and ensures it complies both with the Matrix + specification: + https://matrix.org/docs/spec/client_server/latest#m-room-message-msgtypes + and the supported Qt HTML subset for usage in QML: + https://doc.qt.io/qt-5/richtext-html-subset.html + + Some methods take an `outgoing` argument, specifying if the HTML is + intended to be sent to matrix servers or used locally in our application. + + For local usage, extra transformations are applied: + + - Wrap text lines starting with a `>` in `` with a `quote` class. + This allows them to be styled appropriately from QML. + + Some methods take an `inline` argument, which return text appropriate + for UI elements restricted to display a single line, e.g. the room + last message subtitles in QML or notifications. + In inline filtered HTML, block tags are stripped or substituted and + newlines are turned into ⏎ symbols (U+23CE). + """ + + inline_tags = { + "span", "font", "a", "sup", "sub", "b", "i", "s", "u", "code", + "mx-reply", + } + + block_tags = { + "h1", "h2", "h3", "h4", "h5", "h6", "blockquote", + "p", "ul", "ol", "li", "hr", "br", "img", + "table", "thead", "tbody", "tr", "th", "td", "pre", + "mx-reply", + } + + opaque_id = r"[a-zA-Z\d._-]+?" + user_id_localpart = r"[\x21-\x39\x3B-\x7E]+?" + + user_id_regex = re.compile( + rf"(?P@{user_id_localpart}:(?P[a-zA-Z\d.:-]*[a-zA-Z\d]))", + ) + room_id_regex = re.compile( + rf"(?P!{opaque_id}:(?P[a-zA-Z\d.:-]*[a-zA-Z\d]))", + ) + room_alias_regex = re.compile( + r"(?=^|\W)(?P#\S+?:(?P[a-zA-Z\d.:-]*[a-zA-Z\d]))", + ) + + link_regexes = [re.compile(r, re.IGNORECASE) + if isinstance(r, str) else r for r in [ + # Normal :// URLs + (r"(?P[a-z\d]+://(?P[a-z\d._-]+(?:\:\d+)?)" + r"(?:/[/\-.,\w#%&?:;=~!$*+^@']*)?(?:\([/\-_.,a-z\d#%&?;=~]*\))?)"), + + # mailto: and tel: + r"mailto:(?P[a-z0-9._-]+@(?P[a-z0-9.:-]*[a-z\d]))", + r"tel:(?P[0-9+-]+)(?P)", + + # magnet: + r"(?Pmagnet:\?xt=urn:[a-z0-9]+:.+)(?P)", + + user_id_regex, room_id_regex, room_alias_regex, + ]] + + matrix_to_regex = re.compile(r"^https?://matrix.to/#/", re.IGNORECASE) + + link_is_matrix_to_regex = re.compile( + r"https?://matrix.to/#/\S+", re.IGNORECASE, + ) + link_is_user_id_regex = re.compile( + r"https?://matrix.to/#/@\S+", re.IGNORECASE, + ) + link_is_room_id_regex = re.compile( + r"https?://matrix.to/#/!\S+", re.IGNORECASE, + ) + link_is_room_alias_regex = re.compile( + r"https?://matrix.to/#/#\S+", re.IGNORECASE, + ) + link_is_message_id_regex = re.compile( + r"https?://matrix.to/#/[!#]\S+/\$\S+", re.IGNORECASE, + ) + + inline_quote_regex = re.compile(r"(^|⏎|>)(\s*>[^⏎\n]*)", re.MULTILINE) + + quote_regex = re.compile( + r"(^||

|
||)" + r"(\s*>.*?)" + r"(||
||
|$)", + re.MULTILINE, + ) + + extra_newlines_regex = re.compile(r"\n(\n*)") + + + def __init__(self) -> None: + # The whitespace remover doesn't take

 into account
+		sanitizer.normalize_overall_whitespace = lambda html, *args, **kw: html
+		sanitizer.normalize_whitespace_in_text_or_tail = \
+			lambda el, *args, **kw: el
+
+		# hard_wrap: convert all \n to 
without required two spaces + # escape: escape HTML characters in the input string, e.g. tags + self._markdown_to_html = mistune.create_markdown( + hard_wrap = True, + escape = True, + renderer = "html", + plugins = ['strikethrough', plugin_matrix], + ) + + + def mentions_in_html(self, html: str) -> List[Tuple[str, str]]: + """Return list of (text, href) tuples for all mention links in html.""" + + if not html.strip(): + return [] + + return [ + (a_tag.text, href) + for a_tag, _, href, _ in lxml.html.iterlinks(html) + if a_tag.text and + self.link_is_matrix_to_regex.match(unquote(href.strip())) + ] + + + def from_markdown( + self, + text: str, + inline: bool = False, + outgoing: bool = False, + display_name_mentions: Optional[Dict[str, str]] = None, + ) -> str: + """Return filtered HTML from Markdown text.""" + + return self.filter( + self._markdown_to_html(text), + inline, + outgoing, + display_name_mentions, + ) + + + def filter( + self, + html: str, + inline: bool = False, + outgoing: bool = False, + display_name_mentions: Optional[Dict[str, str]] = None, + ) -> str: + """Filter and return HTML.""" + + mentions = display_name_mentions + + sanit = Sanitizer(self.sanitize_settings(inline, outgoing, mentions)) + html = sanit.sanitize(html).rstrip("\n") + + if not html.strip(): + return html + + tree = etree.fromstring( + html, parser=etree.HTMLParser(encoding="utf-8"), + ) + + for a_tag in tree.iterdescendants("a"): + self._mentions_to_matrix_to_links(a_tag, mentions, outgoing) + + if not outgoing: + self._matrix_to_links_add_classes(a_tag) + + html = etree.tostring(tree, encoding="utf-8", method="html").decode() + html = sanit.sanitize(html).rstrip("\n") + + if outgoing: + return html + + # Client-side modifications + + html = self.quote_regex.sub(r'\1\2\3', html) + + if not inline: + return html + + return self.inline_quote_regex.sub( + r'\1\2', html, + ) + + + def sanitize_settings( + self, + inline: bool = False, + outgoing: bool = False, + display_name_mentions: Optional[Dict[str, str]] = None, + ) -> dict: + """Return an html_sanitizer configuration.""" + + # https://matrix.org/docs/spec/client_server/latest#m-room-message-msgtypes + + inline_tags = self.inline_tags + all_tags = inline_tags | self.block_tags + + inlines_attributes = { + "font": {"color"}, + "a": {"href", "class", "data-mention"}, + "code": {"class"}, + } + attributes = {**inlines_attributes, **{ + "ol": {"start"}, + "hr": {"width"}, + "span": {"data-mx-color"}, + "img": { + "data-mx-emote", "src", "alt", "title", "width", "height", + }, + }} + + username_link_regexes = [re.compile(r) for r in [ + rf"(?{re.escape(name or user_id)})(?!\w)(?P)" + for user_id, name in (display_name_mentions or {}).items() + ]] + + return { + "tags": inline_tags if inline else all_tags, + "attributes": inlines_attributes if inline else attributes, + "empty": {} if inline else {"hr", "br", "img"}, + "separate": {"a"} if inline else { + "a", "p", "li", "table", "tr", "th", "td", "br", "hr", "img", + }, + "whitespace": {}, + "keep_typographic_whitespace": True, + "add_nofollow": False, + "autolink": { + "link_regexes": + self.link_regexes + username_link_regexes, # type: ignore + "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("form", "p"), + sanitizer.tag_replacer("div", "p"), + sanitizer.tag_replacer("caption", "p"), + sanitizer.target_blank_noopener, + + self._span_color_to_font if not outgoing else lambda el: el, + + self._img_to_a, + self._remove_extra_newlines, + self._newlines_to_return_symbol if inline else lambda el: el, + self._reply_to_inline if inline else lambda el: el, + ], + "element_postprocessors": [ + self._font_color_to_span if outgoing else lambda el: el, + self._hr_to_dashes if not outgoing else lambda el: el, + ], + "is_mergeable": lambda e1, e2: e1.attrib == e2.attrib, + } + + + @staticmethod + def _span_color_to_font(el: HtmlElement) -> HtmlElement: + """Convert HTML ``.""" - if el.tag not in ("span", "font"): - return el + if el.tag not in ("span", "font"): + return el - color = el.attrib.pop("data-mx-color", None) - if color: - el.tag = "font" - el.attrib["color"] = color + color = el.attrib.pop("data-mx-color", None) + if color: + el.tag = "font" + el.attrib["color"] = color - return el + return el - @staticmethod - def _font_color_to_span(el: HtmlElement) -> HtmlElement: - """Convert HTML `` to ` HtmlElement: + """Convert HTML `` to ` HtmlElement: - """Linkify images by wrapping `` tags in `
`.""" - - if el.tag != "img": - return el - - src = el.attrib.get("src", "") - width = el.attrib.get("width") - height = el.attrib.get("height") - is_emote = "data-mx-emote" in el.attrib - - if src.startswith("mxc://"): - el.attrib["src"] = nio.Api.mxc_to_http(src) - - if is_emote and not width and not height: - el.attrib["width"] = 32 - el.attrib["height"] = 32 + if el.tag not in ("span", "font"): + return el + + color = el.attrib.pop("color", None) + if color: + el.tag = "span" + el.attrib["data-mx-color"] = color + + return el + + + @staticmethod + def _img_to_a(el: HtmlElement) -> HtmlElement: + """Linkify images by wrapping `` tags in ``.""" + + if el.tag != "img": + return el + + src = el.attrib.get("src", "") + width = el.attrib.get("width") + height = el.attrib.get("height") + is_emote = "data-mx-emote" in el.attrib + + if src.startswith("mxc://"): + el.attrib["src"] = nio.Api.mxc_to_http(src) + + if is_emote and not width and not height: + el.attrib["width"] = 32 + el.attrib["height"] = 32 - elif is_emote and width and not height: - el.attrib["height"] = width + elif is_emote and width and not height: + el.attrib["height"] = width - elif is_emote and height and not width: - el.attrib["width"] = height + elif is_emote and height and not width: + el.attrib["width"] = height - elif not is_emote and (not width or not height): - el.tag = "a" - el.attrib["href"] = el.attrib.pop("src", "") - el.text = el.attrib.pop("alt", None) or el.attrib["href"] + elif not is_emote and (not width or not height): + el.tag = "a" + el.attrib["href"] = el.attrib.pop("src", "") + el.text = el.attrib.pop("alt", None) or el.attrib["href"] - return el + return el - def _remove_extra_newlines(self, el: HtmlElement) -> HtmlElement: - r"""Remove excess `\n` characters from HTML elements. + def _remove_extra_newlines(self, el: HtmlElement) -> HtmlElement: + r"""Remove excess `\n` characters from HTML elements. - This is done to avoid additional blank lines when the CSS directive - `white-space: pre` is used. + This is done to avoid additional blank lines when the CSS directive + `white-space: pre` is used. - Text inside `
` tags is ignored, except for the final newlines.
-        """
+		Text inside `
` tags is ignored, except for the final newlines.
+		"""
 
-        pre_parent = any(parent.tag == "pre" for parent in el.iterancestors())
+		pre_parent = any(parent.tag == "pre" for parent in el.iterancestors())
 
-        if el.tag != "pre" and not pre_parent:
-            if el.text:
-                el.text = self.extra_newlines_regex.sub(r"\1", el.text)
-            if el.tail:
-                el.tail = self.extra_newlines_regex.sub(r"\1", el.tail)
-        else:
-            if el.text and el.text.endswith("\n"):
-                el.text = el.text[:-1]
-            if el.tail and el.tail.endswith("\n"):
-                el.tail = el.tail[:-1]
+		if el.tag != "pre" and not pre_parent:
+			if el.text:
+				el.text = self.extra_newlines_regex.sub(r"\1", el.text)
+			if el.tail:
+				el.tail = self.extra_newlines_regex.sub(r"\1", el.tail)
+		else:
+			if el.text and el.text.endswith("\n"):
+				el.text = el.text[:-1]
+			if el.tail and el.tail.endswith("\n"):
+				el.tail = el.tail[:-1]
 
-        return el
+		return el
 
 
-    def _newlines_to_return_symbol(self, el: HtmlElement) -> HtmlElement:
-        """Turn newlines into unicode return symbols (⏎, U+23CE).
+	def _newlines_to_return_symbol(self, el: HtmlElement) -> HtmlElement:
+		"""Turn newlines into unicode return symbols (⏎, U+23CE).
 
-        The symbol is added to blocks with siblings (e.g. a `

` followed by - another `

`) and `
` tags. - The `
` themselves will be removed by the inline sanitizer. - """ + The symbol is added to blocks with siblings (e.g. a `

` followed by + another `

`) and `
` tags. + The `
` themselves will be removed by the inline sanitizer. + """ - is_block_with_siblings = (el.tag in self.block_tags and - next(el.itersiblings(), None) is not None) + is_block_with_siblings = (el.tag in self.block_tags and + next(el.itersiblings(), None) is not None) - if el.tag == "br" or is_block_with_siblings: - el.tail = f" ⏎ {el.tail or ''}" + if el.tag == "br" or is_block_with_siblings: + el.tail = f" ⏎ {el.tail or ''}" - # Replace left \n in text/tail of

 content by the return symbol.
-        if el.text:
-            el.text = re.sub(r"\n", r" ⏎ ", el.text)
+		# Replace left \n in text/tail of 
 content by the return symbol.
+		if el.text:
+			el.text = re.sub(r"\n", r" ⏎ ", el.text)
 
-        if el.tail:
-            el.tail = re.sub(r"\n", r" ⏎ ", el.tail)
+		if el.tail:
+			el.tail = re.sub(r"\n", r" ⏎ ", el.tail)
 
-        return el
+		return el
 
 
-    def _reply_to_inline(self, el: HtmlElement) -> HtmlElement:
-        """Shorten  to only include the replied to event's sender."""
+	def _reply_to_inline(self, el: HtmlElement) -> HtmlElement:
+		"""Shorten  to only include the replied to event's sender."""
 
-        if el.tag != "mx-reply":
-            return el
+		if el.tag != "mx-reply":
+			return el
 
-        try:
-            user_id = el.find("blockquote").findall("a")[1].text
-            text    = f"↩ {user_id[1: ].split(':')[0]}: "  # U+21A9 arrow
-            tail    = el.tail.rstrip().rstrip("⏎")
-        except (AttributeError, IndexError):
-            return el
+		try:
+			user_id = el.find("blockquote").findall("a")[1].text
+			text	= f"↩ {user_id[1: ].split(':')[0]}: "  # U+21A9 arrow
+			tail	= el.tail.rstrip().rstrip("⏎")
+		except (AttributeError, IndexError):
+			return el
 
-        el.clear()
-        el.text = text
-        el.tail = tail
-        return el
+		el.clear()
+		el.text = text
+		el.tail = tail
+		return el
 
 
-    def _mentions_to_matrix_to_links(
-        self,
-        el:                    HtmlElement,
-        display_name_mentions: Optional[Dict[str, str]] = None,
-        outgoing:              bool                     = False,
-    ) -> HtmlElement:
-        """Turn user ID, usernames and room ID/aliases into matrix.to URL.
+	def _mentions_to_matrix_to_links(
+		self,
+		el:					HtmlElement,
+		display_name_mentions: Optional[Dict[str, str]] = None,
+		outgoing:			  bool					 = False,
+	) -> HtmlElement:
+		"""Turn user ID, usernames and room ID/aliases into matrix.to URL.
 
-        After the HTML sanitizer autolinks these, the links's hrefs are the
-        link text, e.g. `@foo:bar.com`.
-        We turn them into proper matrix.to URL in this function.
-        """
+		After the HTML sanitizer autolinks these, the links's hrefs are the
+		link text, e.g. `@foo:bar.com`.
+		We turn them into proper matrix.to URL in this function.
+		"""
 
-        if el.tag != "a" or not el.attrib.get("href"):
-            return el
+		if el.tag != "a" or not el.attrib.get("href"):
+			return el
 
-        id_regexes = (
-            self.user_id_regex, self.room_id_regex, self.room_alias_regex,
-        )
+		id_regexes = (
+			self.user_id_regex, self.room_id_regex, self.room_alias_regex,
+		)
 
-        for regex in id_regexes:
-            if regex.match(unquote(el.attrib["href"])):
-                el.attrib["href"] = f"https://matrix.to/#/{el.attrib['href']}"
-                return el
+		for regex in id_regexes:
+			if regex.match(unquote(el.attrib["href"])):
+				el.attrib["href"] = f"https://matrix.to/#/{el.attrib['href']}"
+				return el
 
-        for user_id, name in (display_name_mentions or {}).items():
-            if unquote(el.attrib["href"]) == (name or user_id):
-                el.attrib["href"] = f"https://matrix.to/#/{user_id}"
-                return el
+		for user_id, name in (display_name_mentions or {}).items():
+			if unquote(el.attrib["href"]) == (name or user_id):
+				el.attrib["href"] = f"https://matrix.to/#/{user_id}"
+				return el
 
-        return el
+		return el
 
 
-    def _matrix_to_links_add_classes(self, el: HtmlElement) -> HtmlElement:
-        """Add special CSS classes to matrix.to mention links."""
+	def _matrix_to_links_add_classes(self, el: HtmlElement) -> HtmlElement:
+		"""Add special CSS classes to matrix.to mention links."""
 
-        href = unquote(el.attrib.get("href", ""))
+		href = unquote(el.attrib.get("href", ""))
 
-        if not href or not el.text:
-            return el
+		if not href or not el.text:
+			return el
 
 
-        el.text = self.matrix_to_regex.sub("", el.text or "")
+		el.text = self.matrix_to_regex.sub("", el.text or "")
 
-        # This must be first, or link will be mistaken by room ID/alias regex
-        if self.link_is_message_id_regex.match(href):
-            el.attrib["class"]        = "mention message-id-mention"
-            el.attrib["data-mention"] = el.text.strip()
+		# This must be first, or link will be mistaken by room ID/alias regex
+		if self.link_is_message_id_regex.match(href):
+			el.attrib["class"]		= "mention message-id-mention"
+			el.attrib["data-mention"] = el.text.strip()
 
-        elif self.link_is_user_id_regex.match(href):
-            if el.text.strip().startswith("@"):
-                el.attrib["class"] = "mention user-id-mention"
-            else:
-                el.attrib["class"] = "mention username-mention"
+		elif self.link_is_user_id_regex.match(href):
+			if el.text.strip().startswith("@"):
+				el.attrib["class"] = "mention user-id-mention"
+			else:
+				el.attrib["class"] = "mention username-mention"
 
-            el.attrib["data-mention"] = el.text.strip()
+			el.attrib["data-mention"] = el.text.strip()
 
-        elif self.link_is_room_id_regex.match(href):
-            el.attrib["class"]        = "mention room-id-mention"
-            el.attrib["data-mention"] = el.text.strip()
+		elif self.link_is_room_id_regex.match(href):
+			el.attrib["class"]		= "mention room-id-mention"
+			el.attrib["data-mention"] = el.text.strip()
 
-        elif self.link_is_room_alias_regex.match(href):
-            el.attrib["class"]        = "mention room-alias-mention"
-            el.attrib["data-mention"] = el.text.strip()
+		elif self.link_is_room_alias_regex.match(href):
+			el.attrib["class"]		= "mention room-alias-mention"
+			el.attrib["data-mention"] = el.text.strip()
 
-        return el
+		return el
 
 
-    def _hr_to_dashes(self, el: HtmlElement) -> HtmlElement:
-        if el.tag != "hr":
-            return el
+	def _hr_to_dashes(self, el: HtmlElement) -> HtmlElement:
+		if el.tag != "hr":
+			return el
 
-        el.tag             = "p"
-        el.attrib["class"] = "ruler"
-        el.text            = "─" * 19
-        return el
+		el.tag			 = "p"
+		el.attrib["class"] = "ruler"
+		el.text			= "─" * 19
+		return el
 
 
 HTML_PROCESSOR = HTMLProcessor()
diff --git a/src/backend/matrix_client.py b/src/backend/matrix_client.py
index 49ee1836..7bb377e3 100644
--- a/src/backend/matrix_client.py
+++ b/src/backend/matrix_client.py
@@ -19,8 +19,8 @@ from functools import partial
 from pathlib import Path
 from tempfile import NamedTemporaryFile
 from typing import (
-    TYPE_CHECKING, Any, Callable, ClassVar, Coroutine, DefaultDict, Dict, List,
-    NamedTuple, Optional, Set, Tuple, Type, Union,
+	TYPE_CHECKING, Any, Callable, ClassVar, Coroutine, DefaultDict, Dict, List,
+	NamedTuple, Optional, Set, Tuple, Type, Union,
 )
 from urllib.parse import urlparse
 from uuid import UUID, uuid4
@@ -34,32 +34,32 @@ from pymediainfo import MediaInfo
 
 from . import __display_name__, __reverse_dns__, utils
 from .errors import (
-    BadMimeType, InvalidUserId, InvalidUserInContext, MatrixBadGateway,
-    MatrixError, MatrixForbidden, MatrixInvalidAccessToken, MatrixNotFound,
-    MatrixTooLarge, MatrixUnauthorized, MatrixUnrecognized, UneededThumbnail,
-    UserFromOtherServerDisallowed,
+	BadMimeType, InvalidUserId, InvalidUserInContext, MatrixBadGateway,
+	MatrixError, MatrixForbidden, MatrixInvalidAccessToken, MatrixNotFound,
+	MatrixTooLarge, MatrixUnauthorized, MatrixUnrecognized, UneededThumbnail,
+	UserFromOtherServerDisallowed,
 )
 from .html_markdown import HTML_PROCESSOR as HTML
 from .media_cache import Media, Thumbnail
 from .models.items import (
-    ZERO_DATE, Account, Event, Member, PushRule, Room,
-    RoomNotificationOverride, Transfer, TransferStatus, TypeSpecifier,
+	ZERO_DATE, Account, Event, Member, PushRule, Room,
+	RoomNotificationOverride, Transfer, TransferStatus, TypeSpecifier,
 )
 from .models.model_store import ModelStore
 from .nio_callbacks import NioCallbacks
 from .presence import Presence
 from .pyotherside_events import (
-    InvalidAccessToken, LoopException, NotificationRequested,
+	InvalidAccessToken, LoopException, NotificationRequested,
 )
 
 if TYPE_CHECKING:
-    from .backend import Backend
+	from .backend import Backend
 
-PushAction    = Union[Dict[str, Any], nio.PushAction]
+PushAction	= Union[Dict[str, Any], nio.PushAction]
 PushCondition = Union[Dict[str, Any], nio.PushCondition]
-CryptDict     = Dict[str, Any]
+CryptDict	 = Dict[str, Any]
 PathCallable  = Union[
-    str, Path, Callable[[], Coroutine[None, None, Union[str, Path]]],
+	str, Path, Callable[[], Coroutine[None, None, Union[str, Path]]],
 ]
 
 IS_WINDOWS = platform.system() == "Windows"
@@ -67,2508 +67,2509 @@ IS_WINDOWS = platform.system() == "Windows"
 MATRIX_TO = "https://matrix.to/#"
 
 REPLY_FALLBACK = (
-    ""
-        "
" - 'In reply to ' - '{user_id}' - "
" - "{content}" - "
" - "
" - "{reply_content}" + "" + "
" + 'In reply to ' + '{user_id}' + "
" + "{content}" + "
" + "
" + "{reply_content}" ) class SyncFilterIds(NamedTuple): - """Uploaded filter IDs for various API.""" + """Uploaded filter IDs for various API.""" - first: str - others: str + first: str + others: str class UploadReturn(NamedTuple): - """Details for an uploaded file.""" + """Details for an uploaded file.""" - mxc: str - mime: str - decryption_dict: Dict[str, Any] + mxc: str + mime: str + decryption_dict: Dict[str, Any] class MatrixImageInfo(NamedTuple): - """Image informations to be passed for Matrix file events.""" + """Image informations to be passed for Matrix file events.""" - width: int - height: int - mime: str - size: int + width: int + height: int + mime: str + size: int - def as_dict(self) -> Dict[str, Union[int, str]]: - """Return a dict ready to be included in a Matrix file events.""" + def as_dict(self) -> Dict[str, Union[int, str]]: + """Return a dict ready to be included in a Matrix file events.""" - return { - "w": self.width, - "h": self.height, - "mimetype": self.mime, - "size": self.size, - } + return { + "w": self.width, + "h": self.height, + "mimetype": self.mime, + "size": self.size, + } class MatrixClient(nio.AsyncClient): - """A client for an account to interact with a matrix homeserver.""" - - user_id_regex = re.compile(r"^@.+:.+") - room_id_or_alias_regex = re.compile(r"^[#!].+:.+") - http_s_url_regex = re.compile(r"^https?://") - - lazy_load_filter: ClassVar[Dict[str, Any]] = { - "room": { - "ephemeral": {"lazy_load_members": True}, - "state": {"lazy_load_members": True}, - "timeline": {"lazy_load_members": True}, - "account_data": {"lazy_load_members": True}, - }, - } - - low_limit_filter: ClassVar[Dict[str, Any]] = { - "room": { - "ephemeral": {"limit": 1}, - "timeline": { - "limit": 5, - # This kind says another event was redacted, but we wouldn't - # have it in our model, so nothing would be shown - "not_types": ["m.room.redaction"], - }, - }, - } - - no_unknown_events_filter: ClassVar[Dict[str, Any]] = { - "room": { - "timeline": { - "not_types": [ - "m.room.message.feedback", - "m.room.pinned_events", - "m.call.*", - "m.room.third_party_invite", - "m.room.tombstone", - "m.reaction", - ], - }, - }, - } - - - def __init__( - self, - backend, - user: str = "", - homeserver: str = "https://matrix.org", - device_id: Optional[str] = None, - ) -> None: - - store = Path( - os.environ.get("MOMENT_DATA_DIR") or backend.appdirs.user_data_dir, - ) / "encryption" - - store.mkdir(parents=True, exist_ok=True) - - proxy: Optional[str] - proxy = os.environ.get("http_proxy", backend.settings.General.proxy) - host = re.sub(r":\d+$", "", urlparse(homeserver).netloc) - - if host in ("127.0.0.1", "localhost", "::1"): - proxy = None - - super().__init__( - homeserver = homeserver, - user = user, - device_id = device_id, - store_path = store, - proxy = proxy, - config = nio.AsyncClientConfig( - max_timeout_retry_wait_time = 10, - # TODO: pass a custom encryption DB pickle key? - ), - ) - - self.backend: "Backend" = backend - self.models: ModelStore = self.backend.models - - self.profile_task: Optional[asyncio.Future] = None - self.server_config_task: Optional[asyncio.Future] = None - self.sync_task: Optional[asyncio.Future] = None - self.start_task: Optional[asyncio.Future] = None - - self.transfer_monitors: Dict[UUID, nio.TransferMonitor] = {} - self.transfer_tasks: Dict[UUID, asyncio.Task] = {} - self.send_message_tasks: Dict[UUID, asyncio.Task] = {} - - self._presence: str = "" - self._sync_filter_ids: Optional[SyncFilterIds] = None - self._sync_filter_ids_lock: asyncio.Lock = asyncio.Lock() - self.first_sync_done: asyncio.Event = asyncio.Event() - self.first_sync_date: Optional[datetime] = None - self.last_sync_error: Optional[Exception] = None - self.last_set_presence: datetime = datetime.now() - - self.past_tokens: Dict[str, str] = {} # {room_id: token} - self.fully_loaded_rooms: Set[str] = set() # {room_id} - self.loaded_once_rooms: Set[str] = set() # {room_id} - self.cleared_events_rooms: Set[str] = set() # {room_id} - self.ignored_rooms: Set[str] = set() # {room_id} - - self.event_to_echo_ids: Dict[str, str] = {} - - # {(room_id, user_id): event_id} - self.unassigned_member_last_read_event: Dict[Tuple[str, str], str] = {} - - # {event_id: {user_id: server_timestamp}} - self.unassigned_event_last_read_by: DefaultDict[str, Dict[str, int]] =\ - DefaultDict(dict) - - self.push_rules: nio.PushRulesEvent = nio.PushRulesEvent() - self.ignored_user_ids: Set[str] = set() - - # {room_id: event} - self.power_level_events: Dict[str, nio.PowerLevelsEvent] = {} - - self.invalid_disconnecting: bool = False - - self.nio_callbacks = NioCallbacks(self) - - - 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, - ) + """A client for an account to interact with a matrix homeserver.""" + + user_id_regex = re.compile(r"^@.+:.+") + room_id_or_alias_regex = re.compile(r"^[#!].+:.+") + http_s_url_regex = re.compile(r"^https?://") + + lazy_load_filter: ClassVar[Dict[str, Any]] = { + "room": { + "ephemeral": {"lazy_load_members": True}, + "state": {"lazy_load_members": True}, + "timeline": {"lazy_load_members": True}, + "account_data": {"lazy_load_members": True}, + }, + } + + low_limit_filter: ClassVar[Dict[str, Any]] = { + "room": { + "ephemeral": {"limit": 1}, + "timeline": { + "limit": 5, + # This kind says another event was redacted, but we wouldn't + # have it in our model, so nothing would be shown + "not_types": ["m.room.redaction"], + }, + }, + } + + no_unknown_events_filter: ClassVar[Dict[str, Any]] = { + "room": { + "timeline": { + "not_types": [ + "m.room.message.feedback", + "m.room.pinned_events", + "m.call.*", + "m.room.third_party_invite", + "m.room.tombstone", + "m.reaction", + ], + }, + }, + } + + + def __init__( + self, + backend, + user: str = "", + homeserver: str = "https://matrix.org", + device_id: Optional[str] = None, + ) -> None: + + store = Path( + os.environ.get("MOMENT_DATA_DIR") or backend.appdirs.user_data_dir, + ) / "encryption" + + store.mkdir(parents=True, exist_ok=True) + + proxy: Optional[str] + proxy = os.environ.get("http_proxy", backend.settings.General.proxy) + host = re.sub(r":\d+$", "", urlparse(homeserver).netloc) + + if host in ("127.0.0.1", "localhost", "::1"): + proxy = None + + super().__init__( + homeserver = homeserver, + user = user, + device_id = device_id, + store_path = store, + proxy = proxy, + config = nio.AsyncClientConfig( + max_timeout_retry_wait_time = 10, + # TODO: pass a custom encryption DB pickle key? + ), + ) + + self.backend: "Backend" = backend + self.models: ModelStore = self.backend.models + + self.profile_task: Optional[asyncio.Future] = None + self.server_config_task: Optional[asyncio.Future] = None + self.sync_task: Optional[asyncio.Future] = None + self.start_task: Optional[asyncio.Future] = None + + self.transfer_monitors: Dict[UUID, nio.TransferMonitor] = {} + self.transfer_tasks: Dict[UUID, asyncio.Task] = {} + self.send_message_tasks: Dict[UUID, asyncio.Task] = {} + + self._presence: str = "" + self._sync_filter_ids: Optional[SyncFilterIds] = None + self._sync_filter_ids_lock: asyncio.Lock = asyncio.Lock() + self.first_sync_done: asyncio.Event = asyncio.Event() + self.first_sync_date: Optional[datetime] = None + self.last_sync_error: Optional[Exception] = None + self.last_set_presence: datetime = datetime.now() + + self.past_tokens: Dict[str, str] = {} # {room_id: token} + self.fully_loaded_rooms: Set[str] = set() # {room_id} + self.loaded_once_rooms: Set[str] = set() # {room_id} + self.cleared_events_rooms: Set[str] = set() # {room_id} + self.ignored_rooms: Set[str] = set() # {room_id} + + self.event_to_echo_ids: Dict[str, str] = {} + + # {(room_id, user_id): event_id} + self.unassigned_member_last_read_event: Dict[Tuple[str, str], str] = {} + + # {event_id: {user_id: server_timestamp}} + self.unassigned_event_last_read_by: DefaultDict[str, Dict[str, int]] =\ + DefaultDict(dict) + + self.push_rules: nio.PushRulesEvent = nio.PushRulesEvent() + self.ignored_user_ids: Set[str] = set() + + # {room_id: event} + self.power_level_events: Dict[str, nio.PowerLevelsEvent] = {} + + self.invalid_disconnecting: bool = False + + self.nio_callbacks = NioCallbacks(self) + + + 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, + ) - @property - def default_device_name(self) -> str: - """Device name to set at login if the user hasn't set a custom one.""" + @property + def default_device_name(self) -> str: + """Device name to set at login if the user hasn't set a custom one.""" - os_name = platform.system() + os_name = platform.system() - if not os_name: # unknown OS - return __display_name__ + if not os_name: # unknown OS + return __display_name__ - # On Linux, the kernel version is returned, so for a one-time-set - # device name it would quickly be outdated. - os_ver = platform.release() if os_name == "Windows" else "" - return f"{__display_name__} on {os_name} {os_ver}".rstrip() + # On Linux, the kernel version is returned, so for a one-time-set + # device name it would quickly be outdated. + os_ver = platform.release() if os_name == "Windows" else "" + return f"{__display_name__} on {os_name} {os_ver}".rstrip() - async def _send(self, *args, **kwargs) -> nio.Response: - """Raise a `MatrixError` subclass for any `nio.ErrorResponse`. + async def _send(self, *args, **kwargs) -> nio.Response: + """Raise a `MatrixError` subclass for any `nio.ErrorResponse`. - This function is called by `nio.AsyncClient`'s methods to send - requests to the server. Return normal responses, but catch any - `ErrorResponse` to turn them into `MatrixError` exceptions we raise. - """ + This function is called by `nio.AsyncClient`'s methods to send + requests to the server. Return normal responses, but catch any + `ErrorResponse` to turn them into `MatrixError` exceptions we raise. + """ - response = await super()._send(*args, **kwargs) + response = await super()._send(*args, **kwargs) - if isinstance(response, nio.ErrorResponse): - try: - raise await MatrixError.from_nio(response) - except MatrixInvalidAccessToken: - if not self.invalid_disconnecting: - self.invalid_disconnecting = True - InvalidAccessToken(self.user_id) - await self.backend.logout_client(self.user_id) + if isinstance(response, nio.ErrorResponse): + try: + raise await MatrixError.from_nio(response) + except MatrixInvalidAccessToken: + if not self.invalid_disconnecting: + self.invalid_disconnecting = True + InvalidAccessToken(self.user_id) + await self.backend.logout_client(self.user_id) - raise + raise - return response + return response - async def login( - self, password: Optional[str] = None, token: Optional[str] = None, - ) -> None: - """Login to server using `m.login.password` or `m.login.token` flows. + async def login( + self, password: Optional[str] = None, token: Optional[str] = None, + ) -> None: + """Login to server using `m.login.password` or `m.login.token` flows. - Login can be done with the account's password (if the server supports - this flow) OR a token obtainable through various means. + Login can be done with the account's password (if the server supports + this flow) OR a token obtainable through various means. - One of the way to obtain a token is to follow the `m.login.sso` flow - first, see `Backend.start_sso_auth()` & `Backend.continue_sso_auth()`. - """ + One of the way to obtain a token is to follow the `m.login.sso` flow + first, see `Backend.start_sso_auth()` & `Backend.continue_sso_auth()`. + """ - await super().login(password, self.default_device_name, token) + await super().login(password, self.default_device_name, token) - order = 0 - saved_accounts = self.backend.saved_accounts + order = 0 + saved_accounts = self.backend.saved_accounts - if saved_accounts: - order = max( - account.get("order", i) - for i, account in enumerate(saved_accounts.values()) - ) + 1 + if saved_accounts: + order = max( + account.get("order", i) + for i, account in enumerate(saved_accounts.values()) + ) + 1 - # We need to create account model item here, because _start() needs it - item = self.models["accounts"].setdefault( - self.user_id, Account(self.user_id, order), - ) + # We need to create account model item here, because _start() needs it + item = self.models["accounts"].setdefault( + self.user_id, Account(self.user_id, order), + ) - # TODO: be able to set presence before logging in - item.set_fields(presence=Presence.State.online, connecting=True) - self._presence = "online" - self.start_task = asyncio.ensure_future(self._start()) + # TODO: be able to set presence before logging in + item.set_fields(presence=Presence.State.online, connecting=True) + self._presence = "online" + self.start_task = asyncio.ensure_future(self._start()) - async def resume( - self, - user_id: str, - access_token: str, - device_id: str, - state: str = "online", - status_msg: str = "", - ) -> None: - """Restore a previous login to the server with a saved access token.""" + async def resume( + self, + user_id: str, + access_token: str, + device_id: str, + state: str = "online", + status_msg: str = "", + ) -> None: + """Restore a previous login to the server with a saved access token.""" - self.restore_login(user_id, device_id, access_token) + self.restore_login(user_id, device_id, access_token) - account = self.models["accounts"][user_id] - self._presence = "offline" if state == "invisible" else state + account = self.models["accounts"][user_id] + self._presence = "offline" if state == "invisible" else state - account.set_fields( - presence=Presence.State(state), status_msg=status_msg, - ) + account.set_fields( + presence=Presence.State(state), status_msg=status_msg, + ) - if state != "offline": - account.connecting = True - self.start_task = asyncio.ensure_future(self._start()) + if state != "offline": + account.connecting = True + self.start_task = asyncio.ensure_future(self._start()) - async def logout(self) -> None: - """Logout from the server. This will delete the device.""" + async def logout(self) -> None: + """Logout from the server. This will delete the device.""" - await self._stop() - await super().logout() - await self.close() + await self._stop() + await super().logout() + await self.close() - async def terminate(self) -> None: - """Stop tasks, Set our presence offline and close HTTP connections.""" + async def terminate(self) -> None: + """Stop tasks, Set our presence offline and close HTTP connections.""" - await self._stop() + await self._stop() - if self._presence != "offline": - try: - await asyncio.wait_for( - self.set_presence("offline", save=False), - timeout = 10, - ) - except asyncio.TimeoutError: - log.warn("%s timed out", self.user_id) + if self._presence != "offline": + try: + await asyncio.wait_for( + self.set_presence("offline", save=False), + timeout = 10, + ) + except asyncio.TimeoutError: + log.warn("%s timed out", self.user_id) - await self.close() + await self.close() - async def _start(self) -> None: - """Fetch our user profile, server config and enter the sync loop.""" + async def _start(self) -> None: + """Fetch our user profile, server config and enter the sync loop.""" - def on_server_config_response(future) -> None: - """Update our model `Account` with the received config details.""" + def on_server_config_response(future) -> None: + """Update our model `Account` with the received config details.""" - if future.cancelled(): # Account logged out - return + if future.cancelled(): # Account logged out + return - try: - account.max_upload_size = future.result() or 0 - except MatrixError: - trace = traceback.format_exc().rstrip() - log.warn( - "On %s server config retrieval: %s", self.user_id, trace, - ) - self.server_config_task = asyncio.ensure_future( - self.get_server_config(), - ) - self.server_config_task.add_done_callback( - on_server_config_response, - ) + try: + account.max_upload_size = future.result() or 0 + except MatrixError: + trace = traceback.format_exc().rstrip() + log.warn( + "On %s server config retrieval: %s", self.user_id, trace, + ) + self.server_config_task = asyncio.ensure_future( + self.get_server_config(), + ) + self.server_config_task.add_done_callback( + on_server_config_response, + ) - account = self.models["accounts"][self.user_id] + account = self.models["accounts"][self.user_id] - presence = self.backend.presences.setdefault(self.user_id, Presence()) - presence.account = account - presence.presence = Presence.State(self._presence) + presence = self.backend.presences.setdefault(self.user_id, Presence()) + presence.account = account + presence.presence = Presence.State(self._presence) - self.profile_task = asyncio.ensure_future(self.update_own_profile()) + self.profile_task = asyncio.ensure_future(self.update_own_profile()) - self.server_config_task = asyncio.ensure_future( - self.get_server_config(), - ) - self.server_config_task.add_done_callback(on_server_config_response) + self.server_config_task = asyncio.ensure_future( + self.get_server_config(), + ) + self.server_config_task.add_done_callback(on_server_config_response) - await self.auto_verify_all_other_accounts() + await self.auto_verify_all_other_accounts() - while True: - try: - sync_filter_ids = await self.sync_filter_ids() + while True: + try: + sync_filter_ids = await self.sync_filter_ids() - self.sync_task = asyncio.ensure_future(self.sync_forever( - timeout = 10_000, - loop_sleep_time = 1000, - first_sync_filter = sync_filter_ids.first, - sync_filter = sync_filter_ids.others, - )) - await self.sync_task - self.last_sync_error = None - break # task cancelled - except Exception as err: # noqa - self.last_sync_error = err + self.sync_task = asyncio.ensure_future(self.sync_forever( + timeout = 10_000, + loop_sleep_time = 1000, + first_sync_filter = sync_filter_ids.first, + sync_filter = sync_filter_ids.others, + )) + await self.sync_task + self.last_sync_error = None + break # task cancelled + except Exception as err: # noqa + self.last_sync_error = err - trace = traceback.format_exc().rstrip() + trace = traceback.format_exc().rstrip() - if isinstance(err, MatrixError) and err.http_code >= 500: - log.warning( - "Server failure during sync for %s:\n%s", - self.user_id, - trace, - ) - else: - LoopException(str(err), err, trace) + if isinstance(err, MatrixError) and err.http_code >= 500: + log.warning( + "Server failure during sync for %s:\n%s", + self.user_id, + trace, + ) + else: + LoopException(str(err), err, trace) - await asyncio.sleep(5) + await asyncio.sleep(5) - async def _stop(self) -> None: - """Stop client tasks. Will prevent client to receive further events.""" + async def _stop(self) -> None: + """Stop client tasks. Will prevent client to receive further events.""" - # Remove account model from presence update - presence = self.backend.presences.get(self.user_id, None) + # Remove account model from presence update + presence = self.backend.presences.get(self.user_id, None) - if presence: - presence.account = None + if presence: + presence.account = None - tasks = ( - self.profile_task, - self.sync_task, - self.server_config_task, - self.start_task, - ) + tasks = ( + self.profile_task, + self.sync_task, + self.server_config_task, + self.start_task, + ) - for task in tasks: - if task: - task.cancel() - with suppress(asyncio.CancelledError): - await task + for task in tasks: + if task: + task.cancel() + with suppress(asyncio.CancelledError): + await task - self.first_sync_done.clear() + self.first_sync_done.clear() - async def get_profile( - self, user_id: str, use_cache: bool = True, - ) -> nio.ProfileGetResponse: - """Cache and return the matrix profile of `user_id`.""" + async def get_profile( + self, user_id: str, use_cache: bool = True, + ) -> nio.ProfileGetResponse: + """Cache and return the matrix profile of `user_id`.""" - async with self.backend.get_profile_locks[user_id]: - if use_cache and user_id in self.backend.profile_cache: - return self.backend.profile_cache[user_id] + async with self.backend.get_profile_locks[user_id]: + if use_cache and user_id in self.backend.profile_cache: + return self.backend.profile_cache[user_id] - response = await super().get_profile(user_id) + response = await super().get_profile(user_id) - self.backend.profile_cache[user_id] = response - return response + self.backend.profile_cache[user_id] = response + return response - async def update_own_profile(self) -> None: - """Fetch our profile from server and Update our model `Account`.""" + async def update_own_profile(self) -> None: + """Fetch our profile from server and Update our model `Account`.""" - resp = await self.get_profile(self.user_id, use_cache=False) + resp = await self.get_profile(self.user_id, use_cache=False) - account = self.models["accounts"][self.user_id] - account.set_fields( - profile_updated = datetime.now(), - display_name = resp.displayname or "", - avatar_url = resp.avatar_url or "", - ) + account = self.models["accounts"][self.user_id] + account.set_fields( + profile_updated = datetime.now(), + display_name = resp.displayname or "", + avatar_url = resp.avatar_url or "", + ) - async def get_server_config(self) -> int: - """Return the maximum upload size on this server""" - return (await self.content_repository_config()).upload_size + async def get_server_config(self) -> int: + """Return the maximum upload size on this server""" + return (await self.content_repository_config()).upload_size - async def sync_filter_ids(self) -> SyncFilterIds: - """Return our sync/messages filter IDs, upload them if needed.""" + async def sync_filter_ids(self) -> SyncFilterIds: + """Return our sync/messages filter IDs, upload them if needed.""" - async with self._sync_filter_ids_lock: - if self._sync_filter_ids: - return self._sync_filter_ids + async with self._sync_filter_ids_lock: + if self._sync_filter_ids: + return self._sync_filter_ids - others = deepcopy(self.lazy_load_filter) - first = deepcopy(others) + others = deepcopy(self.lazy_load_filter) + first = deepcopy(others) - utils.dict_update_recursive(first, self.low_limit_filter) + utils.dict_update_recursive(first, self.low_limit_filter) - if not self.backend.settings.Chat.show_unknown_events: - first["room"]["timeline"]["not_types"].extend( - self.no_unknown_events_filter - ["room"]["timeline"]["not_types"], - ) + if not self.backend.settings.Chat.show_unknown_events: + first["room"]["timeline"]["not_types"].extend( + self.no_unknown_events_filter + ["room"]["timeline"]["not_types"], + ) - others_id = (await self.upload_filter(**others)).filter_id - first_id = others_id + others_id = (await self.upload_filter(**others)).filter_id + first_id = others_id - if others != first: - resp = await self.upload_filter(**first) - first_id = resp.filter_id + if others != first: + resp = await self.upload_filter(**first) + first_id = resp.filter_id - self._sync_filter_ids = SyncFilterIds(first_id, others_id) - return self._sync_filter_ids + self._sync_filter_ids = SyncFilterIds(first_id, others_id) + return self._sync_filter_ids - async def pause_while_offline(self) -> None: - """Block until our account is online.""" + async def pause_while_offline(self) -> None: + """Block until our account is online.""" - account = self.models["accounts"][self.user_id] + account = self.models["accounts"][self.user_id] - while account.presence == Presence.State.offline: - await asyncio.sleep(0.2) + while account.presence == Presence.State.offline: + await asyncio.sleep(0.2) - async def set_ignored_users(self, *user_ids: str) -> None: - previous_ignored = self.ignored_user_ids - now_ignored = set(user_ids) - no_longer_ignored = previous_ignored - now_ignored + async def set_ignored_users(self, *user_ids: str) -> None: + previous_ignored = self.ignored_user_ids + now_ignored = set(user_ids) + no_longer_ignored = previous_ignored - now_ignored - path = ["user", self.user_id, "account_data", "m.ignored_user_list"] - params = {"access_token": self.access_token} + path = ["user", self.user_id, "account_data", "m.ignored_user_list"] + params = {"access_token": self.access_token} - await self._send( - nio.responses.EmptyResponse, - "PUT", - nio.Api._build_path(path, params), - nio.Api.to_json({"ignored_users": {u: {} for u in now_ignored}}), - ) + await self._send( + nio.responses.EmptyResponse, + "PUT", + nio.Api._build_path(path, params), + nio.Api.to_json({"ignored_users": {u: {} for u in now_ignored}}), + ) - # Invites and messages from ignored users won't be returned anymore on - # syncs, thus will be absent on client restart. - # Clean up immediatly, and also update Member.ignored fields: + # Invites and messages from ignored users won't be returned anymore on + # syncs, thus will be absent on client restart. + # Clean up immediatly, and also update Member.ignored fields: - room_model = self.models[self.user_id, "rooms"] + room_model = self.models[self.user_id, "rooms"] - with room_model.batch_remove(): - for room_id, room in room_model.copy().items(): - if room.inviter_id in now_ignored: - self.ignored_rooms.add(room_id) - del room_model[room_id] - self.models.pop((self.user_id, room_id, "events"), None) - self.models.pop((self.user_id, room_id, "members"), None) - continue + with room_model.batch_remove(): + for room_id, room in room_model.copy().items(): + if room.inviter_id in now_ignored: + self.ignored_rooms.add(room_id) + del room_model[room_id] + self.models.pop((self.user_id, room_id, "events"), None) + self.models.pop((self.user_id, room_id, "members"), None) + continue - event_model = self.models[self.user_id, room_id, "events"] - member_model = self.models[self.user_id, room_id, "members"] + event_model = self.models[self.user_id, room_id, "events"] + member_model = self.models[self.user_id, room_id, "members"] - for user_id in now_ignored: - if user_id in member_model: - member_model[user_id].ignored = True + for user_id in now_ignored: + if user_id in member_model: + member_model[user_id].ignored = True - for user_id in no_longer_ignored: - if user_id in member_model: - member_model[user_id].ignored = False + for user_id in no_longer_ignored: + if user_id in member_model: + member_model[user_id].ignored = False - with event_model.batch_remove(): - for event_id, event in event_model.copy().items(): - if event.sender_id in now_ignored: - del event_model[event_id] + with event_model.batch_remove(): + for event_id, event in event_model.copy().items(): + if event.sender_id in now_ignored: + del event_model[event_id] - await self.update_account_unread_counts() + await self.update_account_unread_counts() - async def ignore_user(self, user_id: str, ignore: bool) -> None: - current = self.ignored_user_ids - new = current | {user_id} if ignore else current - {user_id} - await self.set_ignored_users(*new) + async def ignore_user(self, user_id: str, ignore: bool) -> None: + current = self.ignored_user_ids + new = current | {user_id} if ignore else current - {user_id} + await self.set_ignored_users(*new) - async def can_kick(self, room_id: str, target_user_id: str) -> bool: - """Return whether we can kick a certain user in a room.""" + async def can_kick(self, room_id: str, target_user_id: str) -> bool: + """Return whether we can kick a certain user in a room.""" - levels = self.all_rooms[room_id].power_levels - return levels.can_user_kick(self.user_id, target_user_id) + levels = self.all_rooms[room_id].power_levels + return levels.can_user_kick(self.user_id, target_user_id) - async def can_ban(self, room_id: str, target_user_id: str) -> bool: - """Return whether we can ban/unbun a certain user in a room.""" + async def can_ban(self, room_id: str, target_user_id: str) -> bool: + """Return whether we can ban/unbun a certain user in a room.""" - levels = self.all_rooms[room_id].power_levels - return levels.can_user_ban(self.user_id, target_user_id) + levels = self.all_rooms[room_id].power_levels + return levels.can_user_ban(self.user_id, target_user_id) - @property - def all_rooms(self) -> Dict[str, nio.MatrixRoom]: - """Return dict containing both our joined and invited rooms.""" + @property + def all_rooms(self) -> Dict[str, nio.MatrixRoom]: + """Return dict containing both our joined and invited rooms.""" - return {**self.invited_rooms, **self.rooms} + return {**self.invited_rooms, **self.rooms} - async def send_text( - self, - room_id: str, - text: str, - display_name_mentions: Optional[Dict[str, str]] = None, # {id: name} - reply_to_event_id: Optional[str] = None, - ) -> None: - """Send a markdown `m.text` or `m.notice` (with `/me`) message .""" + async def send_text( + self, + room_id: str, + text: str, + display_name_mentions: Optional[Dict[str, str]] = None, # {id: name} + reply_to_event_id: Optional[str] = None, + ) -> None: + """Send a markdown `m.text` or `m.notice` (with `/me`) message .""" - from_md = partial( - HTML.from_markdown, display_name_mentions=display_name_mentions, - ) + from_md = partial( + HTML.from_markdown, display_name_mentions=display_name_mentions, + ) - escape = False - if text.startswith("//") or text.startswith(r"\/"): - escape = True - text = text[1:] + escape = False + if text.startswith("//") or text.startswith(r"\/"): + escape = True + text = text[1:] - content: Dict[str, Any] + content: Dict[str, Any] - if text.startswith("/me ") and not escape: - event_type = nio.RoomMessageEmote - text = text[len("/me "):] - content = {"body": text, "msgtype": "m.emote"} - to_html = from_md(text, inline=True, outgoing=True) - echo_body = from_md(text, inline=True) - else: - event_type = nio.RoomMessageText - content = {"body": text, "msgtype": "m.text"} - to_html = from_md(text, outgoing=True) - echo_body = from_md(text) + if text.startswith("/me ") and not escape: + event_type = nio.RoomMessageEmote + text = text[len("/me "):] + content = {"body": text, "msgtype": "m.emote"} + to_html = from_md(text, inline=True, outgoing=True) + echo_body = from_md(text, inline=True) + else: + event_type = nio.RoomMessageText + content = {"body": text, "msgtype": "m.text"} + to_html = from_md(text, outgoing=True) + echo_body = from_md(text) - if to_html not in (html.escape(text), f"

{html.escape(text)}

"): - content["format"] = "org.matrix.custom.html" - content["formatted_body"] = to_html + if to_html not in (html.escape(text), f"

{html.escape(text)}

"): + content["format"] = "org.matrix.custom.html" + content["formatted_body"] = to_html - if reply_to_event_id: - to: Event = \ - self.models[self.user_id, room_id, "events"][reply_to_event_id] + if reply_to_event_id: + to: Event = \ + self.models[self.user_id, room_id, "events"][reply_to_event_id] - source_body = getattr(to.source, "body", "") + source_body = getattr(to.source, "body", "") - content["format"] = "org.matrix.custom.html" - plain_source_body = "\n".join( - f"> <{to.sender_id}> {line}" if i == 0 else f"> {line}" - for i, line in enumerate(source_body.splitlines()) - ) - content["body"] = f"{plain_source_body}\n\n{text}" + content["format"] = "org.matrix.custom.html" + plain_source_body = "\n".join( + f"> <{to.sender_id}> {line}" if i == 0 else f"> {line}" + for i, line in enumerate(source_body.splitlines()) + ) + content["body"] = f"{plain_source_body}\n\n{text}" - to_html = REPLY_FALLBACK.format( - matrix_to = MATRIX_TO, - room_id = room_id, - event_id = to.event_id, - user_id = to.sender_id, - content = - getattr(to.source, "formatted_body", "") or - source_body or - html.escape(to.source.source["type"] if to.source else ""), + to_html = REPLY_FALLBACK.format( + matrix_to = MATRIX_TO, + room_id = room_id, + event_id = to.event_id, + user_id = to.sender_id, + content = + getattr(to.source, "formatted_body", "") or + source_body or + html.escape(to.source.source["type"] if to.source else ""), - reply_content = to_html, - ) + reply_content = to_html, + ) - echo_body = HTML.filter(to_html) - content["formatted_body"] = HTML.filter(to_html, outgoing=True) + echo_body = HTML.filter(to_html) + content["formatted_body"] = HTML.filter(to_html, outgoing=True) - content["m.relates_to"] = { - "m.in_reply_to": {"event_id": to.event_id}, - } + content["m.relates_to"] = { + "m.in_reply_to": {"event_id": to.event_id}, + } - # Can't use the standard Matrix transaction IDs; they're only visible - # to the sender so our other accounts wouldn't be able to replace - # local echoes by real messages. - tx_id = uuid4() - content[f"{__reverse_dns__}.transaction_id"] = str(tx_id) + # Can't use the standard Matrix transaction IDs; they're only visible + # to the sender so our other accounts wouldn't be able to replace + # local echoes by real messages. + tx_id = uuid4() + content[f"{__reverse_dns__}.transaction_id"] = str(tx_id) - mentions = HTML.mentions_in_html(echo_body) - await self._local_echo( - room_id, - tx_id, - event_type, - content = echo_body, - mentions = mentions, - ) + mentions = HTML.mentions_in_html(echo_body) + await self._local_echo( + room_id, + tx_id, + event_type, + content = echo_body, + mentions = mentions, + ) - await self.pause_while_offline() - await self._send_message(room_id, content, tx_id) + await self.pause_while_offline() + await self._send_message(room_id, content, tx_id) - async def toggle_pause_transfer( - self, room_id: str, uuid: Union[str, UUID], - ) -> None: - if isinstance(uuid, str): - uuid = UUID(uuid) - - pause = not self.transfer_monitors[uuid].pause - - self.transfer_monitors[uuid].pause = pause - self.models[room_id, "transfers"][str(uuid)].paused = pause - - - async def cancel_transfer(self, uuid: Union[str, UUID]) -> None: - if isinstance(uuid, str): - uuid = UUID(uuid) - - self.transfer_tasks[uuid].cancel() + async def toggle_pause_transfer( + self, room_id: str, uuid: Union[str, UUID], + ) -> None: + if isinstance(uuid, str): + uuid = UUID(uuid) + + pause = not self.transfer_monitors[uuid].pause + + self.transfer_monitors[uuid].pause = pause + self.models[room_id, "transfers"][str(uuid)].paused = pause + + + async def cancel_transfer(self, uuid: Union[str, UUID]) -> None: + if isinstance(uuid, str): + uuid = UUID(uuid) + + self.transfer_tasks[uuid].cancel() - async def send_clipboard_image( - self, - room_id: str, - image: bytes, - reply_to_event_id: Optional[str] = None, - ) -> None: - """Send a clipboard image passed from QML as a `m.image` message.""" - - prefix = datetime.now().strftime("%Y%m%d-%H%M%S.") - - with NamedTemporaryFile(prefix=prefix, suffix=".png") as temp: - - async def get_path() -> Path: - # optimize is too slow for large images - compressed = await utils.compress_image(image, optimize=False) - - async with utils.aiopen(temp.name, "wb") as file: - await file.write(compressed) - - return Path(temp.name) - - await self.send_file(room_id, get_path, reply_to_event_id) - + async def send_clipboard_image( + self, + room_id: str, + image: bytes, + reply_to_event_id: Optional[str] = None, + ) -> None: + """Send a clipboard image passed from QML as a `m.image` message.""" + + prefix = datetime.now().strftime("%Y%m%d-%H%M%S.") + + with NamedTemporaryFile(prefix=prefix, suffix=".png") as temp: + + async def get_path() -> Path: + # optimize is too slow for large images + compressed = await utils.compress_image(image, optimize=False) + + async with utils.aiopen(temp.name, "wb") as file: + await file.write(compressed) + + return Path(temp.name) + + await self.send_file(room_id, get_path, reply_to_event_id) + - async def send_file( - self, - room_id: str, - path: PathCallable, - reply_to_event_id: Optional[str] = None, - ) -> None: - """Send a `m.file`, `m.image`, `m.audio` or `m.video` message. + async def send_file( + self, + room_id: str, + path: PathCallable, + reply_to_event_id: Optional[str] = None, + ) -> None: + """Send a `m.file`, `m.image`, `m.audio` or `m.video` message. - The Matrix client-server API states that media messages can't have a - reply attached. - Thus, if a `reply_to_event_id` is passed, we send a pseudo-reply as two - events: a `m.text` one with the reply but an empty body, then the - actual media. - """ + The Matrix client-server API states that media messages can't have a + reply attached. + Thus, if a `reply_to_event_id` is passed, we send a pseudo-reply as two + events: a `m.text` one with the reply but an empty body, then the + actual media. + """ - item_uuid = uuid4() + item_uuid = uuid4() - try: - await self._send_file(item_uuid, room_id, path, reply_to_event_id) - except (nio.TransferCancelledError, asyncio.CancelledError): - self.transfer_monitors.pop(item_uuid, None) - self.transfer_tasks.pop(item_uuid, None) - self.models[room_id, "transfers"].pop(str(item_uuid), None) + try: + await self._send_file(item_uuid, room_id, path, reply_to_event_id) + except (nio.TransferCancelledError, asyncio.CancelledError): + self.transfer_monitors.pop(item_uuid, None) + self.transfer_tasks.pop(item_uuid, None) + self.models[room_id, "transfers"].pop(str(item_uuid), None) - async def _send_file( - self, - item_uuid: UUID, - room_id: str, - path: PathCallable, - reply_to_event_id: Optional[str] = None, - ) -> None: - """Upload and monitor a file + thumbnail and send the built event(s)""" + async def _send_file( + self, + item_uuid: UUID, + room_id: str, + path: PathCallable, + reply_to_event_id: Optional[str] = None, + ) -> None: + """Upload and monitor a file + thumbnail and send the built event(s)""" - # TODO: this function is way too complex, and most of it should be - # refactored into nio. + # TODO: this function is way too complex, and most of it should be + # refactored into nio. - self.transfer_tasks[item_uuid] = utils.current_task() # type: ignore + self.transfer_tasks[item_uuid] = utils.current_task() # type: ignore - transfer = Transfer(item_uuid, is_upload=True) - self.models[room_id, "transfers"][str(item_uuid)] = transfer + transfer = Transfer(item_uuid, is_upload=True) + self.models[room_id, "transfers"][str(item_uuid)] = transfer - transaction_id = uuid4() - path = Path(await path() if callable(path) else path) - encrypt = room_id in self.encrypted_rooms + transaction_id = uuid4() + path = Path(await path() if callable(path) else path) + encrypt = room_id in self.encrypted_rooms - thumb_crypt_dict: Dict[str, Any] = {} - crypt_dict: Dict[str, Any] = {} + thumb_crypt_dict: Dict[str, Any] = {} + crypt_dict: Dict[str, Any] = {} - try: - size = path.resolve().stat().st_size - except (PermissionError, FileNotFoundError): - # This error will be caught again by the try block later below - size = 0 + try: + size = path.resolve().stat().st_size + except (PermissionError, FileNotFoundError): + # This error will be caught again by the try block later below + size = 0 - transfer.set_fields( - status=TransferStatus.Transfering, filepath=path, total_size=size, - ) - - monitor = nio.TransferMonitor(size) - self.transfer_monitors[item_uuid] = monitor + transfer.set_fields( + status=TransferStatus.Transfering, filepath=path, total_size=size, + ) + + monitor = nio.TransferMonitor(size) + self.transfer_monitors[item_uuid] = monitor - def on_transferred(transferred: int) -> None: - transfer.transferred = transferred + def on_transferred(transferred: int) -> None: + transfer.transferred = transferred - def on_speed_changed(speed: float) -> None: - transfer.set_fields( - speed = speed, - time_left = monitor.remaining_time or timedelta(0), - ) - - monitor.on_transferred = on_transferred - monitor.on_speed_changed = on_speed_changed - - await self.pause_while_offline() - - try: - url, mime, crypt_dict = await self.upload( - lambda *_: path, - filename = path.name, - filesize = size, - encrypt = encrypt, - monitor = monitor, - ) - - # FIXME: nio might not catch the cancel in time - if monitor.cancel: - raise nio.TransferCancelledError() - - except (MatrixError, OSError) as err: - transfer.set_fields( - status = TransferStatus.Error, - error = type(err), - error_args = err.args, - ) - - # Wait for cancellation from UI, see parent send_file() method - while True: - await asyncio.sleep(0.1) - - transfer.status = TransferStatus.Caching - local_media = await Media.from_existing_file( - self.backend.media_cache, self.user_id, url, path, - ) - - kind = (mime or "").split("/")[0] - - thumb_url: str = "" - thumb_info: Optional[MatrixImageInfo] = None - - content: dict = { - f"{__reverse_dns__}.transaction_id": str(transaction_id), - - "body": path.name, - "info": { - "mimetype": mime, - "size": transfer.total_size, - }, - } - - if encrypt: - content["file"] = {"url": url, **crypt_dict} - else: - content["url"] = url - - if kind == "image": - is_svg = mime == "image/svg+xml" - - event_type = \ - nio.RoomEncryptedImage if encrypt else nio.RoomMessageImage - - content["msgtype"] = "m.image" - - content["info"]["w"], content["info"]["h"] = ( - await utils.svg_dimensions(path) if is_svg else - PILImage.open(path).size - ) - - try: - thumb_data, thumb_info = await self.generate_thumbnail( - path, is_svg=is_svg, - ) - except UneededThumbnail: - pass - except Exception: # noqa - trace = traceback.format_exc().rstrip() - log.warning("Failed thumbnailing %s:\n%s", path, trace) - else: - thumb_ext = "png" if thumb_info.mime == "image/png" else "jpg" - thumb_name = f"{path.stem}_thumbnail.{thumb_ext}" - - transfer.set_fields( - status = TransferStatus.Transfering, - filepath = Path(thumb_name), - total_size = len(thumb_data), - ) - - try: - transfer.total_size = thumb_info.size - - monitor = nio.TransferMonitor(thumb_info.size) - monitor.on_transferred = on_transferred - monitor.on_speed_changed = on_speed_changed - - self.transfer_monitors[item_uuid] = monitor - - thumb_url, _, thumb_crypt_dict = await self.upload( - lambda *_: thumb_data, - filename = f"{path.stem}_sample{path.suffix}", - filesize = thumb_info.size, - encrypt = encrypt, - monitor = monitor, - ) - - # FIXME: nio might not catch the cancel in time - if monitor.cancel: - raise nio.TransferCancelledError() - except MatrixError as err: - log.warning(f"Failed uploading thumbnail {path}: {err}") - else: - transfer.status = TransferStatus.Caching - - await Thumbnail.from_bytes( - self.backend.media_cache, - self.user_id, - thumb_url, - path.name, - thumb_data, - wanted_size = (content["info"]["w"], - content["info"]["h"]), - ) - - if encrypt: - content["info"]["thumbnail_file"] = { - "url": thumb_url, - **thumb_crypt_dict, - } - else: - content["info"]["thumbnail_url"] = thumb_url - - content["info"]["thumbnail_info"] = thumb_info.as_dict() - - elif kind == "audio": - event_type = \ - nio.RoomEncryptedAudio if encrypt else nio.RoomMessageAudio - - content["msgtype"] = "m.audio" - content["info"]["duration"] = getattr( - MediaInfo.parse(path).tracks[0], "duration", 0, - ) or 0 - - elif kind == "video": - event_type = \ - nio.RoomEncryptedVideo if encrypt else nio.RoomMessageVideo - - content["msgtype"] = "m.video" - - tracks = MediaInfo.parse(path).tracks - - content["info"]["duration"] = \ - getattr(tracks[0], "duration", 0) or 0 - - content["info"]["w"] = max( - getattr(t, "width", 0) or 0 for t in tracks - ) - content["info"]["h"] = max( - getattr(t, "height", 0) or 0 for t in tracks - ) - - else: - event_type = \ - nio.RoomEncryptedFile if encrypt else nio.RoomMessageFile - - content["msgtype"] = "m.file" - content["filename"] = path.name - - del self.transfer_monitors[item_uuid] - del self.transfer_tasks[item_uuid] - del self.models[room_id, "transfers"][str(transfer.id)] - - if reply_to_event_id: - await self.send_text( - room_id=room_id, text="", reply_to_event_id=reply_to_event_id, - ) - - await self._local_echo( - room_id, - transaction_id, - event_type, - inline_content = content["body"], - - media_url = url, - media_http_url = await self.mxc_to_http(url), - media_title = path.name, - media_width = content["info"].get("w", 0), - media_height = content["info"].get("h", 0), - media_duration = content["info"].get("duration", 0), - media_size = content["info"]["size"], - media_mime = content["info"]["mimetype"], - media_crypt_dict = crypt_dict, - media_local_path = await local_media.get_local(), - - thumbnail_url = thumb_url, - thumbnail_crypt_dict = thumb_crypt_dict, - - thumbnail_width = - content["info"].get("thumbnail_info", {}).get("w", 0), - thumbnail_height = - content["info"].get("thumbnail_info", {}).get("h", 0), - thumbnail_mime = - content["info"].get("thumbnail_info", {}).get("mimetype", ""), - ) - - await self._send_message(room_id, content, transaction_id) - - - async def _local_echo( - self, - room_id: str, - transaction_id: UUID, - event_type: Type[nio.Event], - **event_fields, - ) -> None: - """Register a local model `Event` while waiting for the server. - - When the user sends a message, we want to show instant feedback in - the UI timeline without waiting for the servers to receive our message - and retransmit it to us. - - The event will be locally echoed for all our accounts that are members - of the `room_id` room. - This allows sending messages from other accounts within the same - composer without having to go to another page in the UI, - and getting direct feedback for these accounts in the timeline. - - When we do get the real event retransmited by the server, it will - replace the local one we registered. - """ - - our_info = self.models["accounts"][self.user_id] - - content = event_fields.get("content", "").strip() - - if content and "inline_content" not in event_fields: - event_fields["inline_content"] = HTML.filter(content, inline=True) - - event = Event( - id = f"echo-{transaction_id}", - event_id = "", - event_type = event_type, - date = datetime.now(), - sender_id = self.user_id, - sender_name = our_info.display_name, - sender_avatar = our_info.avatar_url, - is_local_echo = True, - links = Event.parse_links(content), - **event_fields, - ) + def on_speed_changed(speed: float) -> None: + transfer.set_fields( + speed = speed, + time_left = monitor.remaining_time or timedelta(0), + ) + + monitor.on_transferred = on_transferred + monitor.on_speed_changed = on_speed_changed + + await self.pause_while_offline() + + try: + url, mime, crypt_dict = await self.upload( + lambda *_: path, + filename = path.name, + filesize = size, + encrypt = encrypt, + monitor = monitor, + ) + + # FIXME: nio might not catch the cancel in time + if monitor.cancel: + raise nio.TransferCancelledError() + + except (MatrixError, OSError) as err: + transfer.set_fields( + status = TransferStatus.Error, + error = type(err), + error_args = err.args, + ) + + # Wait for cancellation from UI, see parent send_file() method + while True: + await asyncio.sleep(0.1) + + transfer.status = TransferStatus.Caching + local_media = await Media.from_existing_file( + self.backend.media_cache, self.user_id, url, path, + ) + + kind = (mime or "").split("/")[0] + + thumb_url: str = "" + thumb_info: Optional[MatrixImageInfo] = None + + content: dict = { + f"{__reverse_dns__}.transaction_id": str(transaction_id), + + "body": path.name, + "info": { + "mimetype": mime, + "size": transfer.total_size, + }, + } + + if encrypt: + content["file"] = {"url": url, **crypt_dict} + else: + content["url"] = url + + if kind == "image": + is_svg = mime == "image/svg+xml" + + event_type = \ + nio.RoomEncryptedImage if encrypt else nio.RoomMessageImage + + content["msgtype"] = "m.image" + + content["info"]["w"], content["info"]["h"] = ( + await utils.svg_dimensions(path) if is_svg else + PILImage.open(path).size + ) + + try: + thumb_data, thumb_info = await self.generate_thumbnail( + path, is_svg=is_svg, + ) + except UneededThumbnail: + pass + except Exception: # noqa + trace = traceback.format_exc().rstrip() + log.warning("Failed thumbnailing %s:\n%s", path, trace) + else: + thumb_ext = "png" if thumb_info.mime == "image/png" else "jpg" + thumb_name = f"{path.stem}_thumbnail.{thumb_ext}" + + transfer.set_fields( + status = TransferStatus.Transfering, + filepath = Path(thumb_name), + total_size = len(thumb_data), + ) + + try: + transfer.total_size = thumb_info.size + + monitor = nio.TransferMonitor(thumb_info.size) + monitor.on_transferred = on_transferred + monitor.on_speed_changed = on_speed_changed + + self.transfer_monitors[item_uuid] = monitor + + thumb_url, _, thumb_crypt_dict = await self.upload( + lambda *_: thumb_data, + filename = f"{path.stem}_sample{path.suffix}", + filesize = thumb_info.size, + encrypt = encrypt, + monitor = monitor, + ) + + # FIXME: nio might not catch the cancel in time + if monitor.cancel: + raise nio.TransferCancelledError() + except MatrixError as err: + log.warning(f"Failed uploading thumbnail {path}: {err}") + else: + transfer.status = TransferStatus.Caching + + await Thumbnail.from_bytes( + self.backend.media_cache, + self.user_id, + thumb_url, + path.name, + thumb_data, + wanted_size = (content["info"]["w"], + content["info"]["h"]), + ) + + if encrypt: + content["info"]["thumbnail_file"] = { + "url": thumb_url, + **thumb_crypt_dict, + } + else: + content["info"]["thumbnail_url"] = thumb_url + + content["info"]["thumbnail_info"] = thumb_info.as_dict() + + elif kind == "audio": + event_type = \ + nio.RoomEncryptedAudio if encrypt else nio.RoomMessageAudio + + content["msgtype"] = "m.audio" + content["info"]["duration"] = getattr( + MediaInfo.parse(path).tracks[0], "duration", 0, + ) or 0 + + elif kind == "video": + event_type = \ + nio.RoomEncryptedVideo if encrypt else nio.RoomMessageVideo + + content["msgtype"] = "m.video" + + tracks = MediaInfo.parse(path).tracks + + content["info"]["duration"] = \ + getattr(tracks[0], "duration", 0) or 0 + + content["info"]["w"] = max( + getattr(t, "width", 0) or 0 for t in tracks + ) + content["info"]["h"] = max( + getattr(t, "height", 0) or 0 for t in tracks + ) + + else: + event_type = \ + nio.RoomEncryptedFile if encrypt else nio.RoomMessageFile + + content["msgtype"] = "m.file" + content["filename"] = path.name + + del self.transfer_monitors[item_uuid] + del self.transfer_tasks[item_uuid] + del self.models[room_id, "transfers"][str(transfer.id)] + + if reply_to_event_id: + await self.send_text( + room_id=room_id, text="", reply_to_event_id=reply_to_event_id, + ) + + await self._local_echo( + room_id, + transaction_id, + event_type, + inline_content = content["body"], + + media_url = url, + media_http_url = await self.mxc_to_http(url), + media_title = path.name, + media_width = content["info"].get("w", 0), + media_height = content["info"].get("h", 0), + media_duration = content["info"].get("duration", 0), + media_size = content["info"]["size"], + media_mime = content["info"]["mimetype"], + media_crypt_dict = crypt_dict, + media_local_path = await local_media.get_local(), + + thumbnail_url = thumb_url, + thumbnail_crypt_dict = thumb_crypt_dict, + + thumbnail_width = + content["info"].get("thumbnail_info", {}).get("w", 0), + thumbnail_height = + content["info"].get("thumbnail_info", {}).get("h", 0), + thumbnail_mime = + content["info"].get("thumbnail_info", {}).get("mimetype", ""), + ) + + await self._send_message(room_id, content, transaction_id) + + + async def _local_echo( + self, + room_id: str, + transaction_id: UUID, + event_type: Type[nio.Event], + **event_fields, + ) -> None: + """Register a local model `Event` while waiting for the server. + + When the user sends a message, we want to show instant feedback in + the UI timeline without waiting for the servers to receive our message + and retransmit it to us. + + The event will be locally echoed for all our accounts that are members + of the `room_id` room. + This allows sending messages from other accounts within the same + composer without having to go to another page in the UI, + and getting direct feedback for these accounts in the timeline. + + When we do get the real event retransmited by the server, it will + replace the local one we registered. + """ + + our_info = self.models["accounts"][self.user_id] + + content = event_fields.get("content", "").strip() + + if content and "inline_content" not in event_fields: + event_fields["inline_content"] = HTML.filter(content, inline=True) + + event = Event( + id = f"echo-{transaction_id}", + event_id = "", + event_type = event_type, + date = datetime.now(), + sender_id = self.user_id, + sender_name = our_info.display_name, + sender_avatar = our_info.avatar_url, + is_local_echo = True, + links = Event.parse_links(content), + **event_fields, + ) + + for user_id in self.models["accounts"]: + if user_id in self.models[self.user_id, room_id, "members"]: + key = f"echo-{transaction_id}" + self.models[user_id, room_id, "events"][key] = deepcopy(event) + + await self.set_room_last_event(room_id, event) + + + async def _send_message( + self, room_id: str, content: dict, transaction_id: UUID, + ) -> None: + """Send a message event with `content` dict to a room.""" + + self.send_message_tasks[transaction_id] = \ + utils.current_task() # type: ignore + + async with self.backend.send_locks[room_id]: + await self.room_send( + room_id = room_id, + message_type = "m.room.message", + content = content, + ignore_unverified_devices = True, + ) + + + async def load_all_room_members(self, room_id: str) -> None: + """Request a room's full member list if it hasn't already been loaded. + + Member lazy-loading is used to accelerate the initial sync with the + server. This method will be called from QML to load a room's entire + member list when the user is currently viewing the room. + """ + + # Room may be gone by the time this is called due to room_forget() + room = self.all_rooms.get(room_id) + + if room and not room.members_synced: + await super().joined_members(room_id) + await self.register_nio_room(room, force_register_members=True) + + + async def load_past_events(self, room_id: str) -> bool: + """Ask the server for previous events of the room. + + If it's the first time that the room is being loaded, 10 events + will be requested (to give the user something to read quickly), else + 100 events will be requested. + + Events from before the client was started will be requested and + registered into our models. + + Returns whether there are any messages left to load. + """ + log.info("loading events for %s",room_id) + if room_id in self.fully_loaded_rooms or \ + room_id in self.invited_rooms or \ + room_id in self.cleared_events_rooms or \ + self.models[self.user_id, "rooms"][room_id].left: + return False + log.info("waiting for first sync %s",room_id) + await self.first_sync_done.wait() + log.info("waiting for past token %s",room_id) + i=0 + while i<100 and not self.past_tokens.get(room_id): + # If a new room was added, wait for onSyncResponse to set the token + await asyncio.sleep(0.1) + i+=1 # Give up waiting after 10 seconds and just fetch from newest instead. This fixes a bug where initial sync occasionally inserts None into the batch for some reason - I'm blaming nio currently. Also fixes a bug where the room just straight up doesn't seem to exist in initial sync, but somehow still ends up in room list. + log.info("performing actual fetch %s",room_id) + args={"room_id":room_id,"limit":100 if room_id in self.loaded_once_rooms else 10,"message_filter":self.lazy_load_filter,"start":self.past_tokens.get(room_id)} + response = await self.room_messages(**args) + log.info("data returned %s",room_id) + self.loaded_once_rooms.add(room_id) + more_to_load = True + + self.past_tokens[room_id] = response.end + + for event in response.chunk: + if isinstance(event, nio.RoomCreateEvent): + self.fully_loaded_rooms.add(room_id) + more_to_load = False + + for cb in self.event_callbacks: + if (cb.filter is None or isinstance(event, cb.filter)): + await cb.func(self.all_rooms[room_id], event) + + return more_to_load + + + async def new_direct_chat(self, invite: str, encrypt: bool = False) -> str: + """Create a room and invite a single user in it for a direct chat.""" + + if invite == self.user_id: + raise InvalidUserInContext(invite) + + if not self.user_id_regex.match(invite): + raise InvalidUserId(invite) + + # Raise MatrixNotFound if profile doesn't exist + await self.get_profile(invite) + + response = await super().room_create( + invite = [invite], + is_direct = True, + visibility = nio.RoomVisibility.private, + initial_state = + [nio.EnableEncryptionBuilder().as_dict()] if encrypt else [], + ) + return response.room_id + + + async def new_group_chat( + self, + name: Optional[str] = None, + topic: Optional[str] = None, + public: bool = False, + encrypt: bool = False, + federate: bool = True, + ) -> str: + """Create a new matrix room with the purpose of being a group chat.""" + + response = await super().room_create( + name = name or None, + topic = topic or None, + federate = federate, + visibility = + nio.RoomVisibility.public if public else + nio.RoomVisibility.private, + initial_state = + [nio.EnableEncryptionBuilder().as_dict()] if encrypt else [], + ) + return response.room_id + + async def room_join(self, alias_or_id_or_url: str) -> str: + """Join an existing matrix room.""" + + string = alias_or_id_or_url.strip() + + if self.http_s_url_regex.match(string): + for part in urlparse(string).fragment.split("/"): + if self.room_id_or_alias_regex.match(part): + string = part + break + else: + raise ValueError(f"No alias or room id found in url {string}") + + if not self.room_id_or_alias_regex.match(string): + raise ValueError("Not an alias or room id") + + response = await super().join(string) + return response.room_id + + async def toggle_room_pin(self, room_id: str) -> None: + room = self.models[self.user_id, "rooms"][room_id] + room.pinned = not room.pinned + + settings = self.backend.settings + pinned = settings.RoomList.Pinned + user_pinned = pinned.setdefault(self.user_id, []) + + if room.pinned and room_id not in user_pinned: + user_pinned.append(room_id) + + while not room.pinned and room_id in user_pinned: + user_pinned.remove(room_id) - for user_id in self.models["accounts"]: - if user_id in self.models[self.user_id, room_id, "members"]: - key = f"echo-{transaction_id}" - self.models[user_id, room_id, "events"][key] = deepcopy(event) - - await self.set_room_last_event(room_id, event) - - - async def _send_message( - self, room_id: str, content: dict, transaction_id: UUID, - ) -> None: - """Send a message event with `content` dict to a room.""" + # Changes inside dicts/lists aren't monitored, need to reassign + settings.RoomList.Pinned[self.user_id] = user_pinned + self.backend.settings.save() - self.send_message_tasks[transaction_id] = \ - utils.current_task() # type: ignore - - async with self.backend.send_locks[room_id]: - await self.room_send( - room_id = room_id, - message_type = "m.room.message", - content = content, - ignore_unverified_devices = True, - ) - - - async def load_all_room_members(self, room_id: str) -> None: - """Request a room's full member list if it hasn't already been loaded. - - Member lazy-loading is used to accelerate the initial sync with the - server. This method will be called from QML to load a room's entire - member list when the user is currently viewing the room. - """ - - # Room may be gone by the time this is called due to room_forget() - room = self.all_rooms.get(room_id) - - if room and not room.members_synced: - await super().joined_members(room_id) - await self.register_nio_room(room, force_register_members=True) - - - async def load_past_events(self, room_id: str) -> bool: - """Ask the server for previous events of the room. - - If it's the first time that the room is being loaded, 10 events - will be requested (to give the user something to read quickly), else - 100 events will be requested. - - Events from before the client was started will be requested and - registered into our models. - - Returns whether there are any messages left to load. - """ + async def room_forget(self, room_id: str) -> None: + """Leave a joined room (or decline an invite) and forget its history. - if room_id in self.fully_loaded_rooms or \ - room_id in self.invited_rooms or \ - room_id in self.cleared_events_rooms or \ - self.models[self.user_id, "rooms"][room_id].left: - return False - - await self.first_sync_done.wait() - - while not self.past_tokens.get(room_id): - # If a new room was added, wait for onSyncResponse to set the token - await asyncio.sleep(0.1) + If all the members of a room leave and forget it, that room + will be marked as suitable for destruction by the server. + """ - response = await self.room_messages( - room_id = room_id, - start = self.past_tokens[room_id], - limit = 100 if room_id in self.loaded_once_rooms else 10, - message_filter = self.lazy_load_filter, - ) + self.ignored_rooms.add(room_id) + self.models[self.user_id, "rooms"].pop(room_id, None) + self.models.pop((self.user_id, room_id, "events"), None) + self.models.pop((self.user_id, room_id, "members"), None) - self.loaded_once_rooms.add(room_id) - more_to_load = True + await self.update_account_unread_counts() - self.past_tokens[room_id] = response.end + try: + await super().room_leave(room_id) + except MatrixError as e: # room was already left + if e.http_code != 404: + raise - for event in response.chunk: - if isinstance(event, nio.RoomCreateEvent): - self.fully_loaded_rooms.add(room_id) - more_to_load = False + await super().room_forget(room_id) - for cb in self.event_callbacks: - if (cb.filter is None or isinstance(event, cb.filter)): - await cb.func(self.all_rooms[room_id], event) + async def room_mass_invite( + self, room_id: str, *user_ids: str, + ) -> Tuple[List[str], List[Tuple[str, Exception]]]: + """Invite users to a room in parallel. + + Returns a tuple with: + + - A list of users we successfully invited + - A list of `(user_id, Exception)` tuples for those failed to invite. + """ + + user_ids = tuple( + uid for uid in user_ids + # Server would return a 403 forbidden for users already in the room + if uid not in self.all_rooms[room_id].users + ) - return more_to_load - - - async def new_direct_chat(self, invite: str, encrypt: bool = False) -> str: - """Create a room and invite a single user in it for a direct chat.""" + async def invite(user_id: str): + if not self.user_id_regex.match(user_id): + return InvalidUserId(user_id) - if invite == self.user_id: - raise InvalidUserInContext(invite) + if not self.rooms[room_id].federate: + _, user_server = user_id.split(":", maxsplit=1) + _, room_server = room_id.split(":", maxsplit=1) - if not self.user_id_regex.match(invite): - raise InvalidUserId(invite) + user_server = re.sub(r":443$", "", user_server) + room_server = re.sub(r":443$", "", room_server) - # Raise MatrixNotFound if profile doesn't exist - await self.get_profile(invite) + if user_server != room_server: + return UserFromOtherServerDisallowed(user_id) - response = await super().room_create( - invite = [invite], - is_direct = True, - visibility = nio.RoomVisibility.private, - initial_state = - [nio.EnableEncryptionBuilder().as_dict()] if encrypt else [], - ) - return response.room_id + try: + await self.get_profile(user_id) + except (MatrixNotFound, MatrixBadGateway) as err: + return err + return await self.room_invite(room_id, user_id) - async def new_group_chat( - self, - name: Optional[str] = None, - topic: Optional[str] = None, - public: bool = False, - encrypt: bool = False, - federate: bool = True, - ) -> str: - """Create a new matrix room with the purpose of being a group chat.""" + coros = [invite(uid) for uid in user_ids] + successes = [] + errors: list = [] + responses = await asyncio.gather(*coros) - response = await super().room_create( - name = name or None, - topic = topic or None, - federate = federate, - visibility = - nio.RoomVisibility.public if public else - nio.RoomVisibility.private, - initial_state = - [nio.EnableEncryptionBuilder().as_dict()] if encrypt else [], - ) - return response.room_id + for user_id, response in zip(user_ids, responses): + if isinstance(response, nio.RoomInviteError): + errors.append((user_id, await MatrixError.from_nio(response))) - async def room_join(self, alias_or_id_or_url: str) -> str: - """Join an existing matrix room.""" + elif isinstance(response, Exception): + errors.append((user_id, response)) - string = alias_or_id_or_url.strip() + else: + successes.append(user_id) - if self.http_s_url_regex.match(string): - for part in urlparse(string).fragment.split("/"): - if self.room_id_or_alias_regex.match(part): - string = part - break - else: - raise ValueError(f"No alias or room id found in url {string}") + return (successes, errors) - if not self.room_id_or_alias_regex.match(string): - raise ValueError("Not an alias or room id") - response = await super().join(string) - return response.room_id + async def room_put_state_builder( + self, room_id: str, builder: nio.EventBuilder, + ) -> str: + """Send state event to room based from a `nio.EventBuilder` object.""" - async def toggle_room_pin(self, room_id: str) -> None: - room = self.models[self.user_id, "rooms"][room_id] - room.pinned = not room.pinned + dct = builder.as_dict() - settings = self.backend.settings - pinned = settings.RoomList.Pinned - user_pinned = pinned.setdefault(self.user_id, []) + response = await self.room_put_state( + room_id = room_id, + event_type = dct["type"], + content = dct["content"], + state_key = dct["state_key"], + ) + return response.event_id - if room.pinned and room_id not in user_pinned: - user_pinned.append(room_id) - while not room.pinned and room_id in user_pinned: - user_pinned.remove(room_id) + async def room_set( + self, + room_id: str, + name: Optional[str] = None, + topic: Optional[str] = None, + encrypt: Optional[bool] = None, + require_invite: Optional[bool] = None, + forbid_guests: Optional[bool] = None, + ) -> None: + """Send setting state events for arguments that aren't `None`.""" - # Changes inside dicts/lists aren't monitored, need to reassign - settings.RoomList.Pinned[self.user_id] = user_pinned - self.backend.settings.save() + builders: List[nio.EventBuilder] = [] - async def room_forget(self, room_id: str) -> None: - """Leave a joined room (or decline an invite) and forget its history. + if name is not None: + builders.append(nio.ChangeNameBuilder(name=name)) - If all the members of a room leave and forget it, that room - will be marked as suitable for destruction by the server. - """ + if topic is not None: + builders.append(nio.ChangeTopicBuilder(topic=topic)) - self.ignored_rooms.add(room_id) - self.models[self.user_id, "rooms"].pop(room_id, None) - self.models.pop((self.user_id, room_id, "events"), None) - self.models.pop((self.user_id, room_id, "members"), None) + if encrypt is False: + raise ValueError("Cannot disable encryption in a E2E room") - await self.update_account_unread_counts() + if encrypt is True: + builders.append(nio.EnableEncryptionBuilder()) - try: - await super().room_leave(room_id) - except MatrixError as e: # room was already left - if e.http_code != 404: - raise + if require_invite is not None: + builders.append(nio.ChangeJoinRulesBuilder( + rule="invite" if require_invite else "public", + )) - await super().room_forget(room_id) + if forbid_guests is not None: + builders.append(nio.ChangeGuestAccessBuilder( + access = "forbidden" if forbid_guests else "can_join", + )) - async def room_mass_invite( - self, room_id: str, *user_ids: str, - ) -> Tuple[List[str], List[Tuple[str, Exception]]]: - """Invite users to a room in parallel. - - Returns a tuple with: - - - A list of users we successfully invited - - A list of `(user_id, Exception)` tuples for those failed to invite. - """ - - user_ids = tuple( - uid for uid in user_ids - # Server would return a 403 forbidden for users already in the room - if uid not in self.all_rooms[room_id].users - ) + await asyncio.gather(*[ + self.room_put_state_builder(room_id, b) for b in builders + ]) - async def invite(user_id: str): - if not self.user_id_regex.match(user_id): - return InvalidUserId(user_id) - if not self.rooms[room_id].federate: - _, user_server = user_id.split(":", maxsplit=1) - _, room_server = room_id.split(":", maxsplit=1) + async def room_set_member_power( + self, room_id: str, user_id: str, level: int, + ) -> None: + """Set a room member's power level.""" - user_server = re.sub(r":443$", "", user_server) - room_server = re.sub(r":443$", "", room_server) + while room_id not in self.power_level_events: + await asyncio.sleep(0.2) - if user_server != room_server: - return UserFromOtherServerDisallowed(user_id) + content = deepcopy(self.power_level_events[room_id].source["content"]) + content.setdefault("users", {})[user_id] = level - try: - await self.get_profile(user_id) - except (MatrixNotFound, MatrixBadGateway) as err: - return err + await self.room_put_state(room_id, "m.room.power_levels", content) - return await self.room_invite(room_id, user_id) - coros = [invite(uid) for uid in user_ids] - successes = [] - errors: list = [] - responses = await asyncio.gather(*coros) + async def room_typing( + self, room_id: str, typing_state: bool = True, timeout: int = 5000, + ): + """Set typing notice to the server.""" - for user_id, response in zip(user_ids, responses): - if isinstance(response, nio.RoomInviteError): - errors.append((user_id, await MatrixError.from_nio(response))) + if not utils.config_get_account_room_rule( + rules = self.backend.settings.Chat.Composer.TypingNotifications, + user_id = self.user_id, + room_id = room_id, + ): + return - elif isinstance(response, Exception): - errors.append((user_id, response)) + if self.models["accounts"][self.user_id].presence not in [ + Presence.State.invisible, + Presence.State.offline, + ]: + await super().room_typing(room_id, typing_state, timeout) - else: - successes.append(user_id) - return (successes, errors) + async def get_redacted_event_content( + self, + nio_type: Type[nio.Event], + redacter: str, + sender: str, + reason: str = "", + ) -> str: + """Get content to be displayed in place of a redacted event.""" + content = "%1 removed this message" if redacter == sender else \ + "%1's message was removed by %2" - async def room_put_state_builder( - self, room_id: str, builder: nio.EventBuilder, - ) -> str: - """Send state event to room based from a `nio.EventBuilder` object.""" + if reason: + content = f"{content}, reason: {reason}" - dct = builder.as_dict() + return content - response = await self.room_put_state( - room_id = room_id, - event_type = dct["type"], - content = dct["content"], - state_key = dct["state_key"], - ) - return response.event_id + async def room_mass_redact( + self, room_id: str, reason: str, *event_client_ids: str, + ) -> List[nio.RoomRedactResponse]: + """Redact events from a room in parallel.""" - async def room_set( - self, - room_id: str, - name: Optional[str] = None, - topic: Optional[str] = None, - encrypt: Optional[bool] = None, - require_invite: Optional[bool] = None, - forbid_guests: Optional[bool] = None, - ) -> None: - """Send setting state events for arguments that aren't `None`.""" + tasks = [] - builders: List[nio.EventBuilder] = [] + for user_id in self.backend.clients: + for client_id in event_client_ids: - if name is not None: - builders.append(nio.ChangeNameBuilder(name=name)) + event = self.models[user_id, room_id, "events"].get(client_id) - if topic is not None: - builders.append(nio.ChangeTopicBuilder(topic=topic)) + if not event: + continue - if encrypt is False: - raise ValueError("Cannot disable encryption in a E2E room") + if event.is_local_echo: + if user_id == self.user_id: + uuid = UUID(event.id.replace("echo-", "")) + self.send_message_tasks[uuid].cancel() - if encrypt is True: - builders.append(nio.EnableEncryptionBuilder()) + event.is_local_echo = False + else: + if user_id == self.user_id: + tasks.append( + self.room_redact(room_id, event.event_id, reason), + ) - if require_invite is not None: - builders.append(nio.ChangeJoinRulesBuilder( - rule="invite" if require_invite else "public", - )) + event.is_local_echo = True - if forbid_guests is not None: - builders.append(nio.ChangeGuestAccessBuilder( - access = "forbidden" if forbid_guests else "can_join", - )) + event.set_fields( + content = await self.get_redacted_event_content( + event.event_type, self.user_id, event.sender_id, + reason, + ), - await asyncio.gather(*[ - self.room_put_state_builder(room_id, b) for b in builders - ]) + event_type = nio.RedactedEvent, + mentions = [], + type_specifier = TypeSpecifier.Unset, + media_url = "", + media_http_url = "", + media_title = "", + media_local_path = "", + thumbnail_url = "", + ) + await self.pause_while_offline() + return await asyncio.gather(*tasks) - async def room_set_member_power( - self, room_id: str, user_id: str, level: int, - ) -> None: - """Set a room member's power level.""" - while room_id not in self.power_level_events: - await asyncio.sleep(0.2) + async def generate_thumbnail( + self, data: UploadData, is_svg: bool = False, + ) -> Tuple[bytes, MatrixImageInfo]: + """Create a thumbnail from an image, return the bytes and info.""" - content = deepcopy(self.power_level_events[room_id].source["content"]) - content.setdefault("users", {})[user_id] = level + png_modes = ("1", "L", "P", "RGBA") - await self.room_put_state(room_id, "m.room.power_levels", content) + data = b"".join([c async for c in async_generator_from_data(data)]) + is_svg = await utils.guess_mime(data) == "image/svg+xml" + if is_svg: + svg_width, svg_height = await utils.svg_dimensions(data) - async def room_typing( - self, room_id: str, typing_state: bool = True, timeout: int = 5000, - ): - """Set typing notice to the server.""" + data = cairosvg.svg2png( + bytestring = data, + parent_width = svg_width, + parent_height = svg_height, + ) - if not utils.config_get_account_room_rule( - rules = self.backend.settings.Chat.Composer.TypingNotifications, - user_id = self.user_id, - room_id = room_id, - ): - return + thumb = PILImage.open(io.BytesIO(data)) - if self.models["accounts"][self.user_id].presence not in [ - Presence.State.invisible, - Presence.State.offline, - ]: - await super().room_typing(room_id, typing_state, timeout) + small = thumb.width <= 800 and thumb.height <= 600 + is_jpg_png = thumb.format in ("JPEG", "PNG") + jpgable_png = thumb.format == "PNG" and thumb.mode not in png_modes + if small and is_jpg_png and not jpgable_png and not is_svg: + raise UneededThumbnail() - async def get_redacted_event_content( - self, - nio_type: Type[nio.Event], - redacter: str, - sender: str, - reason: str = "", - ) -> str: - """Get content to be displayed in place of a redacted event.""" + if not small: + thumb.thumbnail((800, 600)) - content = "%1 removed this message" if redacter == sender else \ - "%1's message was removed by %2" + if thumb.mode in png_modes: + thumb_data = await utils.compress_image(thumb) + mime = "image/png" + else: + thumb = thumb.convert("RGB") + thumb_data = await utils.compress_image(thumb, "JPEG") + mime = "image/jpeg" - if reason: - content = f"{content}, reason: {reason}" + thumb_size = len(thumb_data) - return content + if thumb_size >= len(data) and is_jpg_png and not is_svg: + raise UneededThumbnail() + info = MatrixImageInfo(thumb.width, thumb.height, mime, thumb_size) + return (thumb_data, info) - async def room_mass_redact( - self, room_id: str, reason: str, *event_client_ids: str, - ) -> List[nio.RoomRedactResponse]: - """Redact events from a room in parallel.""" - tasks = [] + async def upload( + self, + data_provider: nio.DataProvider, + filename: Optional[str] = None, + filesize: Optional[int] = None, + mime: Optional[str] = None, + encrypt: bool = False, + monitor: Optional[nio.TransferMonitor] = None, + ) -> UploadReturn: + """Upload a file to the matrix homeserver.""" + + max_size = self.models["accounts"][self.user_id].max_upload_size + + if max_size and filesize > max_size: + raise MatrixTooLarge() + + mime = mime or await utils.guess_mime(data_provider(0, 0)) + + response, decryption_dict = await super().upload( + data_provider = data_provider, + content_type = "application/octet-stream" if encrypt else mime, + filename = filename, + encrypt = encrypt, + monitor = monitor, + filesize = filesize, + ) + + return UploadReturn(response.content_uri, mime, decryption_dict or {}) + + + async def set_avatar_from_file(self, path: Union[Path, str]) -> None: + """Upload an image to the homeserver and set it as our avatar.""" + + path = Path(path) + mime = await utils.guess_mime(path) + + if mime.split("/")[0] != "image": + raise BadMimeType(wanted="image/*", got=mime) + + mxc, *_ = await self.upload( + data_provider = lambda *_: path, + filename = path.name, + filesize = path.resolve().stat().st_size, + mime = mime, + ) + await self.set_avatar(mxc) - for user_id in self.backend.clients: - for client_id in event_client_ids: - event = self.models[user_id, room_id, "events"].get(client_id) + async def get_offline_presence(self, user_id: str) -> None: + """Get a offline room member's presence and set it on model item. - if not event: - continue + This is called by QML when a member list delegate or profile that + is offline is displayed. + Since we don't get last seen times for offline in users in syncs, + we have to fetch those manually. + """ - if event.is_local_echo: - if user_id == self.user_id: - uuid = UUID(event.id.replace("echo-", "")) - self.send_message_tasks[uuid].cancel() + if self.backend.presences.get(user_id): + return - event.is_local_echo = False - else: - if user_id == self.user_id: - tasks.append( - self.room_redact(room_id, event.event_id, reason), - ) + if not self.models["accounts"][self.user_id].presence_support: + return - event.is_local_echo = True + try: + async with self.backend.concurrent_get_presence_limit: + resp = await self.get_presence(user_id) + except (MatrixForbidden, MatrixUnrecognized): + return + except MatrixError as e: + if e.http_code==500: return # Probably conduit which hasn't implemented it yet + else: raise - event.set_fields( - content = await self.get_redacted_event_content( - event.event_type, self.user_id, event.sender_id, - reason, - ), + await self.nio_callbacks.onPresenceEvent(nio.PresenceEvent( + user_id = resp.user_id, + presence = resp.presence, + last_active_ago = resp.last_active_ago, + currently_active = resp.currently_active, + status_msg = resp.status_msg, + )) - event_type = nio.RedactedEvent, - mentions = [], - type_specifier = TypeSpecifier.Unset, - media_url = "", - media_http_url = "", - media_title = "", - media_local_path = "", - thumbnail_url = "", - ) - await self.pause_while_offline() - return await asyncio.gather(*tasks) + async def set_presence( + self, + presence: str, + status_msg: Optional[str] = None, + save: bool = True, + ) -> None: + """Set presence state for this account.""" + account = self.models["accounts"][self.user_id] + call_presence_api = True + self._presence = "offline" if presence == "invisible" else presence - async def generate_thumbnail( - self, data: UploadData, is_svg: bool = False, - ) -> Tuple[bytes, MatrixImageInfo]: - """Create a thumbnail from an image, return the bytes and info.""" + if status_msg is None: + status_msg = account.status_msg - png_modes = ("1", "L", "P", "RGBA") + # Starting/stopping client if current/new presence is offline - data = b"".join([c async for c in async_generator_from_data(data)]) - is_svg = await utils.guess_mime(data) == "image/svg+xml" + if presence == "offline": + if account.presence == Presence.State.offline: + return - if is_svg: - svg_width, svg_height = await utils.svg_dimensions(data) + await self._stop() - data = cairosvg.svg2png( - bytestring = data, - parent_width = svg_width, - parent_height = svg_height, - ) + # We stop syncing, so update the account manually + account.set_fields( + presence = Presence.State.offline, + status_msg = "", + currently_active = False, + ) + elif account.presence == Presence.State.offline: + # We might receive a recent status_msg set from another client on + # startup, so don't try to set a new one immediatly. + # Presence though will be sent on first sync. + call_presence_api = False + account.connecting = True + self.start_task = asyncio.ensure_future(self._start()) - thumb = PILImage.open(io.BytesIO(data)) + # Update our account model item's presence - small = thumb.width <= 800 and thumb.height <= 600 - is_jpg_png = thumb.format in ("JPEG", "PNG") - jpgable_png = thumb.format == "PNG" and thumb.mode not in png_modes + if ( + Presence.State(presence) != account.presence and + presence != "offline" + ): + account.presence = Presence.State(presence) - if small and is_jpg_png and not jpgable_png and not is_svg: - raise UneededThumbnail() + # Saving new details in accounts.json - if not small: - thumb.thumbnail((800, 600)) + if save: + account.save_presence = True - if thumb.mode in png_modes: - thumb_data = await utils.compress_image(thumb) - mime = "image/png" - else: - thumb = thumb.convert("RGB") - thumb_data = await utils.compress_image(thumb, "JPEG") - mime = "image/jpeg" + await self.backend.saved_accounts.set( + self.user_id, presence=presence, status_msg=status_msg, + ) + else: + account.save_presence = False - thumb_size = len(thumb_data) + # Update our presence/status on the server - if thumb_size >= len(data) and is_jpg_png and not is_svg: - raise UneededThumbnail() + if call_presence_api: + account.status_msg = status_msg + await super().set_presence(self._presence, status_msg) - info = MatrixImageInfo(thumb.width, thumb.height, mime, thumb_size) - return (thumb_data, info) + async def import_keys(self, infile: str, passphrase: str) -> None: + """Import decryption keys from a file, then retry decrypting events.""" - async def upload( - self, - data_provider: nio.DataProvider, - filename: Optional[str] = None, - filesize: Optional[int] = None, - mime: Optional[str] = None, - encrypt: bool = False, - monitor: Optional[nio.TransferMonitor] = None, - ) -> UploadReturn: - """Upload a file to the matrix homeserver.""" - - max_size = self.models["accounts"][self.user_id].max_upload_size - - if max_size and filesize > max_size: - raise MatrixTooLarge() - - mime = mime or await utils.guess_mime(data_provider(0, 0)) - - response, decryption_dict = await super().upload( - data_provider = data_provider, - content_type = "application/octet-stream" if encrypt else mime, - filename = filename, - encrypt = encrypt, - monitor = monitor, - filesize = filesize, - ) - - return UploadReturn(response.content_uri, mime, decryption_dict or {}) + await super().import_keys(infile, passphrase) + await self.retry_decrypting_events() - async def set_avatar_from_file(self, path: Union[Path, str]) -> None: - """Upload an image to the homeserver and set it as our avatar.""" - - path = Path(path) - mime = await utils.guess_mime(path) + async def export_keys(self, outfile: str, passphrase: str) -> None: + """Export our decryption keys to a file.""" - if mime.split("/")[0] != "image": - raise BadMimeType(wanted="image/*", got=mime) - - mxc, *_ = await self.upload( - data_provider = lambda *_: path, - filename = path.name, - filesize = path.resolve().stat().st_size, - mime = mime, - ) - await self.set_avatar(mxc) + path = Path(outfile) + path.parent.mkdir(parents=True, exist_ok=True) + # The QML dialog asks the user if he wants to overwrite before this + if path.exists(): + path.unlink() - async def get_offline_presence(self, user_id: str) -> None: - """Get a offline room member's presence and set it on model item. + await super().export_keys(outfile, passphrase) - This is called by QML when a member list delegate or profile that - is offline is displayed. - Since we don't get last seen times for offline in users in syncs, - we have to fetch those manually. - """ - if self.backend.presences.get(user_id): - return + async def retry_decrypting_events(self) -> None: + """Retry decrypting room `Event`s in our model we failed to decrypt.""" - if not self.models["accounts"][self.user_id].presence_support: - return + for sync_id, model in self.models.items(): + if not (isinstance(sync_id, tuple) and + len(sync_id) == 3 and + sync_id[0] == self.user_id and + sync_id[2] == "events"): + continue - try: - async with self.backend.concurrent_get_presence_limit: - resp = await self.get_presence(user_id) - except (MatrixForbidden, MatrixUnrecognized): - return + _, room_id, _ = sync_id - await self.nio_callbacks.onPresenceEvent(nio.PresenceEvent( - user_id = resp.user_id, - presence = resp.presence, - last_active_ago = resp.last_active_ago, - currently_active = resp.currently_active, - status_msg = resp.status_msg, - )) + with model.write_lock: + for ev in model.values(): + room = self.all_rooms[room_id] + if isinstance(ev.source, nio.MegolmEvent): + try: + decrypted = self.decrypt_event(ev.source) - async def set_presence( - self, - presence: str, - status_msg: Optional[str] = None, - save: bool = True, - ) -> None: - """Set presence state for this account.""" + if not decrypted: + raise nio.EncryptionError() - account = self.models["accounts"][self.user_id] - call_presence_api = True - self._presence = "offline" if presence == "invisible" else presence + except nio.EncryptionError: + continue - if status_msg is None: - status_msg = account.status_msg + for callback in self.event_callbacks: + filter_ = callback.filter + if not filter_ or isinstance(decrypted, filter_): + coro = asyncio.coroutine(callback.func) + await coro(room, decrypted) - # Starting/stopping client if current/new presence is offline - if presence == "offline": - if account.presence == Presence.State.offline: - return + async def clear_events(self, room_id: str) -> None: + """Remove every `Event` of a room we registered in our model. - await self._stop() + The events will be gone from the UI, until the client is restarted. + """ - # We stop syncing, so update the account manually - account.set_fields( - presence = Presence.State.offline, - status_msg = "", - currently_active = False, - ) - elif account.presence == Presence.State.offline: - # We might receive a recent status_msg set from another client on - # startup, so don't try to set a new one immediatly. - # Presence though will be sent on first sync. - call_presence_api = False - account.connecting = True - self.start_task = asyncio.ensure_future(self._start()) + self.cleared_events_rooms.add(room_id) - # Update our account model item's presence + model = self.models[self.user_id, room_id, "events"] + if model: + model.clear() - if ( - Presence.State(presence) != account.presence and - presence != "offline" - ): - account.presence = Presence.State(presence) + self.models[self.user_id, "rooms"][room_id].last_event_date = \ + ZERO_DATE - # Saving new details in accounts.json - if save: - account.save_presence = True + async def devices_info(self) -> List[Dict[str, Any]]: + """Get sorted list of devices and their info for our user.""" - await self.backend.saved_accounts.set( - self.user_id, presence=presence, status_msg=status_msg, - ) - else: - account.save_presence = False + def get_type(device_id: str) -> str: + # Return "current", "no_keys", "verified", "blacklisted", + # "ignored" or "unset" - # Update our presence/status on the server + if device_id == self.device_id: + return "current" - if call_presence_api: - account.status_msg = status_msg - await super().set_presence(self._presence, status_msg) + if device_id not in self.device_store[self.user_id]: + return "no_keys" + trust = self.device_store[self.user_id][device_id].trust_state + return trust.name - async def import_keys(self, infile: str, passphrase: str) -> None: - """Import decryption keys from a file, then retry decrypting events.""" + def get_ed25519(device_id: str) -> str: + key = "" - await super().import_keys(infile, passphrase) - await self.retry_decrypting_events() + if device_id == self.device_id: + key = self.olm.account.identity_keys["ed25519"] + elif device_id in self.device_store[self.user_id]: + key = self.device_store[self.user_id][device_id].ed25519 + return " ".join(textwrap.wrap(key, 4)) - async def export_keys(self, outfile: str, passphrase: str) -> None: - """Export our decryption keys to a file.""" + devices = [ + { + "id": device.id, + "display_name": device.display_name or "", + "last_seen_ip": (device.last_seen_ip or "").strip(" -"), + "last_seen_date": device.last_seen_date or ZERO_DATE, + "last_seen_country": "", + "type": get_type(device.id), + "ed25519_key": get_ed25519(device.id), + } + for device in (await self.devices()).devices + ] - path = Path(outfile) - path.parent.mkdir(parents=True, exist_ok=True) + # Reversed due to sorted(reverse=True) call below + types_order = { + "current": 5, + "unset": 4, + "no_keys": 3, + "verified": 2, + "ignored": 1, + "blacklisted": 0, + } - # The QML dialog asks the user if he wants to overwrite before this - if path.exists(): - path.unlink() + # Sort by type, then by descending date + return sorted( + devices, + key = lambda d: (types_order[d["type"]], d["last_seen_date"]), + reverse = True, + ) - await super().export_keys(outfile, passphrase) + async def member_devices(self, user_id: str) -> List[Dict[str, Any]]: + """Get list of E2E-aware devices for a user we share a room with.""" - async def retry_decrypting_events(self) -> None: - """Retry decrypting room `Event`s in our model we failed to decrypt.""" + devices = [ + # types: "verified", "blacklisted", "ignored" or "unset" + { + "id": device.id, + "display_name": device.display_name or "", + "type": device.trust_state.name, + "ed25519_key": " ".join(textwrap.wrap(device.ed25519, 4)), + } + for device in self.device_store.active_user_devices(user_id) + ] - for sync_id, model in self.models.items(): - if not (isinstance(sync_id, tuple) and - len(sync_id) == 3 and - sync_id[0] == self.user_id and - sync_id[2] == "events"): - continue + types_order = { + "unset": 0, "verified": 1, "ignored": 2, "blacklisted": 3, + } - _, room_id, _ = sync_id + # Sort by type, then by display name, then by ID + return sorted( + devices, + key = lambda d: + (types_order[d["type"]], d["display_name"], d["id"]), + ) - with model.write_lock: - for ev in model.values(): - room = self.all_rooms[room_id] - if isinstance(ev.source, nio.MegolmEvent): - try: - decrypted = self.decrypt_event(ev.source) + async def rename_device(self, device_id: str, name: str) -> bool: + """Rename one of our device, return `False` if it doesn't exist.""" - if not decrypted: - raise nio.EncryptionError() + try: + await self.update_device(device_id, {"display_name": name}) + return True + except MatrixNotFound: + return False - except nio.EncryptionError: - continue - for callback in self.event_callbacks: - filter_ = callback.filter - if not filter_ or isinstance(decrypted, filter_): - coro = asyncio.coroutine(callback.func) - await coro(room, decrypted) + async def auto_verify_all_other_accounts(self) -> None: + """Automatically verify/blacklist our other accounts's devices.""" + + for client in self.backend.clients.values(): + await self.auto_verify_account(client) + + + async def auto_verify_account(self, client: "MatrixClient") -> None: + """Automatically verify/blacklist one of our accounts's devices.""" + + if client.user_id == self.user_id: + return + + for device in self.device_store.active_user_devices(client.user_id): + if device.device_id != client.device_id: + continue + + if device.verified or device.blacklisted: + continue + + if device.ed25519 == client.olm.account.identity_keys["ed25519"]: + self.verify_device(device) + else: + self.blacklist_device(device) + + + async def delete_devices_with_password( + self, device_ids: List[str], password: str, + ) -> None: + """Delete devices, authentifying using the account's password.""" + + auth = { + "type": "m.login.password", + "identifier": {"type":"m.id.user", "user":self.user_id}, + "password": password, + } + + resp = await super().delete_devices(device_ids, auth) + + if isinstance(resp, nio.DeleteDevicesAuthResponse): + raise MatrixUnauthorized() + + + async def edit_pushrule( + self, + kind: Union[nio.PushRuleKind, str], + rule_id: str, + old_kind: Union[None, nio.PushRuleKind, str] = None, + old_rule_id: Optional[str] = None, + move_before_rule_id: Optional[str] = None, + move_after_rule_id: Optional[str] = None, + enable: Optional[bool] = None, + conditions: Optional[List[PushCondition]] = None, + pattern: Optional[str] = None, + actions: Optional[List[PushAction]] = None, + ) -> None: + """Create or edit an existing non-builtin pushrule. + For builtin server ("default") rules, only actions can be edited. + """ + + # Convert arguments that were passed as basic types (usually from QML) + + if isinstance(old_kind, str): + old_kind = nio.PushRuleKind[old_kind] + + kind = nio.PushRuleKind[kind] if isinstance(kind, str) else kind + + conditions = [ + nio.PushCondition.from_dict(c) if isinstance(c, dict) else c + for c in conditions + ] if isinstance(conditions, list) else None + actions = [ + nio.PushAction.from_dict(a) if isinstance(a, (str, dict)) else a + for a in actions + ] if isinstance(actions, list) else None - async def clear_events(self, room_id: str) -> None: - """Remove every `Event` of a room we registered in our model. + # Now edit the rule - The events will be gone from the UI, until the client is restarted. - """ + old: Optional[PushRule] = None + key = (old_kind.value if old_kind else None, old_rule_id) - self.cleared_events_rooms.add(room_id) + if None not in key: + old = self.models[self.user_id, "pushrules"].get(key) - model = self.models[self.user_id, room_id, "events"] - if model: - model.clear() + kind_change = old and old_kind and old_kind != kind + rule_id_change = old and old_rule_id and old_rule_id != rule_id + explicit_move = move_before_rule_id or move_after_rule_id - self.models[self.user_id, "rooms"][room_id].last_event_date = \ - ZERO_DATE + if old and not kind_change and not explicit_move: + # If user edits a rule without specifying a new position, + # the server would move it to the first position + move_after_rule_id = old.rule_id + if old and actions is None: + # Matrix API forces us to always pass a non-null actions paramater + actions = [nio.PushAction.from_dict(a) for a in old.actions] - async def devices_info(self) -> List[Dict[str, Any]]: - """Get sorted list of devices and their info for our user.""" + if old and old.default: + await self.set_pushrule_actions("global", kind, rule_id, actions) + else: + await self.set_pushrule( + scope = "global", + kind = kind, + rule_id = rule_id, + before = move_before_rule_id, + after = move_after_rule_id, + actions = actions or [], + conditions = conditions, + pattern = pattern, + ) - def get_type(device_id: str) -> str: - # Return "current", "no_keys", "verified", "blacklisted", - # "ignored" or "unset" + # If we're editing an existing rule but its kind or ID is changed, + # set_pushrule creates a new rule, thus we must delete the old one + if kind_change or rule_id_change: + await self.delete_pushrule("global", old_kind, old_rule_id) - if device_id == self.device_id: - return "current" + if enable is not None and (old.enabled if old else True) != enable: + await self.enable_pushrule("global", kind, rule_id, enable) + elif kind_change or rule_id_change and old and not old.enabled: + await self.enable_pushrule("global", kind, rule_id, False) - if device_id not in self.device_store[self.user_id]: - return "no_keys" - trust = self.device_store[self.user_id][device_id].trust_state - return trust.name + async def tweak_pushrule_actions( + self, + kind: Union[nio.PushRuleKind, str], + rule_id: str, + notify: Optional[bool] = None, + highlight: Optional[bool] = None, + bubble: Optional[bool] = None, + sound: Optional[str] = None, + urgency_hint: Optional[bool] = None, + ) -> None: + """Edit individual actions for any existing push rule.""" - def get_ed25519(device_id: str) -> str: - key = "" + kind = nio.PushRuleKind[kind] if isinstance(kind, str) else kind - if device_id == self.device_id: - key = self.olm.account.identity_keys["ed25519"] - elif device_id in self.device_store[self.user_id]: - key = self.device_store[self.user_id][device_id].ed25519 + current: PushRule = \ + self.models[self.user_id, "pushrules"][kind.value, rule_id] - return " ".join(textwrap.wrap(key, 4)) + actions: List[nio.PushAction] = [] - devices = [ - { - "id": device.id, - "display_name": device.display_name or "", - "last_seen_ip": (device.last_seen_ip or "").strip(" -"), - "last_seen_date": device.last_seen_date or ZERO_DATE, - "last_seen_country": "", - "type": get_type(device.id), - "ed25519_key": get_ed25519(device.id), - } - for device in (await self.devices()).devices - ] + if notify or (notify is None and current.notify): + actions.append(nio.PushNotify()) - # Reversed due to sorted(reverse=True) call below - types_order = { - "current": 5, - "unset": 4, - "no_keys": 3, - "verified": 2, - "ignored": 1, - "blacklisted": 0, - } + if highlight or (highlight is None and current.highlight): + actions.append(nio.PushSetTweak("highlight", True)) - # Sort by type, then by descending date - return sorted( - devices, - key = lambda d: (types_order[d["type"]], d["last_seen_date"]), - reverse = True, - ) + if bubble or (bubble is None and current.bubble): + actions.append(nio.PushSetTweak("bubble", True)) + elif bubble is False or (bubble is None and not current.bubble): + actions.append(nio.PushSetTweak("bubble", False)) + if sound or (sound is None and current.sound): + actions.append(nio.PushSetTweak("sound", sound)) - async def member_devices(self, user_id: str) -> List[Dict[str, Any]]: - """Get list of E2E-aware devices for a user we share a room with.""" + hint = urgency_hint - devices = [ - # types: "verified", "blacklisted", "ignored" or "unset" - { - "id": device.id, - "display_name": device.display_name or "", - "type": device.trust_state.name, - "ed25519_key": " ".join(textwrap.wrap(device.ed25519, 4)), - } - for device in self.device_store.active_user_devices(user_id) - ] + if hint or (hint is None and current.urgency_hint): + actions.append(nio.PushSetTweak("urgency_hint", True)) + elif hint is False or (hint is None and not current.urgency_hint): + actions.append(nio.PushSetTweak("urgency_hint", False)) - types_order = { - "unset": 0, "verified": 1, "ignored": 2, "blacklisted": 3, - } + await self.set_pushrule_actions("global", kind, rule_id, actions) - # Sort by type, then by display name, then by ID - return sorted( - devices, - key = lambda d: - (types_order[d["type"]], d["display_name"], d["id"]), - ) + async def mass_tweak_pushrules_actions(self, *tweaks_kwargs) -> None: + coros = [self.tweak_pushrule_actions(**kws) for kws in tweaks_kwargs] + await asyncio.gather(*coros) - async def rename_device(self, device_id: str, name: str) -> bool: - """Rename one of our device, return `False` if it doesn't exist.""" - try: - await self.update_device(device_id, {"display_name": name}) - return True - except MatrixNotFound: - return False + async def remove_pushrule( + self, kind: Union[str, nio.PushRuleKind], rule_id: str, + ) -> None: + """Remove an existing non-builtin pushrule.""" + kind = nio.PushRuleKind[kind] if isinstance(kind, str) else kind - async def auto_verify_all_other_accounts(self) -> None: - """Automatically verify/blacklist our other accounts's devices.""" - - for client in self.backend.clients.values(): - await self.auto_verify_account(client) - - - async def auto_verify_account(self, client: "MatrixClient") -> None: - """Automatically verify/blacklist one of our accounts's devices.""" - - if client.user_id == self.user_id: - return - - for device in self.device_store.active_user_devices(client.user_id): - if device.device_id != client.device_id: - continue - - if device.verified or device.blacklisted: - continue - - if device.ed25519 == client.olm.account.identity_keys["ed25519"]: - self.verify_device(device) - else: - self.blacklist_device(device) - - - async def delete_devices_with_password( - self, device_ids: List[str], password: str, - ) -> None: - """Delete devices, authentifying using the account's password.""" - - auth = { - "type": "m.login.password", - "user": self.user_id, - "password": password, - } - - resp = await super().delete_devices(device_ids, auth) - - if isinstance(resp, nio.DeleteDevicesAuthResponse): - raise MatrixUnauthorized() - - - async def edit_pushrule( - self, - kind: Union[nio.PushRuleKind, str], - rule_id: str, - old_kind: Union[None, nio.PushRuleKind, str] = None, - old_rule_id: Optional[str] = None, - move_before_rule_id: Optional[str] = None, - move_after_rule_id: Optional[str] = None, - enable: Optional[bool] = None, - conditions: Optional[List[PushCondition]] = None, - pattern: Optional[str] = None, - actions: Optional[List[PushAction]] = None, - ) -> None: - """Create or edit an existing non-builtin pushrule. - For builtin server ("default") rules, only actions can be edited. - """ - - # Convert arguments that were passed as basic types (usually from QML) - - if isinstance(old_kind, str): - old_kind = nio.PushRuleKind[old_kind] - - kind = nio.PushRuleKind[kind] if isinstance(kind, str) else kind - - conditions = [ - nio.PushCondition.from_dict(c) if isinstance(c, dict) else c - for c in conditions - ] if isinstance(conditions, list) else None + if (kind.value, rule_id) in self.models[self.user_id, "pushrules"]: + await self.delete_pushrule("global", kind, rule_id) - actions = [ - nio.PushAction.from_dict(a) if isinstance(a, (str, dict)) else a - for a in actions - ] if isinstance(actions, list) else None - # Now edit the rule + def _rule_overrides_room(self, rule: PushRule) -> Optional[str]: + override = rule.kind is nio.PushRuleKind.override + one_cnd = len(rule.conditions) == 1 - old: Optional[PushRule] = None - key = (old_kind.value if old_kind else None, old_rule_id) + if not one_cnd: + return None - if None not in key: - old = self.models[self.user_id, "pushrules"].get(key) + cnd = nio.PushCondition.from_dict(rule.conditions[0]) + ev_match = isinstance(cnd, nio.PushEventMatch) - kind_change = old and old_kind and old_kind != kind - rule_id_change = old and old_rule_id and old_rule_id != rule_id - explicit_move = move_before_rule_id or move_after_rule_id + if override and ev_match and cnd.key == "room_id": + return cnd.pattern - if old and not kind_change and not explicit_move: - # If user edits a rule without specifying a new position, - # the server would move it to the first position - move_after_rule_id = old.rule_id + return None - if old and actions is None: - # Matrix API forces us to always pass a non-null actions paramater - actions = [nio.PushAction.from_dict(a) for a in old.actions] - if old and old.default: - await self.set_pushrule_actions("global", kind, rule_id, actions) - else: - await self.set_pushrule( - scope = "global", - kind = kind, - rule_id = rule_id, - before = move_before_rule_id, - after = move_after_rule_id, - actions = actions or [], - conditions = conditions, - pattern = pattern, - ) + async def _remove_room_override_rule(self, room_id: str) -> None: + for rule in self.models[self.user_id, "pushrules"].values(): + if self._rule_overrides_room(rule) == room_id: + await self.remove_pushrule(rule.kind, rule.rule_id) - # If we're editing an existing rule but its kind or ID is changed, - # set_pushrule creates a new rule, thus we must delete the old one - if kind_change or rule_id_change: - await self.delete_pushrule("global", old_kind, old_rule_id) - if enable is not None and (old.enabled if old else True) != enable: - await self.enable_pushrule("global", kind, rule_id, enable) - elif kind_change or rule_id_change and old and not old.enabled: - await self.enable_pushrule("global", kind, rule_id, False) + async def room_pushrule_use_default(self, room_id: str) -> None: + await self._remove_room_override_rule(room_id) + await self.remove_pushrule(nio.PushRuleKind.room, room_id) - async def tweak_pushrule_actions( - self, - kind: Union[nio.PushRuleKind, str], - rule_id: str, - notify: Optional[bool] = None, - highlight: Optional[bool] = None, - bubble: Optional[bool] = None, - sound: Optional[str] = None, - urgency_hint: Optional[bool] = None, - ) -> None: - """Edit individual actions for any existing push rule.""" + async def room_pushrule_all_events(self, room_id: str) -> None: + await self._remove_room_override_rule(room_id) + await self.edit_pushrule( + kind = nio.PushRuleKind.room, + rule_id = room_id, + actions = [nio.PushNotify(), nio.PushSetTweak("sound", "default")], + ) - kind = nio.PushRuleKind[kind] if isinstance(kind, str) else kind - current: PushRule = \ - self.models[self.user_id, "pushrules"][kind.value, rule_id] + async def room_pushrule_highlights_only(self, room_id: str) -> None: + await self._remove_room_override_rule(room_id) + await self.edit_pushrule(nio.PushRuleKind.room, room_id, actions=[]) - actions: List[nio.PushAction] = [] - if notify or (notify is None and current.notify): - actions.append(nio.PushNotify()) + async def room_pushrule_ignore_all(self, room_id: str) -> None: + await self._remove_room_override_rule(room_id) + await self.remove_pushrule(nio.PushRuleKind.room, room_id) - if highlight or (highlight is None and current.highlight): - actions.append(nio.PushSetTweak("highlight", True)) + cnd = nio.PushEventMatch("room_id", room_id) + await self.edit_pushrule( + nio.PushRuleKind.override, room_id, conditions=[cnd], actions=[], + ) - if bubble or (bubble is None and current.bubble): - actions.append(nio.PushSetTweak("bubble", True)) - elif bubble is False or (bubble is None and not current.bubble): - actions.append(nio.PushSetTweak("bubble", False)) - if sound or (sound is None and current.sound): - actions.append(nio.PushSetTweak("sound", sound)) + # Functions to register/modify data into models - hint = urgency_hint + async def update_account_unread_counts(self) -> None: + """Recalculate total unread notifications/highlights for our account""" - if hint or (hint is None and current.urgency_hint): - actions.append(nio.PushSetTweak("urgency_hint", True)) - elif hint is False or (hint is None and not current.urgency_hint): - actions.append(nio.PushSetTweak("urgency_hint", False)) + unreads = 0 + highlights = 0 + local_unreads = False - await self.set_pushrule_actions("global", kind, rule_id, actions) + for room in self.models[self.user_id, "rooms"].values(): + unreads += room.unreads + highlights += room.highlights + if room.local_unreads: + local_unreads = True - async def mass_tweak_pushrules_actions(self, *tweaks_kwargs) -> None: - coros = [self.tweak_pushrule_actions(**kws) for kws in tweaks_kwargs] - await asyncio.gather(*coros) + account = self.models["accounts"][self.user_id] + account.set_fields( + total_unread = unreads, + total_highlights = highlights, + local_unreads = local_unreads, + ) - async def remove_pushrule( - self, kind: Union[str, nio.PushRuleKind], rule_id: str, - ) -> None: - """Remove an existing non-builtin pushrule.""" + async def event_is_past(self, ev: Union[nio.Event, Event]) -> bool: + """Return whether an event was created before this client started.""" - kind = nio.PushRuleKind[kind] if isinstance(kind, str) else kind + if not self.first_sync_date: + return True - if (kind.value, rule_id) in self.models[self.user_id, "pushrules"]: - await self.delete_pushrule("global", kind, rule_id) + if isinstance(ev, Event): + return ev.date < self.first_sync_date + date = datetime.fromtimestamp(ev.server_timestamp / 1000) + return date < self.first_sync_date - def _rule_overrides_room(self, rule: PushRule) -> Optional[str]: - override = rule.kind is nio.PushRuleKind.override - one_cnd = len(rule.conditions) == 1 - if not one_cnd: - return None + async def set_room_last_event(self, room_id: str, item: Event) -> None: + """Set the `last_event` for a `Room` using data in our `Event` model. - cnd = nio.PushCondition.from_dict(rule.conditions[0]) - ev_match = isinstance(cnd, nio.PushEventMatch) + The `last_event` is notably displayed in the UI room subtitles. + """ - if override and ev_match and cnd.key == "room_id": - return cnd.pattern + room = self.models[self.user_id, "rooms"][room_id] - return None + if item.date > room.last_event_date: + room.last_event_date = item.date - async def _remove_room_override_rule(self, room_id: str) -> None: - for rule in self.models[self.user_id, "pushrules"].values(): - if self._rule_overrides_room(rule) == room_id: - await self.remove_pushrule(rule.kind, rule.rule_id) + async def lock_room_position(self, room_id: str, lock: bool) -> None: + """Set wheter a room should try to hold its current sort position.""" + room = self.models[self.user_id, "rooms"].get(room_id) - async def room_pushrule_use_default(self, room_id: str) -> None: - await self._remove_room_override_rule(room_id) - await self.remove_pushrule(nio.PushRuleKind.room, room_id) + if not room: + return + if not lock: + room._sort_overrides = {} + return + + for k in ("last_event_date", "unreads", "highlights", "local_unreads"): + room._sort_overrides[k] = getattr(room, k) + + room.notify_change("_sort_overrides") + + + async def register_nio_room( + self, + room: nio.MatrixRoom, + left: bool = False, + force_register_members: bool = False, + ) -> None: + """Register/update a `nio.MatrixRoom` as a `models.items.Room`.""" + + self.ignored_rooms.discard(room.room_id) + + inviter = getattr(room, "inviter", "") or "" + levels = room.power_levels + can_send_state = partial(levels.can_user_send_state, self.user_id) + can_send_msg = partial(levels.can_user_send_message, self.user_id) + + try: + registered = self.models[self.user_id, "rooms"][room.room_id] + except KeyError: + registered = None + sort_overrides = {} + last_event_date = datetime.fromtimestamp(0) + typing_members = [] + local_unreads = False + update_account_unread_counts = True + unverified_devices = ( + False + if isinstance(room, nio.MatrixInvitedRoom) else + self.room_contains_unverified(room.room_id) + ) + else: + sort_overrides = registered._sort_overrides + last_event_date = registered.last_event_date + typing_members = registered.typing_members + local_unreads = registered.local_unreads + update_account_unread_counts = ( + registered.unreads != room.unread_notifications or + registered.highlights != room.unread_highlights + ) + unverified_devices = registered.unverified_devices + + notification_setting = RoomNotificationOverride.UseDefaultSettings + + for rule in self.models[self.user_id, "pushrules"].values(): + overrides = self._rule_overrides_room(rule) == room.room_id + is_room_kind = rule.kind is nio.PushRuleKind.room + room_kind_match = is_room_kind and rule.rule_id == room.room_id + + if overrides and not rule.actions: + notification_setting = RoomNotificationOverride.IgnoreEvents + break + elif overrides: + notification_setting = RoomNotificationOverride.AllEvents + break + elif room_kind_match and not rule.actions: + notification_setting = RoomNotificationOverride.HighlightsOnly + break + elif room_kind_match: + notification_setting = RoomNotificationOverride.AllEvents + break + + pinned = self.backend.settings.RoomList.Pinned + + room_item = Room( + id = room.room_id, + for_account = self.user_id, + given_name = room.name or "", + display_name = room.display_name or "", + avatar_url = room.gen_avatar_url or "", + plain_topic = room.topic or "", + topic = HTML.filter( + utils.plain2html(room.topic or ""), + inline = True, + ), + inviter_id = inviter, + inviter_name = room.user_name(inviter) if inviter else "", + inviter_avatar = + (room.avatar_url(inviter) or "") if inviter else "", + left = left, + + typing_members = typing_members, + + encrypted = room.encrypted, + unverified_devices = unverified_devices, + invite_required = room.join_rule == "invite", + guests_allowed = room.guest_access == "can_join", + + default_power_level = levels.defaults.users_default, + own_power_level = levels.get_user_level(self.user_id), + can_invite = levels.can_user_invite(self.user_id), + can_kick = levels.can_user_kick(self.user_id), + can_redact_all = levels.can_user_redact(self.user_id), + can_send_messages = can_send_msg(), + can_set_name = can_send_state("m.room.name"), + can_set_topic = can_send_state("m.room.topic"), + can_set_avatar = can_send_state("m.room.avatar"), + can_set_encryption = can_send_state("m.room.encryption"), + can_set_join_rules = can_send_state("m.room.join_rules"), + can_set_guest_access = can_send_state("m.room.guest_access"), + can_set_power_levels = can_send_state("m.room.power_levels"), + + last_event_date = last_event_date, + + unreads = room.unread_notifications, + highlights = room.unread_highlights, + local_unreads = local_unreads, + notification_setting = notification_setting, + + lexical_sorting = self.backend.settings.RoomList.lexical_sort, + pinned = room.room_id in pinned.get(self.user_id, []), + + _sort_overrides = sort_overrides, + ) + + self.models[self.user_id, "rooms"][room.room_id] = room_item + + if not registered or force_register_members: + model = self.models[self.user_id, room.room_id, "members"] + + # The members we initially get from lazy sync may be outdated + # and contain members that already left. + # tuple() used to avoid "dict changed size during iteration". + for member_id in tuple(model): + if member_id not in room.users: + await self.remove_member(room, member_id) + + for user_id in room.users: + await self.add_member(room, user_id) + + if update_account_unread_counts: + await self.update_account_unread_counts() + + + async def add_member(self, room: nio.MatrixRoom, user_id: str) -> None: + """Register/update a room member into our models.""" + + room_id = room.room_id + member_model = self.models[self.user_id, room_id, "members"] + member = room.users[user_id] + presence = self.backend.presences.get(user_id, None) + + try: + registered = member_model[user_id] + except KeyError: + last_read_event = self.unassigned_member_last_read_event\ + .pop((room_id, user_id), "") + else: + last_read_event = registered.last_read_event + + member_item = Member( + id = user_id, + display_name = room.user_name(user_id) # disambiguated + if member.display_name else "", + avatar_url = member.avatar_url or "", + typing = user_id in room.typing_users, + ignored = user_id in self.ignored_user_ids, + power_level = member.power_level, + invited = member.invited, + last_read_event = last_read_event, + ) - async def room_pushrule_all_events(self, room_id: str) -> None: - await self._remove_room_override_rule(room_id) - await self.edit_pushrule( - kind = nio.PushRuleKind.room, - rule_id = room_id, - actions = [nio.PushNotify(), nio.PushSetTweak("sound", "default")], - ) + # Associate presence with member, if it exists + if presence: + presence.members[room_id] = member_item + + # And then update presence fields + presence.update_members() + member_model[user_id] = member_item - async def room_pushrule_highlights_only(self, room_id: str) -> None: - await self._remove_room_override_rule(room_id) - await self.edit_pushrule(nio.PushRuleKind.room, room_id, actions=[]) + async def remove_member(self, room: nio.MatrixRoom, user_id: str) -> None: + """Remove a room member from our models.""" - async def room_pushrule_ignore_all(self, room_id: str) -> None: - await self._remove_room_override_rule(room_id) - await self.remove_pushrule(nio.PushRuleKind.room, room_id) + self.models[self.user_id, room.room_id, "members"].pop(user_id, None) - cnd = nio.PushEventMatch("room_id", room_id) - await self.edit_pushrule( - nio.PushRuleKind.override, room_id, conditions=[cnd], actions=[], - ) + room_item = self.models[self.user_id, "rooms"].get(room.room_id) + if room_item: + room_item.unverified_devices = \ + self.room_contains_unverified(room.room_id) - # Functions to register/modify data into models - async def update_account_unread_counts(self) -> None: - """Recalculate total unread notifications/highlights for our account""" + async def get_event_profiles(self, room_id: str, event_id: str) -> None: + """Fetch from network an event's sender, target and remover's profile. - unreads = 0 - highlights = 0 - local_unreads = False + This should be called from QML, see `MatrixClient.get_member_profile`'s + docstring. + """ - for room in self.models[self.user_id, "rooms"].values(): - unreads += room.unreads - highlights += room.highlights + ev: Event = self.models[self.user_id, room_id, "events"][event_id] - if room.local_unreads: - local_unreads = True + if not ev.fetch_profile: + return - account = self.models["accounts"][self.user_id] - account.set_fields( - total_unread = unreads, - total_highlights = highlights, - local_unreads = local_unreads, - ) + get_profile = partial( + self.get_member_profile, room_id, can_fetch_from_network=True, + ) + if not ev.sender_name and not ev.sender_avatar: + sender_name, sender_avatar, _ = await get_profile(ev.sender_id) + ev.set_fields(sender_name=sender_name, sender_avatar=sender_avatar) - async def event_is_past(self, ev: Union[nio.Event, Event]) -> bool: - """Return whether an event was created before this client started.""" + if ev.target_id and not ev.target_name and not ev.target_avatar: + target_name, target_avatar, _ = await get_profile(ev.target_id) + ev.set_fields(target_name=target_name, target_avatar=target_avatar) - if not self.first_sync_date: - return True + if ev.redacter_id and not ev.redacter_name: + redacter_name, _, _ = await get_profile(ev.redacter_id) + ev.redacter_name = redacter_name - if isinstance(ev, Event): - return ev.date < self.first_sync_date + ev.fetch_profile = False - date = datetime.fromtimestamp(ev.server_timestamp / 1000) - return date < self.first_sync_date + async def get_member_profile( + self, room_id: str, user_id: str, can_fetch_from_network: bool = False, + ) -> Tuple[str, str, bool]: + """Return a room member's (display_name, avatar, should_lazy_fetch) - async def set_room_last_event(self, room_id: str, item: Event) -> None: - """Set the `last_event` for a `Room` using data in our `Event` model. + The returned tuple's last element tells whether + `MatrixClient.get_event_profiles()` should be called by QML + with `can_fetch_from_network = True` when appropriate, + e.g. when this message comes in the user's view. - The `last_event` is notably displayed in the UI room subtitles. - """ + If the member isn't found in the room (e.g. they left) and + `can_fetch_from_network` is `True`, their + profile is retrieved using `MatrixClient.get_profile()`. + """ - room = self.models[self.user_id, "rooms"][room_id] + try: + member = self.models[self.user_id, room_id, "members"][user_id] + return (member.display_name, member.avatar_url, False) - if item.date > room.last_event_date: - room.last_event_date = item.date + except KeyError: # e.g. member is not in the room anymore + if not can_fetch_from_network: + return ("", "", True) + try: + info = await self.get_profile(user_id) + return (info.displayname or "", info.avatar_url or "", False) + except MatrixError: + return ("", "", False) - async def lock_room_position(self, room_id: str, lock: bool) -> None: - """Set wheter a room should try to hold its current sort position.""" - room = self.models[self.user_id, "rooms"].get(room_id) + async def get_notification_avatar(self, mxc: str, user_id: str) -> Path: + """Get the path to an avatar for desktop notifications.""" + # TODO: test this function on windows - if not room: - return + if mxc in self.backend.notification_avatar_cache: + return self.backend.notification_avatar_cache[mxc] - if not lock: - room._sort_overrides = {} - return - - for k in ("last_event_date", "unreads", "highlights", "local_unreads"): - room._sort_overrides[k] = getattr(room, k) - - room.notify_change("_sort_overrides") - - - async def register_nio_room( - self, - room: nio.MatrixRoom, - left: bool = False, - force_register_members: bool = False, - ) -> None: - """Register/update a `nio.MatrixRoom` as a `models.items.Room`.""" - - self.ignored_rooms.discard(room.room_id) - - inviter = getattr(room, "inviter", "") or "" - levels = room.power_levels - can_send_state = partial(levels.can_user_send_state, self.user_id) - can_send_msg = partial(levels.can_user_send_message, self.user_id) - - try: - registered = self.models[self.user_id, "rooms"][room.room_id] - except KeyError: - registered = None - sort_overrides = {} - last_event_date = datetime.fromtimestamp(0) - typing_members = [] - local_unreads = False - update_account_unread_counts = True - unverified_devices = ( - False - if isinstance(room, nio.MatrixInvitedRoom) else - self.room_contains_unverified(room.room_id) - ) - else: - sort_overrides = registered._sort_overrides - last_event_date = registered.last_event_date - typing_members = registered.typing_members - local_unreads = registered.local_unreads - update_account_unread_counts = ( - registered.unreads != room.unread_notifications or - registered.highlights != room.unread_highlights - ) - unverified_devices = registered.unverified_devices - - notification_setting = RoomNotificationOverride.UseDefaultSettings - - for rule in self.models[self.user_id, "pushrules"].values(): - overrides = self._rule_overrides_room(rule) == room.room_id - is_room_kind = rule.kind is nio.PushRuleKind.room - room_kind_match = is_room_kind and rule.rule_id == room.room_id - - if overrides and not rule.actions: - notification_setting = RoomNotificationOverride.IgnoreEvents - break - elif overrides: - notification_setting = RoomNotificationOverride.AllEvents - break - elif room_kind_match and not rule.actions: - notification_setting = RoomNotificationOverride.HighlightsOnly - break - elif room_kind_match: - notification_setting = RoomNotificationOverride.AllEvents - break - - pinned = self.backend.settings.RoomList.Pinned - - room_item = Room( - id = room.room_id, - for_account = self.user_id, - given_name = room.name or "", - display_name = room.display_name or "", - avatar_url = room.gen_avatar_url or "", - plain_topic = room.topic or "", - topic = HTML.filter( - utils.plain2html(room.topic or ""), - inline = True, - ), - inviter_id = inviter, - inviter_name = room.user_name(inviter) if inviter else "", - inviter_avatar = - (room.avatar_url(inviter) or "") if inviter else "", - left = left, - - typing_members = typing_members, - - encrypted = room.encrypted, - unverified_devices = unverified_devices, - invite_required = room.join_rule == "invite", - guests_allowed = room.guest_access == "can_join", - - default_power_level = levels.defaults.users_default, - own_power_level = levels.get_user_level(self.user_id), - can_invite = levels.can_user_invite(self.user_id), - can_kick = levels.can_user_kick(self.user_id), - can_redact_all = levels.can_user_redact(self.user_id), - can_send_messages = can_send_msg(), - can_set_name = can_send_state("m.room.name"), - can_set_topic = can_send_state("m.room.topic"), - can_set_avatar = can_send_state("m.room.avatar"), - can_set_encryption = can_send_state("m.room.encryption"), - can_set_join_rules = can_send_state("m.room.join_rules"), - can_set_guest_access = can_send_state("m.room.guest_access"), - can_set_power_levels = can_send_state("m.room.power_levels"), - - last_event_date = last_event_date, - - unreads = room.unread_notifications, - highlights = room.unread_highlights, - local_unreads = local_unreads, - notification_setting = notification_setting, - - lexical_sorting = self.backend.settings.RoomList.lexical_sort, - pinned = room.room_id in pinned.get(self.user_id, []), - - _sort_overrides = sort_overrides, - ) - - self.models[self.user_id, "rooms"][room.room_id] = room_item - - if not registered or force_register_members: - model = self.models[self.user_id, room.room_id, "members"] - - # The members we initially get from lazy sync may be outdated - # and contain members that already left. - # tuple() used to avoid "dict changed size during iteration". - for member_id in tuple(model): - if member_id not in room.users: - await self.remove_member(room, member_id) - - for user_id in room.users: - await self.add_member(room, user_id) - - if update_account_unread_counts: - await self.update_account_unread_counts() - - - async def add_member(self, room: nio.MatrixRoom, user_id: str) -> None: - """Register/update a room member into our models.""" - - room_id = room.room_id - member_model = self.models[self.user_id, room_id, "members"] - member = room.users[user_id] - presence = self.backend.presences.get(user_id, None) - - try: - registered = member_model[user_id] - except KeyError: - last_read_event = self.unassigned_member_last_read_event\ - .pop((room_id, user_id), "") - else: - last_read_event = registered.last_read_event - - member_item = Member( - id = user_id, - display_name = room.user_name(user_id) # disambiguated - if member.display_name else "", - avatar_url = member.avatar_url or "", - typing = user_id in room.typing_users, - ignored = user_id in self.ignored_user_ids, - power_level = member.power_level, - invited = member.invited, - last_read_event = last_read_event, - ) + avatar_size = (48, 48) - # Associate presence with member, if it exists - if presence: - presence.members[room_id] = member_item - - # And then update presence fields - presence.update_members() + avatar_path = await Thumbnail( + cache = self.backend.media_cache, + client_user_id = self.user_id, + mxc = mxc, + title = f"user_{user_id}.notification", + wanted_size = avatar_size, + ).get() - member_model[user_id] = member_item + image_data = None + create = False + async with utils.aiopen(avatar_path, "rb") as file: + if await utils.is_svg(file): + await file.seek(0, 0) - async def remove_member(self, room: nio.MatrixRoom, user_id: str) -> None: - """Remove a room member from our models.""" + create = True + image_data = cairosvg.svg2png( + bytestring = await file.read(), + parent_width = avatar_size[0], + parent_height = avatar_size[1], + ) + else: + await file.seek(0, 0) + image_data = await file.read() - self.models[self.user_id, room.room_id, "members"].pop(user_id, None) + pil_image = PILImage.open(io.BytesIO(image_data)) - room_item = self.models[self.user_id, "rooms"].get(room.room_id) - - if room_item: - room_item.unverified_devices = \ - self.room_contains_unverified(room.room_id) - - - async def get_event_profiles(self, room_id: str, event_id: str) -> None: - """Fetch from network an event's sender, target and remover's profile. - - This should be called from QML, see `MatrixClient.get_member_profile`'s - docstring. - """ - - ev: Event = self.models[self.user_id, room_id, "events"][event_id] - - if not ev.fetch_profile: - return - - get_profile = partial( - self.get_member_profile, room_id, can_fetch_from_network=True, - ) - - if not ev.sender_name and not ev.sender_avatar: - sender_name, sender_avatar, _ = await get_profile(ev.sender_id) - ev.set_fields(sender_name=sender_name, sender_avatar=sender_avatar) - - if ev.target_id and not ev.target_name and not ev.target_avatar: - target_name, target_avatar, _ = await get_profile(ev.target_id) - ev.set_fields(target_name=target_name, target_avatar=target_avatar) - - if ev.redacter_id and not ev.redacter_name: - redacter_name, _, _ = await get_profile(ev.target_id) - ev.redacter_name = redacter_name - - ev.fetch_profile = False - - - async def get_member_profile( - self, room_id: str, user_id: str, can_fetch_from_network: bool = False, - ) -> Tuple[str, str, bool]: - """Return a room member's (display_name, avatar, should_lazy_fetch) - - The returned tuple's last element tells whether - `MatrixClient.get_event_profiles()` should be called by QML - with `can_fetch_from_network = True` when appropriate, - e.g. when this message comes in the user's view. - - If the member isn't found in the room (e.g. they left) and - `can_fetch_from_network` is `True`, their - profile is retrieved using `MatrixClient.get_profile()`. - """ - - try: - member = self.models[self.user_id, room_id, "members"][user_id] - return (member.display_name, member.avatar_url, False) - - except KeyError: # e.g. member is not in the room anymore - if not can_fetch_from_network: - return ("", "", True) - - try: - info = await self.get_profile(user_id) - return (info.displayname or "", info.avatar_url or "", False) - except MatrixError: - return ("", "", False) - - - async def get_notification_avatar(self, mxc: str, user_id: str) -> Path: - """Get the path to an avatar for desktop notifications.""" - # TODO: test this function on windows - - if mxc in self.backend.notification_avatar_cache: - return self.backend.notification_avatar_cache[mxc] - - avatar_size = (48, 48) - - avatar_path = await Thumbnail( - cache = self.backend.media_cache, - client_user_id = self.user_id, - mxc = mxc, - title = f"user_{user_id}.notification", - wanted_size = avatar_size, - ).get() - - image_data = None - create = False - - async with utils.aiopen(avatar_path, "rb") as file: - if await utils.is_svg(file): - await file.seek(0, 0) - - create = True - image_data = cairosvg.svg2png( - bytestring = await file.read(), - parent_width = avatar_size[0], - parent_height = avatar_size[1], - ) - else: - await file.seek(0, 0) - image_data = await file.read() - - pil_image = PILImage.open(io.BytesIO(image_data)) - - if pil_image.size != avatar_size: - create = True - pil_image.thumbnail(avatar_size) - - if IS_WINDOWS and pil_image.format != "ICO": - create = True - - if not create: - self.backend.notification_avatar_cache[mxc] = avatar_path - return avatar_path - - out = io.BytesIO() - - if IS_WINDOWS: - pil_image.save(out, "ICO", sizes=[avatar_size]) - else: - pil_image.save(out, "PNG") - - thumb = await Thumbnail.from_bytes( - cache = self.backend.media_cache, - client_user_id = self.user_id, - mxc = mxc, - filename = f"user_{user_id}.notification", - overwrite = True, - data = out.getvalue(), - wanted_size = avatar_size, - ) - - path = await thumb.get() - self.backend.notification_avatar_cache[mxc] = path - return path - - - async def register_nio_event( - self, - room: nio.MatrixRoom, - ev: Union[nio.Event, nio.BadEvent], - event_id: str = "", - override_fetch_profile: Optional[bool] = None, - **fields, - ) -> Event: - """Register/update a `nio.Event` as a `models.items.Event` object.""" - - await self.register_nio_room(room) - - sender_name, sender_avatar, must_fetch_sender = \ - await self.get_member_profile(room.room_id, ev.sender) - - target_id = getattr(ev, "state_key", "") or "" - - target_name, target_avatar, must_fetch_target = \ - await self.get_member_profile(room.room_id, target_id) \ - if target_id else ("", "", False) - - content = fields.get("content", "").strip() - - if content and "inline_content" not in fields: - fields["inline_content"] = HTML.filter(content, inline=True) - - event_model = self.models[self.user_id, room.room_id, "events"] - - try: - registered = event_model[event_id or ev.event_id] - except KeyError: - last_read_by = self.unassigned_event_last_read_by.pop( - event_id or ev.event_id, {}, - ) - else: - last_read_by = registered.last_read_by - - # Create Event ModelItem - - item = Event( - id = event_id or ev.event_id, - event_id = ev.event_id, - event_type = type(ev), - source = ev, - date = datetime.fromtimestamp(ev.server_timestamp / 1000), - sender_id = ev.sender, - sender_name = sender_name, - sender_avatar = sender_avatar, - target_id = target_id, - target_name = target_name, - target_avatar = target_avatar, - links = Event.parse_links(content), - last_read_by = last_read_by, - read_by_count = len(last_read_by), - - fetch_profile = - (must_fetch_sender or must_fetch_target) - if override_fetch_profile is None else - override_fetch_profile, - - **fields, - ) - - # Add the Event to model - - model = self.models[self.user_id, room.room_id, "events"] - - tx_id = ev.source.get("content", {}).get( - f"{__reverse_dns__}.transaction_id", - ) - from_us = ev.sender in self.backend.clients - - if from_us and tx_id and f"echo-{tx_id}" in model: - item.id = f"echo-{tx_id}" - self.event_to_echo_ids[ev.event_id] = item.id - - model[item.id] = item - await self.set_room_last_event(room.room_id, item) - - if from_us: - return item - - if await self.event_is_past(ev): - await self.update_account_unread_counts() - return item - - if self.backend.settings.RoomList.local_unread_markers: - room_item = self.models[self.user_id, "rooms"][room.room_id] - room_item.local_unreads = True - await self.update_account_unread_counts() - - # Alerts & notifications - - name = self.models["accounts"][self.user_id].display_name - nio_rule = self.push_rules.global_rules.matching_rule(ev, room, name) - - if not nio_rule: - return item - - model = self.models[self.user_id, "pushrules"] - rule = model[nio_rule.kind.value, nio_rule.id] - - if not rule.notify and not rule.highlight: - return item - - if must_fetch_sender: - sender_name, sender_avatar, _ = await self.get_member_profile( - room.room_id, ev.sender, can_fetch_from_network=True, - ) - item.set_fields( - sender_name=sender_name, sender_avatar=sender_avatar, - ) - - sender = item.sender_name or item.sender_id - is_linux = platform.system() == "Linux" - use_html = is_linux and self.backend.settings.Notifications.use_html - content = item.inline_content if use_html else item.plain_content - - if isinstance(ev, nio.RoomMessageEmote) and use_html: - body = f"{sender} {content}" - elif isinstance(ev, nio.RoomMessageEmote): - body = f"{sender} {content}" - elif not isinstance(ev, nio.RoomMessage): - body = content.replace( - "%1", item.sender_name or item.sender_id, - ).replace( - "%2", item.target_name or item.target_id, - ) - elif room.member_count == 2 and room.display_name == sender: - body = content - else: - body = f"{sender}: {content}" - - NotificationRequested( - id = item.id, - critical = rule.highlight, - bubble = rule.bubble, - sound = rule.sound, - urgency_hint = rule.urgency_hint, - - title = room.display_name, - body = body.replace(" ⏎ ", "
") - .replace(" ⏎⏎ ", f"
{'─' * 24}
"), - - image = await self.get_notification_avatar( - mxc=item.sender_avatar, user_id=item.sender_id, - ) if item.sender_avatar else "", - ) - - return item + if pil_image.size != avatar_size: + create = True + pil_image.thumbnail(avatar_size) + + if IS_WINDOWS and pil_image.format != "ICO": + create = True + + if not create: + self.backend.notification_avatar_cache[mxc] = avatar_path + return avatar_path + + out = io.BytesIO() + + if IS_WINDOWS: + pil_image.save(out, "ICO", sizes=[avatar_size]) + else: + pil_image.save(out, "PNG") + + thumb = await Thumbnail.from_bytes( + cache = self.backend.media_cache, + client_user_id = self.user_id, + mxc = mxc, + filename = f"user_{user_id}.notification", + overwrite = True, + data = out.getvalue(), + wanted_size = avatar_size, + ) + + path = await thumb.get() + self.backend.notification_avatar_cache[mxc] = path + return path + + + async def register_nio_event( + self, + room: nio.MatrixRoom, + ev: Union[nio.Event, nio.BadEvent], + event_id: str = "", + override_fetch_profile: Optional[bool] = None, + **fields, + ) -> Event: + """Register/update a `nio.Event` as a `models.items.Event` object.""" + + await self.register_nio_room(room) + + sender_name, sender_avatar, must_fetch_sender = \ + await self.get_member_profile(room.room_id, ev.sender) + + target_id = getattr(ev, "state_key", "") or "" + + target_name, target_avatar, must_fetch_target = \ + await self.get_member_profile(room.room_id, target_id) \ + if target_id else ("", "", False) + + content = fields.get("content", "").strip() + + if content and "inline_content" not in fields: + fields["inline_content"] = HTML.filter(content, inline=True) + + event_model = self.models[self.user_id, room.room_id, "events"] + + try: + registered = event_model[event_id or ev.event_id] + except KeyError: + last_read_by = self.unassigned_event_last_read_by.pop( + event_id or ev.event_id, {}, + ) + else: + last_read_by = registered.last_read_by + + # Create Event ModelItem + + item = Event( + id = event_id or ev.event_id, + event_id = ev.event_id, + event_type = type(ev), + source = ev, + date = datetime.fromtimestamp(ev.server_timestamp / 1000), + sender_id = ev.sender, + sender_name = sender_name, + sender_avatar = sender_avatar, + target_id = target_id, + target_name = target_name, + target_avatar = target_avatar, + links = Event.parse_links(content), + last_read_by = last_read_by, + read_by_count = len(last_read_by), + + fetch_profile = + (must_fetch_sender or must_fetch_target) + if override_fetch_profile is None else + override_fetch_profile, + + **fields, + ) + + # Add the Event to model + + model = self.models[self.user_id, room.room_id, "events"] + + tx_id = ev.source.get("content", {}).get( + f"{__reverse_dns__}.transaction_id", + ) + from_us = ev.sender in self.backend.clients + + if from_us and tx_id and f"echo-{tx_id}" in model: + item.id = f"echo-{tx_id}" + self.event_to_echo_ids[ev.event_id] = item.id + + model[item.id] = item + await self.set_room_last_event(room.room_id, item) + + if from_us: + return item + + if await self.event_is_past(ev): + await self.update_account_unread_counts() + return item + + if self.backend.settings.RoomList.local_unread_markers: + room_item = self.models[self.user_id, "rooms"][room.room_id] + room_item.local_unreads = True + await self.update_account_unread_counts() + + # Alerts & notifications + + name = self.models["accounts"][self.user_id].display_name + nio_rule = self.push_rules.global_rules.matching_rule(ev, room, name) + + if not nio_rule: + return item + + model = self.models[self.user_id, "pushrules"] + rule = model[nio_rule.kind.value, nio_rule.id] + + if not rule.notify and not rule.highlight: + return item + + if must_fetch_sender: + sender_name, sender_avatar, _ = await self.get_member_profile( + room.room_id, ev.sender, can_fetch_from_network=True, + ) + item.set_fields( + sender_name=sender_name, sender_avatar=sender_avatar, + ) + + sender = item.sender_name or item.sender_id + is_linux = platform.system() == "Linux" + use_html = is_linux and self.backend.settings.Notifications.use_html + content = item.inline_content if use_html else item.plain_content + + if isinstance(ev, nio.RoomMessageEmote) and use_html: + body = f"{sender} {content}" + elif isinstance(ev, nio.RoomMessageEmote): + body = f"{sender} {content}" + elif not isinstance(ev, nio.RoomMessage): + body = content.replace( + "%1", item.sender_name or item.sender_id, + ).replace( + "%2", item.target_name or item.target_id, + ) + elif room.member_count == 2 and room.display_name == sender: + body = content + else: + body = f"{sender}: {content}" + + NotificationRequested( + id = item.id, + critical = rule.highlight, + bubble = rule.bubble, + sound = rule.sound, + urgency_hint = rule.urgency_hint, + + title = room.display_name, + body = body.replace(" ⏎ ", "
") + .replace(" ⏎⏎ ", f"
{'─' * 24}
"), + + image = await self.get_notification_avatar( + mxc=item.sender_avatar, user_id=item.sender_id, + ) if item.sender_avatar else "", + ) + + return item diff --git a/src/backend/media_cache.py b/src/backend/media_cache.py index b6726181..854fbd8a 100644 --- a/src/backend/media_cache.py +++ b/src/backend/media_cache.py @@ -24,354 +24,354 @@ from .models.model import Model from .utils import Size, atomic_write, current_task if TYPE_CHECKING: - from .backend import Backend + from .backend import Backend if sys.version_info < (3, 8): - import pyfastcopy # noqa + import pyfastcopy # noqa -CONCURRENT_DOWNLOADS_LIMIT = asyncio.BoundedSemaphore(8) +CONCURRENT_DOWNLOADS_LIMIT = asyncio.BoundedSemaphore(8) ACCESS_LOCKS: DefaultDict[str, asyncio.Lock] = DefaultDict(asyncio.Lock) @dataclass class MediaCache: - """Matrix downloaded media cache.""" + """Matrix downloaded media cache.""" - backend: "Backend" = field() - base_dir: Path = field() + backend: "Backend" = field() + base_dir: Path = field() - def __post_init__(self) -> None: - self.thumbs_dir = self.base_dir / "thumbnails" - self.downloads_dir = self.base_dir / "downloads" + def __post_init__(self) -> None: + self.thumbs_dir = self.base_dir / "thumbnails" + self.downloads_dir = self.base_dir / "downloads" - self.thumbs_dir.mkdir(parents=True, exist_ok=True) - self.downloads_dir.mkdir(parents=True, exist_ok=True) + self.thumbs_dir.mkdir(parents=True, exist_ok=True) + self.downloads_dir.mkdir(parents=True, exist_ok=True) - async def get_media(self, *args) -> Path: - """Return `Media(self, ...).get()`'s result. Intended for QML.""" - return await Media(self, *args).get() + async def get_media(self, *args) -> Path: + """Return `Media(self, ...).get()`'s result. Intended for QML.""" + return await Media(self, *args).get() - async def get_thumbnail(self, width: float, height: float, *args) -> Path: - """Return `Thumbnail(self, ...).get()`'s result. Intended for QML.""" - # QML sometimes pass float sizes, which matrix API doesn't like. - size = (round(width), round(height)) - return await Thumbnail( - self, *args, wanted_size=size, # type: ignore - ).get() + async def get_thumbnail(self, width: float, height: float, *args) -> Path: + """Return `Thumbnail(self, ...).get()`'s result. Intended for QML.""" + # QML sometimes pass float sizes, which matrix API doesn't like. + size = (round(width), round(height)) + return await Thumbnail( + self, *args, wanted_size=size, # type: ignore + ).get() @dataclass class Media: - """A matrix media file that is downloaded or has yet to be. + """A matrix media file that is downloaded or has yet to be. - If the `room_id` is not set, no `Transfer` model item will be registered - while this media is being downloaded. - """ + If the `room_id` is not set, no `Transfer` model item will be registered + while this media is being downloaded. + """ - cache: "MediaCache" = field() - client_user_id: str = field() - mxc: str = field() - title: str = field() - room_id: Optional[str] = None - filesize: Optional[int] = None - crypt_dict: Optional[Dict[str, Any]] = field(default=None, repr=False) + cache: "MediaCache" = field() + client_user_id: str = field() + mxc: str = field() + title: str = field() + room_id: Optional[str] = None + filesize: Optional[int] = None + crypt_dict: Optional[Dict[str, Any]] = field(default=None, repr=False) - def __post_init__(self) -> None: - self.mxc = re.sub(r"#auto$", "", self.mxc) + def __post_init__(self) -> None: + self.mxc = re.sub(r"#auto$", "", self.mxc) - if not re.match(r"^mxc://.+/.+", self.mxc): - raise ValueError(f"Invalid mxc URI: {self.mxc}") + if not re.match(r"^mxc://.+/.+", self.mxc): + raise ValueError(f"Invalid mxc URI: {self.mxc}") - @property - def local_path(self) -> Path: - """The path where the file either exists or should be downloaded. + @property + def local_path(self) -> Path: + """The path where the file either exists or should be downloaded. - The returned paths are in this form: - ``` - // - _.` - ``` - e.g. `~/.cache/moment/downloads/matrix.org/foo_Hm24ar11i768b0el.png`. - """ + The returned paths are in this form: + ``` + // + _.` + ``` + e.g. `~/.cache/moment/downloads/matrix.org/foo_Hm24ar11i768b0el.png`. + """ - parsed = urlparse(self.mxc) - mxc_id = parsed.path.lstrip("/") - title = Path(self.title) - filename = f"{title.stem}_{mxc_id}{title.suffix}" - return self.cache.downloads_dir / parsed.netloc / filename + parsed = urlparse(self.mxc) + mxc_id = parsed.path.lstrip("/") + title = Path(self.title) + filename = f"{title.stem}_{mxc_id}{title.suffix}" + return self.cache.downloads_dir / parsed.netloc / filename - async def get(self) -> Path: - """Return the cached file's path, downloading it first if needed.""" + async def get(self) -> Path: + """Return the cached file's path, downloading it first if needed.""" - async with ACCESS_LOCKS[self.mxc]: - try: - return await self.get_local() - except FileNotFoundError: - return await self.create() + async with ACCESS_LOCKS[self.mxc]: + try: + return await self.get_local() + except FileNotFoundError: + return await self.create() - async def get_local(self) -> Path: - """Return a cached local existing path for this media or raise.""" + async def get_local(self) -> Path: + """Return a cached local existing path for this media or raise.""" - if not self.local_path.exists(): - raise FileNotFoundError() + if not self.local_path.exists(): + raise FileNotFoundError() - return self.local_path + return self.local_path - async def create(self) -> Path: - """Download and cache the media file to disk.""" + async def create(self) -> Path: + """Download and cache the media file to disk.""" - async with CONCURRENT_DOWNLOADS_LIMIT: - data = await self._get_remote_data() + async with CONCURRENT_DOWNLOADS_LIMIT: + data = await self._get_remote_data() - self.local_path.parent.mkdir(parents=True, exist_ok=True) + self.local_path.parent.mkdir(parents=True, exist_ok=True) - async with atomic_write(self.local_path, binary=True) as (file, done): - await file.write(data) - done() + async with atomic_write(self.local_path, binary=True) as (file, done): + await file.write(data) + done() - if type(self) is Media: - for event in self.cache.backend.mxc_events[self.mxc]: - event.media_local_path = self.local_path + if type(self) is Media: + for event in self.cache.backend.mxc_events[self.mxc]: + event.media_local_path = self.local_path - return self.local_path + return self.local_path - async def _get_remote_data(self) -> bytes: - """Return the file's data from the matrix server, decrypt if needed.""" + async def _get_remote_data(self) -> bytes: + """Return the file's data from the matrix server, decrypt if needed.""" - client = self.cache.backend.clients[self.client_user_id] + client = self.cache.backend.clients[self.client_user_id] - transfer: Optional[Transfer] = None - model: Optional[Model] = None + transfer: Optional[Transfer] = None + model: Optional[Model] = None - if self.room_id: - model = self.cache.backend.models[self.room_id, "transfers"] - transfer = Transfer( - id = uuid4(), - is_upload = False, - filepath = self.local_path, - total_size = self.filesize or 0, - status = TransferStatus.Transfering, - ) - assert model is not None - client.transfer_tasks[transfer.id] = current_task() # type: ignore - model[str(transfer.id)] = transfer + if self.room_id: + model = self.cache.backend.models[self.room_id, "transfers"] + transfer = Transfer( + id = uuid4(), + is_upload = False, + filepath = self.local_path, + total_size = self.filesize or 0, + status = TransferStatus.Transfering, + ) + assert model is not None + client.transfer_tasks[transfer.id] = current_task() # type: ignore + model[str(transfer.id)] = transfer - try: - parsed = urlparse(self.mxc) - resp = await client.download( - server_name = parsed.netloc, - media_id = parsed.path.lstrip("/"), - ) - except (nio.TransferCancelledError, asyncio.CancelledError): - if transfer and model: - del model[str(transfer.id)] - del client.transfer_tasks[transfer.id] - raise + try: + parsed = urlparse(self.mxc) + resp = await client.download( + server_name = parsed.netloc, + media_id = parsed.path.lstrip("/"), + ) + except (nio.TransferCancelledError, asyncio.CancelledError): + if transfer and model: + del model[str(transfer.id)] + del client.transfer_tasks[transfer.id] + raise - if transfer and model: - del model[str(transfer.id)] - del client.transfer_tasks[transfer.id] + if transfer and model: + del model[str(transfer.id)] + del client.transfer_tasks[transfer.id] - return await self._decrypt(resp.body) + return await self._decrypt(resp.body) - async def _decrypt(self, data: bytes) -> bytes: - """Decrypt an encrypted file's data.""" + async def _decrypt(self, data: bytes) -> bytes: + """Decrypt an encrypted file's data.""" - if not self.crypt_dict: - return data + if not self.crypt_dict: + return data - func = functools.partial( - nio.crypto.attachments.decrypt_attachment, - data, - self.crypt_dict["key"]["k"], - self.crypt_dict["hashes"]["sha256"], - self.crypt_dict["iv"], - ) + func = functools.partial( + nio.crypto.attachments.decrypt_attachment, + data, + self.crypt_dict["key"]["k"], + self.crypt_dict["hashes"]["sha256"], + self.crypt_dict["iv"], + ) - # Run in a separate thread - return await asyncio.get_event_loop().run_in_executor(None, func) + # Run in a separate thread + return await asyncio.get_event_loop().run_in_executor(None, func) - @classmethod - async def from_existing_file( - cls, - cache: "MediaCache", - client_user_id: str, - mxc: str, - existing: Path, - overwrite: bool = False, - **kwargs, - ) -> "Media": - """Copy an existing file to cache and return a `Media` for it.""" + @classmethod + async def from_existing_file( + cls, + cache: "MediaCache", + client_user_id: str, + mxc: str, + existing: Path, + overwrite: bool = False, + **kwargs, + ) -> "Media": + """Copy an existing file to cache and return a `Media` for it.""" - media = cls( - cache = cache, - client_user_id = client_user_id, - mxc = mxc, - title = existing.name, - filesize = existing.stat().st_size, - **kwargs, - ) - media.local_path.parent.mkdir(parents=True, exist_ok=True) + media = cls( + cache = cache, + client_user_id = client_user_id, + mxc = mxc, + title = existing.name, + filesize = existing.stat().st_size, + **kwargs, + ) + media.local_path.parent.mkdir(parents=True, exist_ok=True) - if not media.local_path.exists() or overwrite: - func = functools.partial(shutil.copy, existing, media.local_path) - await asyncio.get_event_loop().run_in_executor(None, func) + if not media.local_path.exists() or overwrite: + func = functools.partial(shutil.copy, existing, media.local_path) + await asyncio.get_event_loop().run_in_executor(None, func) - return media + return media - @classmethod - async def from_bytes( - cls, - cache: "MediaCache", - client_user_id: str, - mxc: str, - filename: str, - data: bytes, - overwrite: bool = False, - **kwargs, - ) -> "Media": - """Create a cached file from bytes data and return a `Media` for it.""" + @classmethod + async def from_bytes( + cls, + cache: "MediaCache", + client_user_id: str, + mxc: str, + filename: str, + data: bytes, + overwrite: bool = False, + **kwargs, + ) -> "Media": + """Create a cached file from bytes data and return a `Media` for it.""" - media = cls( - cache, client_user_id, mxc, filename, filesize=len(data), **kwargs, - ) - media.local_path.parent.mkdir(parents=True, exist_ok=True) + media = cls( + cache, client_user_id, mxc, filename, filesize=len(data), **kwargs, + ) + media.local_path.parent.mkdir(parents=True, exist_ok=True) - if not media.local_path.exists() or overwrite: - path = media.local_path + if not media.local_path.exists() or overwrite: + path = media.local_path - async with atomic_write(path, binary=True) as (file, done): - await file.write(data) - done() + async with atomic_write(path, binary=True) as (file, done): + await file.write(data) + done() - return media + return media @dataclass class Thumbnail(Media): - """A matrix media's thumbnail, which is downloaded or has yet to be.""" + """A matrix media's thumbnail, which is downloaded or has yet to be.""" - wanted_size: Size = (800, 600) + wanted_size: Size = (800, 600) - server_size: Optional[Size] = field(init=False, repr=False, default=None) + server_size: Optional[Size] = field(init=False, repr=False, default=None) - @staticmethod - def normalize_size(size: Size) -> Size: - """Return standard `(width, height)` matrix thumbnail dimensions. + @staticmethod + def normalize_size(size: Size) -> Size: + """Return standard `(width, height)` matrix thumbnail dimensions. - The Matrix specification defines a few standard thumbnail dimensions - for homeservers to store and return: 32x32, 96x96, 320x240, 640x480, - and 800x600. + The Matrix specification defines a few standard thumbnail dimensions + for homeservers to store and return: 32x32, 96x96, 320x240, 640x480, + and 800x600. - This method returns the best matching size for a `size` without - upscaling, e.g. passing `(641, 480)` will return `(800, 600)`. - """ + This method returns the best matching size for a `size` without + upscaling, e.g. passing `(641, 480)` will return `(800, 600)`. + """ - if size[0] > 640 or size[1] > 480: - return (800, 600) + if size[0] > 640 or size[1] > 480: + return (800, 600) - if size[0] > 320 or size[1] > 240: - return (640, 480) + if size[0] > 320 or size[1] > 240: + return (640, 480) - if size[0] > 96 or size[1] > 96: - return (320, 240) + if size[0] > 96 or size[1] > 96: + return (320, 240) - if size[0] > 32 or size[1] > 32: - return (96, 96) + if size[0] > 32 or size[1] > 32: + return (96, 96) - return (32, 32) + return (32, 32) - @property - def local_path(self) -> Path: - """The path where the thumbnail either exists or should be downloaded. + @property + def local_path(self) -> Path: + """The path where the thumbnail either exists or should be downloaded. - The returned paths are in this form: - ``` - /// - _.` - ``` - e.g. - `~/.cache/moment/thumbnails/matrix.org/32x32/foo_Hm24ar11i768b0el.png`. - """ + The returned paths are in this form: + ``` + /// + _.` + ``` + e.g. + `~/.cache/moment/thumbnails/matrix.org/32x32/foo_Hm24ar11i768b0el.png`. + """ - size = self.normalize_size(self.server_size or self.wanted_size) - size_dir = f"{size[0]}x{size[1]}" + size = self.normalize_size(self.server_size or self.wanted_size) + size_dir = f"{size[0]}x{size[1]}" - parsed = urlparse(self.mxc) - mxc_id = parsed.path.lstrip("/") - title = Path(self.title) - filename = f"{title.stem}_{mxc_id}{title.suffix}" + parsed = urlparse(self.mxc) + mxc_id = parsed.path.lstrip("/") + title = Path(self.title) + filename = f"{title.stem}_{mxc_id}{title.suffix}" - return self.cache.thumbs_dir / parsed.netloc / size_dir / filename + return self.cache.thumbs_dir / parsed.netloc / size_dir / filename - async def get_local(self) -> Path: - """Return an existing thumbnail path or raise `FileNotFoundError`. + async def get_local(self) -> Path: + """Return an existing thumbnail path or raise `FileNotFoundError`. - If we have a bigger size thumbnail downloaded than the `wanted_size` - for the media, return it instead of asking the server for a - smaller thumbnail. - """ + If we have a bigger size thumbnail downloaded than the `wanted_size` + for the media, return it instead of asking the server for a + smaller thumbnail. + """ - if self.local_path.exists(): - return self.local_path + if self.local_path.exists(): + return self.local_path - try_sizes = ((32, 32), (96, 96), (320, 240), (640, 480), (800, 600)) - parts = list(self.local_path.parts) - size = self.normalize_size(self.server_size or self.wanted_size) + try_sizes = ((32, 32), (96, 96), (320, 240), (640, 480), (800, 600)) + parts = list(self.local_path.parts) + size = self.normalize_size(self.server_size or self.wanted_size) - for width, height in try_sizes: - if width < size[0] or height < size[1]: - continue + for width, height in try_sizes: + if width < size[0] or height < size[1]: + continue - parts[-2] = f"{width}x{height}" - path = Path("/".join(parts)) + parts[-2] = f"{width}x{height}" + path = Path("/".join(parts)) - if path.exists(): - return path + if path.exists(): + return path - raise FileNotFoundError() + raise FileNotFoundError() - async def _get_remote_data(self) -> bytes: - """Return the (decrypted) media file's content from the server.""" + async def _get_remote_data(self) -> bytes: + """Return the (decrypted) media file's content from the server.""" - parsed = urlparse(self.mxc) - client = self.cache.backend.clients[self.client_user_id] + parsed = urlparse(self.mxc) + client = self.cache.backend.clients[self.client_user_id] - if self.crypt_dict: - # Matrix makes encrypted thumbs only available through the download - # end-point, not the thumbnail one - resp = await client.download( - server_name = parsed.netloc, - media_id = parsed.path.lstrip("/"), - ) - else: - resp = await client.thumbnail( - server_name = parsed.netloc, - media_id = parsed.path.lstrip("/"), - width = self.wanted_size[0], - height = self.wanted_size[1], - ) + if self.crypt_dict: + # Matrix makes encrypted thumbs only available through the download + # end-point, not the thumbnail one + resp = await client.download( + server_name = parsed.netloc, + media_id = parsed.path.lstrip("/"), + ) + else: + resp = await client.thumbnail( + server_name = parsed.netloc, + media_id = parsed.path.lstrip("/"), + width = self.wanted_size[0], + height = self.wanted_size[1], + ) - decrypted = await self._decrypt(resp.body) + decrypted = await self._decrypt(resp.body) - with io.BytesIO(decrypted) as img: - # The server may return a thumbnail bigger than what we asked for - self.server_size = PILImage.open(img).size + with io.BytesIO(decrypted) as img: + # The server may return a thumbnail bigger than what we asked for + self.server_size = PILImage.open(img).size - return decrypted + return decrypted diff --git a/src/backend/models/filters.py b/src/backend/models/filters.py index 7162250c..05ada8b6 100644 --- a/src/backend/models/filters.py +++ b/src/backend/models/filters.py @@ -2,7 +2,7 @@ # SPDX-License-Identifier: LGPL-3.0-or-later from typing import ( - TYPE_CHECKING, Any, Callable, Collection, Dict, List, Optional, Tuple, + TYPE_CHECKING, Any, Callable, Collection, Dict, List, Optional, Tuple, ) from . import SyncId @@ -10,185 +10,185 @@ from .model import Model from .proxy import ModelProxy if TYPE_CHECKING: - from .model_item import ModelItem + from .model_item import ModelItem class ModelFilter(ModelProxy): - """Filter data from one or more source models.""" + """Filter data from one or more source models.""" - def __init__(self, sync_id: SyncId) -> None: - self.filtered_out: Dict[Tuple[Optional[SyncId], str], "ModelItem"] = {} - self.items_changed_callbacks: List[Callable[[], None]] = [] - super().__init__(sync_id) + def __init__(self, sync_id: SyncId) -> None: + self.filtered_out: Dict[Tuple[Optional[SyncId], str], "ModelItem"] = {} + self.items_changed_callbacks: List[Callable[[], None]] = [] + super().__init__(sync_id) - def accept_item(self, item: "ModelItem") -> bool: - """Return whether an item should be present or filtered out.""" - return True + def accept_item(self, item: "ModelItem") -> bool: + """Return whether an item should be present or filtered out.""" + return True - def source_item_set( - self, - source: Model, - key, - value: "ModelItem", - _changed_fields: Optional[Dict[str, Any]] = None, - ) -> None: - with self.write_lock: - if self.accept_source(source): - value = self.convert_item(value) + def source_item_set( + self, + source: Model, + key, + value: "ModelItem", + _changed_fields: Optional[Dict[str, Any]] = None, + ) -> None: + with self.write_lock: + if self.accept_source(source): + value = self.convert_item(value) - if self.accept_item(value): - self.__setitem__( - (source.sync_id, key), value, _changed_fields, - ) - self.filtered_out.pop((source.sync_id, key), None) - else: - self.filtered_out[source.sync_id, key] = value - self.pop((source.sync_id, key), None) + if self.accept_item(value): + self.__setitem__( + (source.sync_id, key), value, _changed_fields, + ) + self.filtered_out.pop((source.sync_id, key), None) + else: + self.filtered_out[source.sync_id, key] = value + self.pop((source.sync_id, key), None) - for callback in self.items_changed_callbacks: - callback() + for callback in self.items_changed_callbacks: + callback() - def source_item_deleted(self, source: Model, key) -> None: - with self.write_lock: - if self.accept_source(source): - try: - del self[source.sync_id, key] - except KeyError: - del self.filtered_out[source.sync_id, key] + def source_item_deleted(self, source: Model, key) -> None: + with self.write_lock: + if self.accept_source(source): + try: + del self[source.sync_id, key] + except KeyError: + del self.filtered_out[source.sync_id, key] - for callback in self.items_changed_callbacks: - callback() + for callback in self.items_changed_callbacks: + callback() - def source_cleared(self, source: Model) -> None: - with self.write_lock: - if self.accept_source(source): - for source_sync_id, key in self.copy(): - if source_sync_id == source.sync_id: - try: - del self[source.sync_id, key] - except KeyError: - del self.filtered_out[source.sync_id, key] + def source_cleared(self, source: Model) -> None: + with self.write_lock: + if self.accept_source(source): + for source_sync_id, key in self.copy(): + if source_sync_id == source.sync_id: + try: + del self[source.sync_id, key] + except KeyError: + del self.filtered_out[source.sync_id, key] - for callback in self.items_changed_callbacks: - callback() + for callback in self.items_changed_callbacks: + callback() - def refilter( - self, - only_if: Optional[Callable[["ModelItem"], bool]] = None, - ) -> None: - """Recheck every item to decide if they should be filtered out.""" + def refilter( + self, + only_if: Optional[Callable[["ModelItem"], bool]] = None, + ) -> None: + """Recheck every item to decide if they should be filtered out.""" - with self.write_lock: - take_out = [] - bring_back = [] + with self.write_lock: + take_out = [] + bring_back = [] - for key, item in sorted(self.items(), key=lambda kv: kv[1]): - if only_if and not only_if(item): - continue + for key, item in sorted(self.items(), key=lambda kv: kv[1]): + if only_if and not only_if(item): + continue - if not self.accept_item(item): - take_out.append(key) + if not self.accept_item(item): + take_out.append(key) - for key, item in self.filtered_out.items(): - if only_if and not only_if(item): - continue + for key, item in self.filtered_out.items(): + if only_if and not only_if(item): + continue - if self.accept_item(item): - bring_back.append(key) + if self.accept_item(item): + bring_back.append(key) - with self.batch_remove(): - for key in take_out: - self.filtered_out[key] = self.pop(key) + with self.batch_remove(): + for key in take_out: + self.filtered_out[key] = self.pop(key) - for key in bring_back: - self[key] = self.filtered_out.pop(key) + for key in bring_back: + self[key] = self.filtered_out.pop(key) - if take_out or bring_back: - for callback in self.items_changed_callbacks: - callback() + if take_out or bring_back: + for callback in self.items_changed_callbacks: + callback() class FieldStringFilter(ModelFilter): - """Filter source models based on if their fields matches a string. + """Filter source models based on if their fields matches a string. - This is used for filter fields in QML: the user enters some text and only - items with a certain field (typically `display_name`) that starts with the - entered text will be shown. + This is used for filter fields in QML: the user enters some text and only + items with a certain field (typically `display_name`) that starts with the + entered text will be shown. - Matching is done using "smart case": insensitive if the filter text is - all lowercase, sensitive otherwise. - """ + Matching is done using "smart case": insensitive if the filter text is + all lowercase, sensitive otherwise. + """ - def __init__( - self, - sync_id: SyncId, - fields: Collection[str], - no_filter_accept_all_items: bool = True, - ) -> None: + def __init__( + self, + sync_id: SyncId, + fields: Collection[str], + no_filter_accept_all_items: bool = True, + ) -> None: - self.fields = fields - self.no_filter_accept_all_items = no_filter_accept_all_items - self._filter: str = "" + self.fields = fields + self.no_filter_accept_all_items = no_filter_accept_all_items + self._filter: str = "" - super().__init__(sync_id) + super().__init__(sync_id) - @property - def filter(self) -> str: - return self._filter + @property + def filter(self) -> str: + return self._filter - @filter.setter - def filter(self, value: str) -> None: - if value != self._filter: - self._filter = value - self.refilter() + @filter.setter + def filter(self, value: str) -> None: + if value != self._filter: + self._filter = value + self.refilter() - def accept_item(self, item: "ModelItem") -> bool: - if not self.filter: - return self.no_filter_accept_all_items + def accept_item(self, item: "ModelItem") -> bool: + if not self.filter: + return self.no_filter_accept_all_items - fields = {f: getattr(item, f) for f in self.fields} - filtr = self.filter - lowercase = filtr.lower() + fields = {f: getattr(item, f) for f in self.fields} + filtr = self.filter + lowercase = filtr.lower() - if lowercase == filtr: - # Consider case only if filter isn't all lowercase - filtr = lowercase - fields = {name: value.lower() for name, value in fields.items()} + if lowercase == filtr: + # Consider case only if filter isn't all lowercase + filtr = lowercase + fields = {name: value.lower() for name, value in fields.items()} - return self.match(fields, filtr) + return self.match(fields, filtr) - def match(self, fields: Dict[str, str], filtr: str) -> bool: - for value in fields.values(): - if value.startswith(filtr): - return True + def match(self, fields: Dict[str, str], filtr: str) -> bool: + for value in fields.values(): + if value.startswith(filtr): + return True - return False + return False class FieldSubstringFilter(FieldStringFilter): - """Fuzzy-like alternative to `FieldStringFilter`. + """Fuzzy-like alternative to `FieldStringFilter`. - All words in the filter string must fully or partially match words in the - item field values, e.g. "red l" can match "red light", - "tired legs", "light red" (order of the filter words doesn't matter), - but not just "red" or "light" by themselves. - """ + All words in the filter string must fully or partially match words in the + item field values, e.g. "red l" can match "red light", + "tired legs", "light red" (order of the filter words doesn't matter), + but not just "red" or "light" by themselves. + """ - def match(self, fields: Dict[str, str], filtr: str) -> bool: - text = " ".join(fields.values()) + def match(self, fields: Dict[str, str], filtr: str) -> bool: + text = " ".join(fields.values()) - for word in filtr.split(): - if word and word not in text: - return False + for word in filtr.split(): + if word and word not in text: + return False - return True + return True diff --git a/src/backend/models/items.py b/src/backend/models/items.py index 72752a95..eb50d406 100644 --- a/src/backend/models/items.py +++ b/src/backend/models/items.py @@ -23,415 +23,415 @@ ZERO_DATE = datetime.fromtimestamp(0) class TypeSpecifier(AutoStrEnum): - """Enum providing clarification of purpose for some matrix events.""" + """Enum providing clarification of purpose for some matrix events.""" - Unset = auto() - ProfileChange = auto() - MembershipChange = auto() + Unset = auto() + ProfileChange = auto() + MembershipChange = auto() class PingStatus(AutoStrEnum): - """Enum for the status of a homeserver ping operation.""" + """Enum for the status of a homeserver ping operation.""" - Done = auto() - Pinging = auto() - Failed = auto() + Done = auto() + Pinging = auto() + Failed = auto() class RoomNotificationOverride(AutoStrEnum): - """Possible per-room notification override settings, as displayed in the - left sidepane's context menu when right-clicking a room. - """ - UseDefaultSettings = auto() - AllEvents = auto() - HighlightsOnly = auto() - IgnoreEvents = auto() + """Possible per-room notification override settings, as displayed in the + left sidepane's context menu when right-clicking a room. + """ + UseDefaultSettings = auto() + AllEvents = auto() + HighlightsOnly = auto() + IgnoreEvents = auto() @dataclass(eq=False) class Homeserver(ModelItem): - """A homeserver we can connect to. The `id` field is the server's URL.""" + """A homeserver we can connect to. The `id` field is the server's URL.""" - id: str = field() - name: str = field() - site_url: str = field() - country: str = field() - ping: int = -1 - status: PingStatus = PingStatus.Pinging - stability: float = -1 - downtimes_ms: List[float] = field(default_factory=list) + id: str = field() + name: str = field() + site_url: str = field() + country: str = field() + ping: int = -1 + status: PingStatus = PingStatus.Pinging + stability: float = -1 + downtimes_ms: List[float] = field(default_factory=list) - def __lt__(self, other: "Homeserver") -> bool: - return (self.name.lower(), self.id) < (other.name.lower(), other.id) + def __lt__(self, other: "Homeserver") -> bool: + return (self.name.lower(), self.id) < (other.name.lower(), other.id) @dataclass(eq=False) class Account(ModelItem): - """A logged in matrix account.""" + """A logged in matrix account.""" - id: str = field() - order: int = -1 - display_name: str = "" - avatar_url: str = "" - max_upload_size: int = 0 - profile_updated: datetime = ZERO_DATE - connecting: bool = False - total_unread: int = 0 - total_highlights: int = 0 - local_unreads: bool = False - ignored_users: Set[str] = field(default_factory=set) + id: str = field() + order: int = -1 + display_name: str = "" + avatar_url: str = "" + max_upload_size: int = 0 + profile_updated: datetime = ZERO_DATE + connecting: bool = False + total_unread: int = 0 + total_highlights: int = 0 + local_unreads: bool = False + ignored_users: Set[str] = field(default_factory=set) - # For some reason, Account cannot inherit Presence, because QML keeps - # complaining type error on unknown file - presence_support: bool = False - save_presence: bool = True - presence: Presence.State = Presence.State.offline - currently_active: bool = False - last_active_at: datetime = ZERO_DATE - status_msg: str = "" + # For some reason, Account cannot inherit Presence, because QML keeps + # complaining type error on unknown file + presence_support: bool = False + save_presence: bool = True + presence: Presence.State = Presence.State.offline + currently_active: bool = False + last_active_at: datetime = ZERO_DATE + status_msg: str = "" - def __lt__(self, other: "Account") -> bool: - return (self.order, self.id) < (other.order, other.id) + def __lt__(self, other: "Account") -> bool: + return (self.order, self.id) < (other.order, other.id) @dataclass(eq=False) class PushRule(ModelItem): - """A push rule configured for one of our account.""" + """A push rule configured for one of our account.""" - id: Tuple[str, str] = field() # (kind.value, rule_id) - kind: nio.PushRuleKind = field() - rule_id: str = field() - order: int = field() - default: bool = field() - enabled: bool = True - conditions: List[Dict[str, Any]] = field(default_factory=list) - pattern: str = "" - actions: List[Dict[str, Any]] = field(default_factory=list) - notify: bool = False - highlight: bool = False - bubble: bool = False - sound: str = "" # usually "default" when set - urgency_hint: bool = False + id: Tuple[str, str] = field() # (kind.value, rule_id) + kind: nio.PushRuleKind = field() + rule_id: str = field() + order: int = field() + default: bool = field() + enabled: bool = True + conditions: List[Dict[str, Any]] = field(default_factory=list) + pattern: str = "" + actions: List[Dict[str, Any]] = field(default_factory=list) + notify: bool = False + highlight: bool = False + bubble: bool = False + sound: str = "" # usually "default" when set + urgency_hint: bool = False - def __lt__(self, other: "PushRule") -> bool: - return ( - self.kind is nio.PushRuleKind.underride, - self.kind is nio.PushRuleKind.sender, - self.kind is nio.PushRuleKind.room, - self.kind is nio.PushRuleKind.content, - self.kind is nio.PushRuleKind.override, - self.order, - self.id, - ) < ( - other.kind is nio.PushRuleKind.underride, - other.kind is nio.PushRuleKind.sender, - other.kind is nio.PushRuleKind.room, - other.kind is nio.PushRuleKind.content, - other.kind is nio.PushRuleKind.override, - other.order, - other.id, - ) + def __lt__(self, other: "PushRule") -> bool: + return ( + self.kind is nio.PushRuleKind.underride, + self.kind is nio.PushRuleKind.sender, + self.kind is nio.PushRuleKind.room, + self.kind is nio.PushRuleKind.content, + self.kind is nio.PushRuleKind.override, + self.order, + self.id, + ) < ( + other.kind is nio.PushRuleKind.underride, + other.kind is nio.PushRuleKind.sender, + other.kind is nio.PushRuleKind.room, + other.kind is nio.PushRuleKind.content, + other.kind is nio.PushRuleKind.override, + other.order, + other.id, + ) @dataclass class Room(ModelItem): - """A matrix room we are invited to, are or were member of.""" + """A matrix room we are invited to, are or were member of.""" - id: str = field() - for_account: str = "" - given_name: str = "" - display_name: str = "" - main_alias: str = "" - avatar_url: str = "" - plain_topic: str = "" - topic: str = "" - inviter_id: str = "" - inviter_name: str = "" - inviter_avatar: str = "" - left: bool = False + id: str = field() + for_account: str = "" + given_name: str = "" + display_name: str = "" + main_alias: str = "" + avatar_url: str = "" + plain_topic: str = "" + topic: str = "" + inviter_id: str = "" + inviter_name: str = "" + inviter_avatar: str = "" + left: bool = False - typing_members: List[str] = field(default_factory=list) + typing_members: List[str] = field(default_factory=list) - federated: bool = True - encrypted: bool = False - unverified_devices: bool = False - invite_required: bool = True - guests_allowed: bool = True + federated: bool = True + encrypted: bool = False + unverified_devices: bool = False + invite_required: bool = True + guests_allowed: bool = True - default_power_level: int = 0 - own_power_level: int = 0 - can_invite: bool = False - can_kick: bool = False - can_redact_all: bool = False - can_send_messages: bool = False - can_set_name: bool = False - can_set_topic: bool = False - can_set_avatar: bool = False - can_set_encryption: bool = False - can_set_join_rules: bool = False - can_set_guest_access: bool = False - can_set_power_levels: bool = False + default_power_level: int = 0 + own_power_level: int = 0 + can_invite: bool = False + can_kick: bool = False + can_redact_all: bool = False + can_send_messages: bool = False + can_set_name: bool = False + can_set_topic: bool = False + can_set_avatar: bool = False + can_set_encryption: bool = False + can_set_join_rules: bool = False + can_set_guest_access: bool = False + can_set_power_levels: bool = False - last_event_date: datetime = ZERO_DATE + last_event_date: datetime = ZERO_DATE - unreads: int = 0 - highlights: int = 0 - local_unreads: bool = False + unreads: int = 0 + highlights: int = 0 + local_unreads: bool = False - notification_setting: RoomNotificationOverride = \ - RoomNotificationOverride.UseDefaultSettings + notification_setting: RoomNotificationOverride = \ + RoomNotificationOverride.UseDefaultSettings - lexical_sorting: bool = False - pinned: bool = False + lexical_sorting: bool = False + pinned: bool = False - # Allowed keys: "last_event_date", "unreads", "highlights", "local_unreads" - # Keys in this dict will override their corresponding item fields for the - # __lt__ method. This is used when we want to lock a room's position, - # e.g. to avoid having the room move around when it is focused in the GUI - _sort_overrides: Dict[str, Any] = field(default_factory=dict) + # Allowed keys: "last_event_date", "unreads", "highlights", "local_unreads" + # Keys in this dict will override their corresponding item fields for the + # __lt__ method. This is used when we want to lock a room's position, + # e.g. to avoid having the room move around when it is focused in the GUI + _sort_overrides: Dict[str, Any] = field(default_factory=dict) - def _sorting(self, key: str) -> Any: - return self._sort_overrides.get(key, getattr(self, key)) + def _sorting(self, key: str) -> Any: + return self._sort_overrides.get(key, getattr(self, key)) - def __lt__(self, other: "Room") -> bool: - by_activity = not self.lexical_sorting + def __lt__(self, other: "Room") -> bool: + by_activity = not self.lexical_sorting - return ( - self.for_account, - other.pinned, - self.left, # Left rooms may have an inviter_id, check them first - bool(other.inviter_id), - bool(by_activity and other._sorting("highlights")), - bool(by_activity and other._sorting("unreads")), - bool(by_activity and other._sorting("local_unreads")), - other._sorting("last_event_date") if by_activity else ZERO_DATE, - (self.display_name or self.id).lower(), - self.id, + return ( + self.for_account, + other.pinned, + self.left, # Left rooms may have an inviter_id, check them first + bool(other.inviter_id), + bool(by_activity and other._sorting("highlights")), + bool(by_activity and other._sorting("unreads")), + bool(by_activity and other._sorting("local_unreads")), + other._sorting("last_event_date") if by_activity else ZERO_DATE, + (self.display_name or self.id).lower(), + self.id, - ) < ( - other.for_account, - self.pinned, - other.left, - bool(self.inviter_id), - bool(by_activity and self._sorting("highlights")), - bool(by_activity and self._sorting("unreads")), - bool(by_activity and self._sorting("local_unreads")), - self._sorting("last_event_date") if by_activity else ZERO_DATE, - (other.display_name or other.id).lower(), - other.id, - ) + ) < ( + other.for_account, + self.pinned, + other.left, + bool(self.inviter_id), + bool(by_activity and self._sorting("highlights")), + bool(by_activity and self._sorting("unreads")), + bool(by_activity and self._sorting("local_unreads")), + self._sorting("last_event_date") if by_activity else ZERO_DATE, + (other.display_name or other.id).lower(), + other.id, + ) @dataclass(eq=False) class AccountOrRoom(Account, Room): - """The left sidepane in the GUI lists a mixture of accounts and rooms - giving a tree view illusion. Since all items in a QML ListView must have - the same available properties, this class inherits both - `Account` and `Room` to fulfill that purpose. - """ + """The left sidepane in the GUI lists a mixture of accounts and rooms + giving a tree view illusion. Since all items in a QML ListView must have + the same available properties, this class inherits both + `Account` and `Room` to fulfill that purpose. + """ - type: Union[Type[Account], Type[Room]] = Account - account_order: int = -1 + type: Union[Type[Account], Type[Room]] = Account + account_order: int = -1 - def __lt__(self, other: "AccountOrRoom") -> bool: # type: ignore - by_activity = not self.lexical_sorting + def __lt__(self, other: "AccountOrRoom") -> bool: # type: ignore + by_activity = not self.lexical_sorting - return ( - self.account_order, - self.id if self.type is Account else self.for_account, - other.type is Account, - other.pinned, - self.left, - bool(other.inviter_id), - bool(by_activity and other._sorting("highlights")), - bool(by_activity and other._sorting("unreads")), - bool(by_activity and other._sorting("local_unreads")), - other._sorting("last_event_date") if by_activity else ZERO_DATE, - (self.display_name or self.id).lower(), - self.id, + return ( + self.account_order, + self.id if self.type is Account else self.for_account, + other.type is Account, + other.pinned, + self.left, + bool(other.inviter_id), + bool(by_activity and other._sorting("highlights")), + bool(by_activity and other._sorting("unreads")), + bool(by_activity and other._sorting("local_unreads")), + other._sorting("last_event_date") if by_activity else ZERO_DATE, + (self.display_name or self.id).lower(), + self.id, - ) < ( - other.account_order, - other.id if other.type is Account else other.for_account, - self.type is Account, - self.pinned, - other.left, - bool(self.inviter_id), - bool(by_activity and self._sorting("highlights")), - bool(by_activity and self._sorting("unreads")), - bool(by_activity and self._sorting("local_unreads")), - self._sorting("last_event_date") if by_activity else ZERO_DATE, - (other.display_name or other.id).lower(), - other.id, - ) + ) < ( + other.account_order, + other.id if other.type is Account else other.for_account, + self.type is Account, + self.pinned, + other.left, + bool(self.inviter_id), + bool(by_activity and self._sorting("highlights")), + bool(by_activity and self._sorting("unreads")), + bool(by_activity and self._sorting("local_unreads")), + self._sorting("last_event_date") if by_activity else ZERO_DATE, + (other.display_name or other.id).lower(), + other.id, + ) @dataclass(eq=False) class Member(ModelItem): - """A member in a matrix room.""" + """A member in a matrix room.""" - id: str = field() - display_name: str = "" - avatar_url: str = "" - typing: bool = False - power_level: int = 0 - invited: bool = False - ignored: bool = False - profile_updated: datetime = ZERO_DATE - last_read_event: str = "" + id: str = field() + display_name: str = "" + avatar_url: str = "" + typing: bool = False + power_level: int = 0 + invited: bool = False + ignored: bool = False + profile_updated: datetime = ZERO_DATE + last_read_event: str = "" - presence: Presence.State = Presence.State.offline - currently_active: bool = False - last_active_at: datetime = ZERO_DATE - status_msg: str = "" + presence: Presence.State = Presence.State.offline + currently_active: bool = False + last_active_at: datetime = ZERO_DATE + status_msg: str = "" - def __lt__(self, other: "Member") -> bool: - return ( - self.invited, - other.power_level, - self.ignored, - Presence.State.offline if self.ignored else self.presence, - (self.display_name or self.id[1:]).lower(), - self.id, - ) < ( - other.invited, - self.power_level, - other.ignored, - Presence.State.offline if other.ignored else other.presence, - (other.display_name or other.id[1:]).lower(), - other.id, - ) + def __lt__(self, other: "Member") -> bool: + return ( + self.invited, + other.power_level, + self.ignored, + Presence.State.offline if self.ignored else self.presence, + (self.display_name or self.id[1:]).lower(), + self.id, + ) < ( + other.invited, + self.power_level, + other.ignored, + Presence.State.offline if other.ignored else other.presence, + (other.display_name or other.id[1:]).lower(), + other.id, + ) class TransferStatus(AutoStrEnum): - """Enum describing the status of an upload operation.""" + """Enum describing the status of an upload operation.""" - Preparing = auto() - Transfering = auto() - Caching = auto() - Error = auto() + Preparing = auto() + Transfering = auto() + Caching = auto() + Error = auto() @dataclass(eq=False) class Transfer(ModelItem): - """Represent a running or failed file upload/download operation.""" + """Represent a running or failed file upload/download operation.""" - id: UUID = field() - is_upload: bool = field() - filepath: Path = Path("-") + id: UUID = field() + is_upload: bool = field() + filepath: Path = Path("-") - total_size: int = 0 - transferred: int = 0 - speed: float = 0 - time_left: timedelta = timedelta(0) - paused: bool = False + total_size: int = 0 + transferred: int = 0 + speed: float = 0 + time_left: timedelta = timedelta(0) + paused: bool = False - status: TransferStatus = TransferStatus.Preparing - error: OptionalExceptionType = type(None) - error_args: Tuple[Any, ...] = () + status: TransferStatus = TransferStatus.Preparing + error: OptionalExceptionType = type(None) + error_args: Tuple[Any, ...] = () - start_date: datetime = field(init=False, default_factory=datetime.now) + start_date: datetime = field(init=False, default_factory=datetime.now) - def __lt__(self, other: "Transfer") -> bool: - return (self.start_date, self.id) > (other.start_date, other.id) + def __lt__(self, other: "Transfer") -> bool: + return (self.start_date, self.id) > (other.start_date, other.id) @dataclass(eq=False) class Event(ModelItem): - """A matrix state event or message.""" + """A matrix state event or message.""" - id: str = field() - event_id: str = field() - event_type: Type[nio.Event] = field() - date: datetime = field() - sender_id: str = field() - sender_name: str = field() - sender_avatar: str = field() - fetch_profile: bool = False + id: str = field() + event_id: str = field() + event_type: Type[nio.Event] = field() + date: datetime = field() + sender_id: str = field() + sender_name: str = field() + sender_avatar: str = field() + fetch_profile: bool = False - content: str = "" - inline_content: str = "" - reason: str = "" - links: List[str] = field(default_factory=list) - mentions: List[Tuple[str, str]] = field(default_factory=list) + content: str = "" + inline_content: str = "" + reason: str = "" + links: List[str] = field(default_factory=list) + mentions: List[Tuple[str, str]] = field(default_factory=list) - type_specifier: TypeSpecifier = TypeSpecifier.Unset + type_specifier: TypeSpecifier = TypeSpecifier.Unset - target_id: str = "" - target_name: str = "" - target_avatar: str = "" - redacter_id: str = "" - redacter_name: str = "" + target_id: str = "" + target_name: str = "" + target_avatar: str = "" + redacter_id: str = "" + redacter_name: str = "" - # {user_id: server_timestamp} - QML can't parse dates from JSONified dicts - last_read_by: Dict[str, int] = field(default_factory=dict) - read_by_count: int = 0 + # {user_id: server_timestamp} - QML can't parse dates from JSONified dicts + last_read_by: Dict[str, int] = field(default_factory=dict) + read_by_count: int = 0 - is_local_echo: bool = False - source: Optional[nio.Event] = None + is_local_echo: bool = False + source: Optional[nio.Event] = None - media_url: str = "" - media_http_url: str = "" - media_title: str = "" - media_width: int = 0 - media_height: int = 0 - media_duration: int = 0 - media_size: int = 0 - media_mime: str = "" - media_crypt_dict: Dict[str, Any] = field(default_factory=dict) - media_local_path: Union[str, Path] = "" + media_url: str = "" + media_http_url: str = "" + media_title: str = "" + media_width: int = 0 + media_height: int = 0 + media_duration: int = 0 + media_size: int = 0 + media_mime: str = "" + media_crypt_dict: Dict[str, Any] = field(default_factory=dict) + media_local_path: Union[str, Path] = "" - thumbnail_url: str = "" - thumbnail_mime: str = "" - thumbnail_width: int = 0 - thumbnail_height: int = 0 - thumbnail_crypt_dict: Dict[str, Any] = field(default_factory=dict) + thumbnail_url: str = "" + thumbnail_mime: str = "" + thumbnail_width: int = 0 + thumbnail_height: int = 0 + thumbnail_crypt_dict: Dict[str, Any] = field(default_factory=dict) - def __lt__(self, other: "Event") -> bool: - return (self.date, self.id) > (other.date, other.id) + def __lt__(self, other: "Event") -> bool: + return (self.date, self.id) > (other.date, other.id) - @property - def plain_content(self) -> str: - """Plaintext version of the event's content.""" + @property + def plain_content(self) -> str: + """Plaintext version of the event's content.""" - if isinstance(self.source, nio.RoomMessageText): - return self.source.body + if isinstance(self.source, nio.RoomMessageText): + return self.source.body - return strip_html_tags(self.content) + return strip_html_tags(self.content) - @staticmethod - def parse_links(text: str) -> List[str]: - """Return list of URLs (`` tags) present in the content.""" + @staticmethod + def parse_links(text: str) -> List[str]: + """Return list of URLs (`` tags) present in the content.""" - ignore = [] + ignore = [] - if "" in text or "mention" in text: - parser = lxml.html.etree.HTMLParser() - tree = lxml.etree.fromstring(text, parser) - ignore = [ - lxml.etree.tostring(matching_element) - for ugly_disgusting_xpath in [ - # Match mx-reply > blockquote > second a (user ID link) - "//mx-reply/blockquote/a[count(preceding-sibling::*)<=1]", - # Match tags with a mention class - '//a[contains(concat(" ",normalize-space(@class)," ")' - '," mention ")]', - ] - for matching_element in tree.xpath(ugly_disgusting_xpath) - ] + if "" in text or "mention" in text: + parser = lxml.html.etree.HTMLParser() + tree = lxml.etree.fromstring(text, parser) + ignore = [ + lxml.etree.tostring(matching_element) + for ugly_disgusting_xpath in [ + # Match mx-reply > blockquote > second a (user ID link) + "//mx-reply/blockquote/a[count(preceding-sibling::*)<=1]", + # Match tags with a mention class + '//a[contains(concat(" ",normalize-space(@class)," ")' + '," mention ")]', + ] + for matching_element in tree.xpath(ugly_disgusting_xpath) + ] - if not text.strip(): - return [] + if not text.strip(): + return [] - return [ - url for el, attrib, url, pos in lxml.html.iterlinks(text) - if lxml.etree.tostring(el) not in ignore - ] + return [ + url for el, attrib, url, pos in lxml.html.iterlinks(text) + if lxml.etree.tostring(el) not in ignore + ] - def serialized_field(self, field: str) -> Any: - if field == "source": - source_dict = asdict(self.source) if self.source else {} - return json.dumps(source_dict) + def serialized_field(self, field: str) -> Any: + if field == "source": + source_dict = asdict(self.source) if self.source else {} + return json.dumps(source_dict) - return super().serialized_field(field) + return super().serialized_field(field) diff --git a/src/backend/models/model.py b/src/backend/models/model.py index dd0e48e8..45f78acc 100644 --- a/src/backend/models/model.py +++ b/src/backend/models/model.py @@ -5,7 +5,7 @@ import itertools from contextlib import contextmanager from threading import RLock from typing import ( - TYPE_CHECKING, Any, Dict, Iterator, List, MutableMapping, Optional, Tuple, + TYPE_CHECKING, Any, Dict, Iterator, List, MutableMapping, Optional, Tuple, ) from sortedcontainers import SortedList @@ -15,199 +15,199 @@ from ..utils import serialize_value_for_qml from . import SyncId if TYPE_CHECKING: - from .model_item import ModelItem - from .proxy import ModelProxy # noqa + from .model_item import ModelItem + from .proxy import ModelProxy # noqa class Model(MutableMapping): - """A mapping of `{ModelItem.id: ModelItem}` synced between Python & QML. + """A mapping of `{ModelItem.id: ModelItem}` synced between Python & QML. - From the Python side, the model is usable like a normal dict of - `ModelItem` subclass objects. - Different types of `ModelItem` must not be mixed in the same model. + From the Python side, the model is usable like a normal dict of + `ModelItem` subclass objects. + Different types of `ModelItem` must not be mixed in the same model. - When items are added, replaced, removed, have field value changes, or the - model is cleared, corresponding `PyOtherSideEvent` are fired to inform - QML of the changes so that it can keep its models in sync. + When items are added, replaced, removed, have field value changes, or the + model is cleared, corresponding `PyOtherSideEvent` are fired to inform + QML of the changes so that it can keep its models in sync. - Items in the model are kept sorted using the `ModelItem` subclass `__lt__`. - """ + Items in the model are kept sorted using the `ModelItem` subclass `__lt__`. + """ - instances: Dict[SyncId, "Model"] = {} - proxies: Dict[SyncId, "ModelProxy"] = {} + instances: Dict[SyncId, "Model"] = {} + proxies: Dict[SyncId, "ModelProxy"] = {} - def __init__(self, sync_id: Optional[SyncId]) -> None: - self.sync_id: Optional[SyncId] = sync_id - self.write_lock: RLock = RLock() - self._data: Dict[Any, "ModelItem"] = {} - self._sorted_data: SortedList["ModelItem"] = SortedList() + def __init__(self, sync_id: Optional[SyncId]) -> None: + self.sync_id: Optional[SyncId] = sync_id + self.write_lock: RLock = RLock() + self._data: Dict[Any, "ModelItem"] = {} + self._sorted_data: SortedList["ModelItem"] = SortedList() - self.take_items_ownership: bool = True + self.take_items_ownership: bool = True - # [(index, item.id), ...] - self._active_batch_removed: Optional[List[Tuple[int, Any]]] = None + # [(index, item.id), ...] + self._active_batch_removed: Optional[List[Tuple[int, Any]]] = None - if self.sync_id: - self.instances[self.sync_id] = self + if self.sync_id: + self.instances[self.sync_id] = self - def __repr__(self) -> str: - """Provide a full representation of the model and its content.""" + def __repr__(self) -> str: + """Provide a full representation of the model and its content.""" - return "%s(sync_id=%s, %s)" % ( - type(self).__name__, self.sync_id, self._data, - ) + return "%s(sync_id=%s, %s)" % ( + type(self).__name__, self.sync_id, self._data, + ) - def __str__(self) -> str: - """Provide a short ": items" representation.""" - return f"{self.sync_id}: {len(self)} items" + def __str__(self) -> str: + """Provide a short ": items" representation.""" + return f"{self.sync_id}: {len(self)} items" - def __getitem__(self, key): - return self._data[key] + def __getitem__(self, key): + return self._data[key] - def __setitem__( - self, - key, - value: "ModelItem", - _changed_fields: Optional[Dict[str, Any]] = None, - ) -> None: - with self.write_lock: - existing = self._data.get(key) - new = value + def __setitem__( + self, + key, + value: "ModelItem", + _changed_fields: Optional[Dict[str, Any]] = None, + ) -> None: + with self.write_lock: + existing = self._data.get(key) + new = value - # Collect changed fields + # Collect changed fields - changed_fields = _changed_fields or {} + changed_fields = _changed_fields or {} - if not changed_fields: - for field in new.__dataclass_fields__: # type: ignore - if field.startswith("_"): - continue + if not changed_fields: + for field in new.__dataclass_fields__: # type: ignore + if field.startswith("_"): + continue - changed = True + changed = True - if existing: - changed = \ - getattr(new, field) != getattr(existing, field) + if existing: + changed = \ + getattr(new, field) != getattr(existing, field) - if changed: - changed_fields[field] = new.serialized_field(field) + if changed: + changed_fields[field] = new.serialized_field(field) - # Set parent model on new item + # Set parent model on new item - if self.sync_id and self.take_items_ownership: - new.parent_model = self + if self.sync_id and self.take_items_ownership: + new.parent_model = self - # Insert into sorted data + # Insert into sorted data - index_then = None + index_then = None - if existing: - index_then = self._sorted_data.index(existing) - del self._sorted_data[index_then] + if existing: + index_then = self._sorted_data.index(existing) + del self._sorted_data[index_then] - self._sorted_data.add(new) - index_now = self._sorted_data.index(new) + self._sorted_data.add(new) + index_now = self._sorted_data.index(new) - # Insert into dict data + # Insert into dict data - self._data[key] = new + self._data[key] = new - # Callbacks + # Callbacks - for sync_id, proxy in self.proxies.items(): - if sync_id != self.sync_id: - proxy.source_item_set(self, key, value) + for sync_id, proxy in self.proxies.items(): + if sync_id != self.sync_id: + proxy.source_item_set(self, key, value) - # Emit PyOtherSide event + # Emit PyOtherSide event - if self.sync_id and (index_then != index_now or changed_fields): - ModelItemSet( - self.sync_id, index_then, index_now, changed_fields, - ) + if self.sync_id and (index_then != index_now or changed_fields): + ModelItemSet( + self.sync_id, index_then, index_now, changed_fields, + ) - def __delitem__(self, key) -> None: - with self.write_lock: - item = self._data[key] + def __delitem__(self, key) -> None: + with self.write_lock: + item = self._data[key] - if self.sync_id and self.take_items_ownership: - item.parent_model = None + if self.sync_id and self.take_items_ownership: + item.parent_model = None - del self._data[key] + del self._data[key] - index = self._sorted_data.index(item) - del self._sorted_data[index] + index = self._sorted_data.index(item) + del self._sorted_data[index] - for sync_id, proxy in self.proxies.items(): - if sync_id != self.sync_id: - proxy.source_item_deleted(self, key) + for sync_id, proxy in self.proxies.items(): + if sync_id != self.sync_id: + proxy.source_item_deleted(self, key) - if self.sync_id: - if self._active_batch_removed is None: - i = serialize_value_for_qml(item.id, json_list_dicts=True) - ModelItemDeleted(self.sync_id, index, 1, (i,)) - else: - self._active_batch_removed.append((index, item.id)) + if self.sync_id: + if self._active_batch_removed is None: + i = serialize_value_for_qml(item.id, json_list_dicts=True) + ModelItemDeleted(self.sync_id, index, 1, (i,)) + else: + self._active_batch_removed.append((index, item.id)) - def __iter__(self) -> Iterator: - return iter(self._data) + def __iter__(self) -> Iterator: + return iter(self._data) - def __len__(self) -> int: - return len(self._data) + def __len__(self) -> int: + return len(self._data) - def __lt__(self, other: "Model") -> bool: - """Sort `Model` objects lexically by `sync_id`.""" - return str(self.sync_id) < str(other.sync_id) + def __lt__(self, other: "Model") -> bool: + """Sort `Model` objects lexically by `sync_id`.""" + return str(self.sync_id) < str(other.sync_id) - def clear(self) -> None: - super().clear() - if self.sync_id: - ModelCleared(self.sync_id) + def clear(self) -> None: + super().clear() + if self.sync_id: + ModelCleared(self.sync_id) - def copy(self, sync_id: Optional[SyncId] = None) -> "Model": - new = type(self)(sync_id=sync_id) - new.update(self) - return new + def copy(self, sync_id: Optional[SyncId] = None) -> "Model": + new = type(self)(sync_id=sync_id) + new.update(self) + return new - @contextmanager - def batch_remove(self): - """Context manager that accumulates item removal events. + @contextmanager + def batch_remove(self): + """Context manager that accumulates item removal events. - When the context manager exits, sequences of removed items are grouped - and one `ModelItemDeleted` pyotherside event is fired per sequence. - """ + When the context manager exits, sequences of removed items are grouped + and one `ModelItemDeleted` pyotherside event is fired per sequence. + """ - with self.write_lock: - try: - self._active_batch_removed = [] - yield None - finally: - batch = self._active_batch_removed - groups = [ - list(group) for item, group in - itertools.groupby(batch, key=lambda x: x[0]) - ] + with self.write_lock: + try: + self._active_batch_removed = [] + yield None + finally: + batch = self._active_batch_removed + groups = [ + list(group) for item, group in + itertools.groupby(batch, key=lambda x: x[0]) + ] - def serialize_id(id_): - return serialize_value_for_qml(id_, json_list_dicts=True) + def serialize_id(id_): + return serialize_value_for_qml(id_, json_list_dicts=True) - for group in groups: - ModelItemDeleted( - self.sync_id, - index = group[0][0], - count = len(group), - ids = [serialize_id(item[1]) for item in group], - ) + for group in groups: + ModelItemDeleted( + self.sync_id, + index = group[0][0], + count = len(group), + ids = [serialize_id(item[1]) for item in group], + ) - self._active_batch_removed = None + self._active_batch_removed = None diff --git a/src/backend/models/model_item.py b/src/backend/models/model_item.py index 4da6e373..bd33de67 100644 --- a/src/backend/models/model_item.py +++ b/src/backend/models/model_item.py @@ -8,122 +8,122 @@ from ..pyotherside_events import ModelItemSet from ..utils import serialize_value_for_qml if TYPE_CHECKING: - from .model import Model + from .model import Model @dataclass(eq=False) class ModelItem: - """Base class for items stored inside a `Model`. + """Base class for items stored inside a `Model`. - This class must be subclassed and not used directly. - All subclasses must use the `@dataclass(eq=False)` decorator. + This class must be subclassed and not used directly. + All subclasses must use the `@dataclass(eq=False)` decorator. - Subclasses are also expected to implement `__lt__()`, - to provide support for comparisons with the `<`, `>`, `<=`, `=>` operators - and thus allow a `Model` to keep its data sorted. + Subclasses are also expected to implement `__lt__()`, + to provide support for comparisons with the `<`, `>`, `<=`, `=>` operators + and thus allow a `Model` to keep its data sorted. - Make sure to respect SortedList requirements when implementing `__lt__()`: - http://www.grantjenks.com/docs/sortedcontainers/introduction.html#caveats - """ + Make sure to respect SortedList requirements when implementing `__lt__()`: + http://www.grantjenks.com/docs/sortedcontainers/introduction.html#caveats + """ - id: Any = field() + id: Any = field() - def __new__(cls, *_args, **_kwargs) -> "ModelItem": - cls.parent_model: Optional[Model] = None - return super().__new__(cls) + def __new__(cls, *_args, **_kwargs) -> "ModelItem": + cls.parent_model: Optional[Model] = None + return super().__new__(cls) - def __setattr__(self, name: str, value) -> None: - self.set_fields(**{name: value}) + def __setattr__(self, name: str, value) -> None: + self.set_fields(**{name: value}) - def __delattr__(self, name: str) -> None: - raise NotImplementedError() + def __delattr__(self, name: str) -> None: + raise NotImplementedError() - @property - def serialized(self) -> Dict[str, Any]: - """Return this item as a dict ready to be passed to QML.""" + @property + def serialized(self) -> Dict[str, Any]: + """Return this item as a dict ready to be passed to QML.""" - return { - name: self.serialized_field(name) - for name in self.__dataclass_fields__ # type: ignore - if not name.startswith("_") - } + return { + name: self.serialized_field(name) + for name in self.__dataclass_fields__ # type: ignore + if not name.startswith("_") + } - def serialized_field(self, field: str) -> Any: - """Return a field's value in a form suitable for passing to QML.""" + def serialized_field(self, field: str) -> Any: + """Return a field's value in a form suitable for passing to QML.""" - value = getattr(self, field) - return serialize_value_for_qml(value, json_list_dicts=True) + value = getattr(self, field) + return serialize_value_for_qml(value, json_list_dicts=True) - def set_fields(self, _force: bool = False, **fields: Any) -> None: - """Set one or more field's value and call `ModelItem.notify_change`. + def set_fields(self, _force: bool = False, **fields: Any) -> None: + """Set one or more field's value and call `ModelItem.notify_change`. - For efficiency, to change multiple fields, this method should be - used rather than setting them one after another with `=` or `setattr`. - """ + For efficiency, to change multiple fields, this method should be + used rather than setting them one after another with `=` or `setattr`. + """ - parent = self.parent_model + parent = self.parent_model - # If we're currently being created or haven't been put in a model yet: - if not parent: - for name, value in fields.items(): - super().__setattr__(name, value) - return + # If we're currently being created or haven't been put in a model yet: + if not parent: + for name, value in fields.items(): + super().__setattr__(name, value) + return - with parent.write_lock: - qml_changes = {} - changes = { - name: value for name, value in fields.items() - if _force or getattr(self, name) != value - } + with parent.write_lock: + qml_changes = {} + changes = { + name: value for name, value in fields.items() + if _force or getattr(self, name) != value + } - if not changes: - return + if not changes: + return - # To avoid corrupting the SortedList, we have to take out the item, - # apply the field changes, *then* add it back in. + # To avoid corrupting the SortedList, we have to take out the item, + # apply the field changes, *then* add it back in. - index_then = parent._sorted_data.index(self) - del parent._sorted_data[index_then] + index_then = parent._sorted_data.index(self) + del parent._sorted_data[index_then] - for name, value in changes.items(): - super().__setattr__(name, value) - is_field = name in self.__dataclass_fields__ # type: ignore + for name, value in changes.items(): + super().__setattr__(name, value) + is_field = name in self.__dataclass_fields__ # type: ignore - if is_field and not name.startswith("_"): - qml_changes[name] = self.serialized_field(name) + if is_field and not name.startswith("_"): + qml_changes[name] = self.serialized_field(name) - parent._sorted_data.add(self) - index_now = parent._sorted_data.index(self) - index_change = index_then != index_now + parent._sorted_data.add(self) + index_now = parent._sorted_data.index(self) + index_change = index_then != index_now - # Now, inform QML about changed dataclass fields if any. + # Now, inform QML about changed dataclass fields if any. - if not parent.sync_id or (not qml_changes and not index_change): - return + if not parent.sync_id or (not qml_changes and not index_change): + return - ModelItemSet(parent.sync_id, index_then, index_now, qml_changes) + ModelItemSet(parent.sync_id, index_then, index_now, qml_changes) - # Inform any proxy connected to the parent model of the field changes + # Inform any proxy connected to the parent model of the field changes - for sync_id, proxy in parent.proxies.items(): - if sync_id != parent.sync_id: - proxy.source_item_set(parent, self.id, self, qml_changes) + for sync_id, proxy in parent.proxies.items(): + if sync_id != parent.sync_id: + proxy.source_item_set(parent, self.id, self, qml_changes) - def notify_change(self, *fields: str) -> None: - """Notify the parent model that fields of this item have changed. + def notify_change(self, *fields: str) -> None: + """Notify the parent model that fields of this item have changed. - The model cannot automatically detect changes inside - object fields, such as list or dicts having their data modified. - In these cases, this method should be called. - """ + The model cannot automatically detect changes inside + object fields, such as list or dicts having their data modified. + In these cases, this method should be called. + """ - kwargs = {name: getattr(self, name) for name in fields} - kwargs["_force"] = True - self.set_fields(**kwargs) + kwargs = {name: getattr(self, name) for name in fields} + kwargs["_force"] = True + self.set_fields(**kwargs) diff --git a/src/backend/models/model_store.py b/src/backend/models/model_store.py index 67159740..4ef4f9ca 100644 --- a/src/backend/models/model_store.py +++ b/src/backend/models/model_store.py @@ -8,66 +8,66 @@ from typing import Dict, List, Union from . import SyncId from .model import Model from .special_models import ( - AllRooms, AutoCompletedMembers, FilteredHomeservers, FilteredMembers, - MatchingAccounts, + AllRooms, AutoCompletedMembers, FilteredHomeservers, FilteredMembers, + MatchingAccounts, ) @dataclass(frozen=True) class ModelStore(UserDict): - """Dict of sync ID keys and `Model` values. + """Dict of sync ID keys and `Model` values. - The dict keys must be the sync ID of `Model` values. - If a non-existent key is accessed, a corresponding `Model` will be - created, put into the internal `data` dict and returned. - """ + The dict keys must be the sync ID of `Model` values. + If a non-existent key is accessed, a corresponding `Model` will be + created, put into the internal `data` dict and returned. + """ - data: Dict[SyncId, Model] = field(default_factory=dict) + data: Dict[SyncId, Model] = field(default_factory=dict) - def __missing__(self, key: SyncId) -> Model: - """When accessing a non-existent model, create and return it. + def __missing__(self, key: SyncId) -> Model: + """When accessing a non-existent model, create and return it. - Special models rather than a generic `Model` object may be returned - depending on the passed key. - """ + Special models rather than a generic `Model` object may be returned + depending on the passed key. + """ - is_tuple = isinstance(key, tuple) + is_tuple = isinstance(key, tuple) - model: Model + model: Model - if key == "all_rooms": - model = AllRooms(self["accounts"]) - elif key == "matching_accounts": - model = MatchingAccounts(self["all_rooms"]) - elif key == "filtered_homeservers": - model = FilteredHomeservers() - elif is_tuple and len(key) == 3 and key[2] == "filtered_members": - model = FilteredMembers(user_id=key[0], room_id=key[1]) - elif is_tuple and len(key) == 3 and key[2] == "autocompleted_members": - model = AutoCompletedMembers(user_id=key[0], room_id=key[1]) - else: - model = Model(sync_id=key) + if key == "all_rooms": + model = AllRooms(self["accounts"]) + elif key == "matching_accounts": + model = MatchingAccounts(self["all_rooms"]) + elif key == "filtered_homeservers": + model = FilteredHomeservers() + elif is_tuple and len(key) == 3 and key[2] == "filtered_members": + model = FilteredMembers(user_id=key[0], room_id=key[1]) + elif is_tuple and len(key) == 3 and key[2] == "autocompleted_members": + model = AutoCompletedMembers(user_id=key[0], room_id=key[1]) + else: + model = Model(sync_id=key) - self.data[key] = model - return model + self.data[key] = model + return model - def __str__(self) -> str: - """Provide a nice overview of stored models when `print()` called.""" + def __str__(self) -> str: + """Provide a nice overview of stored models when `print()` called.""" - return "%s(\n %s\n)" % ( - type(self).__name__, - "\n ".join(sorted(str(v) for v in self.values())), - ) + return "%s(\n %s\n)" % ( + type(self).__name__, + "\n ".join(sorted(str(v) for v in self.values())), + ) - async def ensure_exists_from_qml( - self, sync_id: Union[SyncId, List[str]], - ) -> None: - """Create model if it doesn't exist. Should only be called by QML.""" + async def ensure_exists_from_qml( + self, sync_id: Union[SyncId, List[str]], + ) -> None: + """Create model if it doesn't exist. Should only be called by QML.""" - if isinstance(sync_id, list): # QML can't pass tuples - sync_id = tuple(sync_id) + if isinstance(sync_id, list): # QML can't pass tuples + sync_id = tuple(sync_id) - self[sync_id] # will call __missing__ if needed + self[sync_id] # will call __missing__ if needed diff --git a/src/backend/models/proxy.py b/src/backend/models/proxy.py index e1da3d12..8ad79fdb 100644 --- a/src/backend/models/proxy.py +++ b/src/backend/models/proxy.py @@ -8,68 +8,68 @@ from . import SyncId from .model import Model if TYPE_CHECKING: - from .model_item import ModelItem + from .model_item import ModelItem class ModelProxy(Model): - """Proxies data from one or more `Model` objects.""" + """Proxies data from one or more `Model` objects.""" - def __init__(self, sync_id: SyncId) -> None: - super().__init__(sync_id) - self.take_items_ownership = False - Model.proxies[sync_id] = self + def __init__(self, sync_id: SyncId) -> None: + super().__init__(sync_id) + self.take_items_ownership = False + Model.proxies[sync_id] = self - with self.write_lock: - for sync_id, model in Model.instances.items(): - if sync_id != self.sync_id and self.accept_source(model): - for key, item in model.items(): - self.source_item_set(model, key, item) + with self.write_lock: + for sync_id, model in Model.instances.items(): + if sync_id != self.sync_id and self.accept_source(model): + for key, item in model.items(): + self.source_item_set(model, key, item) - def accept_source(self, source: Model) -> bool: - """Return whether passed `Model` should be proxied by this proxy.""" - return True + def accept_source(self, source: Model) -> bool: + """Return whether passed `Model` should be proxied by this proxy.""" + return True - def convert_item(self, item: "ModelItem") -> "ModelItem": - """Take a source `ModelItem`, return an appropriate one for proxy. + def convert_item(self, item: "ModelItem") -> "ModelItem": + """Take a source `ModelItem`, return an appropriate one for proxy. - By default, this returns the passed item unchanged. + By default, this returns the passed item unchanged. - Due to QML `ListModel` restrictions, if multiple source models - containing different subclasses of `ModelItem` are proxied, - they should be converted to a same `ModelItem` - subclass by overriding this function. - """ - return copy(item) + Due to QML `ListModel` restrictions, if multiple source models + containing different subclasses of `ModelItem` are proxied, + they should be converted to a same `ModelItem` + subclass by overriding this function. + """ + return copy(item) - def source_item_set( - self, - source: Model, - key, - value: "ModelItem", - _changed_fields: Optional[Dict[str, Any]] = None, - ) -> None: - """Called when a source model item is added or changed.""" + def source_item_set( + self, + source: Model, + key, + value: "ModelItem", + _changed_fields: Optional[Dict[str, Any]] = None, + ) -> None: + """Called when a source model item is added or changed.""" - if self.accept_source(source): - value = self.convert_item(value) - self.__setitem__((source.sync_id, key), value, _changed_fields) + if self.accept_source(source): + value = self.convert_item(value) + self.__setitem__((source.sync_id, key), value, _changed_fields) - def source_item_deleted(self, source: Model, key) -> None: - """Called when a source model item is removed.""" + def source_item_deleted(self, source: Model, key) -> None: + """Called when a source model item is removed.""" - if self.accept_source(source): - del self[source.sync_id, key] + if self.accept_source(source): + del self[source.sync_id, key] - def source_cleared(self, source: Model) -> None: - """Called when a source model is cleared.""" + def source_cleared(self, source: Model) -> None: + """Called when a source model is cleared.""" - if self.accept_source(source): - with self.batch_remove(): - for source_sync_id, key in self.copy(): - if source_sync_id == source.sync_id: - del self[source_sync_id, key] + if self.accept_source(source): + with self.batch_remove(): + for source_sync_id, key in self.copy(): + if source_sync_id == source.sync_id: + del self[source_sync_id, key] diff --git a/src/backend/models/special_models.py b/src/backend/models/special_models.py index 75d65759..6039d135 100644 --- a/src/backend/models/special_models.py +++ b/src/backend/models/special_models.py @@ -11,143 +11,143 @@ from .model_item import ModelItem class AllRooms(FieldSubstringFilter): - """Flat filtered list of all accounts and their rooms.""" + """Flat filtered list of all accounts and their rooms.""" - def __init__(self, accounts: Model) -> None: - self.accounts = accounts - self._collapsed: Set[str] = set() + def __init__(self, accounts: Model) -> None: + self.accounts = accounts + self._collapsed: Set[str] = set() - super().__init__(sync_id="all_rooms", fields=("display_name",)) - self.items_changed_callbacks.append(self.refilter_accounts) + super().__init__(sync_id="all_rooms", fields=("display_name",)) + self.items_changed_callbacks.append(self.refilter_accounts) - def set_account_collapse(self, user_id: str, collapsed: bool) -> None: - """Set whether the rooms for an account should be filtered out.""" + def set_account_collapse(self, user_id: str, collapsed: bool) -> None: + """Set whether the rooms for an account should be filtered out.""" - def only_if(item): - return item.type is Room and item.for_account == user_id + def only_if(item): + return item.type is Room and item.for_account == user_id - if collapsed and user_id not in self._collapsed: - self._collapsed.add(user_id) - self.refilter(only_if) + if collapsed and user_id not in self._collapsed: + self._collapsed.add(user_id) + self.refilter(only_if) - if not collapsed and user_id in self._collapsed: - self._collapsed.remove(user_id) - self.refilter(only_if) + if not collapsed and user_id in self._collapsed: + self._collapsed.remove(user_id) + self.refilter(only_if) - def accept_source(self, source: Model) -> bool: - return source.sync_id == "accounts" or ( - isinstance(source.sync_id, tuple) and - len(source.sync_id) == 2 and - source.sync_id[1] == "rooms" - ) + def accept_source(self, source: Model) -> bool: + return source.sync_id == "accounts" or ( + isinstance(source.sync_id, tuple) and + len(source.sync_id) == 2 and + source.sync_id[1] == "rooms" + ) - def convert_item(self, item: ModelItem) -> AccountOrRoom: - return AccountOrRoom( - **asdict(item), - type = type(item), # type: ignore + def convert_item(self, item: ModelItem) -> AccountOrRoom: + return AccountOrRoom( + **asdict(item), + type = type(item), # type: ignore - account_order = - item.order if isinstance(item, Account) else - self.accounts[item.for_account].order, # type: ignore - ) + account_order = + item.order if isinstance(item, Account) else + self.accounts[item.for_account].order, # type: ignore + ) - def accept_item(self, item: ModelItem) -> bool: - assert isinstance(item, AccountOrRoom) # nosec + def accept_item(self, item: ModelItem) -> bool: + assert isinstance(item, AccountOrRoom) # nosec - if not self.filter and \ - item.type is Room and \ - item.for_account in self._collapsed: - return False + if not self.filter and \ + item.type is Room and \ + item.for_account in self._collapsed: + return False - matches_filter = super().accept_item(item) + matches_filter = super().accept_item(item) - if item.type is not Account or not self.filter: - return matches_filter + if item.type is not Account or not self.filter: + return matches_filter - return next( - (i for i in self.values() if i.for_account == item.id), False, - ) + return next( + (i for i in self.values() if i.for_account == item.id), False, + ) - def refilter_accounts(self) -> None: - self.refilter(lambda i: i.type is Account) # type: ignore + def refilter_accounts(self) -> None: + self.refilter(lambda i: i.type is Account) # type: ignore class MatchingAccounts(ModelFilter): - """List of our accounts in `AllRooms` with at least one matching room if - a `filter` is set, else list of all accounts. - """ + """List of our accounts in `AllRooms` with at least one matching room if + a `filter` is set, else list of all accounts. + """ - def __init__(self, all_rooms: AllRooms) -> None: - self.all_rooms = all_rooms - self.all_rooms.items_changed_callbacks.append(self.refilter) + def __init__(self, all_rooms: AllRooms) -> None: + self.all_rooms = all_rooms + self.all_rooms.items_changed_callbacks.append(self.refilter) - super().__init__(sync_id="matching_accounts") + super().__init__(sync_id="matching_accounts") - def accept_source(self, source: Model) -> bool: - return source.sync_id == "accounts" + def accept_source(self, source: Model) -> bool: + return source.sync_id == "accounts" - def accept_item(self, item: ModelItem) -> bool: - if not self.all_rooms.filter: - return True + def accept_item(self, item: ModelItem) -> bool: + if not self.all_rooms.filter: + return True - return next( - (i for i in self.all_rooms.values() if i.id == item.id), - False, - ) + return next( + (i for i in self.all_rooms.values() if i.id == item.id), + False, + ) class FilteredMembers(FieldSubstringFilter): - """Filtered list of members for a room.""" + """Filtered list of members for a room.""" - def __init__(self, user_id: str, room_id: str) -> None: - self.user_id = user_id - self.room_id = room_id - sync_id = (user_id, room_id, "filtered_members") + def __init__(self, user_id: str, room_id: str) -> None: + self.user_id = user_id + self.room_id = room_id + sync_id = (user_id, room_id, "filtered_members") - super().__init__(sync_id=sync_id, fields=("display_name",)) + super().__init__(sync_id=sync_id, fields=("display_name",)) - def accept_source(self, source: Model) -> bool: - return source.sync_id == (self.user_id, self.room_id, "members") + def accept_source(self, source: Model) -> bool: + return source.sync_id == (self.user_id, self.room_id, "members") class AutoCompletedMembers(FieldStringFilter): - """Filtered list of mentionable members for tab-completion.""" + """Filtered list of mentionable members for tab-completion.""" - def __init__(self, user_id: str, room_id: str) -> None: - self.user_id = user_id - self.room_id = room_id - sync_id = (user_id, room_id, "autocompleted_members") + def __init__(self, user_id: str, room_id: str) -> None: + self.user_id = user_id + self.room_id = room_id + sync_id = (user_id, room_id, "autocompleted_members") - super().__init__( - sync_id = sync_id, - fields = ("display_name", "id"), - no_filter_accept_all_items = False, - ) + super().__init__( + sync_id = sync_id, + fields = ("display_name", "id"), + no_filter_accept_all_items = False, + ) - def accept_source(self, source: Model) -> bool: - return source.sync_id == (self.user_id, self.room_id, "members") + def accept_source(self, source: Model) -> bool: + return source.sync_id == (self.user_id, self.room_id, "members") - def match(self, fields: Dict[str, str], filtr: str) -> bool: - fields["id"] = fields["id"][1:] # remove leading @ - return super().match(fields, filtr) + def match(self, fields: Dict[str, str], filtr: str) -> bool: + fields["id"] = fields["id"][1:] # remove leading @ + return super().match(fields, filtr) class FilteredHomeservers(FieldSubstringFilter): - """Filtered list of public Matrix homeservers.""" + """Filtered list of public Matrix homeservers.""" - def __init__(self) -> None: - super().__init__(sync_id="filtered_homeservers", fields=("id", "name")) + def __init__(self) -> None: + super().__init__(sync_id="filtered_homeservers", fields=("id", "name")) - def accept_source(self, source: Model) -> bool: - return source.sync_id == "homeservers" + def accept_source(self, source: Model) -> bool: + return source.sync_id == "homeservers" diff --git a/src/backend/nio_callbacks.py b/src/backend/nio_callbacks.py index 8913a359..4d833a0d 100644 --- a/src/backend/nio_callbacks.py +++ b/src/backend/nio_callbacks.py @@ -20,927 +20,929 @@ from .pyotherside_events import DevicesUpdated from .utils import classes_defined_in, plain2html if TYPE_CHECKING: - from .matrix_client import MatrixClient + from .matrix_client import MatrixClient @dataclass class NioCallbacks: - """Register callbacks for nio's request responses and events. + """Register callbacks for nio's request responses and events. - For every class defined in the `nio.responses` and `nio.events` modules, - this class can have a method named - `on` (e.g. `onRoomMessageText`) that will - automatically be registered in the `client`'s callbacks. + For every class defined in the `nio.responses` and `nio.events` modules, + this class can have a method named + `on` (e.g. `onRoomMessageText`) that will + automatically be registered in the `client`'s callbacks. - For room event content strings, the `%1` and `%2` placeholders - refer to the event's sender and who this event targets (`state_key`) or - the redactor of this event. - These are processed from QML, to allow for future translations of - the strings. - """ + For room event content strings, the `%1` and `%2` placeholders + refer to the event's sender and who this event targets (`state_key`) or + the redactor of this event. + These are processed from QML, to allow for future translations of + the strings. + """ - client: "MatrixClient" = field() + client: "MatrixClient" = field() - def __post_init__(self) -> None: - """Register our methods as callbacks.""" + def __post_init__(self) -> None: + """Register our methods as callbacks.""" - self.models = self.client.models + self.models = self.client.models - for name, response_class in classes_defined_in(nio.responses).items(): - method = getattr(self, f"on{name}", None) + for name, response_class in classes_defined_in(nio.responses).items(): + method = getattr(self, f"on{name}", None) - if method: - self.client.add_response_callback(method, response_class) + if method: + self.client.add_response_callback(method, response_class) - for name, ev_class in classes_defined_in(nio.events).items(): - method = getattr(self, f"on{name}", None) + for name, ev_class in classes_defined_in(nio.events).items(): + method = getattr(self, f"on{name}", None) - if not method: - continue + if not method: + continue - if issubclass(ev_class, nio.EphemeralEvent): - self.client.add_ephemeral_callback(method, ev_class) - elif issubclass(ev_class, nio.ToDeviceEvent): - self.client.add_to_device_callback(method, ev_class) - elif issubclass(ev_class, nio.AccountDataEvent): - self.client.add_global_account_data_callback(method, ev_class) - elif issubclass(ev_class, nio.PresenceEvent): - self.client.add_presence_callback(method, ev_class) - else: - self.client.add_event_callback(method, ev_class) + if issubclass(ev_class, nio.EphemeralEvent): + self.client.add_ephemeral_callback(method, ev_class) + elif issubclass(ev_class, nio.ToDeviceEvent): + self.client.add_to_device_callback(method, ev_class) + elif issubclass(ev_class, nio.AccountDataEvent): + self.client.add_global_account_data_callback(method, ev_class) + elif issubclass(ev_class, nio.PresenceEvent): + self.client.add_presence_callback(method, ev_class) + else: + self.client.add_event_callback(method, ev_class) - @property - def user_id(self) -> str: - return self.client.user_id + @property + def user_id(self) -> str: + return self.client.user_id - # Response callbacks + # Response callbacks - async def onSyncResponse(self, resp: nio.SyncResponse) -> None: - for room_id in resp.rooms.invite: - await self.client.register_nio_room(self.client.all_rooms[room_id]) + async def onSyncResponse(self, resp: nio.SyncResponse) -> None: + for room_id in resp.rooms.invite: + await self.client.register_nio_room(self.client.all_rooms[room_id]) - for room_id, info in resp.rooms.join.items(): - await self.client.register_nio_room(self.client.rooms[room_id]) + for room_id, info in resp.rooms.join.items(): + log.info("%s %a",room_id,room_id in self.client.past_tokens) + await self.client.register_nio_room(self.client.rooms[room_id]) - if room_id not in self.client.past_tokens: - self.client.past_tokens[room_id] = info.timeline.prev_batch + if room_id not in self.client.past_tokens: + log.info("adding %s to %s's past_tokens", info.timeline.prev_batch,room_id) + self.client.past_tokens[room_id] = info.timeline.prev_batch - for ev in info.state: - if isinstance(ev, nio.PowerLevelsEvent): - stored = self.client.power_level_events.get(room_id) - time = ev.server_timestamp + for ev in info.state: + if isinstance(ev, nio.PowerLevelsEvent): + stored = self.client.power_level_events.get(room_id) + time = ev.server_timestamp - if not stored or time > stored.server_timestamp: - self.client.power_level_events[room_id] = ev + if not stored or time > stored.server_timestamp: + self.client.power_level_events[room_id] = ev - # TODO: way of knowing if a nio.MatrixRoom is left - for room_id, info in resp.rooms.leave.items(): - # We forgot this room or rejected an invite and ignored the sender - if room_id in self.client.ignored_rooms: - continue + # TODO: way of knowing if a nio.MatrixRoom is left + for room_id, info in resp.rooms.leave.items(): + # We forgot this room or rejected an invite and ignored the sender + if room_id in self.client.ignored_rooms: + continue - # TODO: handle in nio, these are rooms that were left before - # starting the client. - if room_id not in self.client.all_rooms: - continue + # TODO: handle in nio, these are rooms that were left before + # starting the client. + if room_id not in self.client.all_rooms: + continue - # TODO: handle left events in nio async client - for ev in info.timeline.events: - if isinstance(ev, nio.RoomMemberEvent): - await self.onRoomMemberEvent( - self.client.all_rooms[room_id], ev, - ) + # TODO: handle left events in nio async client + for ev in info.timeline.events: + if isinstance(ev, nio.RoomMemberEvent): + await self.onRoomMemberEvent( + self.client.all_rooms[room_id], ev, + ) - await self.client.register_nio_room( - self.client.all_rooms[room_id], left=True, - ) + await self.client.register_nio_room( + self.client.all_rooms[room_id], left=True, + ) - account = self.models["accounts"][self.user_id] - account.connecting = False + account = self.models["accounts"][self.user_id] + account.connecting = False + + if not self.client.first_sync_done.is_set(): + self.client.first_sync_done.set() + self.client.first_sync_date = datetime.now() + + + async def onKeysQueryResponse(self, resp: nio.KeysQueryResponse) -> None: + refresh_rooms = {} + clients = self.client.backend.clients + + for user_id in resp.changed: + for room in self.client.rooms.values(): + if user_id in room.users: + refresh_rooms[room.room_id] = room + + if user_id != self.user_id and user_id in clients: + await self.client.auto_verify_account(clients[user_id]) - if not self.client.first_sync_done.is_set(): - self.client.first_sync_done.set() - self.client.first_sync_date = datetime.now() + for room_id, room in refresh_rooms.items(): + room_item = self.models[self.user_id, "rooms"].get(room_id) + + if room_item: + room_item.unverified_devices = \ + self.client.room_contains_unverified(room_id) + else: + await self.client.register_nio_room(room) + + DevicesUpdated(self.user_id) + + + # Room events, invite events and misc events callbacks + + async def onRoomMessageText( + self, room: nio.MatrixRoom, ev: nio.RoomMessageText, + ) -> None: + co = HTML_PROCESSOR.filter( + ev.formatted_body + if ev.format == "org.matrix.custom.html" else + plain2html(ev.body), + ) + + mention_list = HTML_PROCESSOR.mentions_in_html(co) + + await self.client.register_nio_event( + room, ev, content=co, mentions=mention_list, + ) + + + async def onRoomMessageNotice( + self, room: nio.MatrixRoom, ev: nio.RoomMessageNotice, + ) -> None: + await self.onRoomMessageText(room, ev) + + + async def onRoomMessageEmote( + self, room: nio.MatrixRoom, ev: nio.RoomMessageEmote, + ) -> None: + await self.onRoomMessageText(room, ev) + + + async def onRoomMessageUnknown( + self, room: nio.MatrixRoom, ev: nio.RoomMessageUnknown, + ) -> None: + co = f"%1 sent an unsupported {escape(ev.msgtype)} message" + await self.client.register_nio_event(room, ev, content=co) + + + async def onRoomMessageMedia( + self, room: nio.MatrixRoom, ev: nio.RoomMessageMedia, + ) -> None: + info = ev.source["content"].get("info", {}) + media_crypt_dict = ev.source["content"].get("file", {}) + thumb_info = info.get("thumbnail_info", {}) + thumb_crypt_dict = info.get("thumbnail_file", {}) + + try: + media_local_path: Union[Path, str] = await Media( + cache = self.client.backend.media_cache, + client_user_id = self.user_id, + mxc = ev.url, + title = ev.body, + room_id = room.room_id, + filesize = info.get("size") or 0, + crypt_dict = media_crypt_dict, + ).get_local() + except FileNotFoundError: + media_local_path = "" + + item = await self.client.register_nio_event( + room, + ev, + content = "", + inline_content = ev.body, + + media_url = ev.url, + media_http_url = await self.client.mxc_to_http(ev.url), + media_title = ev.body, + media_width = info.get("w") or 0, + media_height = info.get("h") or 0, + media_duration = info.get("duration") or 0, + media_size = info.get("size") or 0, + media_mime = info.get("mimetype") or "", + media_crypt_dict = media_crypt_dict, + media_local_path = media_local_path, + + thumbnail_url = + info.get("thumbnail_url") or thumb_crypt_dict.get("url") or "", + + thumbnail_width = thumb_info.get("w") or 0, + thumbnail_height = thumb_info.get("h") or 0, + thumbnail_mime = thumb_info.get("mimetype") or "", + thumbnail_crypt_dict = thumb_crypt_dict, + ) + + self.client.backend.mxc_events[ev.url].append(item) + + + async def onRoomEncryptedMedia( + self, room: nio.MatrixRoom, ev: nio.RoomEncryptedMedia, + ) -> None: + await self.onRoomMessageMedia(room, ev) + + + async def onRedactionEvent( + self, room: nio.MatrixRoom, ev: nio.RedactionEvent, + ) -> None: + model = self.models[self.user_id, room.room_id, "events"] + event = None + + for existing in model._sorted_data: + if existing.event_id == ev.redacts: + event = existing + break + + if not ( + event and + (event.event_type is not nio.RedactedEvent or event.is_local_echo) + ): + await self.client.register_nio_room(room) + return + + event.source.source["content"] = {} + event.source.source["unsigned"] = { + "redacted_by": ev.event_id, + "redacted_because": ev.source, + } + + await self.onRedactedEvent( + room, + nio.RedactedEvent.from_dict(event.source.source), + event_id = event.id, + ) + + + async def onRedactedEvent( + self, room: nio.MatrixRoom, ev: nio.RedactedEvent, event_id: str = "", + ) -> None: + redacter_name, _, must_fetch_redacter = \ + await self.client.get_member_profile(room.room_id, ev.redacter) \ + if ev.redacter else ("", "", False) + + await self.client.register_nio_event( + room, + ev, + event_id = event_id, + reason = ev.reason or "", + + content = await self.client.get_redacted_event_content( + type(ev), ev.redacter, ev.sender, ev.reason, + ), + + mentions = [], + type_specifier = TypeSpecifier.Unset, + media_url = "", + media_http_url = "", + media_title = "", + media_local_path = "", + thumbnail_url = "", + redacter_id = ev.redacter or "", + redacter_name = redacter_name, + override_fetch_profile = True, + ) + + + async def onRoomCreateEvent( + self, room: nio.MatrixRoom, ev: nio.RoomCreateEvent, + ) -> None: + co = "%1 allowed users on other matrix servers to join this room" \ + if ev.federate else \ + "%1 blocked users on other matrix servers from joining this room" + await self.client.register_nio_event(room, ev, content=co) + + + async def onRoomGuestAccessEvent( + self, room: nio.MatrixRoom, ev: nio.RoomGuestAccessEvent, + ) -> None: + allowed = "allowed" if ev.guest_access == "can_join" else "forbad" + co = f"%1 {allowed} guests to join the room" + await self.client.register_nio_event(room, ev, content=co) + + + async def onRoomJoinRulesEvent( + self, room: nio.MatrixRoom, ev: nio.RoomJoinRulesEvent, + ) -> None: + access = "public" if ev.join_rule == "public" else "invite-only" + co = f"%1 made the room {access}" + await self.client.register_nio_event(room, ev, content=co) + + + async def onRoomHistoryVisibilityEvent( + self, room: nio.MatrixRoom, ev: nio.RoomHistoryVisibilityEvent, + ) -> None: + if ev.history_visibility == "shared": + to = "all room members" + elif ev.history_visibility == "world_readable": + to = "any member or outsider" + elif ev.history_visibility == "joined": + to = "all room members, since the time they joined" + elif ev.history_visibility == "invited": + to = "all room members, since the time they were invited" + else: + to = "???" + log.warning("Invalid visibility - %s", + json.dumps(vars(ev), indent=4)) + + co = f"%1 made future room history visible to {to}" + await self.client.register_nio_event(room, ev, content=co) + + + async def onPowerLevelsEvent( + self, room: nio.MatrixRoom, ev: nio.PowerLevelsEvent, + ) -> None: + levels = ev.power_levels + stored = self.client.power_level_events.get(room.room_id) + + if not stored or ev.server_timestamp > stored.server_timestamp: + self.client.power_level_events[room.room_id] = ev + + try: + previous = ev.source["unsigned"]["prev_content"] + except KeyError: + previous = {} + + users_previous = previous.get("users", {}) + events_previous = previous.get("events", {}) + + changes: List[Tuple[str, int, int]] = [] + event_changes: List[Tuple[str, int, int]] = [] + user_changes: List[Tuple[str, int, int]] = [] + + def lvl(level: int) -> str: + return ( + f"Admin ({level})" if level == 100 else + f"Moderator ({level})" if level >= 50 else + f"User ({level})" if level >= 0 else + f"Muted ({level})" + ) + + def format_defaults_dict( + levels: Dict[str, Union[int, dict]], + previous: Dict[str, Union[int, dict]], + prefix: str = "", + ) -> None: + + default_0 = ("users_default", "events_default", "invite") + + for name in set({**levels, **previous}): + if not prefix and name in ("users", "events"): + continue + + old_level = previous.get( + name, 0 if not prefix and name in default_0 else 50, + ) + level = levels.get( + name, 0 if not prefix and name in default_0 else 50, + ) + + if isinstance(level, dict): + if not isinstance(old_level, dict): + old_level = {} + + format_defaults_dict(level, old_level, f"{prefix}{name}.") + continue + + if not isinstance(old_level, int): + old_level = 50 + + if old_level != level or not previous: + changes.append((f"{prefix}{name}", old_level, level)) + + format_defaults_dict(ev.source["content"], previous) + + # Minimum level to send event changes + + for ev_type in set({**levels.events, **events_previous}): + old_level = events_previous.get( + ev_type, + + levels.defaults.state_default + if ev_type.startswith("m.room.") else + levels.defaults.events_default, + ) + level = levels.events.get( + ev_type, + + levels.defaults.state_default + if ev_type.startswith("m.room.") else + levels.defaults.events_default, + ) + + if old_level != level or not previous: + event_changes.append((ev_type, old_level, level)) + + # User level changes + + for user_id in set({**levels.users, **users_previous}): + old_level = \ + users_previous.get(user_id, levels.defaults.users_default) + + level = levels.users.get(user_id, levels.defaults.users_default) + + if old_level != level or not previous: + user_changes.append((user_id, old_level, level)) + + if user_id in room.users: + await self.client.add_member(room, user_id) + + # Gather and format changes + + if changes or event_changes or user_changes: + changes.sort(key=lambda c: (c[2], c[0])) + event_changes.sort(key=lambda c: (c[2], c[0])) + user_changes.sort(key=lambda c: (c[2], c[0])) + + all_changes = changes + event_changes + user_changes + + if len(all_changes) == 1: + co = HTML_PROCESSOR.from_markdown( + "%%1 changed the level for **%s**: %s → %s " % ( + all_changes[0][0], + lvl(all_changes[0][1]).lower(), + lvl(all_changes[0][2]).lower(), + ), + inline = True, + ) + else: + co = HTML_PROCESSOR.from_markdown("\n".join([ + "%1 changed the room's permissions", + "", + "Change | Previous | Current ", + "--- | --- | ---", + *[ + f"{name} | {lvl(old)} | {lvl(now)}" + for name, old, now in all_changes + ], + ])) + else: + co = "%1 didn't change the room's permissions" + + await self.client.register_nio_event(room, ev, content=co) + + + async def process_room_member_event( + self, room: nio.MatrixRoom, ev: nio.RoomMemberEvent, + ) -> Optional[Tuple[TypeSpecifier, str]]: + """Return a `TypeSpecifier` and string describing a member event. + + Matrix member events can represent many actions: + a user joined the room, a user banned another, a user changed their + display name, etc. + """ + if ev.prev_content == ev.content: + return None + + prev = ev.prev_content + now = ev.content + membership = ev.membership + prev_membership = ev.prev_membership + ev_date = datetime.fromtimestamp(ev.server_timestamp / 1000) + + member_change = TypeSpecifier.MembershipChange + + # Membership changes + if not prev or membership != prev_membership: + if not self.client.backend.settings.Chat.show_membership_events: + return None + + reason = escape( + f", reason: {now['reason']}" if now.get("reason") else "", + ) + + if membership == "join": + return ( + member_change, + "%1 accepted their invitation" + if prev and prev_membership == "invite" else + "%1 joined the room", + ) + + if membership == "invite": + return (member_change, "%1 invited %2 to the room") + + if membership == "leave": + if ev.state_key == ev.sender: + return ( + member_change, + f"%1 declined their invitation{reason}" + if prev and prev_membership == "invite" else + f"%1 left the room{reason}", + ) + + return ( + member_change, + + f"%1 withdrew %2's invitation{reason}" + if prev and prev_membership == "invite" else + + f"%1 unbanned %2 from the room{reason}" + if prev and prev_membership == "ban" else + + f"%1 kicked %2 out from the room{reason}", + ) + + if membership == "ban": + return (member_change, f"%1 banned %2 from the room{reason}") + + # Profile changes + changed = [] + + if prev and now.get("avatar_url") != prev.get("avatar_url"): + changed.append("profile picture") # TODO: s + + if prev and now.get("displayname") != prev.get("displayname"): + changed.append('display name from "{}" to "{}"'.format( + escape(prev.get("displayname") or ev.state_key), + escape(now.get("displayname") or ev.state_key), + )) + + if changed: + # Update our account profile if the event is newer than last update + if ev.state_key == self.user_id: + account = self.models["accounts"][self.user_id] + + if account.profile_updated < ev_date: + account.set_fields( + profile_updated = ev_date, + display_name = now.get("displayname") or "", + avatar_url = now.get("avatar_url") or "", + ) + + if not self.client.backend.settings.Chat.show_profile_changes: + return None + + return ( + TypeSpecifier.ProfileChange, + "%1 changed their {}".format(" and ".join(changed)), + ) + # log.warning("Unknown member ev.: %s", json.dumps(vars(ev), indent=4)) + return None + + + async def onRoomMemberEvent( + self, room: nio.MatrixRoom, ev: nio.RoomMemberEvent, + ) -> None: + # The event can be a past event, don't trust it to update the model + # room's current state. + if ev.state_key in room.users: + await self.client.add_member(room, user_id=ev.state_key) + else: + await self.client.remove_member(room, user_id=ev.state_key) + + type_and_content = await self.process_room_member_event(room, ev) + + if type_and_content is not None: + type_specifier, content = type_and_content + + await self.client.register_nio_event( + room, ev, content=content, type_specifier=type_specifier, + ) + else: + # Normally, register_nio_event() will call register_nio_room(). + # but in this case we don't have any event we want to register. + await self.client.register_nio_room(room) - async def onKeysQueryResponse(self, resp: nio.KeysQueryResponse) -> None: - refresh_rooms = {} - clients = self.client.backend.clients - - for user_id in resp.changed: - for room in self.client.rooms.values(): - if user_id in room.users: - refresh_rooms[room.room_id] = room - - if user_id != self.user_id and user_id in clients: - await self.client.auto_verify_account(clients[user_id]) - - for room_id, room in refresh_rooms.items(): - room_item = self.models[self.user_id, "rooms"].get(room_id) - if room_item: - room_item.unverified_devices = \ - self.client.room_contains_unverified(room_id) - else: - await self.client.register_nio_room(room) + async def onRoomAliasEvent( + self, room: nio.MatrixRoom, ev: nio.RoomAliasEvent, + ) -> None: + if ev.canonical_alias: + url = f"https://matrix.to/#/{quote(ev.canonical_alias)}" + link = f"{escape(ev.canonical_alias)}" + co = f"%1 set the room's main address to {link}" + else: + co = "%1 removed the room's main address" - DevicesUpdated(self.user_id) + await self.client.register_nio_event(room, ev, content=co) - # Room events, invite events and misc events callbacks - - async def onRoomMessageText( - self, room: nio.MatrixRoom, ev: nio.RoomMessageText, - ) -> None: - co = HTML_PROCESSOR.filter( - ev.formatted_body - if ev.format == "org.matrix.custom.html" else - plain2html(ev.body), - ) - - mention_list = HTML_PROCESSOR.mentions_in_html(co) - - await self.client.register_nio_event( - room, ev, content=co, mentions=mention_list, - ) - - - async def onRoomMessageNotice( - self, room: nio.MatrixRoom, ev: nio.RoomMessageNotice, - ) -> None: - await self.onRoomMessageText(room, ev) - - - async def onRoomMessageEmote( - self, room: nio.MatrixRoom, ev: nio.RoomMessageEmote, - ) -> None: - await self.onRoomMessageText(room, ev) - - - async def onRoomMessageUnknown( - self, room: nio.MatrixRoom, ev: nio.RoomMessageUnknown, - ) -> None: - co = f"%1 sent an unsupported {escape(ev.msgtype)} message" - await self.client.register_nio_event(room, ev, content=co) - - - async def onRoomMessageMedia( - self, room: nio.MatrixRoom, ev: nio.RoomMessageMedia, - ) -> None: - info = ev.source["content"].get("info", {}) - media_crypt_dict = ev.source["content"].get("file", {}) - thumb_info = info.get("thumbnail_info", {}) - thumb_crypt_dict = info.get("thumbnail_file", {}) - - try: - media_local_path: Union[Path, str] = await Media( - cache = self.client.backend.media_cache, - client_user_id = self.user_id, - mxc = ev.url, - title = ev.body, - room_id = room.room_id, - filesize = info.get("size") or 0, - crypt_dict = media_crypt_dict, - ).get_local() - except FileNotFoundError: - media_local_path = "" - - item = await self.client.register_nio_event( - room, - ev, - content = "", - inline_content = ev.body, - - media_url = ev.url, - media_http_url = await self.client.mxc_to_http(ev.url), - media_title = ev.body, - media_width = info.get("w") or 0, - media_height = info.get("h") or 0, - media_duration = info.get("duration") or 0, - media_size = info.get("size") or 0, - media_mime = info.get("mimetype") or "", - media_crypt_dict = media_crypt_dict, - media_local_path = media_local_path, - - thumbnail_url = - info.get("thumbnail_url") or thumb_crypt_dict.get("url") or "", - - thumbnail_width = thumb_info.get("w") or 0, - thumbnail_height = thumb_info.get("h") or 0, - thumbnail_mime = thumb_info.get("mimetype") or "", - thumbnail_crypt_dict = thumb_crypt_dict, - ) - - self.client.backend.mxc_events[ev.url].append(item) - - - async def onRoomEncryptedMedia( - self, room: nio.MatrixRoom, ev: nio.RoomEncryptedMedia, - ) -> None: - await self.onRoomMessageMedia(room, ev) - - - async def onRedactionEvent( - self, room: nio.MatrixRoom, ev: nio.RedactionEvent, - ) -> None: - model = self.models[self.user_id, room.room_id, "events"] - event = None - - for existing in model._sorted_data: - if existing.event_id == ev.redacts: - event = existing - break - - if not ( - event and - (event.event_type is not nio.RedactedEvent or event.is_local_echo) - ): - await self.client.register_nio_room(room) - return - - event.source.source["content"] = {} - event.source.source["unsigned"] = { - "redacted_by": ev.event_id, - "redacted_because": ev.source, - } - - await self.onRedactedEvent( - room, - nio.RedactedEvent.from_dict(event.source.source), - event_id = event.id, - ) - - - async def onRedactedEvent( - self, room: nio.MatrixRoom, ev: nio.RedactedEvent, event_id: str = "", - ) -> None: - redacter_name, _, must_fetch_redacter = \ - await self.client.get_member_profile(room.room_id, ev.redacter) \ - if ev.redacter else ("", "", False) - - await self.client.register_nio_event( - room, - ev, - event_id = event_id, - reason = ev.reason or "", - - content = await self.client.get_redacted_event_content( - type(ev), ev.redacter, ev.sender, ev.reason, - ), - - mentions = [], - type_specifier = TypeSpecifier.Unset, - media_url = "", - media_http_url = "", - media_title = "", - media_local_path = "", - thumbnail_url = "", - redacter_id = ev.redacter or "", - redacter_name = redacter_name, - override_fetch_profile = True, - ) - - - async def onRoomCreateEvent( - self, room: nio.MatrixRoom, ev: nio.RoomCreateEvent, - ) -> None: - co = "%1 allowed users on other matrix servers to join this room" \ - if ev.federate else \ - "%1 blocked users on other matrix servers from joining this room" - await self.client.register_nio_event(room, ev, content=co) - - - async def onRoomGuestAccessEvent( - self, room: nio.MatrixRoom, ev: nio.RoomGuestAccessEvent, - ) -> None: - allowed = "allowed" if ev.guest_access == "can_join" else "forbad" - co = f"%1 {allowed} guests to join the room" - await self.client.register_nio_event(room, ev, content=co) - - - async def onRoomJoinRulesEvent( - self, room: nio.MatrixRoom, ev: nio.RoomJoinRulesEvent, - ) -> None: - access = "public" if ev.join_rule == "public" else "invite-only" - co = f"%1 made the room {access}" - await self.client.register_nio_event(room, ev, content=co) - - - async def onRoomHistoryVisibilityEvent( - self, room: nio.MatrixRoom, ev: nio.RoomHistoryVisibilityEvent, - ) -> None: - if ev.history_visibility == "shared": - to = "all room members" - elif ev.history_visibility == "world_readable": - to = "any member or outsider" - elif ev.history_visibility == "joined": - to = "all room members, since the time they joined" - elif ev.history_visibility == "invited": - to = "all room members, since the time they were invited" - else: - to = "???" - log.warning("Invalid visibility - %s", - json.dumps(vars(ev), indent=4)) - - co = f"%1 made future room history visible to {to}" - await self.client.register_nio_event(room, ev, content=co) - - - async def onPowerLevelsEvent( - self, room: nio.MatrixRoom, ev: nio.PowerLevelsEvent, - ) -> None: - levels = ev.power_levels - stored = self.client.power_level_events.get(room.room_id) - - if not stored or ev.server_timestamp > stored.server_timestamp: - self.client.power_level_events[room.room_id] = ev - - try: - previous = ev.source["unsigned"]["prev_content"] - except KeyError: - previous = {} - - users_previous = previous.get("users", {}) - events_previous = previous.get("events", {}) - - changes: List[Tuple[str, int, int]] = [] - event_changes: List[Tuple[str, int, int]] = [] - user_changes: List[Tuple[str, int, int]] = [] - - def lvl(level: int) -> str: - return ( - f"Admin ({level})" if level == 100 else - f"Moderator ({level})" if level >= 50 else - f"User ({level})" if level >= 0 else - f"Muted ({level})" - ) - - def format_defaults_dict( - levels: Dict[str, Union[int, dict]], - previous: Dict[str, Union[int, dict]], - prefix: str = "", - ) -> None: - - default_0 = ("users_default", "events_default", "invite") - - for name in set({**levels, **previous}): - if not prefix and name in ("users", "events"): - continue - - old_level = previous.get( - name, 0 if not prefix and name in default_0 else 50, - ) - level = levels.get( - name, 0 if not prefix and name in default_0 else 50, - ) - - if isinstance(level, dict): - if not isinstance(old_level, dict): - old_level = {} - - format_defaults_dict(level, old_level, f"{prefix}{name}.") - continue - - if not isinstance(old_level, int): - old_level = 50 - - if old_level != level or not previous: - changes.append((f"{prefix}{name}", old_level, level)) - - format_defaults_dict(ev.source["content"], previous) - - # Minimum level to send event changes - - for ev_type in set({**levels.events, **events_previous}): - old_level = events_previous.get( - ev_type, - - levels.defaults.state_default - if ev_type.startswith("m.room.") else - levels.defaults.events_default, - ) - level = levels.events.get( - ev_type, - - levels.defaults.state_default - if ev_type.startswith("m.room.") else - levels.defaults.events_default, - ) - - if old_level != level or not previous: - event_changes.append((ev_type, old_level, level)) - - # User level changes - - for user_id in set({**levels.users, **users_previous}): - old_level = \ - users_previous.get(user_id, levels.defaults.users_default) - - level = levels.users.get(user_id, levels.defaults.users_default) - - if old_level != level or not previous: - user_changes.append((user_id, old_level, level)) - - if user_id in room.users: - await self.client.add_member(room, user_id) - - # Gather and format changes - - if changes or event_changes or user_changes: - changes.sort(key=lambda c: (c[2], c[0])) - event_changes.sort(key=lambda c: (c[2], c[0])) - user_changes.sort(key=lambda c: (c[2], c[0])) - - all_changes = changes + event_changes + user_changes - - if len(all_changes) == 1: - co = HTML_PROCESSOR.from_markdown( - "%%1 changed the level for **%s**: %s → %s " % ( - all_changes[0][0], - lvl(all_changes[0][1]).lower(), - lvl(all_changes[0][2]).lower(), - ), - inline = True, - ) - else: - co = HTML_PROCESSOR.from_markdown("\n".join([ - "%1 changed the room's permissions", - "", - "Change | Previous | Current ", - "--- | --- | ---", - *[ - f"{name} | {lvl(old)} | {lvl(now)}" - for name, old, now in all_changes - ], - ])) - else: - co = "%1 didn't change the room's permissions" - - await self.client.register_nio_event(room, ev, content=co) - - - async def process_room_member_event( - self, room: nio.MatrixRoom, ev: nio.RoomMemberEvent, - ) -> Optional[Tuple[TypeSpecifier, str]]: - """Return a `TypeSpecifier` and string describing a member event. - - Matrix member events can represent many actions: - a user joined the room, a user banned another, a user changed their - display name, etc. - """ - if ev.prev_content == ev.content: - return None - - prev = ev.prev_content - now = ev.content - membership = ev.membership - prev_membership = ev.prev_membership - ev_date = datetime.fromtimestamp(ev.server_timestamp / 1000) - - member_change = TypeSpecifier.MembershipChange - - # Membership changes - if not prev or membership != prev_membership: - if not self.client.backend.settings.Chat.show_membership_events: - return None - - reason = escape( - f", reason: {now['reason']}" if now.get("reason") else "", - ) - - if membership == "join": - return ( - member_change, - "%1 accepted their invitation" - if prev and prev_membership == "invite" else - "%1 joined the room", - ) - - if membership == "invite": - return (member_change, "%1 invited %2 to the room") - - if membership == "leave": - if ev.state_key == ev.sender: - return ( - member_change, - f"%1 declined their invitation{reason}" - if prev and prev_membership == "invite" else - f"%1 left the room{reason}", - ) - - return ( - member_change, - - f"%1 withdrew %2's invitation{reason}" - if prev and prev_membership == "invite" else - - f"%1 unbanned %2 from the room{reason}" - if prev and prev_membership == "ban" else - - f"%1 kicked %2 out from the room{reason}", - ) - - if membership == "ban": - return (member_change, f"%1 banned %2 from the room{reason}") - - # Profile changes - changed = [] - - if prev and now.get("avatar_url") != prev.get("avatar_url"): - changed.append("profile picture") # TODO: s - - if prev and now.get("displayname") != prev.get("displayname"): - changed.append('display name from "{}" to "{}"'.format( - escape(prev.get("displayname") or ev.state_key), - escape(now.get("displayname") or ev.state_key), - )) - - if changed: - # Update our account profile if the event is newer than last update - if ev.state_key == self.user_id: - account = self.models["accounts"][self.user_id] - - if account.profile_updated < ev_date: - account.set_fields( - profile_updated = ev_date, - display_name = now.get("displayname") or "", - avatar_url = now.get("avatar_url") or "", - ) - - if not self.client.backend.settings.Chat.show_profile_changes: - return None - - return ( - TypeSpecifier.ProfileChange, - "%1 changed their {}".format(" and ".join(changed)), - ) + async def onRoomNameEvent( + self, room: nio.MatrixRoom, ev: nio.RoomNameEvent, + ) -> None: + if ev.name: + co = f"%1 changed the room's name to \"{escape(ev.name)}\"" + else: + co = "%1 removed the room's name" - # log.warning("Unknown member ev.: %s", json.dumps(vars(ev), indent=4)) - return None - - - async def onRoomMemberEvent( - self, room: nio.MatrixRoom, ev: nio.RoomMemberEvent, - ) -> None: - # The event can be a past event, don't trust it to update the model - # room's current state. - if ev.state_key in room.users: - await self.client.add_member(room, user_id=ev.state_key) - else: - await self.client.remove_member(room, user_id=ev.state_key) - - type_and_content = await self.process_room_member_event(room, ev) - - if type_and_content is not None: - type_specifier, content = type_and_content - - await self.client.register_nio_event( - room, ev, content=content, type_specifier=type_specifier, - ) - else: - # Normally, register_nio_event() will call register_nio_room(). - # but in this case we don't have any event we want to register. - await self.client.register_nio_room(room) + await self.client.register_nio_event(room, ev, content=co) - async def onRoomAliasEvent( - self, room: nio.MatrixRoom, ev: nio.RoomAliasEvent, - ) -> None: - if ev.canonical_alias: - url = f"https://matrix.to/#/{quote(ev.canonical_alias)}" - link = f"{escape(ev.canonical_alias)}" - co = f"%1 set the room's main address to {link}" - else: - co = "%1 removed the room's main address" + async def onRoomAvatarEvent( + self, room: nio.MatrixRoom, ev: nio.RoomAvatarEvent, + ) -> None: + if ev.avatar_url: + co = "%1 changed the room's picture" + else: + co = "%1 removed the room's picture" - await self.client.register_nio_event(room, ev, content=co) + http = await self.client.mxc_to_http(ev.avatar_url) + await self.client.register_nio_event( + room, ev, content=co, media_url=ev.avatar_url, media_http_url=http, + ) - async def onRoomNameEvent( - self, room: nio.MatrixRoom, ev: nio.RoomNameEvent, - ) -> None: - if ev.name: - co = f"%1 changed the room's name to \"{escape(ev.name)}\"" - else: - co = "%1 removed the room's name" - await self.client.register_nio_event(room, ev, content=co) + async def onRoomTopicEvent( + self, room: nio.MatrixRoom, ev: nio.RoomTopicEvent, + ) -> None: + if ev.topic: + topic = HTML_PROCESSOR.filter(plain2html(ev.topic), inline=True) + co = f"%1 changed the room's topic to \"{topic}\"" + else: + co = "%1 removed the room's topic" + + await self.client.register_nio_event(room, ev, content=co) - async def onRoomAvatarEvent( - self, room: nio.MatrixRoom, ev: nio.RoomAvatarEvent, - ) -> None: - if ev.avatar_url: - co = "%1 changed the room's picture" - else: - co = "%1 removed the room's picture" + async def onRoomEncryptionEvent( + self, room: nio.MatrixRoom, ev: nio.RoomEncryptionEvent, + ) -> None: + co = "%1 turned on encryption for this room" + await self.client.register_nio_event(room, ev, content=co) - http = await self.client.mxc_to_http(ev.avatar_url) - await self.client.register_nio_event( - room, ev, content=co, media_url=ev.avatar_url, media_http_url=http, - ) + async def onMegolmEvent( + self, room: nio.MatrixRoom, ev: nio.MegolmEvent, + ) -> None: + co = "%1 sent an undecryptable message" + await self.client.register_nio_event(room, ev, content=co) - async def onRoomTopicEvent( - self, room: nio.MatrixRoom, ev: nio.RoomTopicEvent, - ) -> None: - if ev.topic: - topic = HTML_PROCESSOR.filter(plain2html(ev.topic), inline=True) - co = f"%1 changed the room's topic to \"{topic}\"" - else: - co = "%1 removed the room's topic" - - await self.client.register_nio_event(room, ev, content=co) + async def onBadEvent( + self, room: nio.MatrixRoom, ev: nio.BadEvent, + ) -> None: + co = f"%1 sent a malformed {escape(ev.type)} event" + await self.client.register_nio_event(room, ev, content=co) - async def onRoomEncryptionEvent( - self, room: nio.MatrixRoom, ev: nio.RoomEncryptionEvent, - ) -> None: - co = "%1 turned on encryption for this room" - await self.client.register_nio_event(room, ev, content=co) + async def onUnknownEvent( + self, room: nio.MatrixRoom, ev: nio.UnknownEvent, + ) -> None: + if not self.client.backend.settings.Chat.show_unknown_events: + await self.client.register_nio_room(room) + return + co = f"%1 sent an unsupported {escape(ev.type)} event" + await self.client.register_nio_event(room, ev, content=co) - async def onMegolmEvent( - self, room: nio.MatrixRoom, ev: nio.MegolmEvent, - ) -> None: - co = "%1 sent an undecryptable message" - await self.client.register_nio_event(room, ev, content=co) + async def onUnknownEncryptedEvent( + self, room: nio.MatrixRoom, ev: nio.UnknownEncryptedEvent, + ) -> None: + co = ( + f"%1 sent an {escape(ev.type)} event encrypted with " + f"unsupported {escape(ev.algorithm)} algorithm" + ) + await self.client.register_nio_event(room, ev, content=co) - async def onBadEvent( - self, room: nio.MatrixRoom, ev: nio.BadEvent, - ) -> None: - co = f"%1 sent a malformed {escape(ev.type)} event" - await self.client.register_nio_event(room, ev, content=co) + async def onInviteEvent( + self, room: nio.MatrixRoom, ev: nio.InviteEvent, + ) -> None: + await self.client.register_nio_room(room) - async def onUnknownEvent( - self, room: nio.MatrixRoom, ev: nio.UnknownEvent, - ) -> None: - if not self.client.backend.settings.Chat.show_unknown_events: - await self.client.register_nio_room(room) - return - co = f"%1 sent an unsupported {escape(ev.type)} event" - await self.client.register_nio_event(room, ev, content=co) + # Ephemeral event callbacks + async def onTypingNoticeEvent( + self, room: nio.MatrixRoom, ev: nio.TypingNoticeEvent, + ) -> None: + # Prevent recent past typing notices from being shown for a split + # second on client startup: + if not self.client.first_sync_done.is_set(): + return - async def onUnknownEncryptedEvent( - self, room: nio.MatrixRoom, ev: nio.UnknownEncryptedEvent, - ) -> None: - co = ( - f"%1 sent an {escape(ev.type)} event encrypted with " - f"unsupported {escape(ev.algorithm)} algorithm" - ) - await self.client.register_nio_event(room, ev, content=co) + await self.client.register_nio_room(room) + room_id = room.room_id - async def onInviteEvent( - self, room: nio.MatrixRoom, ev: nio.InviteEvent, - ) -> None: - await self.client.register_nio_room(room) + room_item = self.models[self.user_id, "rooms"][room_id] + room_item.typing_members = sorted( + room.user_name(user_id) or user_id for user_id in ev.users + if user_id not in self.client.backend.clients + ) - # Ephemeral event callbacks - async def onTypingNoticeEvent( - self, room: nio.MatrixRoom, ev: nio.TypingNoticeEvent, - ) -> None: - # Prevent recent past typing notices from being shown for a split - # second on client startup: - if not self.client.first_sync_done.is_set(): - return + async def onReceiptEvent( + self, room: nio.MatrixRoom, ev: nio.ReceiptEvent, + ) -> None: + member_model = self.models[self.user_id, room.room_id, "members"] + event_model = self.models[self.user_id, room.room_id, "events"] + unassigned_mems = self.client.unassigned_member_last_read_event + unassigned_evs = self.client.unassigned_event_last_read_by + recount_markers = [] - await self.client.register_nio_room(room) + for receipt in ev.receipts: + if receipt.user_id in self.client.backend.clients: + continue - room_id = room.room_id + if receipt.receipt_type != "m.read": + continue - room_item = self.models[self.user_id, "rooms"][room_id] + echo_id = self.client.event_to_echo_ids.get(receipt.event_id) + read_event = event_model.get(echo_id or receipt.event_id) + timestamp = receipt.timestamp + + if read_event: + recount_markers.append(read_event) + read_event.last_read_by[receipt.user_id] = timestamp + read_event.notify_change("last_read_by") + else: + # We haven't received the read event from the server yet + unassigned_evs[receipt.event_id][receipt.user_id] = timestamp + + if receipt.user_id not in member_model: + # We haven't loaded the member yet (lazy loading), or they left + unassigned_mems[room.room_id, receipt.user_id] = \ + echo_id or receipt.event_id + continue + + member = member_model[receipt.user_id] + previous_read_event = event_model.get(member.last_read_event) + + if previous_read_event: + # Remove the read marker from the previous last read event + recount_markers.append(previous_read_event) + previous_read_event.last_read_by.pop(receipt.user_id, None) + previous_read_event.notify_change("last_read_by") - room_item.typing_members = sorted( - room.user_name(user_id) or user_id for user_id in ev.users - if user_id not in self.client.backend.clients - ) + member.last_read_event = echo_id or receipt.event_id + + for ev in recount_markers: + ev.read_by_count = len(ev.last_read_by) - async def onReceiptEvent( - self, room: nio.MatrixRoom, ev: nio.ReceiptEvent, - ) -> None: - member_model = self.models[self.user_id, room.room_id, "members"] - event_model = self.models[self.user_id, room.room_id, "events"] - unassigned_mems = self.client.unassigned_member_last_read_event - unassigned_evs = self.client.unassigned_event_last_read_by - recount_markers = [] - - for receipt in ev.receipts: - if receipt.user_id in self.client.backend.clients: - continue - - if receipt.receipt_type != "m.read": - continue - - echo_id = self.client.event_to_echo_ids.get(receipt.event_id) - read_event = event_model.get(echo_id or receipt.event_id) - timestamp = receipt.timestamp - - if read_event: - recount_markers.append(read_event) - read_event.last_read_by[receipt.user_id] = timestamp - read_event.notify_change("last_read_by") - else: - # We haven't received the read event from the server yet - unassigned_evs[receipt.event_id][receipt.user_id] = timestamp - - if receipt.user_id not in member_model: - # We haven't loaded the member yet (lazy loading), or they left - unassigned_mems[room.room_id, receipt.user_id] = \ - echo_id or receipt.event_id - continue - - member = member_model[receipt.user_id] - previous_read_event = event_model.get(member.last_read_event) - - if previous_read_event: - # Remove the read marker from the previous last read event - recount_markers.append(previous_read_event) - previous_read_event.last_read_by.pop(receipt.user_id, None) - previous_read_event.notify_change("last_read_by") - - member.last_read_event = echo_id or receipt.event_id - - for ev in recount_markers: - ev.read_by_count = len(ev.last_read_by) - - - # Account data callbacks - - async def onPushRulesEvent(self, ev: nio.PushRulesEvent) -> None: - async def update_affected_room(rule: PushRule) -> None: - affects_room: Optional[str] - - if rule.kind == nio.PushRuleKind.room: - affects_room = rule.rule_id - else: - affects_room = self.client._rule_overrides_room(rule) - - if affects_room in self.client.rooms: - nio_room = self.client.rooms[affects_room] - await self.client.register_nio_room(nio_room) - - model = self.models[self.user_id, "pushrules"] - - kinds: Dict[nio.PushRuleKind, List[nio.PushRule]] = { - kind: getattr(ev.global_rules, kind.value) - for kind in nio.PushRuleKind - } - - # Remove from model rules that are now deleted. - # MUST be done first to avoid having rules sharing the same kind+order. - - new_keys: Set[Tuple[str, str]] = set() - - for kind, rules in kinds.items(): - for rule in rules: - new_keys.add((kind.value, rule.id)) - - with model.batch_remove(): - for key, rule in list(model.items()): - if key not in new_keys: - del model[key] - await update_affected_room(rule) - - # Then, add new rules/modify changed existing ones - - for kind, rules in kinds.items(): - for order, rule in enumerate(rules): - tweaks = { - action.tweak: action.value for action in rule.actions - if isinstance(action, nio.PushSetTweak) - } - - # Note: The `dont_notify` action does nothing. - # As of now (sept 2020), `coalesce` is just a `notify` synonym. - notify = any( - isinstance(action, (nio.PushNotify, nio.PushCoalesce)) - for action in rule.actions - ) - - high = tweaks.get("highlight", False) is not False - bubble = tweaks.get("bubble", notify) is not False - sound = str(tweaks.get("sound") or "") - hint = tweaks.get("urgency_hint", bool(sound)) is not False - - rule_item = PushRule( - id = (kind.value, rule.id), - kind = kind, - rule_id = rule.id, - order = order, - default = rule.default, - enabled = rule.enabled, - conditions = [c.as_value for c in rule.conditions], - pattern = rule.pattern, - actions = [a.as_value for a in rule.actions], - notify = notify, - highlight = high, - bubble = bubble, - sound = sound, - urgency_hint = hint, - ) - model[kind.value, rule.id] = rule_item - await update_affected_room(rule_item) - - self.client.push_rules = ev - - - async def onUnknownAccountDataEvent( - self, ev: nio.UnknownAccountDataEvent, - ) -> None: - - if ev.type == "m.ignored_user_list": - users = set(ev.content.get("ignored_users", {})) - self.client.ignored_user_ids = users - self.models["accounts"][self.client.user_id].ignored_users = users - - - # Presence event callbacks - - async def onPresenceEvent( - self, ev: Union[nio.PresenceEvent, nio.PresenceGetResponse], - ) -> None: - - # Servers that send presence events support presence - self.models["accounts"][self.client.user_id].presence_support = True - - account = self.models["accounts"].get(ev.user_id) - presence = self.client.backend.presences.get(ev.user_id, Presence()) - invisible = False - - if account: - invisible = account.presence == Presence.State.invisible - client = self.client.backend.clients[ev.user_id] - - # Synapse is stupid enough to return an older presence state on - # sync, which then causes a never-ending loop of presence cycling. - # Let's hope they didn't screw up the get_presence API too: - ev = await client.get_presence(ev.user_id) - - if ev.presence == "offline" and not invisible: - to_set = account.presence.value - await client.set_presence(to_set, account.status_msg) - return - elif not (invisible and ev.presence != "offline"): - client._presence = ev.presence - - if invisible and ev.presence == "offline": - presence.presence = Presence.State.invisible - else: - presence.presence = Presence.State(ev.presence) - - presence.currently_active = ev.currently_active or False - - # Restore status msg lost from server due to e.g. getting offline - if account and account.status_msg and not ev.status_msg: - if invisible: - presence.status_msg = account.status_msg - else: - await client.set_presence(ev.presence, account.status_msg) - else: - presence.status_msg = ev.status_msg or "" - - if ev.last_active_ago: - presence.last_active_at = datetime.now() - timedelta( - milliseconds=ev.last_active_ago, - ) - else: - presence.last_active_at = datetime.fromtimestamp(0) - - # Add all existing members related to this presence - for room_id in self.models[self.user_id, "rooms"]: - members = self.models[self.user_id, room_id, "members"] - - if ev.user_id in members: - presence.members[room_id] = members[ev.user_id] - - presence.update_members() - - if not account: - self.client.backend.presences[ev.user_id] = presence - return - - client = self.client.backend.clients[ev.user_id] - - # Save the presence to be restored next time we restart application - if account.save_presence: - status_msg = presence.status_msg - state = presence.presence - - await self.client.backend.saved_accounts.set( - user_id = ev.user_id, - status_msg = status_msg, - presence = state.value, - ) - - presence.update_account() + # Account data callbacks + + async def onPushRulesEvent(self, ev: nio.PushRulesEvent) -> None: + async def update_affected_room(rule: PushRule) -> None: + affects_room: Optional[str] + + if rule.kind == nio.PushRuleKind.room: + affects_room = rule.rule_id + else: + affects_room = self.client._rule_overrides_room(rule) + + if affects_room in self.client.rooms: + nio_room = self.client.rooms[affects_room] + await self.client.register_nio_room(nio_room) + + model = self.models[self.user_id, "pushrules"] + + kinds: Dict[nio.PushRuleKind, List[nio.PushRule]] = { + kind: getattr(ev.global_rules, kind.value) + for kind in nio.PushRuleKind + } + + # Remove from model rules that are now deleted. + # MUST be done first to avoid having rules sharing the same kind+order. + + new_keys: Set[Tuple[str, str]] = set() + + for kind, rules in kinds.items(): + for rule in rules: + new_keys.add((kind.value, rule.id)) + + with model.batch_remove(): + for key, rule in list(model.items()): + if key not in new_keys: + del model[key] + await update_affected_room(rule) + + # Then, add new rules/modify changed existing ones + + for kind, rules in kinds.items(): + for order, rule in enumerate(rules): + tweaks = { + action.tweak: action.value for action in rule.actions + if isinstance(action, nio.PushSetTweak) + } + + # Note: The `dont_notify` action does nothing. + # As of now (sept 2020), `coalesce` is just a `notify` synonym. + notify = any( + isinstance(action, (nio.PushNotify, nio.PushCoalesce)) + for action in rule.actions + ) + + high = tweaks.get("highlight", False) is not False + bubble = tweaks.get("bubble", notify) is not False + sound = str(tweaks.get("sound") or "") + hint = tweaks.get("urgency_hint", bool(sound)) is not False + + rule_item = PushRule( + id = (kind.value, rule.id), + kind = kind, + rule_id = rule.id, + order = order, + default = rule.default, + enabled = rule.enabled, + conditions = [c.as_value for c in rule.conditions], + pattern = rule.pattern, + actions = [a.as_value for a in rule.actions], + notify = notify, + highlight = high, + bubble = bubble, + sound = sound, + urgency_hint = hint, + ) + model[kind.value, rule.id] = rule_item + await update_affected_room(rule_item) + + self.client.push_rules = ev + + + async def onUnknownAccountDataEvent( + self, ev: nio.UnknownAccountDataEvent, + ) -> None: + + if ev.type == "m.ignored_user_list": + users = set(ev.content.get("ignored_users", {})) + self.client.ignored_user_ids = users + self.models["accounts"][self.client.user_id].ignored_users = users + + + # Presence event callbacks + + async def onPresenceEvent( + self, ev: Union[nio.PresenceEvent, nio.PresenceGetResponse], + ) -> None: + + # Servers that send presence events support presence + self.models["accounts"][self.client.user_id].presence_support = True + + account = self.models["accounts"].get(ev.user_id) + presence = self.client.backend.presences.get(ev.user_id, Presence()) + invisible = False + + if account: + invisible = account.presence == Presence.State.invisible + client = self.client.backend.clients[ev.user_id] + + # Synapse is stupid enough to return an older presence state on + # sync, which then causes a never-ending loop of presence cycling. + # Let's hope they didn't screw up the get_presence API too: + ev = await client.get_presence(ev.user_id) + + if ev.presence == "offline" and not invisible: + to_set = account.presence.value + await client.set_presence(to_set, account.status_msg) + return + elif not (invisible and ev.presence != "offline"): + client._presence = ev.presence + + if invisible and ev.presence == "offline": + presence.presence = Presence.State.invisible + else: + presence.presence = Presence.State(ev.presence) + + presence.currently_active = ev.currently_active or False + + # Restore status msg lost from server due to e.g. getting offline + if account and account.status_msg and not ev.status_msg: + if invisible: + presence.status_msg = account.status_msg + else: + await client.set_presence(ev.presence, account.status_msg) + else: + presence.status_msg = ev.status_msg or "" + + if ev.last_active_ago: + presence.last_active_at = datetime.now() - timedelta( + milliseconds=ev.last_active_ago, + ) + else: + presence.last_active_at = datetime.fromtimestamp(0) + + # Add all existing members related to this presence + for room_id in self.models[self.user_id, "rooms"]: + members = self.models[self.user_id, room_id, "members"] + + if ev.user_id in members: + presence.members[room_id] = members[ev.user_id] + + presence.update_members() + + if not account: + self.client.backend.presences[ev.user_id] = presence + return + + client = self.client.backend.clients[ev.user_id] + + # Save the presence to be restored next time we restart application + if account.save_presence: + status_msg = presence.status_msg + state = presence.presence + + await self.client.backend.saved_accounts.set( + user_id = ev.user_id, + status_msg = status_msg, + presence = state.value, + ) + + presence.update_account() diff --git a/src/backend/pcn/globals_dict.py b/src/backend/pcn/globals_dict.py index 24a8c96d..11786283 100644 --- a/src/backend/pcn/globals_dict.py +++ b/src/backend/pcn/globals_dict.py @@ -2,46 +2,46 @@ from collections import UserDict from typing import TYPE_CHECKING, Any, Dict, Iterator if TYPE_CHECKING: - from .section import Section + from .section import Section from .. import color PCN_GLOBALS: Dict[str, Any] = { - "color": color.Color, - "hsluv": color.hsluv, - "hsluva": color.hsluva, - "hsl": color.hsl, - "hsla": color.hsla, - "rgb": color.rgb, - "rgba": color.rgba, + "color": color.Color, + "hsluv": color.hsluv, + "hsluva": color.hsluva, + "hsl": color.hsl, + "hsla": color.hsla, + "rgb": color.rgb, + "rgba": color.rgba, } class GlobalsDict(UserDict): - def __init__(self, section: "Section") -> None: - super().__init__() - self.section = section + def __init__(self, section: "Section") -> None: + super().__init__() + self.section = section - @property - def full_dict(self) -> Dict[str, Any]: - return { - **PCN_GLOBALS, - **(self.section.root if self.section.root else {}), - **(self.section.root.globals if self.section.root else {}), - "self": self.section, - "parent": self.section.parent, - "root": self.section.parent, - **self.data, - } + @property + def full_dict(self) -> Dict[str, Any]: + return { + **PCN_GLOBALS, + **(self.section.root if self.section.root else {}), + **(self.section.root.globals if self.section.root else {}), + "self": self.section, + "parent": self.section.parent, + "root": self.section.parent, + **self.data, + } - def __getitem__(self, key: str) -> Any: - return self.full_dict[key] + def __getitem__(self, key: str) -> Any: + return self.full_dict[key] - def __iter__(self) -> Iterator[str]: - return iter(self.full_dict) + def __iter__(self) -> Iterator[str]: + return iter(self.full_dict) - def __len__(self) -> int: - return len(self.full_dict) + def __len__(self) -> int: + return len(self.full_dict) - def __repr__(self) -> str: - return repr(self.full_dict) + def __repr__(self) -> str: + return repr(self.full_dict) diff --git a/src/backend/pcn/property.py b/src/backend/pcn/property.py index 26c67657..601fd0f2 100644 --- a/src/backend/pcn/property.py +++ b/src/backend/pcn/property.py @@ -3,50 +3,50 @@ from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any, Callable, Dict, Type if TYPE_CHECKING: - from .section import Section + from .section import Section TYPE_PROCESSORS: Dict[str, Callable[[Any], Any]] = { - "tuple": lambda v: tuple(v), - "set": lambda v: set(v), + "tuple": lambda v: tuple(v), + "set": lambda v: set(v), } class Unset: - pass + pass @dataclass class Property: - name: str = field() - annotation: str = field() - expression: str = field() - section: "Section" = field() - value_override: Any = Unset + name: str = field() + annotation: str = field() + expression: str = field() + section: "Section" = field() + value_override: Any = Unset - def __get__(self, obj: "Section", objtype: Type["Section"]) -> Any: - if not obj: - return self + def __get__(self, obj: "Section", objtype: Type["Section"]) -> Any: + if not obj: + return self - if self.value_override is not Unset: - return self.value_override + if self.value_override is not Unset: + return self.value_override - env = obj.globals - result = eval(self.expression, dict(env), env) # nosec + env = obj.globals + result = eval(self.expression, dict(env), env) # nosec - return process_value(self.annotation, result) + return process_value(self.annotation, result) - def __set__(self, obj: "Section", value: Any) -> None: - self.value_override = value - obj._edited[self.name] = value + def __set__(self, obj: "Section", value: Any) -> None: + self.value_override = value + obj._edited[self.name] = value def process_value(annotation: str, value: Any) -> Any: - annotation = re.sub(r"\[.*\]$", "", annotation) + annotation = re.sub(r"\[.*\]$", "", annotation) - if annotation in TYPE_PROCESSORS: - return TYPE_PROCESSORS[annotation](value) + if annotation in TYPE_PROCESSORS: + return TYPE_PROCESSORS[annotation](value) - if annotation.lower() in TYPE_PROCESSORS: - return TYPE_PROCESSORS[annotation.lower()](value) + if annotation.lower() in TYPE_PROCESSORS: + return TYPE_PROCESSORS[annotation.lower()](value) - return value + return value diff --git a/src/backend/pcn/section.py b/src/backend/pcn/section.py index 900c28bd..680497a7 100644 --- a/src/backend/pcn/section.py +++ b/src/backend/pcn/section.py @@ -7,8 +7,8 @@ from dataclasses import dataclass, field from operator import attrgetter from pathlib import Path from typing import ( - Any, Callable, ClassVar, Dict, Generator, List, Optional, Set, Tuple, Type, - Union, + Any, Callable, ClassVar, Dict, Generator, List, Optional, Set, Tuple, Type, + Union, ) import pyotherside @@ -25,423 +25,423 @@ assert BUILTINS_DIR.name == "src" @dataclass(repr=False, eq=False) class Section(MutableMapping): - sections: ClassVar[Set[str]] = set() - methods: ClassVar[Set[str]] = set() - properties: ClassVar[Set[str]] = set() - order: ClassVar[Dict[str, None]] = OrderedDict() + sections: ClassVar[Set[str]] = set() + methods: ClassVar[Set[str]] = set() + properties: ClassVar[Set[str]] = set() + order: ClassVar[Dict[str, None]] = OrderedDict() - source_path: Optional[Path] = None - root: Optional["Section"] = None - parent: Optional["Section"] = None - builtins_path: Path = BUILTINS_DIR - included: List[Path] = field(default_factory=list) - globals: GlobalsDict = field(init=False) + source_path: Optional[Path] = None + root: Optional["Section"] = None + parent: Optional["Section"] = None + builtins_path: Path = BUILTINS_DIR + included: List[Path] = field(default_factory=list) + globals: GlobalsDict = field(init=False) - _edited: Dict[str, Any] = field(init=False, default_factory=dict) + _edited: Dict[str, Any] = field(init=False, default_factory=dict) - def __init_subclass__(cls, **kwargs) -> None: - # Make these attributes not shared between Section and its subclasses - cls.sections = set() - cls.methods = set() - cls.properties = set() - cls.order = OrderedDict() + def __init_subclass__(cls, **kwargs) -> None: + # Make these attributes not shared between Section and its subclasses + cls.sections = set() + cls.methods = set() + cls.properties = set() + cls.order = OrderedDict() - for parent_class in cls.__bases__: - if not issubclass(parent_class, Section): - continue + for parent_class in cls.__bases__: + if not issubclass(parent_class, Section): + continue - cls.sections |= parent_class.sections # union operator - cls.methods |= parent_class.methods - cls.properties |= parent_class.properties - cls.order.update(parent_class.order) + cls.sections |= parent_class.sections # union operator + cls.methods |= parent_class.methods + cls.properties |= parent_class.properties + cls.order.update(parent_class.order) - super().__init_subclass__(**kwargs) # type: ignore + super().__init_subclass__(**kwargs) # type: ignore - def __post_init__(self) -> None: - self.globals = GlobalsDict(self) + def __post_init__(self) -> None: + self.globals = GlobalsDict(self) - def __getattr__(self, name: str) -> Union["Section", Any]: - # This method signature tells mypy about the dynamic attribute types - # we can access. The body is run for attributes that aren't found. + def __getattr__(self, name: str) -> Union["Section", Any]: + # This method signature tells mypy about the dynamic attribute types + # we can access. The body is run for attributes that aren't found. - return super().__getattribute__(name) + return super().__getattribute__(name) - def __setattr__(self, name: str, value: Any) -> None: - # This method tells mypy about the dynamic attribute types we can set. - # The body is also run when setting an existing or new attribute. + def __setattr__(self, name: str, value: Any) -> None: + # This method tells mypy about the dynamic attribute types we can set. + # The body is also run when setting an existing or new attribute. - if name in self.__dataclass_fields__: - super().__setattr__(name, value) - return + if name in self.__dataclass_fields__: + super().__setattr__(name, value) + return - if name in self.properties: - value = process_value(getattr(type(self), name).annotation, value) + if name in self.properties: + value = process_value(getattr(type(self), name).annotation, value) - if self[name] == value: - return + if self[name] == value: + return - getattr(type(self), name).value_override = value - self._edited[name] = value - return + getattr(type(self), name).value_override = value + self._edited[name] = value + return - if name in self.sections or isinstance(value, Section): - raise NotImplementedError(f"cannot set section {name!r}") + if name in self.sections or isinstance(value, Section): + raise NotImplementedError(f"cannot set section {name!r}") - if name in self.methods or callable(value): - raise NotImplementedError(f"cannot set method {name!r}") + if name in self.methods or callable(value): + raise NotImplementedError(f"cannot set method {name!r}") - self._set_property(name, "Any", "None") - getattr(type(self), name).value_override = value - self._edited[name] = value + self._set_property(name, "Any", "None") + getattr(type(self), name).value_override = value + self._edited[name] = value - def __delattr__(self, name: str) -> None: - raise NotImplementedError(f"cannot delete existing attribute {name!r}") + def __delattr__(self, name: str) -> None: + raise NotImplementedError(f"cannot delete existing attribute {name!r}") - def __getitem__(self, key: str) -> Any: - try: - return getattr(self, key) - except AttributeError as err: - raise KeyError(str(err)) + def __getitem__(self, key: str) -> Any: + try: + return getattr(self, key) + except AttributeError as err: + raise KeyError(str(err)) - def __setitem__(self, key: str, value: Union["Section", str]) -> None: - setattr(self, key, value) + def __setitem__(self, key: str, value: Union["Section", str]) -> None: + setattr(self, key, value) - def __delitem__(self, key: str) -> None: - delattr(self, key) + def __delitem__(self, key: str) -> None: + delattr(self, key) - def __iter__(self) -> Generator[str, None, None]: - for attr_name in self.order: - yield attr_name + def __iter__(self) -> Generator[str, None, None]: + for attr_name in self.order: + yield attr_name - def __len__(self) -> int: - return len(self.order) + def __len__(self) -> int: + return len(self.order) - def __eq__(self, obj: Any) -> bool: - if not isinstance(obj, Section): - return False + def __eq__(self, obj: Any) -> bool: + if not isinstance(obj, Section): + return False - if self.globals.data != obj.globals.data or self.order != obj.order: - return False + if self.globals.data != obj.globals.data or self.order != obj.order: + return False - return not any(self[attr] != obj[attr] for attr in self.order) + return not any(self[attr] != obj[attr] for attr in self.order) - def __repr__(self) -> str: - name: str = type(self).__name__ - children: List[str] = [] - content: str = "" - newline: bool = False + def __repr__(self) -> str: + name: str = type(self).__name__ + children: List[str] = [] + content: str = "" + newline: bool = False - for attr_name in self.order: - value = getattr(self, attr_name) + for attr_name in self.order: + value = getattr(self, attr_name) - if attr_name in self.sections: - before = "\n" if children else "" - newline = True + if attr_name in self.sections: + before = "\n" if children else "" + newline = True - try: - children.append(f"{before}{value!r},") - except RecursionError as err: - name = type(value).__name__ - children.append(f"{before}{name}(\n {err!r}\n),") - pass + try: + children.append(f"{before}{value!r},") + except RecursionError as err: + name = type(value).__name__ + children.append(f"{before}{name}(\n {err!r}\n),") + pass - elif attr_name in self.methods: - before = "\n" if children else "" - newline = True - children.append(f"{before}def {value.__name__}(…),") + elif attr_name in self.methods: + before = "\n" if children else "" + newline = True + children.append(f"{before}def {value.__name__}(…),") - elif attr_name in self.properties: - before = "\n" if newline else "" - newline = False + elif attr_name in self.properties: + before = "\n" if newline else "" + newline = False - try: - children.append(f"{before}{attr_name} = {value!r},") - except RecursionError as err: - children.append(f"{before}{attr_name} = {err!r},") + try: + children.append(f"{before}{attr_name} = {value!r},") + except RecursionError as err: + children.append(f"{before}{attr_name} = {err!r},") - else: - newline = False + else: + newline = False - if children: - content = "\n%s\n" % textwrap.indent("\n".join(children), " " * 4) + if children: + content = "\n%s\n" % textwrap.indent("\n".join(children), " " * 4) - return f"{name}({content})" + return f"{name}({content})" - def children(self) -> Tuple[Tuple[str, Union["Section", Any]], ...]: - """Return pairs of (name, value) for child sections and properties.""" - return tuple((name, getattr(self, name)) for name in self) + def children(self) -> Tuple[Tuple[str, Union["Section", Any]], ...]: + """Return pairs of (name, value) for child sections and properties.""" + return tuple((name, getattr(self, name)) for name in self) - @classmethod - def _register_set_attr(cls, name: str, add_to_set_name: str) -> None: - cls.methods.discard(name) - cls.properties.discard(name) - cls.sections.discard(name) - getattr(cls, add_to_set_name).add(name) - cls.order[name] = None + @classmethod + def _register_set_attr(cls, name: str, add_to_set_name: str) -> None: + cls.methods.discard(name) + cls.properties.discard(name) + cls.sections.discard(name) + getattr(cls, add_to_set_name).add(name) + cls.order[name] = None - for subclass in cls.__subclasses__(): - subclass._register_set_attr(name, add_to_set_name) + for subclass in cls.__subclasses__(): + subclass._register_set_attr(name, add_to_set_name) - def _set_section(self, section: "Section") -> None: - name = type(section).__name__ + def _set_section(self, section: "Section") -> None: + name = type(section).__name__ - if hasattr(self, name) and name not in self.order: - raise AttributeError(f"{name!r}: forbidden name") + if hasattr(self, name) and name not in self.order: + raise AttributeError(f"{name!r}: forbidden name") - if name in self.sections: - self[name].deep_merge(section) - return + if name in self.sections: + self[name].deep_merge(section) + return - self._register_set_attr(name, "sections") - setattr(type(self), name, section) + self._register_set_attr(name, "sections") + setattr(type(self), name, section) - def _set_method(self, name: str, method: Callable) -> None: - if hasattr(self, name) and name not in self.order: - raise AttributeError(f"{name!r}: forbidden name") + def _set_method(self, name: str, method: Callable) -> None: + if hasattr(self, name) and name not in self.order: + raise AttributeError(f"{name!r}: forbidden name") - self._register_set_attr(name, "methods") - setattr(type(self), name, method) + self._register_set_attr(name, "methods") + setattr(type(self), name, method) - def _set_property( - self, name: str, annotation: str, expression: str, - ) -> None: - if hasattr(self, name) and name not in self.order: - raise AttributeError(f"{name!r}: forbidden name") + def _set_property( + self, name: str, annotation: str, expression: str, + ) -> None: + if hasattr(self, name) and name not in self.order: + raise AttributeError(f"{name!r}: forbidden name") - prop = Property(name, annotation, expression, self) - self._register_set_attr(name, "properties") - setattr(type(self), name, prop) + prop = Property(name, annotation, expression, self) + self._register_set_attr(name, "properties") + setattr(type(self), name, prop) - def deep_merge(self, section2: "Section") -> None: - self.included += section2.included + def deep_merge(self, section2: "Section") -> None: + self.included += section2.included - for key in section2: - if key in self.sections and key in section2.sections: - self.globals.data.update(section2.globals.data) - self[key].deep_merge(section2[key]) + for key in section2: + if key in self.sections and key in section2.sections: + self.globals.data.update(section2.globals.data) + self[key].deep_merge(section2[key]) - elif key in section2.sections: - self.globals.data.update(section2.globals.data) - new_type = type(key, (Section,), {}) - instance = new_type( - source_path = self.source_path, - root = self.root or self, - parent = self, - builtins_path = self.builtins_path, - ) - self._set_section(instance) - instance.deep_merge(section2[key]) + elif key in section2.sections: + self.globals.data.update(section2.globals.data) + new_type = type(key, (Section,), {}) + instance = new_type( + source_path = self.source_path, + root = self.root or self, + parent = self, + builtins_path = self.builtins_path, + ) + self._set_section(instance) + instance.deep_merge(section2[key]) - elif key in section2.methods: - self._set_method(key, section2[key]) + elif key in section2.methods: + self._set_method(key, section2[key]) - else: - prop2 = getattr(type(section2), key) - self._set_property(key, prop2.annotation, prop2.expression) + else: + prop2 = getattr(type(section2), key) + self._set_property(key, prop2.annotation, prop2.expression) - def include_file(self, path: Union[Path, str]) -> None: - path = Path(path) + def include_file(self, path: Union[Path, str]) -> None: + path = Path(path) - if not path.is_absolute() and self.source_path: - path = self.source_path.parent / path + if not path.is_absolute() and self.source_path: + path = self.source_path.parent / path - with suppress(ValueError): - self.included.remove(path) + with suppress(ValueError): + self.included.remove(path) - self.included.append(path) - self.deep_merge(Section.from_file(path)) + self.included.append(path) + self.deep_merge(Section.from_file(path)) - def include_builtin(self, relative_path: Union[Path, str]) -> None: - path = self.builtins_path / relative_path + def include_builtin(self, relative_path: Union[Path, str]) -> None: + path = self.builtins_path / relative_path - with suppress(ValueError): - self.included.remove(path) + with suppress(ValueError): + self.included.remove(path) - self.included.append(path) - self.deep_merge(Section.from_file(path)) + self.included.append(path) + self.deep_merge(Section.from_file(path)) - def as_dict(self, _section: Optional["Section"] = None) -> Dict[str, Any]: - dct = {} - section = self if _section is None else _section + def as_dict(self, _section: Optional["Section"] = None) -> Dict[str, Any]: + dct = {} + section = self if _section is None else _section - for key, value in section.items(): - if isinstance(value, Section): - dct[key] = self.as_dict(value) - else: - dct[key] = value + for key, value in section.items(): + if isinstance(value, Section): + dct[key] = self.as_dict(value) + else: + dct[key] = value - return dct + return dct - def edits_as_dict( - self, _section: Optional["Section"] = None, - ) -> Dict[str, Any]: + def edits_as_dict( + self, _section: Optional["Section"] = None, + ) -> Dict[str, Any]: - warning = ( - "This file is generated when settings are changed from the GUI, " - "and properties in it override the ones in the corresponding " - "PCN user config file. " - "If a property is gets changed in the PCN file, any corresponding " - "property override here is removed." - ) + warning = ( + "This file is generated when settings are changed from the GUI, " + "and properties in it override the ones in the corresponding " + "PCN user config file. " + "If a property is gets changed in the PCN file, any corresponding " + "property override here is removed." + ) - if _section is None: - section = self - dct = {"__comment": warning, "set": section._edited.copy()} - add_to = dct["set"] - else: - section = _section - dct = { - prop_name: ( - getattr(type(section), prop_name).expression, - value_override, - ) - for prop_name, value_override in section._edited.items() - } - add_to = dct - - for name in section.sections: - edits = section.edits_as_dict(section[name]) - - if edits: - add_to[name] = edits # type: ignore - - return dct - - - def deep_merge_edits( - self, edits: Dict[str, Any], has_expressions: bool = True, - ) -> bool: - - changes = False - - if not self.parent: # this is Root - edits = edits.get("set", {}) - - for name, value in edits.copy().items(): - if isinstance(self.get(name), Section) and isinstance(value, dict): - if self[name].deep_merge_edits(value, has_expressions): - changes = True + if _section is None: + section = self + dct = {"__comment": warning, "set": section._edited.copy()} + add_to = dct["set"] + else: + section = _section + dct = { + prop_name: ( + getattr(type(section), prop_name).expression, + value_override, + ) + for prop_name, value_override in section._edited.items() + } + add_to = dct + + for name in section.sections: + edits = section.edits_as_dict(section[name]) + + if edits: + add_to[name] = edits # type: ignore + + return dct + + + def deep_merge_edits( + self, edits: Dict[str, Any], has_expressions: bool = True, + ) -> bool: + + changes = False + + if not self.parent: # this is Root + edits = edits.get("set", {}) + + for name, value in edits.copy().items(): + if isinstance(self.get(name), Section) and isinstance(value, dict): + if self[name].deep_merge_edits(value, has_expressions): + changes = True - elif not has_expressions: - self[name] = value + elif not has_expressions: + self[name] = value - elif isinstance(value, (tuple, list)): - user_expression, gui_value = value + elif isinstance(value, (tuple, list)): + user_expression, gui_value = value - if not hasattr(type(self), name): - self[name] = gui_value - elif getattr(type(self), name).expression == user_expression: - self[name] = gui_value - else: - # If user changed their config file, discard the GUI edit - del edits[name] - changes = True + if not hasattr(type(self), name): + self[name] = gui_value + elif getattr(type(self), name).expression == user_expression: + self[name] = gui_value + else: + # If user changed their config file, discard the GUI edit + del edits[name] + changes = True - return changes + return changes - @property - def all_includes(self) -> Generator[Path, None, None]: + @property + def all_includes(self) -> Generator[Path, None, None]: - yield from self.included + yield from self.included - for sub in self.sections: - yield from self[sub].all_includes + for sub in self.sections: + yield from self[sub].all_includes - @classmethod - def from_source_code( - cls, - code: str, - path: Optional[Path] = None, - builtins: Optional[Path] = None, - *, - inherit: Tuple[Type["Section"], ...] = (), - node: Union[None, red.RedBaron, red.ClassNode] = None, - name: str = "Root", - root: Optional["Section"] = None, - parent: Optional["Section"] = None, - ) -> "Section": - - builtins = builtins or BUILTINS_DIR - section: Type["Section"] = type(name, inherit or (Section,), {}) - instance: Section = section(path, root, parent, builtins) - - node = node or red.RedBaron(code) - - for child in node.node_list: - if isinstance(child, red.ClassNode): - root_arg = instance if root is None else root - child_inherit = [] - - for name in child.inherit_from.dumps().split(","): - name = name.strip() - - if name: - child_inherit.append(type(attrgetter(name)(root_arg))) - - instance._set_section(section.from_source_code( - code = code, - path = path, - builtins = builtins, - inherit = tuple(child_inherit), - node = child, - name = child.name, - root = root_arg, - parent = instance, - )) - - elif isinstance(child, red.AssignmentNode): - if isinstance(child.target, red.NameNode): - name = child.target.value - else: - name = str(child.target.to_python()) + @classmethod + def from_source_code( + cls, + code: str, + path: Optional[Path] = None, + builtins: Optional[Path] = None, + *, + inherit: Tuple[Type["Section"], ...] = (), + node: Union[None, red.RedBaron, red.ClassNode] = None, + name: str = "Root", + root: Optional["Section"] = None, + parent: Optional["Section"] = None, + ) -> "Section": + + builtins = builtins or BUILTINS_DIR + section: Type["Section"] = type(name, inherit or (Section,), {}) + instance: Section = section(path, root, parent, builtins) + + node = node or red.RedBaron(code) + + for child in node.node_list: + if isinstance(child, red.ClassNode): + root_arg = instance if root is None else root + child_inherit = [] + + for name in child.inherit_from.dumps().split(","): + name = name.strip() + + if name: + child_inherit.append(type(attrgetter(name)(root_arg))) + + instance._set_section(section.from_source_code( + code = code, + path = path, + builtins = builtins, + inherit = tuple(child_inherit), + node = child, + name = child.name, + root = root_arg, + parent = instance, + )) + + elif isinstance(child, red.AssignmentNode): + if isinstance(child.target, red.NameNode): + name = child.target.value + else: + name = str(child.target.to_python()) - instance._set_property( - name, - child.annotation.dumps() if child.annotation else "", - child.value.dumps(), - ) + instance._set_property( + name, + child.annotation.dumps() if child.annotation else "", + child.value.dumps(), + ) - else: - env = instance.globals - exec(child.dumps(), dict(env), env) # nosec + else: + env = instance.globals + exec(child.dumps(), dict(env), env) # nosec - if isinstance(child, red.DefNode): - instance._set_method(child.name, env[child.name]) - - return instance - - - @classmethod - def from_file( - cls, path: Union[str, Path], builtins: Union[str, Path] = BUILTINS_DIR, - ) -> "Section": - - path = Path(re.sub(r"^qrc:/", "", str(path))) - - try: - content = pyotherside.qrc_get_file_contents(str(path)).decode() - except ValueError: # App was compiled without QRC - content = path.read_text() - - return Section.from_source_code(content, path, Path(builtins)) + if isinstance(child, red.DefNode): + instance._set_method(child.name, env[child.name]) + + return instance + + + @classmethod + def from_file( + cls, path: Union[str, Path], builtins: Union[str, Path] = BUILTINS_DIR, + ) -> "Section": + + path = Path(re.sub(r"^qrc:/", "", str(path))) + + try: + content = pyotherside.qrc_get_file_contents(str(path)).decode() + except ValueError: # App was compiled without QRC + content = path.read_text() + + return Section.from_source_code(content, path, Path(builtins)) diff --git a/src/backend/presence.py b/src/backend/presence.py index 98171dcd..833f4866 100644 --- a/src/backend/presence.py +++ b/src/backend/presence.py @@ -8,89 +8,89 @@ from typing import TYPE_CHECKING, Dict, Optional from .utils import AutoStrEnum, auto if TYPE_CHECKING: - from .models.items import Account, Member + from .models.items import Account, Member ORDER: Dict[str, int] = { - "online": 0, - "unavailable": 1, - "invisible": 2, - "offline": 3, + "online": 0, + "unavailable": 1, + "invisible": 2, + "offline": 3, } @dataclass class Presence: - """Represents a single matrix user's presence fields. + """Represents a single matrix user's presence fields. - These objects are stored in `Backend.presences`, indexed by user ID. - It must only be instanced when receiving a `PresenceEvent` or - registering an `Account` model item. + These objects are stored in `Backend.presences`, indexed by user ID. + It must only be instanced when receiving a `PresenceEvent` or + registering an `Account` model item. - When receiving a `PresenceEvent`, we get or create a `Presence` object in - `Backend.presences` for the targeted user. If the user is registered in any - room, add its `Member` model item to `members`. Finally, update every - `Member` presence fields inside `members`. + When receiving a `PresenceEvent`, we get or create a `Presence` object in + `Backend.presences` for the targeted user. If the user is registered in any + room, add its `Member` model item to `members`. Finally, update every + `Member` presence fields inside `members`. - When a room member is registered, we try to find a `Presence` in - `Backend.presences` for that user ID. If found, the `Member` item is added - to `members`. + When a room member is registered, we try to find a `Presence` in + `Backend.presences` for that user ID. If found, the `Member` item is added + to `members`. - When an Account model is registered, we create a `Presence` in - `Backend.presences` for the accountu's user ID whether the server supports - presence or not (we cannot know yet at this point), - and assign that `Account` to the `Presence.account` field. + When an Account model is registered, we create a `Presence` in + `Backend.presences` for the accountu's user ID whether the server supports + presence or not (we cannot know yet at this point), + and assign that `Account` to the `Presence.account` field. - Special attributes: - members: A `{room_id: Member}` dict for storing room members related to - this `Presence`. As each room has its own `Member`s objects, we - have to keep track of their presence fields. `Member`s are indexed - by room ID. + Special attributes: + members: A `{room_id: Member}` dict for storing room members related to + this `Presence`. As each room has its own `Member`s objects, we + have to keep track of their presence fields. `Member`s are indexed + by room ID. - account: `Account` related to this `Presence`, if any. Should be - assigned when client starts (`MatrixClient._start()`) and - cleared when client stops (`MatrixClient._start()`). - """ + account: `Account` related to this `Presence`, if any. Should be + assigned when client starts (`MatrixClient._start()`) and + cleared when client stops (`MatrixClient._start()`). + """ - class State(AutoStrEnum): - offline = auto() # can mean offline, invisible or unknwon - unavailable = auto() - online = auto() - invisible = auto() + class State(AutoStrEnum): + offline = auto() # can mean offline, invisible or unknwon + unavailable = auto() + online = auto() + invisible = auto() - def __lt__(self, other: "Presence.State") -> bool: - return ORDER[self.value] < ORDER[other.value] + def __lt__(self, other: "Presence.State") -> bool: + return ORDER[self.value] < ORDER[other.value] - presence: State = State.offline - currently_active: bool = False - last_active_at: datetime = datetime.fromtimestamp(0) - status_msg: str = "" + presence: State = State.offline + currently_active: bool = False + last_active_at: datetime = datetime.fromtimestamp(0) + status_msg: str = "" - members: Dict[str, "Member"] = field(default_factory=dict) - account: Optional["Account"] = None + members: Dict[str, "Member"] = field(default_factory=dict) + account: Optional["Account"] = None - def update_members(self) -> None: - """Update presence fields of every `Member` in `members`. + def update_members(self) -> None: + """Update presence fields of every `Member` in `members`. - Currently it is only called when receiving a `PresenceEvent` and when - registering room members. - """ + Currently it is only called when receiving a `PresenceEvent` and when + registering room members. + """ - for member in self.members.values(): - member.set_fields( - presence = self.presence, - status_msg = self.status_msg, - last_active_at = self.last_active_at, - currently_active = self.currently_active, - ) + for member in self.members.values(): + member.set_fields( + presence = self.presence, + status_msg = self.status_msg, + last_active_at = self.last_active_at, + currently_active = self.currently_active, + ) - def update_account(self) -> None: - """Update presence fields of `Account` related to this `Presence`.""" + def update_account(self) -> None: + """Update presence fields of `Account` related to this `Presence`.""" - if self.account: - self.account.set_fields( - presence = self.presence, - status_msg = self.status_msg, - last_active_at = self.last_active_at, - currently_active = self.currently_active, - ) + if self.account: + self.account.set_fields( + presence = self.presence, + status_msg = self.status_msg, + last_active_at = self.last_active_at, + currently_active = self.currently_active, + ) diff --git a/src/backend/pyotherside_events.py b/src/backend/pyotherside_events.py index 30392293..5c37d040 100644 --- a/src/backend/pyotherside_events.py +++ b/src/backend/pyotherside_events.py @@ -10,117 +10,117 @@ import pyotherside from .utils import serialize_value_for_qml if TYPE_CHECKING: - from .models import SyncId - from .user_files import UserFile + from .models import SyncId + from .user_files import UserFile @dataclass class PyOtherSideEvent: - """Event that will be sent on instanciation to QML by PyOtherSide.""" + """Event that will be sent on instanciation to QML by PyOtherSide.""" - def __post_init__(self) -> None: - # XXX: CPython 3.6 or any Python implemention >= 3.7 is required for - # correct __dataclass_fields__ dict order. - args = [ - serialize_value_for_qml(getattr(self, field)) - for field in self.__dataclass_fields__ # type: ignore - if field != "callbacks" - ] - pyotherside.send(type(self).__name__, *args) + def __post_init__(self) -> None: + # XXX: CPython 3.6 or any Python implemention >= 3.7 is required for + # correct __dataclass_fields__ dict order. + args = [ + serialize_value_for_qml(getattr(self, field)) + for field in self.__dataclass_fields__ # type: ignore + if field != "callbacks" + ] + pyotherside.send(type(self).__name__, *args) @dataclass class NotificationRequested(PyOtherSideEvent): - """Request a notification bubble, sound or window urgency hint. + """Request a notification bubble, sound or window urgency hint. - Urgency hints usually flash or highlight the program's icon in a taskbar, - dock or panel. - """ + Urgency hints usually flash or highlight the program's icon in a taskbar, + dock or panel. + """ - id: str = field() - critical: bool = False - bubble: bool = False - sound: bool = False - urgency_hint: bool = False + id: str = field() + critical: bool = False + bubble: bool = False + sound: bool = False + urgency_hint: bool = False - # Bubble parameters - title: str = "" - body: str = "" - image: Union[Path, str] = "" + # Bubble parameters + title: str = "" + body: str = "" + image: Union[Path, str] = "" @dataclass class CoroutineDone(PyOtherSideEvent): - """Indicate that an asyncio coroutine finished.""" + """Indicate that an asyncio coroutine finished.""" - uuid: str = field() - result: Any = None - exception: Optional[Exception] = None - traceback: Optional[str] = None + uuid: str = field() + result: Any = None + exception: Optional[Exception] = None + traceback: Optional[str] = None @dataclass class LoopException(PyOtherSideEvent): - """Indicate an uncaught exception occurance in the asyncio loop.""" + """Indicate an uncaught exception occurance in the asyncio loop.""" - message: str = field() - exception: Optional[Exception] = field() - traceback: Optional[str] = None + message: str = field() + exception: Optional[Exception] = field() + traceback: Optional[str] = None @dataclass class Pre070SettingsDetected(PyOtherSideEvent): - """Warn that a pre-0.7.0 settings.json file exists.""" - path: Path = field() + """Warn that a pre-0.7.0 settings.json file exists.""" + path: Path = field() @dataclass class UserFileChanged(PyOtherSideEvent): - """Indicate that a config or data file changed on disk.""" + """Indicate that a config or data file changed on disk.""" - type: Type["UserFile"] = field() - new_data: Any = field() + type: Type["UserFile"] = field() + new_data: Any = field() @dataclass class ModelEvent(PyOtherSideEvent): - """Base class for model change events.""" + """Base class for model change events.""" - sync_id: "SyncId" = field() + sync_id: "SyncId" = field() @dataclass class ModelItemSet(ModelEvent): - """Indicate `ModelItem` insert or field changes in a `Backend` `Model`.""" + """Indicate `ModelItem` insert or field changes in a `Backend` `Model`.""" - index_then: Optional[int] = field() - index_now: int = field() - fields: Dict[str, Any] = field() + index_then: Optional[int] = field() + index_now: int = field() + fields: Dict[str, Any] = field() @dataclass class ModelItemDeleted(ModelEvent): - """Indicate the removal of a `ModelItem` from a `Backend` `Model`.""" + """Indicate the removal of a `ModelItem` from a `Backend` `Model`.""" - index: int = field() - count: int = 1 - ids: Sequence[Any] = () + index: int = field() + count: int = 1 + ids: Sequence[Any] = () @dataclass class ModelCleared(ModelEvent): - """Indicate that a `Backend` `Model` was cleared.""" + """Indicate that a `Backend` `Model` was cleared.""" @dataclass class DevicesUpdated(PyOtherSideEvent): - """Indicate changes in devices for us or users we share a room with.""" + """Indicate changes in devices for us or users we share a room with.""" - our_user_id: str = field() + our_user_id: str = field() @dataclass class InvalidAccessToken(PyOtherSideEvent): - """Indicate one of our account's access token is invalid or revoked.""" + """Indicate one of our account's access token is invalid or revoked.""" - user_id: str = field() + user_id: str = field() diff --git a/src/backend/qml_bridge.py b/src/backend/qml_bridge.py index d6994f83..76333112 100644 --- a/src/backend/qml_bridge.py +++ b/src/backend/qml_bridge.py @@ -29,143 +29,143 @@ from .pyotherside_events import CoroutineDone, LoopException class QMLBridge: - """Setup asyncio and provide methods to call coroutines from QML. + """Setup asyncio and provide methods to call coroutines from QML. - A thread is created to run the asyncio loop in, to ensure all calls from - QML return instantly. - Synchronous methods are provided for QML to call coroutines using - PyOtherSide, which doesn't have this ability out of the box. + A thread is created to run the asyncio loop in, to ensure all calls from + QML return instantly. + Synchronous methods are provided for QML to call coroutines using + PyOtherSide, which doesn't have this ability out of the box. - Attributes: - backend: The `backend.Backend` object containing general coroutines - for QML and that manages `MatrixClient` objects. - """ + Attributes: + backend: The `backend.Backend` object containing general coroutines + for QML and that manages `MatrixClient` objects. + """ - def __init__(self) -> None: - try: - self._loop = asyncio.get_event_loop() - except RuntimeError: - self._loop = asyncio.new_event_loop() - asyncio.set_event_loop(self._loop) - self._loop.set_exception_handler(self._loop_exception_handler) + def __init__(self) -> None: + try: + self._loop = asyncio.get_event_loop() + except RuntimeError: + self._loop = asyncio.new_event_loop() + asyncio.set_event_loop(self._loop) + self._loop.set_exception_handler(self._loop_exception_handler) - from .backend import Backend - self.backend: Backend = Backend() + from .backend import Backend + self.backend: Backend = Backend() - self._running_futures: Dict[str, Future] = {} - self._cancelled_early: Set[str] = set() + self._running_futures: Dict[str, Future] = {} + self._cancelled_early: Set[str] = set() - Thread(target=self._start_asyncio_loop).start() + Thread(target=self._start_asyncio_loop).start() - def _loop_exception_handler( - self, loop: asyncio.AbstractEventLoop, context: dict, - ) -> None: - if "exception" in context: - err = context["exception"] - trace = "".join( - traceback.format_exception(type(err), err, err.__traceback__), - ) - LoopException(context["message"], err, trace) + def _loop_exception_handler( + self, loop: asyncio.AbstractEventLoop, context: dict, + ) -> None: + if "exception" in context: + err = context["exception"] + trace = "".join( + traceback.format_exception(type(err), err, err.__traceback__), + ) + LoopException(context["message"], err, trace) - loop.default_exception_handler(context) + loop.default_exception_handler(context) - def _start_asyncio_loop(self) -> None: - asyncio.set_event_loop(self._loop) - self._loop.run_forever() + def _start_asyncio_loop(self) -> None: + asyncio.set_event_loop(self._loop) + self._loop.run_forever() - def _call_coro(self, coro: Coroutine, uuid: str) -> None: - """Schedule a coroutine to run in our thread and return a `Future`.""" + def _call_coro(self, coro: Coroutine, uuid: str) -> None: + """Schedule a coroutine to run in our thread and return a `Future`.""" - if uuid in self._cancelled_early: - self._cancelled_early.remove(uuid) - return + if uuid in self._cancelled_early: + self._cancelled_early.remove(uuid) + return - def on_done(future: Future) -> None: - """Send a PyOtherSide event with the coro's result/exception.""" - result = exception = trace = None + def on_done(future: Future) -> None: + """Send a PyOtherSide event with the coro's result/exception.""" + result = exception = trace = None - try: - result = future.result() - except Exception as err: # noqa - exception = err - trace = traceback.format_exc().rstrip() + try: + result = future.result() + except Exception as err: # noqa + exception = err + trace = traceback.format_exc().rstrip() - CoroutineDone(uuid, result, exception, trace) - del self._running_futures[uuid] + CoroutineDone(uuid, result, exception, trace) + del self._running_futures[uuid] - future = asyncio.run_coroutine_threadsafe(coro, self._loop) - self._running_futures[uuid] = future - future.add_done_callback(on_done) + future = asyncio.run_coroutine_threadsafe(coro, self._loop) + self._running_futures[uuid] = future + future.add_done_callback(on_done) - def call_backend_coro( - self, name: str, uuid: str, args: Sequence[str] = (), - ) -> None: - """Schedule a coroutine from the `QMLBridge.backend` object.""" + def call_backend_coro( + self, name: str, uuid: str, args: Sequence[str] = (), + ) -> None: + """Schedule a coroutine from the `QMLBridge.backend` object.""" - if uuid in self._cancelled_early: - self._cancelled_early.remove(uuid) - else: - self._call_coro(attrgetter(name)(self.backend)(*args), uuid) + if uuid in self._cancelled_early: + self._cancelled_early.remove(uuid) + else: + self._call_coro(attrgetter(name)(self.backend)(*args), uuid) - def call_client_coro( - self, user_id: str, name: str, uuid: str, args: Sequence[str] = (), - ) -> None: - """Schedule a coroutine from a `QMLBridge.backend.clients` client.""" + def call_client_coro( + self, user_id: str, name: str, uuid: str, args: Sequence[str] = (), + ) -> None: + """Schedule a coroutine from a `QMLBridge.backend.clients` client.""" - if uuid in self._cancelled_early: - self._cancelled_early.remove(uuid) - else: - client = self.backend.clients[user_id] - self._call_coro(attrgetter(name)(client)(*args), uuid) + if uuid in self._cancelled_early: + self._cancelled_early.remove(uuid) + else: + client = self.backend.clients[user_id] + self._call_coro(attrgetter(name)(client)(*args), uuid) - def cancel_coro(self, uuid: str) -> None: - """Cancel a couroutine scheduled by the `QMLBridge` methods.""" + def cancel_coro(self, uuid: str) -> None: + """Cancel a couroutine scheduled by the `QMLBridge` methods.""" - if uuid in self._running_futures: - self._running_futures[uuid].cancel() - else: - self._cancelled_early.add(uuid) + if uuid in self._running_futures: + self._running_futures[uuid].cancel() + else: + self._cancelled_early.add(uuid) - def pdb(self, extra_data: Sequence = (), remote: bool = False) -> None: - """Call the python debugger, defining some conveniance variables.""" + def pdb(self, extra_data: Sequence = (), remote: bool = False) -> None: + """Call the python debugger, defining some conveniance variables.""" - ad = extra_data # noqa - ba = self.backend # noqa - mo = self.backend.models # noqa - cl = self.backend.clients - gcl = lambda user: cl[f"@{user}"] # noqa + ad = extra_data # noqa + ba = self.backend # noqa + mo = self.backend.models # noqa + cl = self.backend.clients + gcl = lambda user: cl[f"@{user}"] # noqa - rc = lambda c: asyncio.run_coroutine_threadsafe(c, self._loop) # noqa + rc = lambda c: asyncio.run_coroutine_threadsafe(c, self._loop) # noqa - try: - from devtools import debug # noqa - d = debug # noqa - except ModuleNotFoundError: - log.warning("Module python-devtools not found, can't use debug()") + try: + from devtools import debug # noqa + d = debug # noqa + except ModuleNotFoundError: + log.warning("Module python-devtools not found, can't use debug()") - if remote: - # Run `socat readline tcp:127.0.0.1:4444` in a terminal to connect - import remote_pdb - remote_pdb.RemotePdb("127.0.0.1", 4444).set_trace() - else: - import pdb - pdb.set_trace() + if remote: + # Run `socat readline tcp:127.0.0.1:4444` in a terminal to connect + import remote_pdb + remote_pdb.RemotePdb("127.0.0.1", 4444).set_trace() + else: + import pdb + pdb.set_trace() - def exit(self) -> None: - try: - asyncio.run_coroutine_threadsafe( - self.backend.terminate_clients(), self._loop, - ).result() - except Exception as e: # noqa - print(e) + def exit(self) -> None: + try: + asyncio.run_coroutine_threadsafe( + self.backend.terminate_clients(), self._loop, + ).result() + except Exception as e: # noqa + print(e) # The AppImage AppRun script overwrites some environment path variables to @@ -174,8 +174,8 @@ class QMLBridge: # to prevent problems like QML Qt.openUrlExternally() failing because # the external launched program is affected by our AppImage-specific variables. for var in ("LD_LIBRARY_PATH", "PYTHONHOME", "PYTHONUSERBASE"): - if f"RESTORE_{var}" in os.environ: - os.environ[var] = os.environ[f"RESTORE_{var}"] + if f"RESTORE_{var}" in os.environ: + os.environ[var] = os.environ[f"RESTORE_{var}"] BRIDGE = QMLBridge() diff --git a/src/backend/sso_server.py b/src/backend/sso_server.py index d5ecde36..3d0459e7 100644 --- a/src/backend/sso_server.py +++ b/src/backend/sso_server.py @@ -9,99 +9,99 @@ from . import __display_name__ _SUCCESS_HTML_PAGE = """ - - """ + __display_name__ + """ - - - + .circle { + width: 90px; + height: 90px; + position: absolute; + top: 50%; + left: 50%; + margin: -45px 0 0 -45px; + border-radius: 50%; + font-size: 60px; + line-height: 90px; + text-align: center; + background: hsl(203, 51%, 15%); + color: hsl(162, 56%, 42%, 1); + animation: appear 0.4s linear; + } + + -
+
""" class _SSORequestHandler(BaseHTTPRequestHandler): - def do_GET(self) -> None: - self.server: "SSOServer" + def do_GET(self) -> None: + self.server: "SSOServer" - redirect = "%s/_matrix/client/r0/login/sso/redirect?redirectUrl=%s" % ( - self.server.for_homeserver, - quote(self.server.url_to_open), - ) + redirect = "%s/_matrix/client/r0/login/sso/redirect?redirectUrl=%s" % ( + self.server.for_homeserver, + quote(self.server.url_to_open), + ) - parameters = parse_qs(urlparse(self.path).query) + parameters = parse_qs(urlparse(self.path).query) - if "loginToken" in parameters: - self.server._token = parameters["loginToken"][0] - self.send_response(200) # OK - self.send_header("Content-type", "text/html") - self.end_headers() - self.wfile.write(_SUCCESS_HTML_PAGE.encode()) - else: - self.send_response(308) # Permanent redirect, same method only - self.send_header("Location", redirect) - self.end_headers() + if "loginToken" in parameters: + self.server._token = parameters["loginToken"][0] + self.send_response(200) # OK + self.send_header("Content-type", "text/html") + self.end_headers() + self.wfile.write(_SUCCESS_HTML_PAGE.encode()) + else: + self.send_response(308) # Permanent redirect, same method only + self.send_header("Location", redirect) + self.end_headers() - self.close_connection = True + self.close_connection = True class SSOServer(HTTPServer): - """Local HTTP server to retrieve a SSO login token. + """Local HTTP server to retrieve a SSO login token. - Call `SSOServer.wait_for_token()` in a background task to start waiting - for a SSO login token from the Matrix homeserver. + Call `SSOServer.wait_for_token()` in a background task to start waiting + for a SSO login token from the Matrix homeserver. - Once the task is running, the user must open `SSOServer.url_to_open` in - their browser, where they will be able to complete the login process. - Once they are done, the homeserver will call us back with a login token - and the `SSOServer.wait_for_token()` task will return. - """ + Once the task is running, the user must open `SSOServer.url_to_open` in + their browser, where they will be able to complete the login process. + Once they are done, the homeserver will call us back with a login token + and the `SSOServer.wait_for_token()` task will return. + """ - def __init__(self, for_homeserver: str) -> None: - self.for_homeserver: str = for_homeserver - self._token: str = "" + def __init__(self, for_homeserver: str) -> None: + self.for_homeserver: str = for_homeserver + self._token: str = "" - # Pick the first available port - super().__init__(("127.0.0.1", 0), _SSORequestHandler) + # Pick the first available port + super().__init__(("127.0.0.1", 0), _SSORequestHandler) - @property - def url_to_open(self) -> str: - """URL for the user to open in their browser, to do the SSO process.""" + @property + def url_to_open(self) -> str: + """URL for the user to open in their browser, to do the SSO process.""" - return f"http://{self.server_address[0]}:{self.server_port}" + return f"http://{self.server_address[0]}:{self.server_port}" - async def wait_for_token(self) -> str: - """Wait until the homeserver gives us a login token and return it.""" + async def wait_for_token(self) -> str: + """Wait until the homeserver gives us a login token and return it.""" - loop = asyncio.get_event_loop() + loop = asyncio.get_event_loop() - while not self._token: - await loop.run_in_executor(None, self.handle_request) + while not self._token: + await loop.run_in_executor(None, self.handle_request) - return self._token + return self._token diff --git a/src/backend/theme_parser.py b/src/backend/theme_parser.py index cc803248..c3985481 100644 --- a/src/backend/theme_parser.py +++ b/src/backend/theme_parser.py @@ -11,77 +11,77 @@ import re from typing import Generator PROPERTY_TYPES = {"bool", "double", "int", "list", "real", "string", "url", - "var", "date", "point", "rect", "size", "color"} + "var", "date", "point", "rect", "size", "color"} def _add_property(line: str) -> str: - """Return a QML property declaration line from a QPL property line.""" + """Return a QML property declaration line from a QPL property line.""" - if re.match(r"^\s*[a-zA-Z\d_]+\s*:$", line): - return re.sub(r"^(\s*)(\S*\s*):$", - r"\1readonly property QtObject \2: QtObject", - line) + if re.match(r"^\s*[a-zA-Z\d_]+\s*:$", line): + return re.sub(r"^(\s*)(\S*\s*):$", + r"\1readonly property QtObject \2: QtObject", + line) - types = "|".join(PROPERTY_TYPES) - if re.match(fr"^\s*({types}) [a-zA-Z\d_]+\s*:", line): - return re.sub(r"^(\s*)(\S*)", r"\1property \2", line) + types = "|".join(PROPERTY_TYPES) + if re.match(fr"^\s*({types}) [a-zA-Z\d_]+\s*:", line): + return re.sub(r"^(\s*)(\S*)", r"\1property \2", line) - return line + return line def _process_lines(content: str) -> Generator[str, None, None]: - """Yield lines of real QML from lines of QPL.""" + """Yield lines of real QML from lines of QPL.""" - skip = False - indent = " " * 4 - current_indent = 0 + skip = False + indent = " " * 4 + current_indent = 0 - for line in content.split("\n"): - line = line.rstrip() + for line in content.split("\n"): + line = line.rstrip() - if not line.strip() or line.strip().startswith("//"): - continue + if not line.strip() or line.strip().startswith("//"): + continue - start_space_list = re.findall(r"^ +", line) - start_space = start_space_list[0] if start_space_list else "" + start_space_list = re.findall(r"^ +", line) + start_space = start_space_list[0] if start_space_list else "" - line_indents = len(re.findall(indent, start_space)) + line_indents = len(re.findall(indent, start_space)) - if not skip: - if line_indents > current_indent: - yield "%s{" % (indent * current_indent) - current_indent = line_indents + if not skip: + if line_indents > current_indent: + yield "%s{" % (indent * current_indent) + current_indent = line_indents - while line_indents < current_indent: - current_indent -= 1 - yield "%s}" % (indent * current_indent) + while line_indents < current_indent: + current_indent -= 1 + yield "%s}" % (indent * current_indent) - line = _add_property(line) + line = _add_property(line) - yield line + yield line - skip = any((line.endswith(e) for e in "([{+\\,?:")) + skip = any((line.endswith(e) for e in "([{+\\,?:")) - while current_indent: - current_indent -= 1 - yield "%s}" % (indent * current_indent) + while current_indent: + current_indent -= 1 + yield "%s}" % (indent * current_indent) def convert_to_qml(theme_content: str) -> str: - """Return valid QML code with imports from QPL content.""" + """Return valid QML code with imports from QPL content.""" - theme_content = theme_content.replace("\t", " ") + theme_content = theme_content.replace("\t", " ") - lines = [ - "import QtQuick 2.12", - 'import "../Base"', - "QtObject {", - " function hsluv(h, s, l, a) { return utils.hsluv(h, s, l, a) }", - " function hsl(h, s, l) { return utils.hsl(h, s, l) }", - " function hsla(h, s, l, a) { return utils.hsla(h, s, l, a) }", - " id: theme", - ] - lines += [f" {line}" for line in _process_lines(theme_content)] - lines += ["}"] + lines = [ + "import QtQuick 2.12", + 'import "../Base"', + "QtObject {", + " function hsluv(h, s, l, a) { return utils.hsluv(h, s, l, a) }", + " function hsl(h, s, l) { return utils.hsl(h, s, l) }", + " function hsla(h, s, l, a) { return utils.hsla(h, s, l, a) }", + " id: theme", + ] + lines += [f" {line}" for line in _process_lines(theme_content)] + lines += ["}"] - return "\n".join(lines) + return "\n".join(lines) diff --git a/src/backend/user_files.py b/src/backend/user_files.py index e855ffb9..0e3419e4 100644 --- a/src/backend/user_files.py +++ b/src/backend/user_files.py @@ -12,7 +12,7 @@ from collections.abc import MutableMapping from dataclasses import dataclass, field from pathlib import Path from typing import ( - TYPE_CHECKING, Any, ClassVar, Dict, Iterator, Optional, Tuple, + TYPE_CHECKING, Any, ClassVar, Dict, Iterator, Optional, Tuple, ) import pyotherside @@ -20,521 +20,521 @@ from watchgod import Change, awatch from .pcn.section import Section from .pyotherside_events import ( - LoopException, Pre070SettingsDetected, UserFileChanged, + LoopException, Pre070SettingsDetected, UserFileChanged, ) from .theme_parser import convert_to_qml from .utils import ( - aiopen, atomic_write, deep_serialize_for_qml, dict_update_recursive, - flatten_dict_keys, + aiopen, atomic_write, deep_serialize_for_qml, dict_update_recursive, + flatten_dict_keys, ) if TYPE_CHECKING: - from .backend import Backend + from .backend import Backend @dataclass class UserFile: - """Base class representing a user config or data file.""" + """Base class representing a user config or data file.""" - create_missing: ClassVar[bool] = True + create_missing: ClassVar[bool] = True - backend: "Backend" = field(repr=False) - filename: str = field() - parent: Optional["UserFile"] = None - children: Dict[Path, "UserFile"] = field(default_factory=dict) + backend: "Backend" = field(repr=False) + filename: str = field() + parent: Optional["UserFile"] = None + children: Dict[Path, "UserFile"] = field(default_factory=dict) - data: Any = field(init=False, default_factory=dict) - _need_write: bool = field(init=False, default=False) - _mtime: Optional[float] = field(init=False, default=None) + data: Any = field(init=False, default_factory=dict) + _need_write: bool = field(init=False, default=False) + _mtime: Optional[float] = field(init=False, default=None) - _reader: Optional[asyncio.Future] = field(init=False, default=None) - _writer: Optional[asyncio.Future] = field(init=False, default=None) + _reader: Optional[asyncio.Future] = field(init=False, default=None) + _writer: Optional[asyncio.Future] = field(init=False, default=None) - def __post_init__(self) -> None: - self.data = self.default_data - self._need_write = self.create_missing + def __post_init__(self) -> None: + self.data = self.default_data + self._need_write = self.create_missing - if self.path.exists(): - try: - text = self.path.read_text() - self.data, self._need_write = self.deserialized(text) - except Exception as err: # noqa - LoopException(str(err), err, traceback.format_exc().rstrip()) + if self.path.exists(): + try: + text = self.path.read_text() + self.data, self._need_write = self.deserialized(text) + except Exception as err: # noqa + LoopException(str(err), err, traceback.format_exc().rstrip()) - self._reader = asyncio.ensure_future(self._start_reader()) - self._writer = asyncio.ensure_future(self._start_writer()) + self._reader = asyncio.ensure_future(self._start_reader()) + self._writer = asyncio.ensure_future(self._start_writer()) - @property - def path(self) -> Path: - """Full path of the file to read, can exist or not exist.""" - raise NotImplementedError() + @property + def path(self) -> Path: + """Full path of the file to read, can exist or not exist.""" + raise NotImplementedError() - @property - def write_path(self) -> Path: - """Full path of the file to write, can exist or not exist.""" - return self.path + @property + def write_path(self) -> Path: + """Full path of the file to write, can exist or not exist.""" + return self.path - @property - def default_data(self) -> Any: - """Default deserialized content to use if the file doesn't exist.""" - raise NotImplementedError() + @property + def default_data(self) -> Any: + """Default deserialized content to use if the file doesn't exist.""" + raise NotImplementedError() - @property - def qml_data(self) -> Any: - """Data converted for usage in QML.""" - return self.data + @property + def qml_data(self) -> Any: + """Data converted for usage in QML.""" + return self.data - def deserialized(self, data: str) -> Tuple[Any, bool]: - """Return parsed data from file text and whether to call `save()`.""" - return (data, False) + def deserialized(self, data: str) -> Tuple[Any, bool]: + """Return parsed data from file text and whether to call `save()`.""" + return (data, False) - def serialized(self) -> str: - """Return text from `UserFile.data` that can be written to disk.""" - raise NotImplementedError() + def serialized(self) -> str: + """Return text from `UserFile.data` that can be written to disk.""" + raise NotImplementedError() - def save(self) -> None: - """Inform the disk writer coroutine that the data has changed.""" - self._need_write = True + def save(self) -> None: + """Inform the disk writer coroutine that the data has changed.""" + self._need_write = True - def stop_watching(self) -> None: - """Stop watching the on-disk file for changes.""" - if self._reader: - self._reader.cancel() + def stop_watching(self) -> None: + """Stop watching the on-disk file for changes.""" + if self._reader: + self._reader.cancel() - if self._writer: - self._writer.cancel() + if self._writer: + self._writer.cancel() - for child in self.children.values(): - child.stop_watching() + for child in self.children.values(): + child.stop_watching() - async def set_data(self, data: Any) -> None: - """Set `data` and call `save()`, conveniance method for QML.""" - self.data = data - self.save() + async def set_data(self, data: Any) -> None: + """Set `data` and call `save()`, conveniance method for QML.""" + self.data = data + self.save() - async def update_from_file(self) -> None: - """Read file at `path`, update `data` and call `save()` if needed.""" + async def update_from_file(self) -> None: + """Read file at `path`, update `data` and call `save()` if needed.""" - if not self.path.exists(): - self.data = self.default_data - self._need_write = self.create_missing - return + if not self.path.exists(): + self.data = self.default_data + self._need_write = self.create_missing + return - async with aiopen(self.path) as file: - self.data, self._need_write = self.deserialized(await file.read()) + async with aiopen(self.path) as file: + self.data, self._need_write = self.deserialized(await file.read()) - async def _start_reader(self) -> None: - """Disk reader coroutine, watches for file changes to update `data`.""" + async def _start_reader(self) -> None: + """Disk reader coroutine, watches for file changes to update `data`.""" - while not self.path.exists(): - await asyncio.sleep(1) + while not self.path.exists(): + await asyncio.sleep(1) - async for changes in awatch(self.path): - try: - ignored = 0 + async for changes in awatch(self.path): + try: + ignored = 0 - for change in changes: - if change[0] in (Change.added, Change.modified): - mtime = self.path.stat().st_mtime + for change in changes: + if change[0] in (Change.added, Change.modified): + mtime = self.path.stat().st_mtime - if mtime == self._mtime: - ignored += 1 - continue + if mtime == self._mtime: + ignored += 1 + continue - await self.update_from_file() - self._mtime = mtime + await self.update_from_file() + self._mtime = mtime - elif change[0] == Change.deleted: - self._mtime = None - self.data = self.default_data - self._need_write = self.create_missing + elif change[0] == Change.deleted: + self._mtime = None + self.data = self.default_data + self._need_write = self.create_missing - if changes and ignored < len(changes): - UserFileChanged(type(self), self.qml_data) + if changes and ignored < len(changes): + UserFileChanged(type(self), self.qml_data) - parent = self.parent - while parent: - await parent.update_from_file() - UserFileChanged(type(parent), parent.qml_data) - parent = parent.parent + parent = self.parent + while parent: + await parent.update_from_file() + UserFileChanged(type(parent), parent.qml_data) + parent = parent.parent - while not self.path.exists(): - # Prevent error spam after file gets deleted - await asyncio.sleep(0.5) + while not self.path.exists(): + # Prevent error spam after file gets deleted + await asyncio.sleep(0.5) - except Exception as err: # noqa - LoopException(str(err), err, traceback.format_exc().rstrip()) + except Exception as err: # noqa + LoopException(str(err), err, traceback.format_exc().rstrip()) - async def _start_writer(self) -> None: - """Disk writer coroutine, update the file with a 1 second cooldown.""" + async def _start_writer(self) -> None: + """Disk writer coroutine, update the file with a 1 second cooldown.""" - if self.write_path.parts[0] == "qrc:": - return + if self.write_path.parts[0] == "qrc:": + return - self.write_path.parent.mkdir(parents=True, exist_ok=True) + self.write_path.parent.mkdir(parents=True, exist_ok=True) - while True: - await asyncio.sleep(1) + while True: + await asyncio.sleep(1) - try: - if self._need_write: - async with atomic_write(self.write_path) as (new, done): - await new.write(self.serialized()) - done() + try: + if self._need_write: + async with atomic_write(self.write_path) as (new, done): + await new.write(self.serialized()) + done() - self._need_write = False - self._mtime = self.write_path.stat().st_mtime + self._need_write = False + self._mtime = self.write_path.stat().st_mtime - except Exception as err: # noqa - self._need_write = False - LoopException(str(err), err, traceback.format_exc().rstrip()) + except Exception as err: # noqa + self._need_write = False + LoopException(str(err), err, traceback.format_exc().rstrip()) @dataclass class ConfigFile(UserFile): - """A file that goes in the configuration directory, e.g. ~/.config/app.""" + """A file that goes in the configuration directory, e.g. ~/.config/app.""" - @property - def path(self) -> Path: - return Path( - os.environ.get("MOMENT_CONFIG_DIR") or - self.backend.appdirs.user_config_dir, - ) / self.filename + @property + def path(self) -> Path: + return Path( + os.environ.get("MOMENT_CONFIG_DIR") or + self.backend.appdirs.user_config_dir, + ) / self.filename @dataclass class UserDataFile(UserFile): - """A file that goes in the user data directory, e.g. ~/.local/share/app.""" + """A file that goes in the user data directory, e.g. ~/.local/share/app.""" - @property - def path(self) -> Path: - return Path( - os.environ.get("MOMENT_DATA_DIR") or - self.backend.appdirs.user_data_dir, - ) / self.filename + @property + def path(self) -> Path: + return Path( + os.environ.get("MOMENT_DATA_DIR") or + self.backend.appdirs.user_data_dir, + ) / self.filename @dataclass class MappingFile(MutableMapping, UserFile): - """A file manipulable like a dict. `data` must be a mutable mapping.""" + """A file manipulable like a dict. `data` must be a mutable mapping.""" - def __getitem__(self, key: Any) -> Any: - return self.data[key] + def __getitem__(self, key: Any) -> Any: + return self.data[key] - def __setitem__(self, key: Any, value: Any) -> None: - self.data[key] = value + def __setitem__(self, key: Any, value: Any) -> None: + self.data[key] = value - def __delitem__(self, key: Any) -> None: - del self.data[key] + def __delitem__(self, key: Any) -> None: + del self.data[key] - def __iter__(self) -> Iterator: - return iter(self.data) + def __iter__(self) -> Iterator: + return iter(self.data) - def __len__(self) -> int: - return len(self.data) + def __len__(self) -> int: + return len(self.data) - def __getattr__(self, key: Any) -> Any: - try: - return self.data[key] - except KeyError: - return super().__getattribute__(key) + def __getattr__(self, key: Any) -> Any: + try: + return self.data[key] + except KeyError: + return super().__getattribute__(key) - def __setattr__(self, key: Any, value: Any) -> None: - if key in self.__dataclass_fields__: - super().__setattr__(key, value) - return + def __setattr__(self, key: Any, value: Any) -> None: + if key in self.__dataclass_fields__: + super().__setattr__(key, value) + return - self.data[key] = value + self.data[key] = value - def __delattr__(self, key: Any) -> None: - del self.data[key] + def __delattr__(self, key: Any) -> None: + del self.data[key] @dataclass class JSONFile(MappingFile): - """A file stored on disk in the JSON format.""" + """A file stored on disk in the JSON format.""" - @property - def default_data(self) -> dict: - return {} + @property + def default_data(self) -> dict: + return {} - def deserialized(self, data: str) -> Tuple[dict, bool]: - """Return parsed data from file text and whether to call `save()`. + def deserialized(self, data: str) -> Tuple[dict, bool]: + """Return parsed data from file text and whether to call `save()`. - If the file has missing keys, the missing data will be merged to the - returned dict and the second tuple item will be `True`. - """ + If the file has missing keys, the missing data will be merged to the + returned dict and the second tuple item will be `True`. + """ - loaded = json.loads(data) - all_data = self.default_data.copy() - dict_update_recursive(all_data, loaded) - return (all_data, loaded != all_data) + loaded = json.loads(data) + all_data = self.default_data.copy() + dict_update_recursive(all_data, loaded) + return (all_data, loaded != all_data) - def serialized(self) -> str: - data = self.data - return json.dumps(data, indent=4, ensure_ascii=False, sort_keys=True) + def serialized(self) -> str: + data = self.data + return json.dumps(data, indent=4, ensure_ascii=False, sort_keys=True) @dataclass class PCNFile(MappingFile): - """File stored in the PCN format, with machine edits in a separate JSON.""" + """File stored in the PCN format, with machine edits in a separate JSON.""" - create_missing = False + create_missing = False - path_override: Optional[Path] = None + path_override: Optional[Path] = None - @property - def path(self) -> Path: - return self.path_override or super().path + @property + def path(self) -> Path: + return self.path_override or super().path - @property - def write_path(self) -> Path: - """Full path of file where programatically-done edits are stored.""" - return self.path.with_suffix(".gui.json") + @property + def write_path(self) -> Path: + """Full path of file where programatically-done edits are stored.""" + return self.path.with_suffix(".gui.json") - @property - def qml_data(self) -> Dict[str, Any]: - return deep_serialize_for_qml(self.data.as_dict()) # type: ignore + @property + def qml_data(self) -> Dict[str, Any]: + return deep_serialize_for_qml(self.data.as_dict()) # type: ignore - @property - def default_data(self) -> Section: - return Section() + @property + def default_data(self) -> Section: + return Section() - def deserialized(self, data: str) -> Tuple[Section, bool]: - root = Section.from_source_code(data, self.path) - edits = "{}" + def deserialized(self, data: str) -> Tuple[Section, bool]: + root = Section.from_source_code(data, self.path) + edits = "{}" - if self.write_path.exists(): - edits = self.write_path.read_text() + if self.write_path.exists(): + edits = self.write_path.read_text() - includes_now = list(root.all_includes) + includes_now = list(root.all_includes) - for path, pcn in self.children.copy().items(): - if path not in includes_now: - pcn.stop_watching() - del self.children[path] + for path, pcn in self.children.copy().items(): + if path not in includes_now: + pcn.stop_watching() + del self.children[path] - for path in includes_now: - if path not in self.children: - self.children[path] = PCNFile( - self.backend, - filename = path.name, - parent = self, - path_override = path, - ) + for path in includes_now: + if path not in self.children: + self.children[path] = PCNFile( + self.backend, + filename = path.name, + parent = self, + path_override = path, + ) - return (root, root.deep_merge_edits(json.loads(edits))) + return (root, root.deep_merge_edits(json.loads(edits))) - def serialized(self) -> str: - edits = self.data.edits_as_dict() - return json.dumps(edits, indent=4, ensure_ascii=False) + def serialized(self) -> str: + edits = self.data.edits_as_dict() + return json.dumps(edits, indent=4, ensure_ascii=False) - async def set_data(self, data: Dict[str, Any]) -> None: - self.data.deep_merge_edits({"set": data}, has_expressions=False) - self.save() + async def set_data(self, data: Dict[str, Any]) -> None: + self.data.deep_merge_edits({"set": data}, has_expressions=False) + self.save() @dataclass class Accounts(ConfigFile, JSONFile): - """Config file for saved matrix accounts: user ID, access tokens, etc""" + """Config file for saved matrix accounts: user ID, access tokens, etc""" - filename: str = "accounts.json" + filename: str = "accounts.json" - async def any_saved(self) -> bool: - """Return for QML whether there are any accounts saved on disk.""" - return bool(self.data) + async def any_saved(self) -> bool: + """Return for QML whether there are any accounts saved on disk.""" + return bool(self.data) - async def add(self, user_id: str) -> None: - """Add an account to the config and write it on disk. + async def add(self, user_id: str) -> None: + """Add an account to the config and write it on disk. - The account's details such as its access token are retrieved from - the corresponding `MatrixClient` in `backend.clients`. - """ + The account's details such as its access token are retrieved from + the corresponding `MatrixClient` in `backend.clients`. + """ - client = self.backend.clients[user_id] - account = self.backend.models["accounts"][user_id] + client = self.backend.clients[user_id] + account = self.backend.models["accounts"][user_id] - self.update({ - client.user_id: { - "homeserver": client.homeserver, - "token": client.access_token, - "device_id": client.device_id, - "enabled": True, - "presence": account.presence.value.replace("echo_", ""), - "status_msg": account.status_msg, - "order": account.order, - }, - }) - self.save() + self.update({ + client.user_id: { + "homeserver": client.homeserver, + "token": client.access_token, + "device_id": client.device_id, + "enabled": True, + "presence": account.presence.value.replace("echo_", ""), + "status_msg": account.status_msg, + "order": account.order, + }, + }) + self.save() - async def set( - self, - user_id: str, - enabled: Optional[str] = None, - presence: Optional[str] = None, - order: Optional[int] = None, - status_msg: Optional[str] = None, - ) -> None: - """Update an account if found in the config file and write to disk.""" + async def set( + self, + user_id: str, + enabled: Optional[str] = None, + presence: Optional[str] = None, + order: Optional[int] = None, + status_msg: Optional[str] = None, + ) -> None: + """Update an account if found in the config file and write to disk.""" - if user_id not in self: - return + if user_id not in self: + return - if enabled is not None: - self[user_id]["enabled"] = enabled + if enabled is not None: + self[user_id]["enabled"] = enabled - if presence is not None: - self[user_id]["presence"] = presence + if presence is not None: + self[user_id]["presence"] = presence - if order is not None: - self[user_id]["order"] = order + if order is not None: + self[user_id]["order"] = order - if status_msg is not None: - self[user_id]["status_msg"] = status_msg + if status_msg is not None: + self[user_id]["status_msg"] = status_msg - self.save() + self.save() - async def forget(self, user_id: str) -> None: - """Delete an account from the config and write it on disk.""" + async def forget(self, user_id: str) -> None: + """Delete an account from the config and write it on disk.""" - self.pop(user_id, None) - self.save() + self.pop(user_id, None) + self.save() @dataclass class Pre070Settings(ConfigFile): - """Detect and warn about the presence of a pre-0.7.0 settings.json file.""" + """Detect and warn about the presence of a pre-0.7.0 settings.json file.""" - filename: str = "settings.json" + filename: str = "settings.json" - def __post_init__(self) -> None: - if self.path.exists(): - Pre070SettingsDetected(self.path) + def __post_init__(self) -> None: + if self.path.exists(): + Pre070SettingsDetected(self.path) @dataclass class Settings(ConfigFile, PCNFile): - """General config file for UI and backend settings""" + """General config file for UI and backend settings""" - filename: str = "settings.py" + filename: str = "settings.py" - @property - def default_data(self) -> Section: - root = Section.from_file("src/config/settings.py") - edits = "{}" + @property + def default_data(self) -> Section: + root = Section.from_file("src/config/settings.py") + edits = "{}" - if self.write_path.exists(): - edits = self.write_path.read_text() + if self.write_path.exists(): + edits = self.write_path.read_text() - root.deep_merge_edits(json.loads(edits)) - return root + root.deep_merge_edits(json.loads(edits)) + return root - def deserialized(self, data: str) -> Tuple[Section, bool]: - section, save = super().deserialized(data) + def deserialized(self, data: str) -> Tuple[Section, bool]: + section, save = super().deserialized(data) - if self and self.General.theme != section.General.theme: - if hasattr(self.backend, "theme"): - self.backend.theme.stop_watching() + if self and self.General.theme != section.General.theme: + if hasattr(self.backend, "theme"): + self.backend.theme.stop_watching() - self.backend.theme = Theme( - self.backend, section.General.theme, # type: ignore - ) - UserFileChanged(Theme, self.backend.theme.qml_data) + self.backend.theme = Theme( + self.backend, section.General.theme, # type: ignore + ) + UserFileChanged(Theme, self.backend.theme.qml_data) - # if self and self.General.new_theme != section.General.new_theme: - # self.backend.new_theme.stop_watching() - # self.backend.new_theme = NewTheme( - # self.backend, section.General.new_theme, # type: ignore - # ) - # UserFileChanged(Theme, self.backend.new_theme.qml_data) + # if self and self.General.new_theme != section.General.new_theme: + # self.backend.new_theme.stop_watching() + # self.backend.new_theme = NewTheme( + # self.backend, section.General.new_theme, # type: ignore + # ) + # UserFileChanged(Theme, self.backend.new_theme.qml_data) - return (section, save) + return (section, save) @dataclass class NewTheme(UserDataFile, PCNFile): - """A theme file defining the look of QML components.""" + """A theme file defining the look of QML components.""" - create_missing = False + create_missing = False - @property - def path(self) -> Path: - data_dir = Path( - os.environ.get("MOMENT_DATA_DIR") or - self.backend.appdirs.user_data_dir, - ) - return data_dir / "themes" / self.filename + @property + def path(self) -> Path: + data_dir = Path( + os.environ.get("MOMENT_DATA_DIR") or + self.backend.appdirs.user_data_dir, + ) + return data_dir / "themes" / self.filename - @property - def qml_data(self) -> Dict[str, Any]: - return flatten_dict_keys(super().qml_data, last_level=False) + @property + def qml_data(self) -> Dict[str, Any]: + return flatten_dict_keys(super().qml_data, last_level=False) @dataclass class UIState(UserDataFile, JSONFile): - """File used to save and restore the state of QML components.""" + """File used to save and restore the state of QML components.""" - filename: str = "state.json" + filename: str = "state.json" - @property - def default_data(self) -> dict: - return { - "collapseAccounts": {}, - "page": "Pages/Default.qml", - "pageProperties": {}, - } + @property + def default_data(self) -> dict: + return { + "collapseAccounts": {}, + "page": "Pages/Default.qml", + "pageProperties": {}, + } - def deserialized(self, data: str) -> Tuple[dict, bool]: - dict_data, save = super().deserialized(data) + def deserialized(self, data: str) -> Tuple[dict, bool]: + dict_data, save = super().deserialized(data) - for user_id, do in dict_data["collapseAccounts"].items(): - self.backend.models["all_rooms"].set_account_collapse(user_id, do) + for user_id, do in dict_data["collapseAccounts"].items(): + self.backend.models["all_rooms"].set_account_collapse(user_id, do) - return (dict_data, save) + return (dict_data, save) @dataclass class History(UserDataFile, JSONFile): - """File to save and restore lines typed by the user in QML components.""" + """File to save and restore lines typed by the user in QML components.""" - filename: str = "history.json" + filename: str = "history.json" - @property - def default_data(self) -> dict: - return {"console": []} + @property + def default_data(self) -> dict: + return {"console": []} @dataclass class Theme(UserDataFile): - """A theme file defining the look of QML components.""" + """A theme file defining the look of QML components.""" - # Since it currently breaks at every update and the file format will be - # changed later, don't copy the theme to user data dir if it doesn't exist. - create_missing = False + # Since it currently breaks at every update and the file format will be + # changed later, don't copy the theme to user data dir if it doesn't exist. + create_missing = False - @property - def path(self) -> Path: - data_dir = Path( - os.environ.get("MOMENT_DATA_DIR") or - self.backend.appdirs.user_data_dir, - ) - return data_dir / "themes" / self.filename + @property + def path(self) -> Path: + data_dir = Path( + os.environ.get("MOMENT_DATA_DIR") or + self.backend.appdirs.user_data_dir, + ) + return data_dir / "themes" / self.filename - @property - def default_data(self) -> str: - if self.filename in ("Foliage.qpl", "Midnight.qpl", "Glass.qpl"): - path = f"src/themes/{self.filename}" - else: - path = "src/themes/Foliage.qpl" + @property + def default_data(self) -> str: + if self.filename in ("Foliage.qpl", "Midnight.qpl", "Glass.qpl"): + path = f"src/themes/{self.filename}" + else: + path = "src/themes/Foliage.qpl" - try: - byte_content = pyotherside.qrc_get_file_contents(path) - except ValueError: - # App was compiled without QRC - return convert_to_qml(Path(path).read_text()) - else: - return convert_to_qml(byte_content.decode()) + try: + byte_content = pyotherside.qrc_get_file_contents(path) + except ValueError: + # App was compiled without QRC + return convert_to_qml(Path(path).read_text()) + else: + return convert_to_qml(byte_content.decode()) - def deserialized(self, data: str) -> Tuple[str, bool]: - return (convert_to_qml(data), False) + def deserialized(self, data: str) -> Tuple[str, bool]: + return (convert_to_qml(data), False) diff --git a/src/backend/utils.py b/src/backend/utils.py index 7a7e5b8e..1844b02b 100644 --- a/src/backend/utils.py +++ b/src/backend/utils.py @@ -20,8 +20,8 @@ from pathlib import Path from tempfile import NamedTemporaryFile from types import ModuleType from typing import ( - Any, AsyncIterator, Callable, Collection, Dict, Iterable, Mapping, - Optional, Tuple, Type, Union, + Any, AsyncIterator, Callable, Collection, Dict, Iterable, Mapping, + Optional, Tuple, Type, Union, ) from uuid import UUID @@ -36,348 +36,348 @@ from .color import Color from .pcn.section import Section if sys.version_info >= (3, 7): - from contextlib import asynccontextmanager - current_task = asyncio.current_task + from contextlib import asynccontextmanager + current_task = asyncio.current_task else: - from async_generator import asynccontextmanager - current_task = asyncio.Task.current_task + from async_generator import asynccontextmanager + current_task = asyncio.Task.current_task if sys.version_info >= (3, 10): - import collections.abc as collections + import collections.abc as collections else: - import collections + import collections -Size = Tuple[int, int] +Size = Tuple[int, int] BytesOrPIL = Union[bytes, PILImage.Image] -auto = autostr +auto = autostr COMPRESSION_POOL = ProcessPoolExecutor() class AutoStrEnum(Enum): - """An Enum where auto() assigns the member's name instead of an integer. + """An Enum where auto() assigns the member's name instead of an integer. - Example: - >>> class Fruits(AutoStrEnum): apple = auto() - >>> Fruits.apple.value - "apple" - """ + Example: + >>> class Fruits(AutoStrEnum): apple = auto() + >>> Fruits.apple.value + "apple" + """ - @staticmethod - def _generate_next_value_(name, *_): - return name + @staticmethod + def _generate_next_value_(name, *_): + return name def dict_update_recursive(dict1: dict, dict2: dict) -> None: - """Deep-merge `dict1` and `dict2`, recursive version of `dict.update()`.""" - # https://gist.github.com/angstwad/bf22d1822c38a92ec0a9 + """Deep-merge `dict1` and `dict2`, recursive version of `dict.update()`.""" + # https://gist.github.com/angstwad/bf22d1822c38a92ec0a9 - for k in dict2: - if (k in dict1 and isinstance(dict1[k], dict) and - isinstance(dict2[k], collections.Mapping)): - dict_update_recursive(dict1[k], dict2[k]) - else: - dict1[k] = dict2[k] + for k in dict2: + if (k in dict1 and isinstance(dict1[k], dict) and + isinstance(dict2[k], collections.Mapping)): + dict_update_recursive(dict1[k], dict2[k]) + else: + dict1[k] = dict2[k] def flatten_dict_keys( - source: Optional[Dict[str, Any]] = None, - separator: str = ".", - last_level: bool = True, - _flat: Optional[Dict[str, Any]] = None, - _prefix: str = "", + source: Optional[Dict[str, Any]] = None, + separator: str = ".", + last_level: bool = True, + _flat: Optional[Dict[str, Any]] = None, + _prefix: str = "", ) -> Dict[str, Any]: - """Return a flattened version of the ``source`` dict. + """Return a flattened version of the ``source`` dict. - Example: - >>> dct - {"content": {"body": "foo"}, "m.test": {"key": {"bar": 1}}} - >>> flatten_dict_keys(dct) - {"content.body": "foo", "m.test.key.bar": 1} - >>> flatten_dict_keys(dct, last_level=False) - {"content": {"body": "foo"}, "m.test.key": {bar": 1}} - """ + Example: + >>> dct + {"content": {"body": "foo"}, "m.test": {"key": {"bar": 1}}} + >>> flatten_dict_keys(dct) + {"content.body": "foo", "m.test.key.bar": 1} + >>> flatten_dict_keys(dct, last_level=False) + {"content": {"body": "foo"}, "m.test.key": {bar": 1}} + """ - flat = {} if _flat is None else _flat + flat = {} if _flat is None else _flat - for key, value in (source or {}).items(): - if isinstance(value, dict): - prefix = f"{_prefix}{key}{separator}" - flatten_dict_keys(value, separator, last_level, flat, prefix) - elif last_level: - flat[f"{_prefix}{key}"] = value - else: - prefix = _prefix[:-len(separator)] # remove trailing separator - flat.setdefault(prefix, {})[key] = value + for key, value in (source or {}).items(): + if isinstance(value, dict): + prefix = f"{_prefix}{key}{separator}" + flatten_dict_keys(value, separator, last_level, flat, prefix) + elif last_level: + flat[f"{_prefix}{key}"] = value + else: + prefix = _prefix[:-len(separator)] # remove trailing separator + flat.setdefault(prefix, {})[key] = value - return flat + return flat def config_get_account_room_rule( - rules: Section, user_id: str, room_id: str, + rules: Section, user_id: str, room_id: str, ) -> Any: - """Return best matching rule value for an account/room PCN free Section.""" + """Return best matching rule value for an account/room PCN free Section.""" - for name, value in reversed(rules.children()): - name = re.sub(r"\s+", " ", name.strip()) + for name, value in reversed(rules.children()): + name = re.sub(r"\s+", " ", name.strip()) - if name in (user_id, room_id, f"{user_id} {room_id}"): - return value + if name in (user_id, room_id, f"{user_id} {room_id}"): + return value - return rules.default + return rules.default async def is_svg(file: File) -> bool: - """Return whether the file is a SVG (`lxml` is used for detection).""" + """Return whether the file is a SVG (`lxml` is used for detection).""" - chunks = [c async for c in async_generator_from_data(file)] + chunks = [c async for c in async_generator_from_data(file)] - with io.BytesIO(b"".join(chunks)) as file: - try: - _, element = next(xml_etree.iterparse(file, ("start",))) - return element.tag == "{http://www.w3.org/2000/svg}svg" - except (StopIteration, xml_etree.ParseError): - return False + with io.BytesIO(b"".join(chunks)) as file: + try: + _, element = next(xml_etree.iterparse(file, ("start",))) + return element.tag == "{http://www.w3.org/2000/svg}svg" + except (StopIteration, xml_etree.ParseError): + return False async def svg_dimensions(file: File) -> Size: - """Return the width and height, or viewBox width and height for a SVG. + """Return the width and height, or viewBox width and height for a SVG. - If these properties are missing (broken file), ``(256, 256)`` is returned. - """ + If these properties are missing (broken file), ``(256, 256)`` is returned. + """ - chunks = [c async for c in async_generator_from_data(file)] + chunks = [c async for c in async_generator_from_data(file)] - with io.BytesIO(b"".join(chunks)) as file: - attrs = xml_etree.parse(file).getroot().attrib + with io.BytesIO(b"".join(chunks)) as file: + attrs = xml_etree.parse(file).getroot().attrib - try: - width = round(float(attrs.get("width", attrs["viewBox"].split()[3]))) - except (KeyError, IndexError, ValueError, TypeError): - width = 256 + try: + width = round(float(attrs.get("width", attrs["viewBox"].split()[3]))) + except (KeyError, IndexError, ValueError, TypeError): + width = 256 - try: - height = round(float(attrs.get("height", attrs["viewBox"].split()[4]))) - except (KeyError, IndexError, ValueError, TypeError): - height = 256 + try: + height = round(float(attrs.get("height", attrs["viewBox"].split()[4]))) + except (KeyError, IndexError, ValueError, TypeError): + height = 256 - return (width, height) + return (width, height) async def guess_mime(file: File) -> str: - """Return the file's mimetype, or `application/octet-stream` if unknown.""" + """Return the file's mimetype, or `application/octet-stream` if unknown.""" - if isinstance(file, io.IOBase): - file.seek(0, 0) - elif isinstance(file, AsyncBufferedIOBase): - await file.seek(0, 0) + if isinstance(file, io.IOBase): + file.seek(0, 0) + elif isinstance(file, AsyncBufferedIOBase): + await file.seek(0, 0) - try: - first_chunk: bytes - async for first_chunk in async_generator_from_data(file): - break - else: - return "inode/x-empty" # empty file + try: + first_chunk: bytes + async for first_chunk in async_generator_from_data(file): + break + else: + return "inode/x-empty" # empty file - # TODO: plaintext - mime = filetype.guess_mime(first_chunk) + # TODO: plaintext + mime = filetype.guess_mime(first_chunk) - return mime or ( - "image/svg+xml" if await is_svg(file) else - "application/octet-stream" - ) - finally: - if isinstance(file, io.IOBase): - file.seek(0, 0) - elif isinstance(file, AsyncBufferedIOBase): - await file.seek(0, 0) + return mime or ( + "image/svg+xml" if await is_svg(file) else + "application/octet-stream" + ) + finally: + if isinstance(file, io.IOBase): + file.seek(0, 0) + elif isinstance(file, AsyncBufferedIOBase): + await file.seek(0, 0) def plain2html(text: str) -> str: - """Convert `\\n` into `
` tags and `\\t` into four spaces.""" + """Convert `\\n` into `
` tags and `\\t` into four spaces.""" - return html.escape(text)\ - .replace("\n", "
")\ - .replace("\t", " " * 4) + return html.escape(text)\ + .replace("\n", "
")\ + .replace("\t", " " * 4) def strip_html_tags(text: str) -> str: - """Remove HTML tags from text.""" - return re.sub(r"<\/?[^>]+(>|$)", "", text) + """Remove HTML tags from text.""" + return re.sub(r"<\/?[^>]+(>|$)", "", text) def serialize_value_for_qml( - value: Any, json_list_dicts: bool = False, reject_unknown: bool = False, + value: Any, json_list_dicts: bool = False, reject_unknown: bool = False, ) -> Any: - """Convert a value to make it easier to use from QML. + """Convert a value to make it easier to use from QML. - Returns: + Returns: - - For `bool`, `int`, `float`, `bytes`, `str`, `datetime`, `date`, `time`: - the unchanged value (PyOtherSide handles these) + - For `bool`, `int`, `float`, `bytes`, `str`, `datetime`, `date`, `time`: + the unchanged value (PyOtherSide handles these) - - For `Collection` objects (includes `list` and `dict`): - a JSON dump if `json_list_dicts` is `True`, else the unchanged value + - For `Collection` objects (includes `list` and `dict`): + a JSON dump if `json_list_dicts` is `True`, else the unchanged value - - If the value is an instancied object and has a `serialized` attribute or - property, return that + - If the value is an instancied object and has a `serialized` attribute or + property, return that - - For `Enum` members, the actual value of the member + - For `Enum` members, the actual value of the member - - For `Path` objects, a `file://` string + - For `Path` objects, a `file://` string - - For `UUID` object: the UUID in string form + - For `UUID` object: the UUID in string form - - For `timedelta` objects: the delta as a number of milliseconds `int` + - For `timedelta` objects: the delta as a number of milliseconds `int` - - For `Color` objects: the color's hexadecimal value + - For `Color` objects: the color's hexadecimal value - - For class types: the class `__name__` + - For class types: the class `__name__` - - For anything else: raise a `TypeError` if `reject_unknown` is `True`, - else return the unchanged value. - """ + - For anything else: raise a `TypeError` if `reject_unknown` is `True`, + else return the unchanged value. + """ - if isinstance(value, (bool, int, float, bytes, str, datetime, date, time)): - return value + if isinstance(value, (bool, int, float, bytes, str, datetime, date, time)): + return value - if json_list_dicts and isinstance(value, Collection): - if isinstance(value, set): - value = list(value) - return json.dumps(value) + if json_list_dicts and isinstance(value, Collection): + if isinstance(value, set): + value = list(value) + return json.dumps(value) - if not inspect.isclass(value) and hasattr(value, "serialized"): - return value.serialized + if not inspect.isclass(value) and hasattr(value, "serialized"): + return value.serialized - if isinstance(value, Iterable): - return value + if isinstance(value, Iterable): + return value - if hasattr(value, "__class__") and issubclass(value.__class__, Enum): - return value.value + if hasattr(value, "__class__") and issubclass(value.__class__, Enum): + return value.value - if isinstance(value, Path): - return f"file://{value!s}" + if isinstance(value, Path): + return f"file://{value!s}" - if isinstance(value, UUID): - return str(value) + if isinstance(value, UUID): + return str(value) - if isinstance(value, timedelta): - return value.total_seconds() * 1000 + if isinstance(value, timedelta): + return value.total_seconds() * 1000 - if isinstance(value, Color): - return value.hex + if isinstance(value, Color): + return value.hex - if inspect.isclass(value): - return value.__name__ + if inspect.isclass(value): + return value.__name__ - if reject_unknown: - raise TypeError("Unknown type reject") + if reject_unknown: + raise TypeError("Unknown type reject") - return value + return value def deep_serialize_for_qml(obj: Iterable) -> Union[list, dict]: - """Recursively serialize lists and dict values for QML.""" + """Recursively serialize lists and dict values for QML.""" - if isinstance(obj, Mapping): - dct = {} + if isinstance(obj, Mapping): + dct = {} - for key, value in obj.items(): - if isinstance(value, Iterable) and not isinstance(value, str): - # PyOtherSide only accept dicts with string keys - dct[str(key)] = deep_serialize_for_qml(value) - continue + for key, value in obj.items(): + if isinstance(value, Iterable) and not isinstance(value, str): + # PyOtherSide only accept dicts with string keys + dct[str(key)] = deep_serialize_for_qml(value) + continue - with suppress(TypeError): - dct[str(key)] = \ - serialize_value_for_qml(value, reject_unknown=True) + with suppress(TypeError): + dct[str(key)] = \ + serialize_value_for_qml(value, reject_unknown=True) - return dct + return dct - lst = [] + lst = [] - for value in obj: - if isinstance(value, Iterable) and not isinstance(value, str): - lst.append(deep_serialize_for_qml(value)) - continue + for value in obj: + if isinstance(value, Iterable) and not isinstance(value, str): + lst.append(deep_serialize_for_qml(value)) + continue - with suppress(TypeError): - lst.append(serialize_value_for_qml(value, reject_unknown=True)) + with suppress(TypeError): + lst.append(serialize_value_for_qml(value, reject_unknown=True)) - return lst + return lst def classes_defined_in(module: ModuleType) -> Dict[str, Type]: - """Return a `{name: class}` dict of all the classes a module defines.""" + """Return a `{name: class}` dict of all the classes a module defines.""" - return { - m[0]: m[1] for m in inspect.getmembers(module, inspect.isclass) - if not m[0].startswith("_") and - m[1].__module__.startswith(module.__name__) - } + return { + m[0]: m[1] for m in inspect.getmembers(module, inspect.isclass) + if not m[0].startswith("_") and + m[1].__module__.startswith(module.__name__) + } @asynccontextmanager async def aiopen(*args, **kwargs) -> AsyncIterator[Any]: - """Wrapper for `aiofiles.open()` that doesn't break mypy""" - async with aiofiles.open(*args, **kwargs) as file: - yield file + """Wrapper for `aiofiles.open()` that doesn't break mypy""" + async with aiofiles.open(*args, **kwargs) as file: + yield file @asynccontextmanager async def atomic_write( - path: Union[Path, str], binary: bool = False, **kwargs, + path: Union[Path, str], binary: bool = False, **kwargs, ) -> AsyncIterator[Tuple[Any, Callable[[], None]]]: - """Write a file asynchronously (using aiofiles) and atomically. + """Write a file asynchronously (using aiofiles) and atomically. - Yields a `(open_temporary_file, done_function)` tuple. - The done function should be called after writing to the given file. - When the context manager exits, the temporary file will either replace - `path` if the function was called, or be deleted. + Yields a `(open_temporary_file, done_function)` tuple. + The done function should be called after writing to the given file. + When the context manager exits, the temporary file will either replace + `path` if the function was called, or be deleted. - Example: - >>> async with atomic_write("foo.txt") as (file, done): - >>> await file.write("Sample text") - >>> done() - """ + Example: + >>> async with atomic_write("foo.txt") as (file, done): + >>> await file.write("Sample text") + >>> done() + """ - mode = "wb" if binary else "w" - path = Path(path) - temp = NamedTemporaryFile(dir=path.parent, delete=False) - temp_path = Path(temp.name) + mode = "wb" if binary else "w" + path = Path(path) + temp = NamedTemporaryFile(dir=path.parent, delete=False) + temp_path = Path(temp.name) - can_replace = False + can_replace = False - def done() -> None: - nonlocal can_replace - can_replace = True + def done() -> None: + nonlocal can_replace + can_replace = True - try: - async with aiopen(temp_path, mode, **kwargs) as out: - yield (out, done) - finally: - if can_replace: - temp_path.replace(path) - else: - temp_path.unlink() + try: + async with aiopen(temp_path, mode, **kwargs) as out: + yield (out, done) + finally: + if can_replace: + temp_path.replace(path) + else: + temp_path.unlink() def _compress(image: BytesOrPIL, fmt: str, optimize: bool) -> bytes: - if isinstance(image, bytes): - pil_image = PILImage.open(io.BytesIO(image)) - else: - pil_image = image + if isinstance(image, bytes): + pil_image = PILImage.open(io.BytesIO(image)) + else: + pil_image = image - with io.BytesIO() as buffer: - pil_image.save(buffer, fmt, optimize=optimize) - return buffer.getvalue() + with io.BytesIO() as buffer: + pil_image.save(buffer, fmt, optimize=optimize) + return buffer.getvalue() async def compress_image( - image: BytesOrPIL, fmt: str = "PNG", optimize: bool = True, + image: BytesOrPIL, fmt: str = "PNG", optimize: bool = True, ) -> bytes: - """Compress image in a separate process, without blocking event loop.""" + """Compress image in a separate process, without blocking event loop.""" - return await asyncio.get_event_loop().run_in_executor( - COMPRESSION_POOL, _compress, image, fmt, optimize, - ) + return await asyncio.get_event_loop().run_in_executor( + COMPRESSION_POOL, _compress, image, fmt, optimize, + ) diff --git a/src/config/settings.py b/src/config/settings.py index 23515279..08fd6200 100644 --- a/src/config/settings.py +++ b/src/config/settings.py @@ -3,541 +3,541 @@ # mypy: ignore-errors class General: - # When closing the window, minimize the application to system tray instead - # of quitting the application. - # A click on the tray icon reveals the window, middle click fully quits it - # and right click opens a menu with these options. - close_to_tray: bool = False + # When closing the window, minimize the application to system tray instead + # of quitting the application. + # A click on the tray icon reveals the window, middle click fully quits it + # and right click opens a menu with these options. + close_to_tray: bool = False - # Show rooms, members and messages in way that takes less vertical space. - compact: bool = False + # Show rooms, members and messages in way that takes less vertical space. + compact: bool = False - # When the window width is less than this number of pixels, switch to a - # mobile-like mode where only the left main pane, center page/chat or - # right room pane is visible at a time. - hide_side_panes_under: int = 450 + # When the window width is less than this number of pixels, switch to a + # mobile-like mode where only the left main pane, center page/chat or + # right room pane is visible at a time. + hide_side_panes_under: int = 450 - # Whether to wrap around or do nothing when using the earlier_page or - # later_page keybinds and reaching the start or end of the history. - wrap_history: bool = True + # Whether to wrap around or do nothing when using the earlier_page or + # later_page keybinds and reaching the start or end of the history. + wrap_history: bool = True - # How many seconds the cursor must hover on buttons and other elements - # to show tooltips. - tooltips_delay: float = 0.7 + # How many seconds the cursor must hover on buttons and other elements + # to show tooltips. + tooltips_delay: float = 0.7 - # Application theme to use. - # Can be the name of a built-in theme (Foliage.qpl, Midnight.qpl or - # Glass.qpl), or the name (including extension) of a file in the user theme - # folder, which is "$XDG_DATA_HOME/moment/themes" if that environment - # variable is set, - # else "~/.local/share/moment/themes". - # For Flatpak, it is - # "~/.var/app/xyz.mx_moment.moment/data/moment/themes". - theme: str = "Foliage.qpl" + # Application theme to use. + # Can be the name of a built-in theme (Foliage.qpl, Midnight.qpl or + # Glass.qpl), or the name (including extension) of a file in the user theme + # folder, which is "$XDG_DATA_HOME/moment/themes" if that environment + # variable is set, + # else "~/.local/share/moment/themes". + # For Flatpak, it is + # "~/.var/app/xyz.mx_moment.moment/data/moment/themes". + theme: str = "Foliage.qpl" - # Interface scale multiplier, e.g. 0.5 makes everything half-size. - zoom: float = 1.0 + # Interface scale multiplier, e.g. 0.5 makes everything half-size. + zoom: float = 1.0 - # Adress of an HTTP or SOCKS5 proxy to pass network traffic through. - # Example adresses: "socks5://localhost:9050" (TOR with default port), - # "http://username:password@123.456.7.8:80" (HTTP with authentication). - # The application must be restarted to apply changes for this setting. - proxy: Optional[str] = None + # Adress of an HTTP or SOCKS5 proxy to pass network traffic through. + # Example adresses: "socks5://localhost:9050" (TOR with default port), + # "http://username:password@123.456.7.8:80" (HTTP with authentication). + # The application must be restarted to apply changes for this setting. + proxy: Optional[str] = None class Presence: - # Automatically set your presence to unavailable after this number of - # seconds without any mouse or keyboard activity. - # This currently only works on Linux X11. - auto_away_after: int = 60 * 10 + # Automatically set your presence to unavailable after this number of + # seconds without any mouse or keyboard activity. + # This currently only works on Linux X11. + auto_away_after: int = 60 * 10 - # Number of previously set status messages to keep saved. Available for - # quick access in the context menu when right-clicking an account. - saved_status: int = 5 + # Number of previously set status messages to keep saved. Available for + # quick access in the context menu when right-clicking an account. + saved_status: int = 5 class Notifications: - # Default global notification level when starting the application. - # Allows muting (i.e. preventing desktop bubbles, sounds and flashes) - # all notifications in the running client, overriding account settings. - # Can be either: - # - "enable" (notifications will work normally) - # - "highlights_only" (notify only for highlights, e.g. replies, keywords) - # - "mute" (don't notify for anything) - start_level: str = "enable" + # Default global notification level when starting the application. + # Allows muting (i.e. preventing desktop bubbles, sounds and flashes) + # all notifications in the running client, overriding account settings. + # Can be either: + # - "enable" (notifications will work normally) + # - "highlights_only" (notify only for highlights, e.g. replies, keywords) + # - "mute" (don't notify for anything) + start_level: str = "enable" - # Use HTML formatting in notification bubbles. - # This option has no effect on Windows and OSX. - # Rendering is only supported by notification servers which follow the - # GNOME Desktop Notification Specification, set this to `False` if you - # keep seeing raw in notifications. - use_html: bool = True + # Use HTML formatting in notification bubbles. + # This option has no effect on Windows and OSX. + # Rendering is only supported by notification servers which follow the + # GNOME Desktop Notification Specification, set this to `False` if you + # keep seeing raw in notifications. + use_html: bool = True - # Default sound to play for notifications. Can be the filename - # of a builtin sound (only "default.wav" currently exists), or the - # absolute path to a WAV file (supports ~ user expansion). - default_sound: str = "default.wav" + # Default sound to play for notifications. Can be the filename + # of a builtin sound (only "default.wav" currently exists), or the + # absolute path to a WAV file (supports ~ user expansion). + default_sound: str = "default.wav" - # How long in seconds the window will flash in your dock or taskbar when - # a new message, which matches a notification push rule with a - # window alert/flash/lightbulb action, is posted in a room. - # The effect may differ depending on your OS/desktop. - # Can be set to -1 for alerts that last until the window is focused. - flash_time: float = 5 + # How long in seconds the window will flash in your dock or taskbar when + # a new message, which matches a notification push rule with a + # window alert/flash/lightbulb action, is posted in a room. + # The effect may differ depending on your OS/desktop. + # Can be set to -1 for alerts that last until the window is focused. + flash_time: float = 5 - # Same as flash_time, but for messages that match a push rule with a - # highlight (red [+1]) action. By default this includes when your - # name is mentioned, replied to, or messages containing keywords. - highlight_flash_time: float = -1 + # Same as flash_time, but for messages that match a push rule with a + # highlight (red [+1]) action. By default this includes when your + # name is mentioned, replied to, or messages containing keywords. + highlight_flash_time: float = -1 class Scrolling: - # Use velocity-based kinetic scrolling. - # Can cause problems on laptop touchpads and some special mouse wheels. - kinetic: bool = True + # Use velocity-based kinetic scrolling. + # Can cause problems on laptop touchpads and some special mouse wheels. + kinetic: bool = True - # Maximum allowed velocity when kinetic scrolling is used. - kinetic_max_speed: int = 2500 + # Maximum allowed velocity when kinetic scrolling is used. + kinetic_max_speed: int = 2500 - # When kinetic scrolling is used, how fast the view slows down when you - # stop physically scrolling. - kinetic_deceleration: int = 1500 + # When kinetic scrolling is used, how fast the view slows down when you + # stop physically scrolling. + kinetic_deceleration: int = 1500 - # Multiplier for the scrolling speed when kinetic scrolling is - # disabled, e.g. 1.5 is 1.5x faster than the default speed. - non_kinetic_speed: float = 1.0 + # Multiplier for the scrolling speed when kinetic scrolling is + # disabled, e.g. 1.5 is 1.5x faster than the default speed. + non_kinetic_speed: float = 1.0 class RoomList: - # Prevent resizing the pane below this pixel width. - min_width: int = 144 + # Prevent resizing the pane below this pixel width. + min_width: int = 144 - # Sort rooms in alphabetical order instead of recent activity. - # The application must be restarted to apply changes for this setting. - lexical_sort: bool = False + # Sort rooms in alphabetical order instead of recent activity. + # The application must be restarted to apply changes for this setting. + lexical_sort: bool = False - # When any event is received in a room, mark the room as unread with a [!], - # regardless of notification push rules. This does not take into account - # anything received while the client is not running. - local_unread_markers: bool = False + # When any event is received in a room, mark the room as unread with a [!], + # regardless of notification push rules. This does not take into account + # anything received while the client is not running. + local_unread_markers: bool = False - # When clicking on a room, recenter the room list on that room. - click_centers: bool = False + # When clicking on a room, recenter the room list on that room. + click_centers: bool = False - # When pressing enter in the room filter field, clear the field's text, - # in addition to activating the keyboard-focused room. - enter_clears_filter: bool = True + # When pressing enter in the room filter field, clear the field's text, + # in addition to activating the keyboard-focused room. + enter_clears_filter: bool = True - # When pressing escape in the room filter field, clear the field's text. - # in addition to focusing the current page or chat composer. - escape_clears_filter: bool = True + # When pressing escape in the room filter field, clear the field's text. + # in addition to focusing the current page or chat composer. + escape_clears_filter: bool = True - class Pinned: - # Each property in this section is an account user ID, and the - # value is a list of room ID to always keep on top. - # A room's ID can be copied by right clicking on it in the room list. + class Pinned: + # Each property in this section is an account user ID, and the + # value is a list of room ID to always keep on top. + # A room's ID can be copied by right clicking on it in the room list. - "@account:example.org": List[str] = ["!roomID:a.org", "!other:b.org"] + "@account:example.org": List[str] = ["!roomID:a.org", "!other:b.org"] class Chat: - # Center the chat header (room avatar, name and topic) even when sidepanes - # aren't hidden (see comment for the hide_sidepanes_under setting). - always_center_header: bool = False + # Center the chat header (room avatar, name and topic) even when sidepanes + # aren't hidden (see comment for the hide_sidepanes_under setting). + always_center_header: bool = False - # When the chat timeline is larger than this pixel width, - # align your own messages to the left of the timeline instead of right. - # Can be 0 to always show your messages on the left. - own_messages_on_left_above: int = 895 + # When the chat timeline is larger than this pixel width, + # align your own messages to the left of the timeline instead of right. + # Can be 0 to always show your messages on the left. + own_messages_on_left_above: int = 895 - # Maximum number of characters in a message line before wrapping the text - # to a new line. Ignores messages containing code blocks or tables. - max_messages_line_length: int = 65 + # Maximum number of characters in a message line before wrapping the text + # to a new line. Ignores messages containing code blocks or tables. + max_messages_line_length: int = 65 - # Show membership events in the timeline: when someone is invited to the - # room, joins, leaves, is kicked, banned or unbanned. - show_membership_events: bool = True + # Show membership events in the timeline: when someone is invited to the + # room, joins, leaves, is kicked, banned or unbanned. + show_membership_events: bool = True - # Show room member display name and avatar change events in the timeline. - show_profile_changes: bool = False + # Show room member display name and avatar change events in the timeline. + show_profile_changes: bool = False - # Show a notice in the timeline for types of events that aren't recognized. - show_unknown_events: bool = False + # Show a notice in the timeline for types of events that aren't recognized. + show_unknown_events: bool = False - # In a chat with unread messages, the messages will be marked as read - # after this number of seconds. - # Focusing another window or chat resets the timer. - mark_read_delay: float = 0.2 + # In a chat with unread messages, the messages will be marked as read + # after this number of seconds. + # Focusing another window or chat resets the timer. + mark_read_delay: float = 0.2 - class Composer: - class TypingNotifications: - # Rules controlling whether " is typing..." notifications - # should be sent to other users in rooms. - # The `default` property is required. Other properties can be - # added: user IDs, room IDs, or space-separated user + room IDs. + class Composer: + class TypingNotifications: + # Rules controlling whether " is typing..." notifications + # should be sent to other users in rooms. + # The `default` property is required. Other properties can be + # added: user IDs, room IDs, or space-separated user + room IDs. - # Send typing notifications everywhere by default: - default: bool = True - # But don't send them for rooms under this account: - "@account_1:example.org": bool = False - # Neither send them in this room, regardless of the account used: - "!room:example.org": bool = False - # Except if it's this account and this room, then send them: - "@account_2:example.org !room:example.org": bool = True + # Send typing notifications everywhere by default: + default: bool = True + # But don't send them for rooms under this account: + "@account_1:example.org": bool = False + # Neither send them in this room, regardless of the account used: + "!room:example.org": bool = False + # Except if it's this account and this room, then send them: + "@account_2:example.org !room:example.org": bool = True - class Aliases: - # Each property is the user ID of an account, value is the alias. - # From any chat, start a message with an alias followed by a space - # to type and send as the associated account. - # The account must have permission to talk in the room. - # To ignore an alias when typing, prepend it with a space. + class Aliases: + # Each property is the user ID of an account, value is the alias. + # From any chat, start a message with an alias followed by a space + # to type and send as the associated account. + # The account must have permission to talk in the room. + # To ignore an alias when typing, prepend it with a space. - "!account:example.org": str = "a" - "!other_account:example.org": str = "oa" + "!account:example.org": str = "a" + "!other_account:example.org": str = "oa" - class Files: - # Minimum pixel width of the file name box for files without previews. - min_file_width: int = 256 + class Files: + # Minimum pixel width of the file name box for files without previews. + min_file_width: int = 256 - # Minimum (width, height) for image thumbnails. - min_thumbnail_size: Tuple[int, int] = (256, 256) + # Minimum (width, height) for image thumbnails. + min_thumbnail_size: Tuple[int, int] = (256, 256) - # How much of the chat height image thumbnails can take at most, - # e.g. 0.4 for 40% of the chat or 1 for 100%. - max_thumbnail_height_ratio: float = 0.4 + # How much of the chat height image thumbnails can take at most, + # e.g. 0.4 for 40% of the chat or 1 for 100%. + max_thumbnail_height_ratio: float = 0.4 - # Automatically play animated GIF images in the timeline. - auto_play_gif: bool = True + # Automatically play animated GIF images in the timeline. + auto_play_gif: bool = True - # When clicking on a file in the timeline, open it in an external - # program instead of displaying it using Moment's interface. - # On Linux, the xdg-open command is called. - click_opens_externally: bool = False + # When clicking on a file in the timeline, open it in an external + # program instead of displaying it using Moment's interface. + # On Linux, the xdg-open command is called. + click_opens_externally: bool = False - # In the full image viewer, if the image is large enough to cover the - # info bar or buttons, they will automatically hide after this number - # of seconds. - # Hovering on the top/bottom with a mouse or tapping on a touch screen - # reveals the hidden controls. - autohide_image_controls_after: float = 2.0 + # In the full image viewer, if the image is large enough to cover the + # info bar or buttons, they will automatically hide after this number + # of seconds. + # Hovering on the top/bottom with a mouse or tapping on a touch screen + # reveals the hidden controls. + autohide_image_controls_after: float = 2.0 class Keys: - # All keybind settings, unless their comment says otherwise, are list of - # the possible shortcuts for an action, e.g. ["Ctrl+A", "Alt+Shift+A"]. - # - # The available modifiers are Ctrl, Shift, Alt and Meta. - # On macOS, Ctrl corresponds to Cmd and Meta corresponds to Control. - # On other systems, Meta corresponds to the Windows/Super/mod4 key. - # - # https://doc.qt.io/qt-5/qt.html#Key-enum lists the names of special - # keys, e.g. for "Qt::Key_Space", you would use "Space" in this config. - # - # The Escape key by itself should not be bound, as it would conflict with - # closing popups and various other actions. - # - # Key chords can be defined by having up to four shortcuts - # separated by commas in a string, e.g. for ["Ctrl+A,B"], Ctrl+A then B - # would need to be pressed. - # - # A list of default bindings can be found at: - # https://gitlab.com/mx-moment/moment/-/blob/main/docs/KEYBINDINGS.md - - # Helper functions - - import platform - - def os_ctrl(self) -> str: - # Return Meta on macOS, which corresponds to Ctrl, and Ctrl on others. - return "Meta" if platform.system() == "Darwin" else "Ctrl" - - def alt_or_cmd(self) -> str: - # Return Ctrl on macOS, which corresponds to Cmd, and Alt on others. - return "Ctrl" if platform.system() == "Darwin" else "Alt" - - # Toggle compact interface mode. See the compact setting comment. - compact = ["Alt+Ctrl+C"] - - # Control the interface scale. - zoom_in = ["Ctrl++"] - zoom_out = ["Ctrl+-"] - reset_zoom = ["Ctrl+="] - - # Switch to the previous/next tab in pages. In chats, this controls what - # the right room pane shows, e.g. member list or room settings. - previous_tab = ["Alt+Shift+Left", "Alt+Shift+H"] - next_tab = ["Alt+Shift+Right", "Alt+Shift+L"] - - # Switch to the last opened page/chat, similar to Alt+Tab on most desktops. - last_page = ["Ctrl+Tab"] - - # Go throgh history of opened chats, - # similar to the "page back" and "page forward" keys in web browsers - earlier_page = ["Ctrl+H"] - later_page = ["Ctrl+L"] - - # Toggle muting all notifications in the running client, - # except highlights (e.g. replies or keywords) - notifications_highlights_only = ["Ctrl+Alt+H"] - - # Toggle muting all notifications in the running client - notifications_mute = ["Ctrl+Alt+N"] - - # Toggle the QML developer console. Type ". help" inside it for more info. - qml_console = ["F1"] - - # Start the Python backend debugger. - # Moment must be connected to a terminal for this to work. - python_debugger = ["Shift+F1"] - - # Start the Python backend debugger in remote access mode. - # The remote-pdb Python package must be installed. - # From any terminal, run `socat readline tcp:127.0.0.1:4444` to connect. - python_remote_debugger = ["Alt+F1"] - - # Quit Moment - quit = ["Ctrl+Q"] - - class Scrolling: # Keys.Scrolling - # Pages and chat timeline scrolling - up = ["Alt+Up", "Alt+K"] - down = ["Alt+Down", "Alt+J"] - page_up = ["Alt+Ctrl+Up", "Alt+Ctrl+K", "PgUp"] - page_down = ["Alt+Ctrl+Down", "Alt+Ctrl+J", "PgDown"] - top = ["Alt+Ctrl+Shift+Up", "Alt+Ctrl+Shift+K", "Home"] - bottom = ["Alt+Ctrl+Shift+Down", "Alt+Ctrl+Shift+J", "End"] - - class Accounts: - # The current account is the account under which a page or chat is - # opened, or the keyboard-focused one when using the room filter field. - - # Add a new account - add = ["Alt+Shift+A"] - - # Collapse the current account - collapse = ["Alt+O"] - - # Open the current account settings - settings = ["Alt+A"] - - # Open the current account context menu - menu = ["Alt+P"] - - # Toggle current account presence between this status and online - unavailable = ["Alt+Ctrl+U", "Alt+Ctrl+A"] - invisible = ["Alt+Ctrl+I"] - offline = ["Alt+Ctrl+O"] - - # Switch to first room of the previous/next account in the room list. - previous = ["Alt+Shift+N"] - next = ["Alt+N"] - - class AtIndex: # Accounts.AtIndex - # Switch to the first room of the account number X in the list. - # Each property is a list of keybinds for the account number X. - # Numbers beyond the default ones can be added. - - 1 = [Keys.os_ctrl() + "+1"] - 2 = [Keys.os_ctrl() + "+2"] - 3 = [Keys.os_ctrl() + "+3"] - 4 = [Keys.os_ctrl() + "+4"] - 5 = [Keys.os_ctrl() + "+5"] - 6 = [Keys.os_ctrl() + "+6"] - 7 = [Keys.os_ctrl() + "+7"] - 8 = [Keys.os_ctrl() + "+8"] - 9 = [Keys.os_ctrl() + "+9"] - 10 = [Keys.os_ctrl() + "+0"] - - class Rooms: - # Add a new room (direct chat, join or create a group). - add = ["Alt+C"] - - # Focus or clear the text of the left main pane's room filter field. - # When focusing the field, use Tab/Shift+Tab or the arrows to navigate - # the list, Enter to switch to focused account/room, Escape to cancel, - # Menu to open the context menu. - focus_filter = ["Alt+F","Ctrl+K"] - clear_filter = ["Alt+Shift+F"] - - # Switch to the previous/next room in the list. - previous = ["Alt+Shift+Up", "Alt+Shift+K"] - next = ["Alt+Shift+Down", "Alt+Shift+J"] - - # Switch to the previous/next room with unread messages in the list. - previous_unread = ["Alt+Shift+U"] - next_unread = ["Alt+U"] - - # Switch to the room with the oldest/latest unread message. - oldest_unread = ["Ctrl+Shift+U"] - latest_unread = ["Ctrl+U"] - - # Switch to the previous/next room with highlighted messages in the - # list. What causes a highlight is controlled by push rules - # (editable in GUI account settings): by default, this includes - # when your name is mentioned, replied to, or messages with keywords. - previous_highlight = ["Alt+Shift+M"] - next_highlight = ["Alt+M"] - - # Switch to the room with the oldest/latest unread message, - # but only rooms with highlights are considered. - oldest_highlight = ["Ctrl+Shift+M"] - latest_highlight = ["Ctrl+M"] - - class AtIndex: # Rooms.AtIndex - # Switch to room number X in the current account. - # Each property is a list of keybinds for the room number X: - # Numbers beyond the default ones can be added. - - 1 = [Keys.alt_or_cmd() + "+1"] - 2 = [Keys.alt_or_cmd() + "+2"] - 3 = [Keys.alt_or_cmd() + "+3"] - 4 = [Keys.alt_or_cmd() + "+4"] - 5 = [Keys.alt_or_cmd() + "+5"] - 6 = [Keys.alt_or_cmd() + "+6"] - 7 = [Keys.alt_or_cmd() + "+7"] - 8 = [Keys.alt_or_cmd() + "+8"] - 9 = [Keys.alt_or_cmd() + "+9"] - 10 = [Keys.alt_or_cmd() + "+0"] - - class Direct: - # Switch to specific rooms with keybindings. - # An unlimited number of properties can be added, where each - # property maps a room to a list of keybind. - # A room's ID can be copied by right clicking on it in the room list. - "!roomID:example.org" = [] - - # If you have multiple accounts in the same room, you can also set - # which account should be targeted as " ": - "@account:example.org !roomID:example.org" = [] - - class Chat: # Keys.Chat - # Keybinds specific to the current chat page. - - # Focus the right room pane. If the pane is currently showing the - # room member list, the corresponding filter field is focused. - # When focusing the field, use Tab/Shift+Tab or the arrows to navigate - # the list, Enter to see the focused member's profile, Escape to cancel, - # Menu to open the context menu. - focus_room_pane = ["Alt+R"] - - # Toggle hiding the right pane. - # Can also be done by clicking on current tab button at the top right. - hide_room_pane = ["Alt+Ctrl+R"] - - # Invite new members or leave the current chat. - invite = ["Alt+I"] - leave = ["Alt+Escape"] - - # Open the file picker to upload files in the current chat. - send_file = ["Alt+S"] - - # If your clipboard contains a file path, upload that file. - send_clipboard_path = ["Alt+Shift+S"] - - class Messages: - # Focus the previous/next message in the timeline. - # Keybinds defined below in this section affect the focused message. - # The Menu key can open the context menu for a focused message. - previous = ["Ctrl+Up", "Ctrl+I"] - next = ["Ctrl+Down", "Ctrl+J"] - - # Select the currently focused message, same as clicking on it. - # When there are selected messages, some right click menu options - # and keybinds defined below will affect these messages instead of - # the focused (for keybinds) or mouse-targeted (right click menu) one. - # The Menu key can open the context menu for selected messages. - select = ["Ctrl+Space"] - - # Select all messages from point A to point B. - # If used when no messages are already selected, all the messages - # from the most recent in the timeline to the focused one are selected. - # Otherwise, messages from the last selected to focused are selected. - select_until_here = ["Ctrl+Shift+Space"] - - # Clear the message keyboard focus. - # If no message is focused but some are selected, clear the selection. - unfocus_or_deselect = ["Ctrl+D"] - - # Toggle display of the focused message's seen counter tooltip, - # which shows which user have this message as their last seen and when - # did they send that information. - # When this mode is active, you can move the focus to other messages - # and the tooltip will update itself. - # If a message doesn't have a counter, it won't have a tooltip. - seen_tooltips = ["Ctrl+S"] - - # Remove the selected messages if any, else the focused message if any, - # else the last message you posted. - remove = ["Ctrl+Shift+R", "Alt+Del"] - - # Reply/cancel reply to the focused message if any, - # else the last message posted by someone else. - # Replying can also be cancelled by pressing Escape. - reply = ["Ctrl+R"] - - # Open the QML developer console for the focused message if any, - # and display the event source. - debug = ["Ctrl+Shift+D"] - - # Open the files and links in selected messages if any, else the - # file/links of the focused message if any, else the last - # files/link in the timeline. - open_links_files = ["Ctrl+O"] - - # Like open_links_files, but files open in external programs instead. - # On Linux, this uses the xdg-open command. - open_links_files_externally = ["Ctrl+Shift+O"] - - # Copy the paths of the downloaded files in selected messages if any, - # else the file path for the focused message if any, else the - # path for the last downloaded file in the timeline. - copy_files_path = ["Ctrl+Shift+C"] - - # Clear all messages from the chat. - # This does not remove anything for other users. - clear_all = ["Ctrl+Shift+L"] - - class ImageViewer: - # Close the image viewer. Escape can also be used. - close = ["X", "Q"] - - # Toggle alternate image scaling mode: if the original image size is - # smaller than the window, upscale it to fit the window. - # If the original size is bigger than the window, expand the image - # to show it as its real size. - expand = ["E"] - - # Toggle fullscreen mode. - fullscreen = ["F", "F11", "Alt+Return", "Alt+Enter"] - - # Pan/scroll the image. - pan_left = ["H", "Left", "Alt+H", "Alt+Left"] - pan_down = ["J", "Down", "Alt+J", "Alt+Down"] - pan_up = ["K", "Up", "Alt+K", "Alt+Up"] - pan_right = ["L", "Right", "Alt+L", "Alt+Right"] - - # Control the image zoom. Ctrl+wheel can also be used. - zoom_in = ["Z", "+", "Ctrl++"] - zoom_out = ["Shift+Z", "-", "Ctrl+-"] - reset_zoom = ["Alt+Z", "=", "Ctrl+="] - - # Control the image's rotation. - rotate_right = ["R"] - rotate_left = ["Shift+R"] - reset_rotation = ["Alt+R"] - - # Control the playback speed of animated GIF images. - speed_up = ["S"] - slow_down = ["Shift+S"] - reset_speed = ["Alt+S"] - - # Toggle pausing of animated GIF images. - pause = ["Space"] - - class Security: - # These keybinds affect the Security tab in your account settings. - # - # Currently unchangable keys: - # - Tab/Shift+Tab to navigate the interface - # - Space to check/uncheck a focused session - # - Menu to open the focused session's context menu - - # Refresh the list of sessions. - refresh = ["Alt+R", "F5"] - - # Sign out checked sessions if any, else sign out all sessions. - sign_out = ["Alt+S", "Delete"] + # All keybind settings, unless their comment says otherwise, are list of + # the possible shortcuts for an action, e.g. ["Ctrl+A", "Alt+Shift+A"]. + # + # The available modifiers are Ctrl, Shift, Alt and Meta. + # On macOS, Ctrl corresponds to Cmd and Meta corresponds to Control. + # On other systems, Meta corresponds to the Windows/Super/mod4 key. + # + # https://doc.qt.io/qt-5/qt.html#Key-enum lists the names of special + # keys, e.g. for "Qt::Key_Space", you would use "Space" in this config. + # + # The Escape key by itself should not be bound, as it would conflict with + # closing popups and various other actions. + # + # Key chords can be defined by having up to four shortcuts + # separated by commas in a string, e.g. for ["Ctrl+A,B"], Ctrl+A then B + # would need to be pressed. + # + # A list of default bindings can be found at: + # https://gitlab.com/mx-moment/moment/-/blob/main/docs/KEYBINDINGS.md + + # Helper functions + + import platform + + def os_ctrl(self) -> str: + # Return Meta on macOS, which corresponds to Ctrl, and Ctrl on others. + return "Meta" if platform.system() == "Darwin" else "Ctrl" + + def alt_or_cmd(self) -> str: + # Return Ctrl on macOS, which corresponds to Cmd, and Alt on others. + return "Ctrl" if platform.system() == "Darwin" else "Alt" + + # Toggle compact interface mode. See the compact setting comment. + compact = ["Alt+Ctrl+C"] + + # Control the interface scale. + zoom_in = ["Ctrl++"] + zoom_out = ["Ctrl+-"] + reset_zoom = ["Ctrl+="] + + # Switch to the previous/next tab in pages. In chats, this controls what + # the right room pane shows, e.g. member list or room settings. + previous_tab = ["Alt+Shift+Left", "Alt+Shift+H"] + next_tab = ["Alt+Shift+Right", "Alt+Shift+L"] + + # Switch to the last opened page/chat, similar to Alt+Tab on most desktops. + last_page = ["Ctrl+Tab"] + + # Go throgh history of opened chats, + # similar to the "page back" and "page forward" keys in web browsers + earlier_page = ["Ctrl+H"] + later_page = ["Ctrl+L"] + + # Toggle muting all notifications in the running client, + # except highlights (e.g. replies or keywords) + notifications_highlights_only = ["Ctrl+Alt+H"] + + # Toggle muting all notifications in the running client + notifications_mute = ["Ctrl+Alt+N"] + + # Toggle the QML developer console. Type ". help" inside it for more info. + qml_console = ["F1"] + + # Start the Python backend debugger. + # Moment must be connected to a terminal for this to work. + python_debugger = ["Shift+F1"] + + # Start the Python backend debugger in remote access mode. + # The remote-pdb Python package must be installed. + # From any terminal, run `socat readline tcp:127.0.0.1:4444` to connect. + python_remote_debugger = ["Alt+F1"] + + # Quit Moment + quit = ["Ctrl+Q"] + + class Scrolling: # Keys.Scrolling + # Pages and chat timeline scrolling + up = ["Alt+Up", "Alt+K"] + down = ["Alt+Down", "Alt+J"] + page_up = ["Alt+Ctrl+Up", "Alt+Ctrl+K", "PgUp"] + page_down = ["Alt+Ctrl+Down", "Alt+Ctrl+J", "PgDown"] + top = ["Alt+Ctrl+Shift+Up", "Alt+Ctrl+Shift+K", "Home"] + bottom = ["Alt+Ctrl+Shift+Down", "Alt+Ctrl+Shift+J", "End"] + + class Accounts: + # The current account is the account under which a page or chat is + # opened, or the keyboard-focused one when using the room filter field. + + # Add a new account + add = ["Alt+Shift+A"] + + # Collapse the current account + collapse = ["Alt+O"] + + # Open the current account settings + settings = ["Alt+A"] + + # Open the current account context menu + menu = ["Alt+P"] + + # Toggle current account presence between this status and online + unavailable = ["Alt+Ctrl+U", "Alt+Ctrl+A"] + invisible = ["Alt+Ctrl+I"] + offline = ["Alt+Ctrl+O"] + + # Switch to first room of the previous/next account in the room list. + previous = ["Alt+Shift+N"] + next = ["Alt+N"] + + class AtIndex: # Accounts.AtIndex + # Switch to the first room of the account number X in the list. + # Each property is a list of keybinds for the account number X. + # Numbers beyond the default ones can be added. + + 1 = [Keys.os_ctrl() + "+1"] + 2 = [Keys.os_ctrl() + "+2"] + 3 = [Keys.os_ctrl() + "+3"] + 4 = [Keys.os_ctrl() + "+4"] + 5 = [Keys.os_ctrl() + "+5"] + 6 = [Keys.os_ctrl() + "+6"] + 7 = [Keys.os_ctrl() + "+7"] + 8 = [Keys.os_ctrl() + "+8"] + 9 = [Keys.os_ctrl() + "+9"] + 10 = [Keys.os_ctrl() + "+0"] + + class Rooms: + # Add a new room (direct chat, join or create a group). + add = ["Alt+C"] + + # Focus or clear the text of the left main pane's room filter field. + # When focusing the field, use Tab/Shift+Tab or the arrows to navigate + # the list, Enter to switch to focused account/room, Escape to cancel, + # Menu to open the context menu. + focus_filter = ["Alt+F","Ctrl+K"] + clear_filter = ["Alt+Shift+F"] + + # Switch to the previous/next room in the list. + previous = ["Alt+Shift+Up", "Alt+Shift+K"] + next = ["Alt+Shift+Down", "Alt+Shift+J"] + + # Switch to the previous/next room with unread messages in the list. + previous_unread = ["Alt+Shift+U"] + next_unread = ["Alt+U"] + + # Switch to the room with the oldest/latest unread message. + oldest_unread = ["Ctrl+Shift+U"] + latest_unread = ["Ctrl+U"] + + # Switch to the previous/next room with highlighted messages in the + # list. What causes a highlight is controlled by push rules + # (editable in GUI account settings): by default, this includes + # when your name is mentioned, replied to, or messages with keywords. + previous_highlight = ["Alt+Shift+M"] + next_highlight = ["Alt+M"] + + # Switch to the room with the oldest/latest unread message, + # but only rooms with highlights are considered. + oldest_highlight = ["Ctrl+Shift+M"] + latest_highlight = ["Ctrl+M"] + + class AtIndex: # Rooms.AtIndex + # Switch to room number X in the current account. + # Each property is a list of keybinds for the room number X: + # Numbers beyond the default ones can be added. + + 1 = [Keys.alt_or_cmd() + "+1"] + 2 = [Keys.alt_or_cmd() + "+2"] + 3 = [Keys.alt_or_cmd() + "+3"] + 4 = [Keys.alt_or_cmd() + "+4"] + 5 = [Keys.alt_or_cmd() + "+5"] + 6 = [Keys.alt_or_cmd() + "+6"] + 7 = [Keys.alt_or_cmd() + "+7"] + 8 = [Keys.alt_or_cmd() + "+8"] + 9 = [Keys.alt_or_cmd() + "+9"] + 10 = [Keys.alt_or_cmd() + "+0"] + + class Direct: + # Switch to specific rooms with keybindings. + # An unlimited number of properties can be added, where each + # property maps a room to a list of keybind. + # A room's ID can be copied by right clicking on it in the room list. + "!roomID:example.org" = [] + + # If you have multiple accounts in the same room, you can also set + # which account should be targeted as " ": + "@account:example.org !roomID:example.org" = [] + + class Chat: # Keys.Chat + # Keybinds specific to the current chat page. + + # Focus the right room pane. If the pane is currently showing the + # room member list, the corresponding filter field is focused. + # When focusing the field, use Tab/Shift+Tab or the arrows to navigate + # the list, Enter to see the focused member's profile, Escape to cancel, + # Menu to open the context menu. + focus_room_pane = ["Alt+R"] + + # Toggle hiding the right pane. + # Can also be done by clicking on current tab button at the top right. + hide_room_pane = ["Alt+Ctrl+R"] + + # Invite new members or leave the current chat. + invite = ["Alt+I"] + leave = ["Alt+Escape"] + + # Open the file picker to upload files in the current chat. + send_file = ["Alt+S"] + + # If your clipboard contains a file path, upload that file. + send_clipboard_path = ["Alt+Shift+S"] + + class Messages: + # Focus the previous/next message in the timeline. + # Keybinds defined below in this section affect the focused message. + # The Menu key can open the context menu for a focused message. + previous = ["Ctrl+Up", "Ctrl+I"] + next = ["Ctrl+Down", "Ctrl+J"] + + # Select the currently focused message, same as clicking on it. + # When there are selected messages, some right click menu options + # and keybinds defined below will affect these messages instead of + # the focused (for keybinds) or mouse-targeted (right click menu) one. + # The Menu key can open the context menu for selected messages. + select = ["Ctrl+Space"] + + # Select all messages from point A to point B. + # If used when no messages are already selected, all the messages + # from the most recent in the timeline to the focused one are selected. + # Otherwise, messages from the last selected to focused are selected. + select_until_here = ["Ctrl+Shift+Space"] + + # Clear the message keyboard focus. + # If no message is focused but some are selected, clear the selection. + unfocus_or_deselect = ["Ctrl+D"] + + # Toggle display of the focused message's seen counter tooltip, + # which shows which user have this message as their last seen and when + # did they send that information. + # When this mode is active, you can move the focus to other messages + # and the tooltip will update itself. + # If a message doesn't have a counter, it won't have a tooltip. + seen_tooltips = ["Ctrl+S"] + + # Remove the selected messages if any, else the focused message if any, + # else the last message you posted. + remove = ["Ctrl+Shift+R", "Alt+Del"] + + # Reply/cancel reply to the focused message if any, + # else the last message posted by someone else. + # Replying can also be cancelled by pressing Escape. + reply = ["Ctrl+R"] + + # Open the QML developer console for the focused message if any, + # and display the event source. + debug = ["Ctrl+Shift+D"] + + # Open the files and links in selected messages if any, else the + # file/links of the focused message if any, else the last + # files/link in the timeline. + open_links_files = ["Ctrl+O"] + + # Like open_links_files, but files open in external programs instead. + # On Linux, this uses the xdg-open command. + open_links_files_externally = ["Ctrl+Shift+O"] + + # Copy the paths of the downloaded files in selected messages if any, + # else the file path for the focused message if any, else the + # path for the last downloaded file in the timeline. + copy_files_path = ["Ctrl+Shift+C"] + + # Clear all messages from the chat. + # This does not remove anything for other users. + clear_all = ["Ctrl+Shift+L"] + + class ImageViewer: + # Close the image viewer. Escape can also be used. + close = ["X", "Q"] + + # Toggle alternate image scaling mode: if the original image size is + # smaller than the window, upscale it to fit the window. + # If the original size is bigger than the window, expand the image + # to show it as its real size. + expand = ["E"] + + # Toggle fullscreen mode. + fullscreen = ["F", "F11", "Alt+Return", "Alt+Enter"] + + # Pan/scroll the image. + pan_left = ["H", "Left", "Alt+H", "Alt+Left"] + pan_down = ["J", "Down", "Alt+J", "Alt+Down"] + pan_up = ["K", "Up", "Alt+K", "Alt+Up"] + pan_right = ["L", "Right", "Alt+L", "Alt+Right"] + + # Control the image zoom. Ctrl+wheel can also be used. + zoom_in = ["Z", "+", "Ctrl++"] + zoom_out = ["Shift+Z", "-", "Ctrl+-"] + reset_zoom = ["Alt+Z", "=", "Ctrl+="] + + # Control the image's rotation. + rotate_right = ["R"] + rotate_left = ["Shift+R"] + reset_rotation = ["Alt+R"] + + # Control the playback speed of animated GIF images. + speed_up = ["S"] + slow_down = ["Shift+S"] + reset_speed = ["Alt+S"] + + # Toggle pausing of animated GIF images. + pause = ["Space"] + + class Security: + # These keybinds affect the Security tab in your account settings. + # + # Currently unchangable keys: + # - Tab/Shift+Tab to navigate the interface + # - Space to check/uncheck a focused session + # - Menu to open the focused session's context menu + + # Refresh the list of sessions. + refresh = ["Alt+R", "F5"] + + # Sign out checked sessions if any, else sign out all sessions. + sign_out = ["Alt+S", "Delete"]