diff --git a/src/backend/backend.py b/src/backend/backend.py index a114a7b3..1d3e4ba3 100644 --- a/src/backend/backend.py +++ b/src/backend/backend.py @@ -132,7 +132,7 @@ class Backend: ) try: - await client.login(password) + await client.login(password, order=order) except MatrixError: await client.close() raise @@ -142,22 +142,7 @@ class Backend: await client.logout() return client.user_id - if order is None and not self.models["accounts"]: - order = 0 - elif order is None: - order = max( - account.order - for i, account in enumerate(self.models["accounts"].values()) - ) + 1 - - account = Account(client.user_id, order) - - self.clients[client.user_id] = client - self.models["accounts"][client.user_id] = account - - # Get or create presence for account - presence = self.presences.setdefault(client.user_id, Presence()) - presence.members["account", client.user_id] = account + self.clients[client.user_id] = client return client.user_id @@ -168,7 +153,6 @@ class Backend: token: str, device_id: str, homeserver: str = "https://matrix.org", - order: int = -1, state: str = "online", ) -> None: """Create and register a `MatrixClient` with known account details.""" @@ -178,14 +162,7 @@ class Backend: user=user_id, homeserver=homeserver, device_id=device_id, ) - account = Account(user_id, order) - - self.clients[user_id] = client - self.models["accounts"][user_id] = account - - # Get or create presence for account - presence = self.presences.setdefault(user_id, Presence()) - presence.members["account", user_id] = account + self.clients[user_id] = client await client.resume(user_id, token, device_id, state) @@ -194,14 +171,19 @@ class Backend: """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)), + ) + await self.resume_client( user_id = user_id, token = info["token"], device_id = info["device_id"], homeserver = info["homeserver"], - order = info.get("order", -1), state = info.get("presence", "online"), ) + return user_id return await asyncio.gather(*( diff --git a/src/backend/matrix_client.py b/src/backend/matrix_client.py index 55a21e86..8e5756d3 100644 --- a/src/backend/matrix_client.py +++ b/src/backend/matrix_client.py @@ -40,7 +40,7 @@ from .errors import ( from .html_markdown import HTML_PROCESSOR as HTML from .media_cache import Media, Thumbnail from .models.items import ( - Event, Member, Presence, Room, Upload, UploadStatus, ZeroDate, + Account, Event, Member, Presence, Room, Upload, UploadStatus, ZeroDate, ) from .models.model_store import ModelStore from .nio_callbacks import NioCallbacks @@ -231,14 +231,36 @@ class MatrixClient(nio.AsyncClient): return f"{__display_name__} on {os_name} {os_ver}".rstrip() - async def login(self, password: str, device_name: str = "") -> None: + async def login( + self, password: str, + device_name: str = "", + order: Optional[int] = None, + ) -> None: """Login to the server using the account's password.""" await super().login( password, device_name or self.default_device_name(), ) - self.start_task = asyncio.ensure_future(self._start()) + if order is None and not self.models["accounts"]: + order = 0 + elif order is None: + order = max( + account.order + for i, account in enumerate(self.models["accounts"].values()) + ) + 1 + + # Get or create account model + # We need to create account model in here, because _start() needs it + account = self.models["accounts"].setdefault( + self.user_id, Account(self.user_id, order), + ) + + # TODO: set presence on login + self._presence = "online" + account.presence = Presence.State.online + account.connecting = True + self.start_task = asyncio.ensure_future(self._start()) async def resume( @@ -251,32 +273,21 @@ class MatrixClient(nio.AsyncClient): """Login to the server using an existing access token.""" response = nio.LoginResponse(user_id, device_id, token) + account = self.models["accounts"][user_id] await self.receive_response(response) self._presence = "offline" if state == "invisible" else state - self.start_task = asyncio.ensure_future(self._start()) + account.presence = Presence.State(state) - if state == "invisible": - self.models["accounts"][self.user_id].presence = \ - Presence.State.invisible + 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.""" - 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 - + await self._stop() await super().logout() await self.close() @@ -300,8 +311,6 @@ class MatrixClient(nio.AsyncClient): if future.cancelled(): # Account logged out return - account = self.models["accounts"][self.user_id] - try: account.max_upload_size = future.result() except Exception: @@ -316,6 +325,13 @@ class MatrixClient(nio.AsyncClient): on_server_config_response, ) + account = self.models["accounts"][self.user_id] + + # Get or create presence for account + 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.server_config_task = asyncio.ensure_future( @@ -362,6 +378,31 @@ class MatrixClient(nio.AsyncClient): await asyncio.sleep(5) + async def _stop(self) -> None: + """Stop client tasks. Will prevent client to receive further events.""" + + 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 + + self.first_sync_done.clear() + + # Remove account model from presence update + presence = self.backend.presences.get(self.user_id, None) + + if presence: + presence.members.pop(("account", self.user_id), None) + + async def update_own_profile(self) -> None: """Fetch our profile from server and Update our model `Account`.""" @@ -478,6 +519,12 @@ class MatrixClient(nio.AsyncClient): mentions = mentions, ) + while ( + self.models["accounts"][self.user_id].presence == + Presence.State.offline + ): + await asyncio.sleep(0.2) + await self._send_message(room_id, content, tx_id) @@ -503,6 +550,12 @@ class MatrixClient(nio.AsyncClient): async def send_file(self, room_id: str, path: Union[Path, str]) -> None: """Send a `m.file`, `m.image`, `m.audio` or `m.video` message.""" + while ( + self.models["accounts"][self.user_id].presence == + Presence.State.offline + ): + await asyncio.sleep(0.2) + item_uuid = uuid4() try: @@ -1070,8 +1123,10 @@ class MatrixClient(nio.AsyncClient): """Set typing notice to the server.""" # Do not send typing notice if the user is invisible - if self.models["accounts"][self.user_id].presence != \ - Presence.State.invisible: + 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) @@ -1237,21 +1292,46 @@ class MatrixClient(nio.AsyncClient): ) -> None: """Set presence state for this account.""" + account = self.models["accounts"][self.user_id] status_msg = status_msg if status_msg is not None else ( self.models["accounts"][self.user_id].status_msg ) + if presence == "offline": + # Do not do anything if account is offline and setting to offline + if account.presence == Presence.State.offline: + return + + await self._stop() + + # Uppdate manually since we may not receive the presence event back + # in time + account.presence = Presence.State.offline + account.currently_active = False + elif ( + presence != "offline" and + account.presence == Presence.State.offline + ): + account.connecting = True + self.start_task = asyncio.ensure_future(self._start()) + + # Assign invisible on model in here, because server will tell us we are + # offline + if presence == "invisible": + account.presence = Presence.State.invisible + + if not account.presence_support: + account.presence = Presence.State(presence) + + await self.backend.saved_accounts.update( + self.user_id, presence=presence, + ) + await super().set_presence( "offline" if presence == "invisible" else presence, status_msg, ) - # Assign invisible on model in here, because server will tell us we are - # offline - if presence == "invisible": - self.models["accounts"][self.user_id].presence = \ - Presence.State.invisible - async def import_keys(self, infile: str, passphrase: str) -> None: """Import decryption keys from a file, then retry decrypting events.""" diff --git a/src/backend/models/items.py b/src/backend/models/items.py index 12b3554f..320440ef 100644 --- a/src/backend/models/items.py +++ b/src/backend/models/items.py @@ -56,27 +56,33 @@ class Presence(): last_active_ago: int = -1 status_msg: str = "" - members: Dict[Tuple[str, str], Union["Member", "Account"]] = \ - field(default_factory=dict) + members: Dict[Tuple[str, str], "Member"] = field(default_factory=dict) + account: Optional["Account"] = None - def update_members(self): + def update_members(self) -> None: for member in self.members.values(): - # Do not update if member is changing to invisible - # Because when setting invisible presence will give us presence - # event telling us we are offline, we do not want to set member - # presence to offline. - if ( - member.presence == self.State.invisible - ) and ( - self.presence == self.State.offline - ): - continue - member.presence = self.presence member.status_msg = self.status_msg member.last_active_ago = self.last_active_ago member.currently_active = self.currently_active + def update_account(self) -> None: + # Do not update if account is changing to invisible. + # When setting presence to invisible, the server will give us a + # presence event telling us we are offline, but we do not want to set + # account presence to offline. + if ( + not self.account or + self.account.presence == self.State.invisible and + self.presence == self.State.offline + ): + return + + self.account.presence = self.presence + self.account.status_msg = self.status_msg + self.account.last_active_ago = self.last_active_ago + self.account.currently_active = self.currently_active + @dataclass class Account(ModelItem): @@ -88,7 +94,7 @@ class Account(ModelItem): avatar_url: str = "" max_upload_size: int = 0 profile_updated: datetime = ZeroDate - first_sync_done: bool = False + connecting: bool = False total_unread: int = 0 total_highlights: int = 0 local_unreads: bool = False @@ -96,11 +102,11 @@ class Account(ModelItem): # For some reason, Account cannot inherit Presence, because QML keeps # complaining type error on unknown file - presence_support: bool = False - presence: Presence.State = Presence.State.offline - currently_active: bool = False - last_active_ago: int = -1 - status_msg: str = "" + presence_support: bool = False + presence: Presence.State = Presence.State.offline + currently_active: bool = False + last_active_ago: int = -1 + status_msg: str = "" def __lt__(self, other: "Account") -> bool: """Sort by order, then by user ID.""" @@ -220,7 +226,7 @@ class AccountOrRoom(Account, Room): @dataclass -class Member(Presence, ModelItem): +class Member(ModelItem): """A member in a matrix room.""" id: str = field() @@ -234,6 +240,11 @@ class Member(Presence, ModelItem): last_read_event: str = "" last_read_at: datetime = ZeroDate + presence: Presence.State = Presence.State.offline + currently_active: bool = False + last_active_ago: int = -1 + status_msg: str = "" + def __lt__(self, other: "Member") -> bool: """Sort by presence, power level, then by display name/user ID.""" diff --git a/src/backend/nio_callbacks.py b/src/backend/nio_callbacks.py index 2b8dd110..3ae46eff 100644 --- a/src/backend/nio_callbacks.py +++ b/src/backend/nio_callbacks.py @@ -106,7 +106,7 @@ class NioCallbacks: self.client.first_sync_date = datetime.now() account = self.models["accounts"][self.user_id] - account.first_sync_done = True + account.connecting = False async def onKeysQueryResponse(self, resp: nio.KeysQueryResponse) -> None: @@ -612,11 +612,21 @@ class NioCallbacks: presence.update_members() # Check if presence event is ours - if ev.user_id in self.models["accounts"]: + if ( + ev.user_id in self.models["accounts"] and + presence.presence != Presence.State.offline + ): + account = self.models["accounts"][ev.user_id] + # Servers that send presence events support presence - self.models["accounts"][ev.user_id].presence_support = True + account.presence_support = True # Save the presence for the next resume - await self.client.backend.saved_accounts.add(ev.user_id) + await self.client.backend.saved_accounts.update( + user_id = ev.user_id, + presence = presence.presence.value, + ) + + presence.update_account() self.client.backend.presences[ev.user_id] = presence diff --git a/src/backend/user_files.py b/src/backend/user_files.py index a1641af5..f1955839 100644 --- a/src/backend/user_files.py +++ b/src/backend/user_files.py @@ -184,7 +184,7 @@ class Accounts(JSONDataFile): client = self.backend.clients[user_id] saved = await self.read() - presence = self.backend.models["accounts"][user_id].presence.value + account = self.backend.models["accounts"][user_id] await self.write({ **saved, @@ -193,8 +193,10 @@ class Accounts(JSONDataFile): "token": client.access_token, "device_id": client.device_id, "enabled": True, - "presence": presence or "online", - "order": max([ + "presence": account.presence.value, + + # Can account.order converge with any other saved value? + "order": account.order if account.order >= 0 else max([ account.get("order", i) for i, account in enumerate(saved.values()) ] or [-1]) + 1, @@ -202,6 +204,24 @@ class Accounts(JSONDataFile): }) + async def update( + self, + user_id: str, + enabled: Optional[str] = None, + presence: Optional[str] = None, + order: Optional[int] = None, + ) -> None: + """Update existing account in the config and write to disk.""" + + saved = await self.read() + + saved[user_id]["enabled"] = enabled or saved[user_id]["enabled"] + saved[user_id]["presence"] = presence or saved[user_id]["presence"] + saved[user_id]["order"] = order or saved[user_id]["order"] + + await self.write({**saved}) + + async def delete(self, user_id: str) -> None: """Delete an account from the config and write it on disk.""" diff --git a/src/gui/Base/HUserAvatar.qml b/src/gui/Base/HUserAvatar.qml index f04d343d..811bdb10 100644 --- a/src/gui/Base/HUserAvatar.qml +++ b/src/gui/Base/HUserAvatar.qml @@ -1,7 +1,7 @@ // SPDX-License-Identifier: LGPL-3.0-or-later import QtQuick 2.12 -import QtQuick.Shapes 1.15 +import QtQuick.Shapes 1.12 HAvatar { name: displayName || userId.substring(1) // no leading @ diff --git a/src/gui/MainPane/AccountContextMenu.qml b/src/gui/MainPane/AccountContextMenu.qml index a85070fb..5e68b00f 100644 --- a/src/gui/MainPane/AccountContextMenu.qml +++ b/src/gui/MainPane/AccountContextMenu.qml @@ -12,18 +12,16 @@ HMenu { property string userId property string presence property string statusMsg - property bool firstSyncDone onOpened: statusText.forceActiveFocus() - function setPresence(presence, statusMsg = null) { + function setPresence(presence, statusMsg = undefined) { py.callClientCoro(userId, "set_presence", [presence, statusMsg]) } HMenuItem { - enabled: firstSyncDone icon.name: "presence" icon.color: theme.controls.presence.online text: qsTr("Online") @@ -31,8 +29,7 @@ HMenu { } HMenuItem { - visible: presence - enabled: firstSyncDone + enabled: presence icon.name: "presence-busy" icon.color: theme.controls.presence.unavailable text: qsTr("Unavailable") @@ -40,7 +37,6 @@ HMenu { } HMenuItem { - enabled: firstSyncDone icon.name: "presence-offline" icon.color: theme.controls.presence.offline text: qsTr("Offline") @@ -48,8 +44,7 @@ HMenu { } HMenuItem { - visible: presence - enabled: firstSyncDone + enabled: presence icon.name: "presence-invisible" icon.color: theme.controls.presence.offline text: qsTr("Invisible") @@ -60,8 +55,7 @@ HMenu { HLabeledItem { id: statusMsgLabel - visible: presence - enabled: firstSyncDone + enabled: presence && presence !== "offline" width: parent.width height: visible ? implicitHeight : 0 label.text: qsTr("Status message:") @@ -80,12 +74,14 @@ HMenu { } defaultText: statusMsg + placeholderText: ! presence ? "Unsupported server" : "" Layout.fillWidth: true } HButton { id: button + visible: presence icon.name: "apply" icon.color: theme.colors.positiveBackground diff --git a/src/gui/MainPane/AccountDelegate.qml b/src/gui/MainPane/AccountDelegate.qml index 9de10254..6733728e 100644 --- a/src/gui/MainPane/AccountDelegate.qml +++ b/src/gui/MainPane/AccountDelegate.qml @@ -13,7 +13,12 @@ HTile { tile: account spacing: 0 opacity: - collapsed ? theme.mainPane.listView.account.collapsedOpacity : 1 + collapsed ? + theme.mainPane.listView.account.collapsedOpacity : + + model.presence == "offline" ? + theme.mainPane.listView.offlineOpacity : + 1 Behavior on opacity { HNumberAnimation {} } @@ -25,14 +30,14 @@ HTile { radius: theme.mainPane.listView.account.avatarRadius compact: account.compact - presence: model.presence + presence: model.presence_support ? model.presence : "" Layout.alignment: Qt.AlignCenter HLoader { anchors.fill: parent z: 9998 - opacity: model.first_sync_done ? 0 : 1 + opacity: model.connecting ? 1 : 0 active: opacity > 0 sourceComponent: Rectangle { @@ -157,12 +162,12 @@ HTile { } contextMenu: AccountContextMenu { - userId: model.id - presence: model.presence_support ? model.presence : null - statusMsg: model.status_msg - - // Gray out buttons before first sync - firstSyncDone: model.first_sync_done + userId: model.id + presence: + model.presence_support || model.presence === "offline" ? + model.presence : + null + statusMsg: model.status_msg } diff --git a/src/gui/MainPane/RoomDelegate.qml b/src/gui/MainPane/RoomDelegate.qml index 3fda81b2..6debc88c 100644 --- a/src/gui/MainPane/RoomDelegate.qml +++ b/src/gui/MainPane/RoomDelegate.qml @@ -15,7 +15,13 @@ HTile { contentItem: ContentRow { tile: room - opacity: model.left ? theme.mainPane.listView.room.leftRoomOpacity : 1 + opacity: + accountModel.presence === "offline" ? + theme.mainPane.listView.offlineOpacity : + + model.left ? + theme.mainPane.listView.room.leftRoomOpacity : + 1 Behavior on opacity { HNumberAnimation {} } @@ -179,6 +185,10 @@ HTile { readonly property ListModel eventModel: ModelStore.get(model.for_account, model.id, "events") + // TODO: binding loop + readonly property QtObject accountModel: + ModelStore.get("accounts").find(model.for_account) + readonly property QtObject lastEvent: eventModel.count > 0 ? eventModel.get(0) : null } diff --git a/src/gui/Pages/Chat/RoomPane/RoomPane.qml b/src/gui/Pages/Chat/RoomPane/RoomPane.qml index 09ae81e0..1c95a15a 100644 --- a/src/gui/Pages/Chat/RoomPane/RoomPane.qml +++ b/src/gui/Pages/Chat/RoomPane/RoomPane.qml @@ -57,6 +57,10 @@ MultiviewPane { } + readonly property QtObject accountModel: + ModelStore.get("accounts").find(chat.roomInfo.for_account) + + function toggleFocus() { if (roomPane.activeFocus) { if (roomPane.collapse) roomPane.close() @@ -78,7 +82,9 @@ MultiviewPane { } MemberView {} - SettingsView {} + SettingsView { + enabled: accountModel.presence !== "offline" + } HShortcut { sequences: window.settings.keys.toggleFocusRoomPane diff --git a/src/themes/Glass.qpl b/src/themes/Glass.qpl index 6e404078..59c0440d 100644 --- a/src/themes/Glass.qpl +++ b/src/themes/Glass.qpl @@ -316,7 +316,8 @@ mainPane: color mentionBackground: colors.alertBackground listView: - color background: colors.mediumBackground + color background: colors.mediumBackground + real offlineOpacity: 0.5 account: real collapsedOpacity: 0.3 diff --git a/src/themes/Midnight.qpl b/src/themes/Midnight.qpl index 0a427a35..6cda22ec 100644 --- a/src/themes/Midnight.qpl +++ b/src/themes/Midnight.qpl @@ -325,7 +325,8 @@ mainPane: color mentionBackground: colors.alertBackground listView: - color background: colors.mediumBackground + color background: colors.mediumBackground + real offlineOpacity: 0.5 account: real collapsedOpacity: 0.3