Begin yet another model refactor
Use native ListModel which require a lot of changes, but should be much faster than the old way which exponentially slowed down to a crawl. Also fix some popup bugs (leave/forget). Not working yet: side pane keyboard controls, proper highlight, room & member filtering, local echo replacement
This commit is contained in:
@@ -5,16 +5,16 @@ import logging as log
|
||||
import sys
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
from typing import Any, DefaultDict, Dict, List, Optional, Tuple
|
||||
|
||||
from appdirs import AppDirs
|
||||
from typing import Any, DefaultDict, Dict, List, Optional
|
||||
|
||||
import nio
|
||||
from appdirs import AppDirs
|
||||
|
||||
from . import __app_name__
|
||||
from .errors import MatrixError
|
||||
from .matrix_client import MatrixClient
|
||||
from .models.items import Account, Room
|
||||
from .models import SyncId
|
||||
from .models.items import Account
|
||||
from .models.model_store import ModelStore
|
||||
|
||||
# Logging configuration
|
||||
@@ -116,8 +116,8 @@ class Backend:
|
||||
await client.close()
|
||||
raise
|
||||
|
||||
self.clients[client.user_id] = client
|
||||
self.models[Account][client.user_id] = Account(client.user_id)
|
||||
self.clients[client.user_id] = client
|
||||
self.models["accounts"][client.user_id] = Account(client.user_id)
|
||||
return client.user_id
|
||||
|
||||
|
||||
@@ -133,13 +133,13 @@ class Backend:
|
||||
user=user_id, homeserver=homeserver, device_id=device_id,
|
||||
)
|
||||
|
||||
self.clients[user_id] = client
|
||||
self.models[Account][user_id] = Account(user_id)
|
||||
self.clients[user_id] = client
|
||||
self.models["accounts"][user_id] = Account(user_id)
|
||||
|
||||
await client.resume(user_id=user_id, token=token, device_id=device_id)
|
||||
|
||||
|
||||
async def load_saved_accounts(self) -> Tuple[str, ...]:
|
||||
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, str]) -> str:
|
||||
@@ -162,7 +162,7 @@ class Backend:
|
||||
|
||||
client = self.clients.pop(user_id, None)
|
||||
if client:
|
||||
self.models[Account].pop(user_id, None)
|
||||
self.models["accounts"].pop(user_id, None)
|
||||
await client.logout()
|
||||
|
||||
await self.saved_accounts.delete(user_id)
|
||||
@@ -256,25 +256,24 @@ class Backend:
|
||||
return (settings, ui_state, history, theme)
|
||||
|
||||
|
||||
async def get_flat_mainpane_data(self) -> List[Dict[str, Any]]:
|
||||
"""Return a flat list of accounts and their joined rooms for QML."""
|
||||
async def await_model_item(
|
||||
self, model_id: SyncId, item_id: Any,
|
||||
) -> Dict[str, Any]:
|
||||
|
||||
data = []
|
||||
if isinstance(model_id, list): # when called from QML
|
||||
model_id = tuple(model_id)
|
||||
|
||||
for account in sorted(self.models[Account].values()):
|
||||
data.append({
|
||||
"type": "Account",
|
||||
"id": account.user_id,
|
||||
"user_id": account.user_id,
|
||||
"data": account.serialized,
|
||||
})
|
||||
failures = 0
|
||||
|
||||
for room in sorted(self.models[Room, account.user_id].values()):
|
||||
data.append({
|
||||
"type": "Room",
|
||||
"id": "/".join((account.user_id, room.room_id)),
|
||||
"user_id": account.user_id,
|
||||
"data": room.serialized,
|
||||
})
|
||||
while True:
|
||||
try:
|
||||
return self.models[model_id][item_id].serialized
|
||||
except KeyError:
|
||||
if failures and failures % 300 == 0:
|
||||
log.warn(
|
||||
"Item %r not found in model %r after %ds",
|
||||
item_id, model_id, failures / 10,
|
||||
)
|
||||
|
||||
return data
|
||||
await asyncio.sleep(0.1)
|
||||
failures += 1
|
||||
|
@@ -35,7 +35,7 @@ from .errors import (
|
||||
)
|
||||
from .html_markdown import HTML_PROCESSOR as HTML
|
||||
from .models.items import (
|
||||
Account, Event, Member, Room, TypeSpecifier, Upload, UploadStatus,
|
||||
Event, Member, Room, TypeSpecifier, Upload, UploadStatus,
|
||||
)
|
||||
from .models.model_store import ModelStore
|
||||
from .pyotherside_events import AlertRequested
|
||||
@@ -211,7 +211,7 @@ class MatrixClient(nio.AsyncClient):
|
||||
return
|
||||
|
||||
resp = future.result()
|
||||
account = self.models[Account][self.user_id]
|
||||
account = self.models["accounts"][self.user_id]
|
||||
account.profile_updated = datetime.now()
|
||||
account.display_name = resp.displayname or ""
|
||||
account.avatar_url = resp.avatar_url or ""
|
||||
@@ -284,7 +284,7 @@ class MatrixClient(nio.AsyncClient):
|
||||
await self._send_file(item_uuid, room_id, path)
|
||||
except (nio.TransferCancelledError, asyncio.CancelledError):
|
||||
log.info("Deleting item for cancelled upload %s", item_uuid)
|
||||
del self.models[Upload, room_id][str(item_uuid)]
|
||||
del self.models[room_id, "uploads"][str(item_uuid)]
|
||||
|
||||
|
||||
async def _send_file(
|
||||
@@ -310,7 +310,7 @@ class MatrixClient(nio.AsyncClient):
|
||||
task = asyncio.Task.current_task()
|
||||
monitor = nio.TransferMonitor(size)
|
||||
upload_item = Upload(item_uuid, task, monitor, path, total_size=size)
|
||||
self.models[Upload, room_id][str(item_uuid)] = upload_item
|
||||
self.models[room_id, "uploads"][str(item_uuid)] = upload_item
|
||||
|
||||
def on_transferred(transferred: int) -> None:
|
||||
upload_item.uploaded = transferred
|
||||
@@ -452,7 +452,7 @@ class MatrixClient(nio.AsyncClient):
|
||||
content["msgtype"] = "m.file"
|
||||
content["filename"] = path.name
|
||||
|
||||
del self.models[Upload, room_id][str(upload_item.uuid)]
|
||||
del self.models[room_id, "uploads"][str(upload_item.id)]
|
||||
|
||||
await self._local_echo(
|
||||
room_id,
|
||||
@@ -499,12 +499,12 @@ class MatrixClient(nio.AsyncClient):
|
||||
replace the local one we registered.
|
||||
"""
|
||||
|
||||
our_info = self.models[Member, self.user_id, room_id][self.user_id]
|
||||
our_info = self.models[self.user_id, room_id, "members"][self.user_id]
|
||||
|
||||
event = Event(
|
||||
source = None,
|
||||
client_id = f"echo-{transaction_id}",
|
||||
id = f"echo-{transaction_id}",
|
||||
event_id = "",
|
||||
source = None,
|
||||
date = datetime.now(),
|
||||
sender_id = self.user_id,
|
||||
sender_name = our_info.display_name,
|
||||
@@ -514,13 +514,10 @@ class MatrixClient(nio.AsyncClient):
|
||||
**event_fields,
|
||||
)
|
||||
|
||||
for user_id in self.models[Account]:
|
||||
if user_id in self.models[Member, self.user_id, 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[Event, user_id, room_id][key] = event
|
||||
|
||||
if user_id == self.user_id:
|
||||
self.models[Event, user_id, room_id].sync_now()
|
||||
self.models[user_id, room_id, "events"][key] = event
|
||||
|
||||
await self.set_room_last_event(room_id, event)
|
||||
|
||||
@@ -583,7 +580,7 @@ class MatrixClient(nio.AsyncClient):
|
||||
async def load_rooms_without_visible_events(self) -> None:
|
||||
"""Call `_load_room_without_visible_events` for all joined rooms."""
|
||||
|
||||
for room_id in self.models[Room, self.user_id]:
|
||||
for room_id in self.models[self.user_id, "rooms"]:
|
||||
asyncio.ensure_future(
|
||||
self._load_room_without_visible_events(room_id),
|
||||
)
|
||||
@@ -604,7 +601,7 @@ class MatrixClient(nio.AsyncClient):
|
||||
to show or there is nothing left to load.
|
||||
"""
|
||||
|
||||
events = self.models[Event, self.user_id, room_id]
|
||||
events = self.models[self.user_id, room_id, "events"]
|
||||
more = True
|
||||
|
||||
while self.skipped_events[room_id] and not events and more:
|
||||
@@ -685,11 +682,16 @@ class MatrixClient(nio.AsyncClient):
|
||||
will be marked as suitable for destruction by the server.
|
||||
"""
|
||||
|
||||
await super().room_leave(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)
|
||||
|
||||
try:
|
||||
await super().room_leave(room_id)
|
||||
except MatrixNotFound: # already left
|
||||
pass
|
||||
|
||||
await super().room_forget(room_id)
|
||||
self.models[Room, self.user_id].pop(room_id, None)
|
||||
self.models.pop((Event, self.user_id, room_id), None)
|
||||
self.models.pop((Member, self.user_id, room_id), None)
|
||||
|
||||
|
||||
async def room_mass_invite(
|
||||
@@ -843,7 +845,8 @@ class MatrixClient(nio.AsyncClient):
|
||||
|
||||
for sync_id, model in self.models.items():
|
||||
if not (isinstance(sync_id, tuple) and
|
||||
sync_id[0:2] == (Event, self.user_id)):
|
||||
sync_id[0] == self.user_id and
|
||||
sync_id[2] == "events"):
|
||||
continue
|
||||
|
||||
_, _, room_id = sync_id
|
||||
@@ -873,10 +876,9 @@ class MatrixClient(nio.AsyncClient):
|
||||
"""
|
||||
|
||||
self.cleared_events_rooms.add(room_id)
|
||||
model = self.models[Event, self.user_id, room_id]
|
||||
model = self.models[self.user_id, room_id, "events"]
|
||||
if model:
|
||||
model.clear()
|
||||
model.sync_now()
|
||||
|
||||
|
||||
# Functions to register data into models
|
||||
@@ -900,37 +902,10 @@ class MatrixClient(nio.AsyncClient):
|
||||
The `last_event` is notably displayed in the UI room subtitles.
|
||||
"""
|
||||
|
||||
model = self.models[Room, self.user_id]
|
||||
room = model[room_id]
|
||||
room = self.models[self.user_id, "rooms"][room_id]
|
||||
|
||||
if room.last_event is None:
|
||||
room.last_event = item
|
||||
|
||||
if item.is_local_echo:
|
||||
model.sync_now()
|
||||
|
||||
return
|
||||
|
||||
is_profile_ev = item.type_specifier == TypeSpecifier.profile_change
|
||||
|
||||
# If there were no better events available to show previously
|
||||
prev_is_profile_ev = \
|
||||
room.last_event.type_specifier == TypeSpecifier.profile_change
|
||||
|
||||
# If this is a profile event, only replace the currently shown one if
|
||||
# it was also a profile event (we had nothing better to show).
|
||||
if is_profile_ev and not prev_is_profile_ev:
|
||||
return
|
||||
|
||||
# If this event is older than the currently shown one, only replace
|
||||
# it if the previous was a profile event.
|
||||
if item.date < room.last_event.date and not prev_is_profile_ev:
|
||||
return
|
||||
|
||||
room.last_event = item
|
||||
|
||||
if item.is_local_echo:
|
||||
model.sync_now()
|
||||
if item.date > room.last_event_date:
|
||||
room.last_event_date = item.date
|
||||
|
||||
|
||||
async def register_nio_room(
|
||||
@@ -939,18 +914,21 @@ class MatrixClient(nio.AsyncClient):
|
||||
"""Register a `nio.MatrixRoom` as a `Room` object in our model."""
|
||||
|
||||
# Add room
|
||||
try:
|
||||
last_ev = self.models[Room, self.user_id][room.room_id].last_event
|
||||
except KeyError:
|
||||
last_ev = None
|
||||
|
||||
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)
|
||||
|
||||
self.models[Room, self.user_id][room.room_id] = Room(
|
||||
room_id = room.room_id,
|
||||
try:
|
||||
registered = self.models[self.user_id, "rooms"][room.room_id]
|
||||
last_event_date = registered.last_event_date
|
||||
typing_members = registered.typing_members
|
||||
except KeyError:
|
||||
last_event_date = datetime.fromtimestamp(0)
|
||||
typing_members = []
|
||||
|
||||
self.models[self.user_id, "rooms"][room.room_id] = Room(
|
||||
id = room.room_id,
|
||||
given_name = room.name or "",
|
||||
display_name = room.display_name or "",
|
||||
avatar_url = room.gen_avatar_url or "",
|
||||
@@ -962,6 +940,8 @@ class MatrixClient(nio.AsyncClient):
|
||||
(room.avatar_url(inviter) or "") if inviter else "",
|
||||
left = left,
|
||||
|
||||
typing_members = typing_members,
|
||||
|
||||
encrypted = room.encrypted,
|
||||
invite_required = room.join_rule == "invite",
|
||||
guests_allowed = room.guest_access == "can_join",
|
||||
@@ -975,23 +955,24 @@ class MatrixClient(nio.AsyncClient):
|
||||
can_set_join_rules = can_send_state("m.room.join_rules"),
|
||||
can_set_guest_access = can_send_state("m.room.guest_access"),
|
||||
|
||||
last_event = last_ev,
|
||||
last_event_date = last_event_date,
|
||||
|
||||
)
|
||||
|
||||
# List members that left the room, then remove them from our model
|
||||
left_the_room = [
|
||||
user_id
|
||||
for user_id in self.models[Member, self.user_id, room.room_id]
|
||||
for user_id in self.models[self.user_id, room.room_id, "members"]
|
||||
if user_id not in room.users
|
||||
]
|
||||
|
||||
for user_id in left_the_room:
|
||||
del self.models[Member, self.user_id, room.room_id][user_id]
|
||||
del self.models[self.user_id, room.room_id, "members"][user_id]
|
||||
|
||||
# Add the room members to the added room
|
||||
new_dict = {
|
||||
user_id: Member(
|
||||
user_id = user_id,
|
||||
id = user_id,
|
||||
display_name = room.user_name(user_id) # disambiguated
|
||||
if member.display_name else "",
|
||||
avatar_url = member.avatar_url or "",
|
||||
@@ -1000,7 +981,7 @@ class MatrixClient(nio.AsyncClient):
|
||||
invited = member.invited,
|
||||
) for user_id, member in room.users.items()
|
||||
}
|
||||
self.models[Member, self.user_id, room.room_id].update(new_dict)
|
||||
self.models[self.user_id, room.room_id, "members"].update(new_dict)
|
||||
|
||||
|
||||
async def get_member_name_avatar(
|
||||
@@ -1013,7 +994,7 @@ class MatrixClient(nio.AsyncClient):
|
||||
"""
|
||||
|
||||
try:
|
||||
item = self.models[Member, self.user_id, room_id][user_id]
|
||||
item = self.models[self.user_id, room_id, "members"][user_id]
|
||||
|
||||
except KeyError: # e.g. user is not anymore in the room
|
||||
try:
|
||||
@@ -1030,11 +1011,7 @@ class MatrixClient(nio.AsyncClient):
|
||||
async def register_nio_event(
|
||||
self, room: nio.MatrixRoom, ev: nio.Event, **fields,
|
||||
) -> None:
|
||||
"""Register a `nio.Event` as a `Event` object in our model.
|
||||
|
||||
`MatrixClient.register_nio_room` is called for the passed `room`
|
||||
if neccessary before.
|
||||
"""
|
||||
"""Register a `nio.Event` as a `Event` object in our model."""
|
||||
|
||||
await self.register_nio_room(room)
|
||||
|
||||
@@ -1049,9 +1026,9 @@ class MatrixClient(nio.AsyncClient):
|
||||
|
||||
# Create Event ModelItem
|
||||
item = Event(
|
||||
source = ev,
|
||||
client_id = ev.event_id,
|
||||
id = ev.event_id,
|
||||
event_id = ev.event_id,
|
||||
source = ev,
|
||||
date = datetime.fromtimestamp(ev.server_timestamp / 1000),
|
||||
sender_id = ev.sender,
|
||||
sender_name = sender_name,
|
||||
@@ -1069,14 +1046,11 @@ class MatrixClient(nio.AsyncClient):
|
||||
local_sender = ev.sender in self.backend.clients
|
||||
|
||||
if local_sender and tx_id:
|
||||
item.client_id = f"echo-{tx_id}"
|
||||
item.id = f"echo-{tx_id}"
|
||||
|
||||
if not local_sender and not await self.event_is_past(ev):
|
||||
AlertRequested()
|
||||
|
||||
self.models[Event, self.user_id, room.room_id][item.client_id] = item
|
||||
self.models[self.user_id, room.room_id, "events"][item.id] = item
|
||||
|
||||
await self.set_room_last_event(room.room_id, item)
|
||||
|
||||
if item.sender_id == self.user_id:
|
||||
self.models[Event, self.user_id, room.room_id].sync_now()
|
||||
|
@@ -2,9 +2,6 @@
|
||||
|
||||
"""Provide classes related to data models shared between Python and QML."""
|
||||
|
||||
from typing import Tuple, Type, Union
|
||||
from typing import Tuple, Union
|
||||
|
||||
from .model_item import ModelItem
|
||||
|
||||
# last one: Tuple[Union[Type[ModelItem], Tuple[Type[ModelItem]]], str...]
|
||||
SyncId = Union[Type[ModelItem], Tuple[Type[ModelItem]], tuple]
|
||||
SyncId = Union[str, Tuple[str]]
|
||||
|
@@ -11,31 +11,39 @@ from typing import Any, Dict, List, Optional, Tuple, Type, Union
|
||||
from uuid import UUID
|
||||
|
||||
import lxml # nosec
|
||||
|
||||
import nio
|
||||
|
||||
from ..html_markdown import HTML_PROCESSOR
|
||||
from ..utils import AutoStrEnum, auto
|
||||
from .model_item import ModelItem
|
||||
|
||||
ZeroDate = datetime.fromtimestamp(0)
|
||||
OptionalExceptionType = Union[Type[None], Type[Exception]]
|
||||
|
||||
|
||||
class TypeSpecifier(AutoStrEnum):
|
||||
"""Enum providing clarification of purpose for some matrix events."""
|
||||
|
||||
Unset = auto()
|
||||
ProfileChange = auto()
|
||||
MembershipChange = auto()
|
||||
|
||||
|
||||
@dataclass
|
||||
class Account(ModelItem):
|
||||
"""A logged in matrix account."""
|
||||
|
||||
user_id: str = field()
|
||||
display_name: str = ""
|
||||
avatar_url: str = ""
|
||||
first_sync_done: bool = False
|
||||
profile_updated: Optional[datetime] = None
|
||||
id: str = field()
|
||||
display_name: str = ""
|
||||
avatar_url: str = ""
|
||||
first_sync_done: bool = False
|
||||
profile_updated: datetime = ZeroDate
|
||||
|
||||
def __lt__(self, other: "Account") -> bool:
|
||||
"""Sort by display name or user ID."""
|
||||
name = self.display_name or self.user_id[1:]
|
||||
other_name = other.display_name or other.user_id[1:]
|
||||
return name < other_name
|
||||
name = self.display_name or self.id[1:]
|
||||
other_name = other.display_name or other.id[1:]
|
||||
return name.lower() < other_name.lower()
|
||||
|
||||
@property
|
||||
def filter_string(self) -> str:
|
||||
@@ -47,18 +55,21 @@ class Account(ModelItem):
|
||||
class Room(ModelItem):
|
||||
"""A matrix room we are invited to, are or were member of."""
|
||||
|
||||
room_id: str = field()
|
||||
given_name: str = ""
|
||||
display_name: str = ""
|
||||
avatar_url: str = ""
|
||||
plain_topic: str = ""
|
||||
topic: str = ""
|
||||
inviter_id: str = ""
|
||||
inviter_name: str = ""
|
||||
inviter_avatar: str = ""
|
||||
left: bool = False
|
||||
id: str = field()
|
||||
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)
|
||||
|
||||
federated: bool = True
|
||||
encrypted: bool = False
|
||||
invite_required: bool = True
|
||||
guests_allowed: bool = True
|
||||
@@ -72,7 +83,7 @@ class Room(ModelItem):
|
||||
can_set_join_rules: bool = False
|
||||
can_set_guest_access: bool = False
|
||||
|
||||
last_event: Optional["Event"] = field(default=None, repr=False)
|
||||
last_event_date: datetime = ZeroDate
|
||||
|
||||
def __lt__(self, other: "Room") -> bool:
|
||||
"""Sort by join state, then descending last event date, then name.
|
||||
@@ -85,68 +96,45 @@ class Room(ModelItem):
|
||||
# Left rooms may still have an inviter_id, so check left first.
|
||||
return (
|
||||
self.left,
|
||||
|
||||
other.inviter_id,
|
||||
|
||||
other.last_event.date if other.last_event else
|
||||
datetime.fromtimestamp(0),
|
||||
|
||||
self.display_name.lower() or self.room_id,
|
||||
other.last_event_date,
|
||||
(self.display_name or self.id).lower(),
|
||||
) < (
|
||||
other.left,
|
||||
|
||||
self.inviter_id,
|
||||
|
||||
self.last_event.date if self.last_event else
|
||||
datetime.fromtimestamp(0),
|
||||
|
||||
other.display_name.lower() or other.room_id,
|
||||
self.last_event_date,
|
||||
(other.display_name or other.id).lower(),
|
||||
)
|
||||
|
||||
@property
|
||||
def filter_string(self) -> str:
|
||||
"""Filter based on room display name, topic, and last event content."""
|
||||
|
||||
return " ".join((
|
||||
self.display_name,
|
||||
self.topic,
|
||||
re.sub(r"<.*?>", "", self.last_event.inline_content)
|
||||
if self.last_event else "",
|
||||
))
|
||||
|
||||
|
||||
@property
|
||||
def serialized(self) -> Dict[str, Any]:
|
||||
dct = super().serialized
|
||||
|
||||
if self.last_event is not None:
|
||||
dct["last_event"] = self.last_event.serialized
|
||||
|
||||
return dct
|
||||
|
||||
return " ".join((self.display_name, self.topic))
|
||||
|
||||
|
||||
@dataclass
|
||||
class Member(ModelItem):
|
||||
"""A member in a matrix room."""
|
||||
|
||||
user_id: str = field()
|
||||
display_name: str = ""
|
||||
avatar_url: str = ""
|
||||
typing: bool = False
|
||||
power_level: int = 0
|
||||
invited: bool = False
|
||||
id: str = field()
|
||||
display_name: str = ""
|
||||
avatar_url: str = ""
|
||||
typing: bool = False
|
||||
power_level: int = 0
|
||||
invited: bool = False
|
||||
profile_updated: datetime = ZeroDate
|
||||
|
||||
def __lt__(self, other: "Member") -> bool:
|
||||
"""Sort by power level, then by display name/user ID."""
|
||||
|
||||
name = (self.display_name or self.user_id[1:]).lower()
|
||||
other_name = (other.display_name or other.user_id[1:]).lower()
|
||||
name = self.display_name or self.id[1:]
|
||||
other_name = other.display_name or other.id[1:]
|
||||
|
||||
return (
|
||||
self.invited, other.power_level, name,
|
||||
self.invited, other.power_level, name.lower(),
|
||||
) < (
|
||||
other.invited, self.power_level, other_name,
|
||||
other.invited, self.power_level, other_name.lower(),
|
||||
)
|
||||
|
||||
|
||||
@@ -168,15 +156,15 @@ class UploadStatus(AutoStrEnum):
|
||||
class Upload(ModelItem):
|
||||
"""Represent a running or failed file upload operation."""
|
||||
|
||||
uuid: UUID = field()
|
||||
id: UUID = field()
|
||||
task: asyncio.Task = field()
|
||||
monitor: nio.TransferMonitor = field()
|
||||
filepath: Path = field()
|
||||
|
||||
total_size: int = 0
|
||||
uploaded: int = 0
|
||||
speed: float = 0
|
||||
time_left: Optional[timedelta] = None
|
||||
total_size: int = 0
|
||||
uploaded: int = 0
|
||||
speed: float = 0
|
||||
time_left: timedelta = timedelta(0)
|
||||
|
||||
status: UploadStatus = UploadStatus.Uploading
|
||||
error: OptionalExceptionType = type(None)
|
||||
@@ -191,21 +179,13 @@ class Upload(ModelItem):
|
||||
return self.start_date > other.start_date
|
||||
|
||||
|
||||
class TypeSpecifier(AutoStrEnum):
|
||||
"""Enum providing clarification of purpose for some matrix events."""
|
||||
|
||||
none = auto()
|
||||
profile_change = auto()
|
||||
membership_change = auto()
|
||||
|
||||
|
||||
@dataclass
|
||||
class Event(ModelItem):
|
||||
"""A matrix state event or message."""
|
||||
|
||||
source: Optional[nio.Event] = field()
|
||||
client_id: str = field()
|
||||
id: str = field()
|
||||
event_id: str = field()
|
||||
source: Optional[nio.Event] = field()
|
||||
date: datetime = field()
|
||||
sender_id: str = field()
|
||||
sender_name: str = field()
|
||||
@@ -213,8 +193,9 @@ class Event(ModelItem):
|
||||
|
||||
content: str = ""
|
||||
inline_content: str = ""
|
||||
reason: str = ""
|
||||
|
||||
type_specifier: TypeSpecifier = TypeSpecifier.none
|
||||
type_specifier: TypeSpecifier = TypeSpecifier.Unset
|
||||
|
||||
target_id: str = ""
|
||||
target_name: str = ""
|
||||
@@ -271,12 +252,19 @@ class Event(ModelItem):
|
||||
|
||||
return urls
|
||||
|
||||
@property
|
||||
def serialized(self) -> Dict[str, Any]:
|
||||
dct = super().serialized
|
||||
del dct["source"]
|
||||
del dct["local_event_type"]
|
||||
return dct
|
||||
|
||||
|
||||
@dataclass
|
||||
class Device(ModelItem):
|
||||
"""A matrix user's device. This class is currently unused."""
|
||||
|
||||
device_id: str = field()
|
||||
id: str = field()
|
||||
ed25519_key: str = field()
|
||||
trusted: bool = False
|
||||
blacklisted: bool = False
|
||||
|
@@ -1,12 +1,12 @@
|
||||
# SPDX-License-Identifier: LGPL-3.0-or-later
|
||||
|
||||
import logging as log
|
||||
import time
|
||||
from threading import Lock, Thread
|
||||
from threading import Lock
|
||||
from typing import Any, Dict, Iterator, List, MutableMapping
|
||||
|
||||
from ..pyotherside_events import (
|
||||
ModelCleared, ModelItemDeleted, ModelItemInserted,
|
||||
)
|
||||
from . import SyncId
|
||||
from ..pyotherside_events import ModelUpdated
|
||||
from .model_item import ModelItem
|
||||
|
||||
|
||||
@@ -30,13 +30,10 @@ class Model(MutableMapping):
|
||||
"""
|
||||
|
||||
def __init__(self, sync_id: SyncId) -> None:
|
||||
self.sync_id: SyncId = sync_id
|
||||
self._data: Dict[Any, ModelItem] = {}
|
||||
|
||||
self._changed: bool = False
|
||||
self._sync_lock: Lock = Lock()
|
||||
self._sync_thread: Thread = Thread(target=self._sync_loop, daemon=True)
|
||||
self._sync_thread.start()
|
||||
self.sync_id: SyncId = sync_id
|
||||
self._data: Dict[Any, ModelItem] = {}
|
||||
self._sorted_data: List[ModelItem] = []
|
||||
self._write_lock: Lock = Lock()
|
||||
|
||||
|
||||
def __repr__(self) -> str:
|
||||
@@ -47,27 +44,14 @@ class Model(MutableMapping):
|
||||
except ImportError:
|
||||
from pprint import pformat # type: ignore
|
||||
|
||||
if isinstance(self.sync_id, tuple):
|
||||
sid = (self.sync_id[0].__name__, *self.sync_id[1:])
|
||||
else:
|
||||
sid = self.sync_id.__name__ # type: ignore
|
||||
|
||||
return "%s(sync_id=%s, %s)" % (
|
||||
type(self).__name__, sid, pformat(self._data),
|
||||
type(self).__name__, self.sync_id, pformat(self._data),
|
||||
)
|
||||
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""Provide a short "<sync_id>: <num> items" representation."""
|
||||
|
||||
if isinstance(self.sync_id, tuple):
|
||||
reprs = tuple(repr(s) for s in self.sync_id[1:])
|
||||
sid = ", ".join((self.sync_id[0].__name__, *reprs))
|
||||
sid = f"({sid})"
|
||||
else:
|
||||
sid = self.sync_id.__name__
|
||||
|
||||
return f"{sid!s}: {len(self)} items"
|
||||
return f"{self.sync_id}: {len(self)} items"
|
||||
|
||||
|
||||
def __getitem__(self, key):
|
||||
@@ -81,37 +65,36 @@ class Model(MutableMapping):
|
||||
updated with the passed `ModelItem`'s fields.
|
||||
In other cases, the item is simply added to the model.
|
||||
|
||||
This also sets the `ModelItem.parent_model` hidden attribute on the
|
||||
passed item.
|
||||
This also sets the `ModelItem.parent_model` hidden attributes on
|
||||
the passed item.
|
||||
"""
|
||||
|
||||
new = value
|
||||
with self._write_lock:
|
||||
existing = self._data.get(key)
|
||||
new = value
|
||||
|
||||
if key in self:
|
||||
existing = dict(self[key].serialized) # copy to not alter with pop
|
||||
merged = {**existing, **value.serialized}
|
||||
|
||||
existing.pop("parent_model", None)
|
||||
merged.pop("parent_model", None)
|
||||
|
||||
if merged == existing:
|
||||
if existing:
|
||||
for field in new.__dataclass_fields__: # type: ignore
|
||||
# The same shared item is in _sorted_data, no need to find
|
||||
# and modify it explicitely.
|
||||
setattr(existing, field, getattr(new, field))
|
||||
return
|
||||
|
||||
merged_init_kwargs = {**vars(self[key]), **vars(value)}
|
||||
merged_init_kwargs.pop("parent_model", None)
|
||||
new = type(value)(**merged_init_kwargs)
|
||||
new.parent_model = self
|
||||
|
||||
new.parent_model = self
|
||||
|
||||
with self._sync_lock:
|
||||
self._data[key] = new
|
||||
self._changed = True
|
||||
self._sorted_data.append(new)
|
||||
self._sorted_data.sort()
|
||||
|
||||
ModelItemInserted(self.sync_id, self._sorted_data.index(new), new)
|
||||
|
||||
|
||||
def __delitem__(self, key) -> None:
|
||||
with self._sync_lock:
|
||||
del self._data[key]
|
||||
self._changed = True
|
||||
with self._write_lock:
|
||||
item = self._data.pop(key)
|
||||
index = self._sorted_data.index(item)
|
||||
del self._sorted_data[index]
|
||||
ModelItemDeleted(self.sync_id, index)
|
||||
|
||||
|
||||
def __iter__(self) -> Iterator:
|
||||
@@ -122,31 +105,11 @@ class Model(MutableMapping):
|
||||
return len(self._data)
|
||||
|
||||
|
||||
def _sync_loop(self) -> None:
|
||||
"""Loop to synchronize model when needed with a cooldown of 0.25s."""
|
||||
|
||||
while True:
|
||||
time.sleep(0.25)
|
||||
|
||||
if self._changed:
|
||||
with self._sync_lock:
|
||||
log.debug("Syncing %s", self)
|
||||
self.sync_now()
|
||||
|
||||
|
||||
def sync_now(self) -> None:
|
||||
"""Trigger a model synchronization right now. Use with precaution."""
|
||||
|
||||
ModelUpdated(self.sync_id, self.serialized())
|
||||
self._changed = False
|
||||
|
||||
|
||||
def serialized(self) -> List[Dict[str, Any]]:
|
||||
"""Return serialized model content as a list of dict for QML."""
|
||||
|
||||
return [item.serialized for item in sorted(self._data.values())]
|
||||
|
||||
|
||||
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()
|
||||
ModelCleared(self.sync_id)
|
||||
|
@@ -2,8 +2,6 @@
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from ..utils import serialize_value_for_qml
|
||||
|
||||
|
||||
class ModelItem:
|
||||
"""Base class for items stored inside a `Model`.
|
||||
@@ -28,11 +26,23 @@ class ModelItem:
|
||||
def __setattr__(self, name: str, value) -> None:
|
||||
"""If this item is in a `Model`, alert it of attribute changes."""
|
||||
|
||||
if name == "parent_model" or self.parent_model is None:
|
||||
super().__setattr__(name, value)
|
||||
return
|
||||
|
||||
if getattr(self, name) == value:
|
||||
return
|
||||
|
||||
super().__setattr__(name, value)
|
||||
|
||||
if name != "parent_model" and self.parent_model is not None:
|
||||
with self.parent_model._sync_lock:
|
||||
self.parent_model._changed = True
|
||||
old_index = self.parent_model._sorted_data.index(self)
|
||||
self.parent_model._sorted_data.sort()
|
||||
new_index = self.parent_model._sorted_data.index(self)
|
||||
|
||||
from ..pyotherside_events import ModelItemFieldChanged
|
||||
ModelItemFieldChanged(
|
||||
self.parent_model.sync_id, old_index, new_index, name, value,
|
||||
)
|
||||
|
||||
|
||||
def __delattr__(self, name: str) -> None:
|
||||
@@ -43,8 +53,10 @@ class ModelItem:
|
||||
def serialized(self) -> Dict[str, Any]:
|
||||
"""Return this item as a dict ready to be passed to QML."""
|
||||
|
||||
from ..utils import serialize_value_for_qml
|
||||
|
||||
return {
|
||||
name: serialize_value_for_qml(getattr(self, name))
|
||||
name: serialize_value_for_qml(getattr(self, name), json_lists=True)
|
||||
for name in dir(self)
|
||||
if not (
|
||||
name.startswith("_") or name in ("parent_model", "serialized")
|
||||
|
@@ -14,7 +14,7 @@ import nio
|
||||
from . import utils
|
||||
from .html_markdown import HTML_PROCESSOR
|
||||
from .matrix_client import MatrixClient
|
||||
from .models.items import Account, Room, TypeSpecifier
|
||||
from .models.items import TypeSpecifier
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -63,7 +63,6 @@ class NioCallbacks:
|
||||
# TODO: handle in nio, these are rooms that were left before
|
||||
# starting the client.
|
||||
if room_id not in self.client.all_rooms:
|
||||
log.warning("Left room not in MatrixClient.rooms: %r", room_id)
|
||||
continue
|
||||
|
||||
# TODO: handle left events in nio async client
|
||||
@@ -85,7 +84,7 @@ class NioCallbacks:
|
||||
self.client.first_sync_done.set()
|
||||
self.client.first_sync_date = datetime.now()
|
||||
|
||||
account = self.client.models[Account][self.client.user_id]
|
||||
account = self.client.models["accounts"][self.client.user_id]
|
||||
account.first_sync_done = True
|
||||
|
||||
|
||||
@@ -207,7 +206,7 @@ class NioCallbacks:
|
||||
prev_membership = ev.prev_membership
|
||||
ev_date = datetime.fromtimestamp(ev.server_timestamp / 1000)
|
||||
|
||||
member_change = TypeSpecifier.membership_change
|
||||
member_change = TypeSpecifier.MembershipChange
|
||||
|
||||
# Membership changes
|
||||
if not prev or membership != prev_membership:
|
||||
@@ -263,10 +262,9 @@ class NioCallbacks:
|
||||
if changed:
|
||||
# Update our account profile if the event is newer than last update
|
||||
if ev.state_key == self.client.user_id:
|
||||
account = self.client.models[Account][self.client.user_id]
|
||||
updated = account.profile_updated
|
||||
account = self.client.models["accounts"][self.client.user_id]
|
||||
|
||||
if not updated or updated < ev_date:
|
||||
if account.profile_updated < ev_date:
|
||||
account.profile_updated = ev_date
|
||||
account.display_name = now["displayname"] or ""
|
||||
account.avatar_url = now["avatar_url"] or ""
|
||||
@@ -276,7 +274,7 @@ class NioCallbacks:
|
||||
return None
|
||||
|
||||
return (
|
||||
TypeSpecifier.profile_change,
|
||||
TypeSpecifier.ProfileChange,
|
||||
"%1 changed their {}".format(" and ".join(changed)),
|
||||
)
|
||||
|
||||
@@ -383,10 +381,11 @@ class NioCallbacks:
|
||||
if not self.client.first_sync_done.is_set():
|
||||
return
|
||||
|
||||
if room.room_id not in self.client.models[Room, self.client.user_id]:
|
||||
return
|
||||
await self.client.register_nio_room(room)
|
||||
|
||||
room_item = self.client.models[Room, self.client.user_id][room.room_id]
|
||||
room_id = room.room_id
|
||||
|
||||
room_item = self.client.models[self.client.user_id, "rooms"][room_id]
|
||||
|
||||
room_item.typing_members = sorted(
|
||||
room.user_name(user_id) for user_id in ev.users
|
||||
|
@@ -1,11 +1,13 @@
|
||||
# SPDX-License-Identifier: LGPL-3.0-or-later
|
||||
|
||||
from abc import ABC
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
from typing import Any, Optional
|
||||
|
||||
import pyotherside
|
||||
|
||||
from .models import SyncId
|
||||
from .models.model_item import ModelItem
|
||||
from .utils import serialize_value_for_qml
|
||||
|
||||
|
||||
@@ -13,11 +15,13 @@ from .utils import serialize_value_for_qml
|
||||
class PyOtherSideEvent:
|
||||
"""Event that will be sent on instanciation to QML by PyOtherSide."""
|
||||
|
||||
json_lists = False
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
# 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))
|
||||
serialize_value_for_qml(getattr(self, field), self.json_lists)
|
||||
for field in self.__dataclass_fields__ # type: ignore
|
||||
]
|
||||
pyotherside.send(type(self).__name__, *args)
|
||||
@@ -59,20 +63,32 @@ class LoopException(PyOtherSideEvent):
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModelUpdated(PyOtherSideEvent):
|
||||
"""Indicate that a backend `Model`'s data changed."""
|
||||
class ModelEvent(ABC, PyOtherSideEvent):
|
||||
json_lists = True
|
||||
|
||||
sync_id: SyncId = field()
|
||||
data: List[Dict[str, Any]] = field()
|
||||
|
||||
serialized_sync_id: Union[str, List[str]] = field(init=False)
|
||||
@dataclass
|
||||
class ModelItemInserted(ModelEvent):
|
||||
sync_id: SyncId = field()
|
||||
index: int = field()
|
||||
item: ModelItem = field()
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if isinstance(self.sync_id, tuple):
|
||||
self.serialized_sync_id = [
|
||||
e.__name__ if isinstance(e, type) else e for e in self.sync_id
|
||||
]
|
||||
else:
|
||||
self.serialized_sync_id = self.sync_id.__name__
|
||||
|
||||
super().__post_init__()
|
||||
@dataclass
|
||||
class ModelItemFieldChanged(ModelEvent):
|
||||
sync_id: SyncId = field()
|
||||
item_index_then: int = field()
|
||||
item_index_now: int = field()
|
||||
changed_field: str = field()
|
||||
field_value: Any = field()
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModelItemDeleted(ModelEvent):
|
||||
sync_id: SyncId = field()
|
||||
index: int = field()
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModelCleared(ModelEvent):
|
||||
sync_id: SyncId = field()
|
||||
|
@@ -110,8 +110,6 @@ class QMLBridge:
|
||||
|
||||
rc = lambda c: asyncio.run_coroutine_threadsafe(c, self._loop) # noqa
|
||||
|
||||
from .models.items import Account, Room, Member, Event, Device # noqa
|
||||
|
||||
p = print # pdb's `p` doesn't print a class's __str__ # noqa
|
||||
try:
|
||||
from pprintpp import pprint as pp # noqa
|
||||
|
@@ -6,6 +6,7 @@ import collections
|
||||
import html
|
||||
import inspect
|
||||
import io
|
||||
import json
|
||||
import xml.etree.cElementTree as xml_etree # FIXME: bandit warning
|
||||
from datetime import timedelta
|
||||
from enum import Enum
|
||||
@@ -17,10 +18,11 @@ from uuid import UUID
|
||||
|
||||
import filetype
|
||||
from aiofiles.threadpool.binary import AsyncBufferedReader
|
||||
|
||||
from nio.crypto import AsyncDataT as File
|
||||
from nio.crypto import async_generator_from_data
|
||||
|
||||
from .models.model_item import ModelItem
|
||||
|
||||
Size = Tuple[int, int]
|
||||
auto = autostr
|
||||
|
||||
@@ -125,7 +127,7 @@ def plain2html(text: str) -> str:
|
||||
.replace("\t", " " * 4)
|
||||
|
||||
|
||||
def serialize_value_for_qml(value: Any) -> Any:
|
||||
def serialize_value_for_qml(value: Any, json_lists: bool = False) -> Any:
|
||||
"""Convert a value to make it easier to use from QML.
|
||||
|
||||
Returns:
|
||||
@@ -135,11 +137,18 @@ def serialize_value_for_qml(value: Any) -> Any:
|
||||
- Strings for `UUID` objects
|
||||
- A number of milliseconds for `datetime.timedelta` objects
|
||||
- The class `__name__` for class types.
|
||||
- `ModelItem.serialized` for `ModelItem`s
|
||||
"""
|
||||
|
||||
if json_lists and isinstance(value, list):
|
||||
return json.dumps(value)
|
||||
|
||||
if hasattr(value, "__class__") and issubclass(value.__class__, Enum):
|
||||
return value.value
|
||||
|
||||
if isinstance(value, ModelItem):
|
||||
return value.serialized
|
||||
|
||||
if isinstance(value, Path):
|
||||
return f"file://{value!s}"
|
||||
|
||||
|
Reference in New Issue
Block a user