Make offline presence to stop sync

Setting the presence of an account to offline
will make the client to end sync task and will
prevent messages from being sent.

Setting it to online again or any other presence
will start sync task again.

Left:
- Local echo to presence change
- UI Control to affect all members presence
- Block more requests to be sent (e.g. member
  actions)
This commit is contained in:
vslg 2020-07-09 17:06:14 -03:00 committed by miruka
parent 3fa35b88c9
commit a3c9ac20c6
12 changed files with 231 additions and 109 deletions

View File

@ -132,7 +132,7 @@ class Backend:
) )
try: try:
await client.login(password) await client.login(password, order=order)
except MatrixError: except MatrixError:
await client.close() await client.close()
raise raise
@ -142,22 +142,7 @@ class Backend:
await client.logout() await client.logout()
return client.user_id return client.user_id
if order is None and not self.models["accounts"]: self.clients[client.user_id] = client
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
return client.user_id return client.user_id
@ -168,7 +153,6 @@ class Backend:
token: str, token: str,
device_id: str, device_id: str,
homeserver: str = "https://matrix.org", homeserver: str = "https://matrix.org",
order: int = -1,
state: str = "online", state: str = "online",
) -> None: ) -> None:
"""Create and register a `MatrixClient` with known account details.""" """Create and register a `MatrixClient` with known account details."""
@ -178,14 +162,7 @@ class Backend:
user=user_id, homeserver=homeserver, device_id=device_id, user=user_id, homeserver=homeserver, device_id=device_id,
) )
account = Account(user_id, order) self.clients[user_id] = client
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
await client.resume(user_id, token, device_id, state) 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.""" """Call `resume_client` for all saved accounts in user config."""
async def resume(user_id: str, info: Dict[str, Any]) -> str: 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( await self.resume_client(
user_id = user_id, user_id = user_id,
token = info["token"], token = info["token"],
device_id = info["device_id"], device_id = info["device_id"],
homeserver = info["homeserver"], homeserver = info["homeserver"],
order = info.get("order", -1),
state = info.get("presence", "online"), state = info.get("presence", "online"),
) )
return user_id return user_id
return await asyncio.gather(*( return await asyncio.gather(*(

View File

@ -40,7 +40,7 @@ from .errors import (
from .html_markdown import HTML_PROCESSOR as HTML from .html_markdown import HTML_PROCESSOR as HTML
from .media_cache import Media, Thumbnail from .media_cache import Media, Thumbnail
from .models.items import ( 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 .models.model_store import ModelStore
from .nio_callbacks import NioCallbacks from .nio_callbacks import NioCallbacks
@ -231,14 +231,36 @@ class MatrixClient(nio.AsyncClient):
return f"{__display_name__} on {os_name} {os_ver}".rstrip() 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.""" """Login to the server using the account's password."""
await super().login( await super().login(
password, device_name or self.default_device_name(), 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( async def resume(
@ -251,32 +273,21 @@ class MatrixClient(nio.AsyncClient):
"""Login to the server using an existing access token.""" """Login to the server using an existing access token."""
response = nio.LoginResponse(user_id, device_id, token) response = nio.LoginResponse(user_id, device_id, token)
account = self.models["accounts"][user_id]
await self.receive_response(response) await self.receive_response(response)
self._presence = "offline" if state == "invisible" else state self._presence = "offline" if state == "invisible" else state
self.start_task = asyncio.ensure_future(self._start()) account.presence = Presence.State(state)
if state == "invisible": if state != "offline":
self.models["accounts"][self.user_id].presence = \ account.connecting = True
Presence.State.invisible self.start_task = asyncio.ensure_future(self._start())
async def logout(self) -> None: async def logout(self) -> None:
"""Logout from the server. This will delete the device.""" """Logout from the server. This will delete the device."""
tasks = ( await self._stop()
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 super().logout() await super().logout()
await self.close() await self.close()
@ -300,8 +311,6 @@ class MatrixClient(nio.AsyncClient):
if future.cancelled(): # Account logged out if future.cancelled(): # Account logged out
return return
account = self.models["accounts"][self.user_id]
try: try:
account.max_upload_size = future.result() account.max_upload_size = future.result()
except Exception: except Exception:
@ -316,6 +325,13 @@ class MatrixClient(nio.AsyncClient):
on_server_config_response, 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.profile_task = asyncio.ensure_future(self.update_own_profile())
self.server_config_task = asyncio.ensure_future( self.server_config_task = asyncio.ensure_future(
@ -362,6 +378,31 @@ class MatrixClient(nio.AsyncClient):
await asyncio.sleep(5) 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: async def update_own_profile(self) -> None:
"""Fetch our profile from server and Update our model `Account`.""" """Fetch our profile from server and Update our model `Account`."""
@ -478,6 +519,12 @@ class MatrixClient(nio.AsyncClient):
mentions = mentions, 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) 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: 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.""" """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() item_uuid = uuid4()
try: try:
@ -1070,8 +1123,10 @@ class MatrixClient(nio.AsyncClient):
"""Set typing notice to the server.""" """Set typing notice to the server."""
# Do not send typing notice if the user is invisible # Do not send typing notice if the user is invisible
if self.models["accounts"][self.user_id].presence != \ if (
Presence.State.invisible: self.models["accounts"][self.user_id].presence not in
[Presence.State.invisible, Presence.State.offline]
):
await super().room_typing(room_id, typing_state, timeout) await super().room_typing(room_id, typing_state, timeout)
@ -1237,21 +1292,46 @@ class MatrixClient(nio.AsyncClient):
) -> None: ) -> None:
"""Set presence state for this account.""" """Set presence state for this account."""
account = self.models["accounts"][self.user_id]
status_msg = status_msg if status_msg is not None else ( status_msg = status_msg if status_msg is not None else (
self.models["accounts"][self.user_id].status_msg 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( await super().set_presence(
"offline" if presence == "invisible" else presence, "offline" if presence == "invisible" else presence,
status_msg, 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: async def import_keys(self, infile: str, passphrase: str) -> None:
"""Import decryption keys from a file, then retry decrypting events.""" """Import decryption keys from a file, then retry decrypting events."""

View File

@ -56,27 +56,33 @@ class Presence():
last_active_ago: int = -1 last_active_ago: int = -1
status_msg: str = "" status_msg: str = ""
members: Dict[Tuple[str, str], Union["Member", "Account"]] = \ members: Dict[Tuple[str, str], "Member"] = field(default_factory=dict)
field(default_factory=dict) account: Optional["Account"] = None
def update_members(self): def update_members(self) -> None:
for member in self.members.values(): 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.presence = self.presence
member.status_msg = self.status_msg member.status_msg = self.status_msg
member.last_active_ago = self.last_active_ago member.last_active_ago = self.last_active_ago
member.currently_active = self.currently_active 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 @dataclass
class Account(ModelItem): class Account(ModelItem):
@ -88,7 +94,7 @@ class Account(ModelItem):
avatar_url: str = "" avatar_url: str = ""
max_upload_size: int = 0 max_upload_size: int = 0
profile_updated: datetime = ZeroDate profile_updated: datetime = ZeroDate
first_sync_done: bool = False connecting: bool = False
total_unread: int = 0 total_unread: int = 0
total_highlights: int = 0 total_highlights: int = 0
local_unreads: bool = False local_unreads: bool = False
@ -96,11 +102,11 @@ class Account(ModelItem):
# For some reason, Account cannot inherit Presence, because QML keeps # For some reason, Account cannot inherit Presence, because QML keeps
# complaining type error on unknown file # complaining type error on unknown file
presence_support: bool = False presence_support: bool = False
presence: Presence.State = Presence.State.offline presence: Presence.State = Presence.State.offline
currently_active: bool = False currently_active: bool = False
last_active_ago: int = -1 last_active_ago: int = -1
status_msg: str = "" status_msg: str = ""
def __lt__(self, other: "Account") -> bool: def __lt__(self, other: "Account") -> bool:
"""Sort by order, then by user ID.""" """Sort by order, then by user ID."""
@ -220,7 +226,7 @@ class AccountOrRoom(Account, Room):
@dataclass @dataclass
class Member(Presence, ModelItem): class Member(ModelItem):
"""A member in a matrix room.""" """A member in a matrix room."""
id: str = field() id: str = field()
@ -234,6 +240,11 @@ class Member(Presence, ModelItem):
last_read_event: str = "" last_read_event: str = ""
last_read_at: datetime = ZeroDate 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: def __lt__(self, other: "Member") -> bool:
"""Sort by presence, power level, then by display name/user ID.""" """Sort by presence, power level, then by display name/user ID."""

View File

@ -106,7 +106,7 @@ class NioCallbacks:
self.client.first_sync_date = datetime.now() self.client.first_sync_date = datetime.now()
account = self.models["accounts"][self.user_id] account = self.models["accounts"][self.user_id]
account.first_sync_done = True account.connecting = False
async def onKeysQueryResponse(self, resp: nio.KeysQueryResponse) -> None: async def onKeysQueryResponse(self, resp: nio.KeysQueryResponse) -> None:
@ -612,11 +612,21 @@ class NioCallbacks:
presence.update_members() presence.update_members()
# Check if presence event is ours # 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 # 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 # 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 self.client.backend.presences[ev.user_id] = presence

View File

@ -184,7 +184,7 @@ class Accounts(JSONDataFile):
client = self.backend.clients[user_id] client = self.backend.clients[user_id]
saved = await self.read() saved = await self.read()
presence = self.backend.models["accounts"][user_id].presence.value account = self.backend.models["accounts"][user_id]
await self.write({ await self.write({
**saved, **saved,
@ -193,8 +193,10 @@ class Accounts(JSONDataFile):
"token": client.access_token, "token": client.access_token,
"device_id": client.device_id, "device_id": client.device_id,
"enabled": True, "enabled": True,
"presence": presence or "online", "presence": account.presence.value,
"order": max([
# Can account.order converge with any other saved value?
"order": account.order if account.order >= 0 else max([
account.get("order", i) account.get("order", i)
for i, account in enumerate(saved.values()) for i, account in enumerate(saved.values())
] or [-1]) + 1, ] 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: async def delete(self, user_id: str) -> None:
"""Delete an account from the config and write it on disk.""" """Delete an account from the config and write it on disk."""

View File

@ -1,7 +1,7 @@
// SPDX-License-Identifier: LGPL-3.0-or-later // SPDX-License-Identifier: LGPL-3.0-or-later
import QtQuick 2.12 import QtQuick 2.12
import QtQuick.Shapes 1.15 import QtQuick.Shapes 1.12
HAvatar { HAvatar {
name: displayName || userId.substring(1) // no leading @ name: displayName || userId.substring(1) // no leading @

View File

@ -12,18 +12,16 @@ HMenu {
property string userId property string userId
property string presence property string presence
property string statusMsg property string statusMsg
property bool firstSyncDone
onOpened: statusText.forceActiveFocus() onOpened: statusText.forceActiveFocus()
function setPresence(presence, statusMsg = null) { function setPresence(presence, statusMsg = undefined) {
py.callClientCoro(userId, "set_presence", [presence, statusMsg]) py.callClientCoro(userId, "set_presence", [presence, statusMsg])
} }
HMenuItem { HMenuItem {
enabled: firstSyncDone
icon.name: "presence" icon.name: "presence"
icon.color: theme.controls.presence.online icon.color: theme.controls.presence.online
text: qsTr("Online") text: qsTr("Online")
@ -31,8 +29,7 @@ HMenu {
} }
HMenuItem { HMenuItem {
visible: presence enabled: presence
enabled: firstSyncDone
icon.name: "presence-busy" icon.name: "presence-busy"
icon.color: theme.controls.presence.unavailable icon.color: theme.controls.presence.unavailable
text: qsTr("Unavailable") text: qsTr("Unavailable")
@ -40,7 +37,6 @@ HMenu {
} }
HMenuItem { HMenuItem {
enabled: firstSyncDone
icon.name: "presence-offline" icon.name: "presence-offline"
icon.color: theme.controls.presence.offline icon.color: theme.controls.presence.offline
text: qsTr("Offline") text: qsTr("Offline")
@ -48,8 +44,7 @@ HMenu {
} }
HMenuItem { HMenuItem {
visible: presence enabled: presence
enabled: firstSyncDone
icon.name: "presence-invisible" icon.name: "presence-invisible"
icon.color: theme.controls.presence.offline icon.color: theme.controls.presence.offline
text: qsTr("Invisible") text: qsTr("Invisible")
@ -60,8 +55,7 @@ HMenu {
HLabeledItem { HLabeledItem {
id: statusMsgLabel id: statusMsgLabel
visible: presence enabled: presence && presence !== "offline"
enabled: firstSyncDone
width: parent.width width: parent.width
height: visible ? implicitHeight : 0 height: visible ? implicitHeight : 0
label.text: qsTr("Status message:") label.text: qsTr("Status message:")
@ -80,12 +74,14 @@ HMenu {
} }
defaultText: statusMsg defaultText: statusMsg
placeholderText: ! presence ? "Unsupported server" : ""
Layout.fillWidth: true Layout.fillWidth: true
} }
HButton { HButton {
id: button id: button
visible: presence
icon.name: "apply" icon.name: "apply"
icon.color: theme.colors.positiveBackground icon.color: theme.colors.positiveBackground

View File

@ -13,7 +13,12 @@ HTile {
tile: account tile: account
spacing: 0 spacing: 0
opacity: 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 {} } Behavior on opacity { HNumberAnimation {} }
@ -25,14 +30,14 @@ HTile {
radius: theme.mainPane.listView.account.avatarRadius radius: theme.mainPane.listView.account.avatarRadius
compact: account.compact compact: account.compact
presence: model.presence presence: model.presence_support ? model.presence : ""
Layout.alignment: Qt.AlignCenter Layout.alignment: Qt.AlignCenter
HLoader { HLoader {
anchors.fill: parent anchors.fill: parent
z: 9998 z: 9998
opacity: model.first_sync_done ? 0 : 1 opacity: model.connecting ? 1 : 0
active: opacity > 0 active: opacity > 0
sourceComponent: Rectangle { sourceComponent: Rectangle {
@ -157,12 +162,12 @@ HTile {
} }
contextMenu: AccountContextMenu { contextMenu: AccountContextMenu {
userId: model.id userId: model.id
presence: model.presence_support ? model.presence : null presence:
statusMsg: model.status_msg model.presence_support || model.presence === "offline" ?
model.presence :
// Gray out buttons before first sync null
firstSyncDone: model.first_sync_done statusMsg: model.status_msg
} }

View File

@ -15,7 +15,13 @@ HTile {
contentItem: ContentRow { contentItem: ContentRow {
tile: room 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 {} } Behavior on opacity { HNumberAnimation {} }
@ -179,6 +185,10 @@ HTile {
readonly property ListModel eventModel: readonly property ListModel eventModel:
ModelStore.get(model.for_account, model.id, "events") 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: readonly property QtObject lastEvent:
eventModel.count > 0 ? eventModel.get(0) : null eventModel.count > 0 ? eventModel.get(0) : null
} }

View File

@ -57,6 +57,10 @@ MultiviewPane {
} }
readonly property QtObject accountModel:
ModelStore.get("accounts").find(chat.roomInfo.for_account)
function toggleFocus() { function toggleFocus() {
if (roomPane.activeFocus) { if (roomPane.activeFocus) {
if (roomPane.collapse) roomPane.close() if (roomPane.collapse) roomPane.close()
@ -78,7 +82,9 @@ MultiviewPane {
} }
MemberView {} MemberView {}
SettingsView {} SettingsView {
enabled: accountModel.presence !== "offline"
}
HShortcut { HShortcut {
sequences: window.settings.keys.toggleFocusRoomPane sequences: window.settings.keys.toggleFocusRoomPane

View File

@ -316,7 +316,8 @@ mainPane:
color mentionBackground: colors.alertBackground color mentionBackground: colors.alertBackground
listView: listView:
color background: colors.mediumBackground color background: colors.mediumBackground
real offlineOpacity: 0.5
account: account:
real collapsedOpacity: 0.3 real collapsedOpacity: 0.3

View File

@ -325,7 +325,8 @@ mainPane:
color mentionBackground: colors.alertBackground color mentionBackground: colors.alertBackground
listView: listView:
color background: colors.mediumBackground color background: colors.mediumBackground
real offlineOpacity: 0.5
account: account:
real collapsedOpacity: 0.3 real collapsedOpacity: 0.3