diff --git a/src/backend/backend.py b/src/backend/backend.py index 1d3e4ba3..dcc43452 100644 --- a/src/backend/backend.py +++ b/src/backend/backend.py @@ -82,6 +82,9 @@ class Backend: we managed. Every client is logged to one matrix account. media_cache: A matrix media cache for downloaded files. + + presences: A `{user_id: Presence}` dict for storing presence info about + matrix users registered on Mirage. """ def __init__(self) -> None: diff --git a/src/backend/matrix_client.py b/src/backend/matrix_client.py index d12ddfc8..91101ec3 100644 --- a/src/backend/matrix_client.py +++ b/src/backend/matrix_client.py @@ -232,9 +232,10 @@ class MatrixClient(nio.AsyncClient): async def login( - self, password: str, - device_name: str = "", - order: Optional[int] = None, + self, + password: str, + device_name: str = "", + order: Optional[int] = None, ) -> None: """Login to the server using the account's password.""" @@ -242,12 +243,14 @@ class MatrixClient(nio.AsyncClient): password, device_name or self.default_device_name(), ) - if order is None and not self.models["accounts"]: + saved = await self.backend.saved_accounts.read() + + if order is None and not saved.values(): order = 0 elif order is None: order = max( - account.order - for i, account in enumerate(self.models["accounts"].values()) + account.get("order", i) + for i, account in enumerate(saved.values()) ) + 1 # Get or create account model @@ -519,10 +522,8 @@ class MatrixClient(nio.AsyncClient): mentions = mentions, ) - while ( - self.models["accounts"][self.user_id].presence == - Presence.State.offline - ): + presence = self.models["accounts"][self.user_id].presence + while presence == Presence.State.offline: await asyncio.sleep(0.2) await self._send_message(room_id, content, tx_id) @@ -550,10 +551,8 @@ 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 - ): + presence = self.models["accounts"][self.user_id].presence + while presence == Presence.State.offline: await asyncio.sleep(0.2) item_uuid = uuid4() @@ -1123,10 +1122,8 @@ 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 not in - [Presence.State.invisible, Presence.State.offline] - ): + presence = self.models["accounts"][self.user_id].presence + if presence not in [Presence.State.invisible, Presence.State.offline]: await super().room_typing(room_id, typing_state, timeout) @@ -1304,20 +1301,20 @@ class MatrixClient(nio.AsyncClient): await self._stop() - # Uppdate manually since we may not receive the presence event back + # Update 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.presence == Presence.State.offline and + presence != "offline" ): account.connecting = True - self.start_task = asyncio.ensure_future(self._start()) + self.start_task = asyncio.ensure_future(self._start()) if ( - presence != "offline" and - Presence.State(presence) != account.presence + Presence.State(presence) != account.presence and + presence != "offline" ): account.presence = Presence.State("echo_" + presence) @@ -1689,7 +1686,7 @@ class MatrixClient(nio.AsyncClient): async def add_member(self, room: nio.MatrixRoom, user_id: str) -> None: """Register/update a room member into our models.""" member = room.users[user_id] - presence = self.backend.presences.get(user_id, Presence()) + presence = self.backend.presences.get(user_id, None) member_item = Member( id = user_id, display_name = room.user_name(user_id) # disambiguated @@ -1701,8 +1698,8 @@ class MatrixClient(nio.AsyncClient): ) # Associate presence with member, if it exists - if user_id in self.backend.presences: - presence.members[room.room_id, user_id] = member_item + if presence: + presence.members[room.room_id] = member_item # And then update presence fields presence.update_members() diff --git a/src/backend/models/items.py b/src/backend/models/items.py index 9e288a8a..ba4979b0 100644 --- a/src/backend/models/items.py +++ b/src/backend/models/items.py @@ -17,6 +17,11 @@ from .model_item import ModelItem ZeroDate = datetime.fromtimestamp(0) OptionalExceptionType = Union[Type[None], Type[Exception]] +PresenceOrder: Dict[str, int] = { + "online": 0, + "unavailable": 1, + "offline": 2, +} class TypeSpecifier(AutoStrEnum): @@ -28,7 +33,38 @@ class TypeSpecifier(AutoStrEnum): @dataclass -class Presence(): +class Presence: + """Represents a single matrix user presence fields. + + It is stored in `Backend.presences`, indexed by user ID. It must only be + instiated when receiving a `PresenceEvent` or registering an `Account` + model. + + 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` object to `members`. And 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, add the member to + `members`. + + When an Account model is registered, we create a `Presence` in + `Backend.presences` for account ID wether the server supports or not + presence (we cannot really know at this point). And assign that `Account` + to `Account` field. + + 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 only be + assigned when client starts (`MatrixClient._start()`) and + unassigned when client stops (`MatrixClient._start()`). + """ + class State(AutoStrEnum): offline = auto() # can mean offline, invisible or unknwon unavailable = auto() @@ -40,28 +76,24 @@ class Presence(): echo_invisible = auto() def __lt__(self, other: "Presence.State") -> bool: - order = [ - self.online, - self.unavailable, - self.invisible, - self.offline, - ] + return PresenceOrder[self.value] < PresenceOrder[other.value] - return ( - order.index(self) # type: ignore - ) < ( - order.index(other) # type: ignore - ) presence: State = State.offline currently_active: bool = False last_active_at: datetime = ZeroDate status_msg: str = "" - members: Dict[Tuple[str, 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 `MĚ€ember` in `members`. + + Currently it is only called when receiving a `PresenceEvent` and when + registering room members. + """ + for member in self.members.values(): member.presence = self.presence member.status_msg = self.status_msg @@ -69,6 +101,8 @@ class Presence(): member.currently_active = self.currently_active def update_account(self) -> None: + """Update presence fields of `Account` related to this `Presence`.""" + # 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 diff --git a/src/backend/nio_callbacks.py b/src/backend/nio_callbacks.py index 57b14907..e104d641 100644 --- a/src/backend/nio_callbacks.py +++ b/src/backend/nio_callbacks.py @@ -593,13 +593,14 @@ class NioCallbacks: async def onPresenceEvent(self, ev: nio.PresenceEvent) -> None: presence = self.client.backend.presences.get(ev.user_id, Presence()) + presence.currently_active = ev.currently_active or False presence.status_msg = ev.status_msg or "" - presence.presence = Presence.State(ev.presence) if ev.presence\ - else Presence.State.offline presence.last_active_at = ( datetime.now() - timedelta(milliseconds=ev.last_active_ago) ) if ev.last_active_ago else datetime.fromtimestamp(0) - presence.currently_active = ev.currently_active or False + + presence.presence = Presence.State(ev.presence) if ev.presence\ + else Presence.State.offline # Add all existing members related to this presence for room_id in self.models[self.user_id, "rooms"]: @@ -608,7 +609,7 @@ class NioCallbacks: ) if member: - presence.members[room_id, ev.user_id] = member + presence.members[room_id] = member # Update members and accounts presence.update_members() @@ -624,7 +625,7 @@ class NioCallbacks: ): account = self.models["accounts"][ev.user_id] - # Do not fight back presence + # Do not fight back presence from other clients self.client.backend.clients[ev.user_id]._presence = ev.presence # Servers that send presence events support presence diff --git a/src/backend/user_files.py b/src/backend/user_files.py index f1955839..91bab62e 100644 --- a/src/backend/user_files.py +++ b/src/backend/user_files.py @@ -194,12 +194,7 @@ class Accounts(JSONDataFile): "device_id": client.device_id, "enabled": True, "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, + "order": account.order, }, }) diff --git a/src/gui/Base/HUserAvatar.qml b/src/gui/Base/HUserAvatar.qml index 31467352..eca7e78c 100644 --- a/src/gui/Base/HUserAvatar.qml +++ b/src/gui/Base/HUserAvatar.qml @@ -1,7 +1,6 @@ // SPDX-License-Identifier: LGPL-3.0-or-later import QtQuick 2.12 -import QtQuick.Shapes 1.12 HAvatar { name: displayName || userId.substring(1) // no leading @ @@ -62,8 +61,10 @@ HAvatar { opacity: theme.controls.presence.opacity z: 100 - property bool small: window.settings.compactMode - property int diameter: small ? 10 : 15 + property int diameter: + window.settings.compactMode ? + theme.controls.presence.radius * 2 : + theme.controls.presence.radius * 2.5 sourceComponent: Rectangle { width: diameter @@ -83,7 +84,7 @@ HAvatar { theme.controls.presence.offline border.color: theme.controls.presence.border - border.width: diameter / 10 + border.width: Math.ceil(diameter / 10) Behavior on color { HColorAnimation {} } Behavior on opacity { HNumberAnimation {} } @@ -91,8 +92,8 @@ HAvatar { HoverHandler { id: presenceHover } HToolTip { - visible: presenceHover.hovered - text: presence.replace(/^\w/, c => c.toUpperCase()) + visible: presenceHover.hovered && ! presence.includes("echo") + text: qsTr(presence.replace(/^\w/, c => c.toUpperCase())) } } } diff --git a/src/gui/MainPane/AccountContextMenu.qml b/src/gui/MainPane/AccountContextMenu.qml index 29b97c7a..67ef96fc 100644 --- a/src/gui/MainPane/AccountContextMenu.qml +++ b/src/gui/MainPane/AccountContextMenu.qml @@ -16,7 +16,7 @@ HMenu { onOpened: statusText.forceActiveFocus() - function setPresence(presence, statusMsg = undefined) { + function setPresence(presence, statusMsg=undefined) { py.callClientCoro(userId, "set_presence", [presence, statusMsg]) } @@ -42,7 +42,7 @@ HMenu { } defaultText: statusMsg - placeholderText: ! presence ? "Unsupported server" : "" + placeholderText: presence ? "" : "Unsupported server" Layout.fillWidth: true } @@ -66,7 +66,7 @@ HMenu { HMenuSeparator { } HMenuItem { - icon.name: "presence" + icon.name: "presence-online" icon.color: theme.controls.presence.online text: qsTr("Online") onTriggered: setPresence("online") diff --git a/src/gui/MainPane/AccountDelegate.qml b/src/gui/MainPane/AccountDelegate.qml index 6733728e..a896a778 100644 --- a/src/gui/MainPane/AccountDelegate.qml +++ b/src/gui/MainPane/AccountDelegate.qml @@ -163,11 +163,11 @@ HTile { contextMenu: AccountContextMenu { userId: model.id + statusMsg: model.status_msg 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 6debc88c..645dd269 100644 --- a/src/gui/MainPane/RoomDelegate.qml +++ b/src/gui/MainPane/RoomDelegate.qml @@ -185,7 +185,7 @@ HTile { readonly property ListModel eventModel: ModelStore.get(model.for_account, model.id, "events") - // TODO: binding loop + // FIXME: binding loop readonly property QtObject accountModel: ModelStore.get("accounts").find(model.for_account) diff --git a/src/gui/Pages/Chat/RoomPane/MemberView/MemberDelegate.qml b/src/gui/Pages/Chat/RoomPane/MemberView/MemberDelegate.qml index 98391ec9..fed7c12f 100644 --- a/src/gui/Pages/Chat/RoomPane/MemberView/MemberDelegate.qml +++ b/src/gui/Pages/Chat/RoomPane/MemberView/MemberDelegate.qml @@ -43,26 +43,9 @@ HTile { } TitleRightInfoLabel { + id: lastActiveAt tile: member visible: presenceTimer.running - - Timer { - id: presenceTimer - running: - ! model.currently_active && - model.last_active_at > new Date(1) - repeat: true - interval: - new Date() - model.last_active_at < 60000 ? - 10000 : - 60000 - triggeredOnStart: true - onTriggered: parent.text = Qt.binding(() => - utils.formatRelativeTime( - new Date() - model.last_active_at - ) - ) - } } } @@ -84,6 +67,20 @@ HTile { "" ) } + + Timer { + id: presenceTimer + running: + ! model.currently_active && + model.last_active_at > new Date(1) + repeat: true + interval: + new Date() - model.last_active_at < 60000 ? 10000 : 60000 + triggeredOnStart: true + onTriggered: lastActiveAt.text = Qt.binding(() => + utils.formatRelativeTime(new Date() - model.last_active_at) + ) + } } } diff --git a/src/icons/thin/presence.svg b/src/icons/thin/presence-online.svg similarity index 100% rename from src/icons/thin/presence.svg rename to src/icons/thin/presence-online.svg diff --git a/src/themes/Glass.qpl b/src/themes/Glass.qpl index 59c0440d..cef87ffe 100644 --- a/src/themes/Glass.qpl +++ b/src/themes/Glass.qpl @@ -273,6 +273,7 @@ controls: color offline: hsluv(0, 0, 30, 1) color border: "black" real opacity: 1.0 + real radius: 6.0 * uiScale // Specific interface parts diff --git a/src/themes/Midnight.qpl b/src/themes/Midnight.qpl index 6cda22ec..8df8990b 100644 --- a/src/themes/Midnight.qpl +++ b/src/themes/Midnight.qpl @@ -279,6 +279,7 @@ controls: color offline: hsluv(0, 0, 30, 1) color border: "black" real opacity: 1.0 + real radius: 6.0 * uiScale // Specific interface parts