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:
miruka 2019-12-02 16:29:29 -04:00
parent 2ce5e20efa
commit 9990fecc74
49 changed files with 826 additions and 781 deletions

3
.gitmodules vendored
View File

@ -7,3 +7,6 @@
[submodule "submodules/hsluv-c"]
path = submodules/hsluv-c
url = https://github.com/hsluv/hsluv-c
[submodule "submodules/gel"]
path = submodules/gel
url = https://github.com/Cutehacks/gel

View File

@ -1,9 +1,15 @@
# TODO
- room last event date previous year show month if <3 month
- when inviting members, prevent if user id is on another server and room
doesn't allow that
- "exception during sync" aren't caught
## Media
- nio ClientTimeout
- no thumb if bigger than original
- upload delay at the end?
- Handle upload file size limit
- Handle set avatar upload errors

View File

@ -44,6 +44,7 @@ executables.files = $$TARGET
# Libraries includes
include(submodules/qsyncable/qsyncable.pri)
include(submodules/gel/com_cutehacks_gel.pri)
# Custom functions

View File

@ -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

View File

@ -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()

View File

@ -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]]

View File

@ -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

View File

@ -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)

View File

@ -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")

View File

@ -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

View File

@ -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()

View File

@ -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

View File

@ -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", "&nbsp;" * 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}"

View File

@ -0,0 +1,81 @@
// SPDX-License-Identifier: LGPL-3.0-or-later
import QtQuick 2.12
import QtQuick.Controls 2.12
import QtQuick.Layouts 1.12
HListView {
id: accordion
property Component category
property Component content
property Component expander: HButton {
id: expanderItem
iconItem.small: true
icon.name: "expand"
backgroundColor: "transparent"
toolTip.text: expand ? qsTr("Collapse") : qsTr("Expand")
onClicked: expand = ! expand
leftPadding: theme.spacing / 2
rightPadding: leftPadding
iconItem.transform: Rotation {
origin.x: expanderItem.iconItem.width / 2
origin.y: expanderItem.iconItem.height / 2
angle: expanderItem.loading ? 0 : expand ? 90 : 180
Behavior on angle { HNumberAnimation {} }
}
Behavior on opacity { HNumberAnimation {} }
}
delegate: HColumnLayout {
id: categoryContentColumn
width: accordion.width
property bool expand: true
readonly property QtObject categoryModel: model
HRowLayout {
Layout.fillWidth: true
HLoader {
id: categoryLoader
sourceComponent: category
Layout.fillWidth: true
readonly property QtObject model: categoryModel
}
HLoader {
sourceComponent: expander
readonly property QtObject model: categoryModel
property alias expand: categoryContentColumn.expand
}
}
Item {
opacity: expand ? 1 : 0
visible: opacity > 0
Layout.fillWidth: true
Layout.preferredHeight: contentLoader.implicitHeight * opacity
Behavior on opacity { HNumberAnimation {} }
HLoader {
id: contentLoader
width: parent.width
active: categoryLoader.status === Loader.Ready
sourceComponent: content
readonly property QtObject xcategoryModel: categoryModel
}
}
}
}

View File

@ -1,30 +0,0 @@
// SPDX-License-Identifier: LGPL-3.0-or-later
import QtQuick 2.12
import QSyncable 1.0
JsonListModel {
id: model
source: []
Component.onCompleted: if (! keyField) { throw "keyField not set" }
function toObject(itemList=listModel) {
let objList = []
for (let item of itemList) {
let obj = JSON.parse(JSON.stringify(item))
for (let role in obj) {
if (obj[role]["objectName"] !== undefined) {
obj[role] = toObject(item[role])
}
}
objList.push(obj)
}
return objList
}
function toJson() {
return JSON.stringify(toObject(), null, 4)
}
}

View File

@ -26,10 +26,12 @@ ListView {
visible: listView.interactive || ! listView.allowDragging
}
// property bool debug: false
// Make sure to handle when a previous transition gets interrupted
add: Transition {
ParallelAnimation {
// ScriptAction { script: print("add") }
// ScriptAction { script: if (listView.debug) print("add") }
HNumberAnimation { property: "opacity"; from: 0; to: 1 }
HNumberAnimation { property: "scale"; from: 0; to: 1 }
}
@ -37,7 +39,7 @@ ListView {
move: Transition {
ParallelAnimation {
// ScriptAction { script: print("move") }
// ScriptAction { script: if (listView.debug) print("move") }
HNumberAnimation { property: "opacity"; to: 1 }
HNumberAnimation { property: "scale"; to: 1 }
HNumberAnimation { properties: "x,y" }
@ -46,16 +48,15 @@ ListView {
remove: Transition {
ParallelAnimation {
// ScriptAction { script: print("remove") }
// ScriptAction { script: if (listView.debug) print("remove") }
HNumberAnimation { property: "opacity"; to: 0 }
HNumberAnimation { property: "scale"; to: 0 }
}
}
// displaced: move
displaced: Transition {
ParallelAnimation {
// ScriptAction { script: print("displaced") }
// ScriptAction { script: if (listView.debug) print("displaced") }
HNumberAnimation { property: "opacity"; to: 1 }
HNumberAnimation { property: "scale"; to: 1 }
HNumberAnimation { properties: "x,y" }

View File

@ -0,0 +1,10 @@
// SPDX-License-Identifier: LGPL-3.0-or-later
import QtQuick 2.12
import com.cutehacks.gel 1.0
Collection {
caseSensitiveSort: false
localeAwareSort: true
Component.onCompleted: reSort()
}

View File

@ -18,19 +18,4 @@ HTile {
signal activated()
property HListView view: ListView.view
property bool shouldBeCurrent: false
readonly property QtObject delegateModel: model
readonly property alias setCurrentTimer: setCurrentTimer
Timer {
id: setCurrentTimer
interval: 100
repeat: true
running: true
// Component.onCompleted won't work for this
onTriggered: if (shouldBeCurrent) view.currentIndex = model.index
}
}

View File

@ -6,54 +6,61 @@ import Clipboard 0.1
import "../Base"
HTileDelegate {
id: accountDelegate
id: account
spacing: 0
topPadding: model.index > 0 ? theme.spacing / 2 : 0
bottomPadding: topPadding
backgroundColor: theme.mainPane.account.background
opacity: collapsed && ! forceExpand ?
opacity: collapsed && ! anyFilter ?
theme.mainPane.account.collapsedOpacity : 1
shouldBeCurrent:
window.uiState.page === "Pages/AccountSettings/AccountSettings.qml" &&
window.uiState.pageProperties.userId === model.data.user_id
title.color: theme.mainPane.account.name
title.text: model.display_name || model.id
title.font.pixelSize: theme.fontSize.big
title.leftPadding: theme.spacing
setCurrentTimer.running:
! mainPaneList.activateLimiter.running && ! mainPane.hasFocus
image: HUserAvatar {
userId: model.id
displayName: model.display_name
mxc: model.avatar_url
}
contextMenu: HMenu {
HMenuItem {
icon.name: "copy-user-id"
text: qsTr("Copy user ID")
onTriggered: Clipboard.text = model.id
}
Behavior on opacity { HNumberAnimation {} }
readonly property bool forceExpand: Boolean(mainPaneList.filter)
// Hide harmless error when a filter matches nothing
readonly property bool collapsed: try {
return mainPaneList.collapseAccounts[model.data.user_id] || false
} catch (err) {}
HMenuItemPopupSpawner {
icon.name: "sign-out"
icon.color: theme.colors.negativeBackground
text: qsTr("Sign out")
popup: "Popups/SignOutPopup.qml"
properties: { "userId": model.id }
}
}
onActivated: pageLoader.showPage(
"AccountSettings/AccountSettings", { "userId": model.data.user_id }
"AccountSettings/AccountSettings", { "userId": model.id }
)
readonly property bool collapsed:
window.uiState.collapseAccounts[model.id] || false
readonly property bool anyFilter: Boolean(mainPaneList.filter)
function toggleCollapse() {
window.uiState.collapseAccounts[model.data.user_id] = ! collapsed
window.uiState.collapseAccounts[model.id] = ! collapsed
window.uiStateChanged()
}
image: HUserAvatar {
userId: model.data.user_id
displayName: model.data.display_name
mxc: model.data.avatar_url
}
title.color: theme.mainPane.account.name
title.text: model.data.display_name || model.data.user_id
title.font.pixelSize: theme.fontSize.big
title.leftPadding: theme.spacing
Behavior on opacity { HNumberAnimation {} }
HButton {
id: addChat
@ -62,7 +69,7 @@ HTileDelegate {
backgroundColor: "transparent"
toolTip.text: qsTr("Add new chat")
onClicked: pageLoader.showPage(
"AddChat/AddChat", {userId: model.data.user_id},
"AddChat/AddChat", {userId: model.id},
)
leftPadding: theme.spacing / 2
@ -73,7 +80,7 @@ HTileDelegate {
Layout.fillHeight: true
Layout.maximumWidth:
accountDelegate.width >= 100 * theme.uiScale ? implicitWidth : 0
account.width >= 100 * theme.uiScale ? implicitWidth : 0
Behavior on Layout.maximumWidth { HNumberAnimation {} }
Behavior on opacity { HNumberAnimation {} }
@ -81,22 +88,23 @@ HTileDelegate {
HButton {
id: expand
loading: ! model.data.first_sync_done || ! model.data.profile_updated
loading:
! model.first_sync_done || model.profile_updated < new Date(1)
iconItem.small: true
icon.name: "expand"
backgroundColor: "transparent"
toolTip.text: collapsed ? qsTr("Expand") : qsTr("Collapse")
onClicked: accountDelegate.toggleCollapse()
onClicked: account.toggleCollapse()
leftPadding: theme.spacing / 2
rightPadding: leftPadding
opacity: ! loading && accountDelegate.forceExpand ? 0 : 1
opacity: ! loading && account.anyFilter ? 0 : 1
visible: opacity > 0 && Layout.maximumWidth > 0
Layout.fillHeight: true
Layout.maximumWidth:
accountDelegate.width >= 120 * theme.uiScale ? implicitWidth : 0
account.width >= 120 * theme.uiScale ? implicitWidth : 0
iconItem.transform: Rotation {
@ -110,21 +118,4 @@ HTileDelegate {
Behavior on Layout.maximumWidth { HNumberAnimation {} }
Behavior on opacity { HNumberAnimation {} }
}
contextMenu: HMenu {
HMenuItem {
icon.name: "copy-user-id"
text: qsTr("Copy user ID")
onTriggered: Clipboard.text = model.data.user_id
}
HMenuItemPopupSpawner {
icon.name: "sign-out"
icon.color: theme.colors.negativeBackground
text: qsTr("Sign out")
popup: "Popups/SignOutPopup.qml"
properties: { "userId": model.data.user_id }
}
}
}

View File

@ -1,143 +0,0 @@
// SPDX-License-Identifier: LGPL-3.0-or-later
import QtQuick 2.12
import QtQuick.Layouts 1.12
import "../Base"
HListView {
id: mainPaneList
readonly property var originSource: window.mainPaneModelSource
readonly property var collapseAccounts: window.uiState.collapseAccounts
readonly property string filter: toolBar.roomFilter
readonly property alias activateLimiter: activateLimiter
onOriginSourceChanged: filterLimiter.restart()
onFilterChanged: filterLimiter.restart()
onCollapseAccountsChanged: filterLimiter.restart()
function filterSource() {
let show = []
// Hide a harmless error when activating a RoomDelegate
try { window.mainPaneModelSource } catch (err) { return }
for (let i = 0; i < window.mainPaneModelSource.length; i++) {
let item = window.mainPaneModelSource[i]
if (item.type === "Account" ||
(filter ?
utils.filterMatches(filter, item.data.filter_string) :
! window.uiState.collapseAccounts[item.user_id]))
{
if (filter && show.length && item.type === "Account" &&
show[show.length - 1].type === "Account" &&
! utils.filterMatches(
filter, show[show.length - 1].data.filter_string)
) {
// If filter active, current and previous items are
// both accounts and previous account doesn't match filter,
// that means the previous account had no matching rooms.
show.pop()
}
show.push(item)
}
}
let last = show[show.length - 1]
if (show.length && filter && last.type === "Account" &&
! utils.filterMatches(filter, last.data.filter_string))
{
// If filter active, last item is an account and last item
// doesn't match filter, that account had no matching rooms.
show.pop()
}
model.source = show
}
function previous(activate=true) {
decrementCurrentIndex()
if (activate) activateLimiter.restart()
}
function next(activate=true) {
incrementCurrentIndex()
if (activate) activateLimiter.restart()
}
function activate() {
currentItem.item.activated()
}
function accountSettings() {
if (! currentItem) incrementCurrentIndex()
pageLoader.showPage(
"AccountSettings/AccountSettings",
{userId: currentItem.item.delegateModel.user_id},
)
}
function addNewChat() {
if (! currentItem) incrementCurrentIndex()
pageLoader.showPage(
"AddChat/AddChat",
{userId: currentItem.item.delegateModel.user_id},
)
}
function toggleCollapseAccount() {
if (filter) return
if (! currentItem) incrementCurrentIndex()
if (currentItem.item.delegateModel.type === "Account") {
currentItem.item.toggleCollapse()
return
}
for (let i = 0; i < model.source.length; i++) {
let item = model.source[i]
if (item.type === "Account" && item.user_id ==
currentItem.item.delegateModel.user_id)
{
currentIndex = i
currentItem.item.toggleCollapse()
}
}
}
model: HListModel {
keyField: "id"
source: originSource
}
delegate: Loader {
width: mainPaneList.width
Component.onCompleted: setSource(
model.type === "Account" ?
"AccountDelegate.qml" : "RoomDelegate.qml",
{view: mainPaneList}
)
}
Timer {
id: filterLimiter
interval: 16
onTriggered: filterSource()
}
Timer {
id: activateLimiter
interval: 300
onTriggered: activate()
}
}

View File

@ -0,0 +1,66 @@
// SPDX-License-Identifier: LGPL-3.0-or-later
import QtQuick 2.12
import ".."
import "../Base"
Column {
id: delegate
property string userId: model.id
readonly property HListView view: ListView.view
Account {
id: account
width: parent.width
view: delegate.view
}
HListView {
id: roomList
width: parent.width
height: contentHeight * opacity
opacity: account.collapsed ? 0 : 1
visible: opacity > 0
interactive: false
model: ModelStore.get(delegate.userId, "rooms")
// model: HSortFilterProxy {
// model: ModelStore.get(delegate.userId, "rooms")
// comparator: (a, b) =>
// // Sort by membership, then last event date (most recent first)
// // then room display name or ID.
// // Invited rooms are first, then joined rooms, then left rooms.
// // Left rooms may still have an inviter_id, so check left first
// [
// a.left,
// b.inviter_id,
// b.last_event && b.last_event.date ?
// b.last_event.date.getTime() : 0,
// (a.display_name || a.id).toLocaleLowerCase(),
// ] < [
// b.left,
// a.inviter_id,
// a.last_event && a.last_event.date ?
// a.last_event.date.getTime() : 0,
// (b.display_name || b.id).toLocaleLowerCase(),
// ]
// }
delegate: Room {
width: roomList.width
userId: delegate.userId
}
Behavior on opacity {
HNumberAnimation { easing.type: Easing.InOutCirc }
}
}
}

View File

@ -0,0 +1,88 @@
// SPDX-License-Identifier: LGPL-3.0-or-later
import QtQuick 2.12
import ".."
import "../Base"
HListView {
id: mainPaneList
model: ModelStore.get("accounts")
// model: HSortFilterProxy {
// model: ModelStore.get("accounts")
// comparator: (a, b) =>
// // Sort by display name or user ID
// (a.display_name || a.id).toLocaleLowerCase() <
// (b.display_name || b.id).toLocaleLowerCase()
// }
delegate: AccountRoomsDelegate {
width: mainPaneList.width
height: childrenRect.height
}
readonly property string filter: toolBar.roomFilter
function previous(activate=true) {
decrementCurrentIndex()
if (activate) activateLimiter.restart()
}
function next(activate=true) {
incrementCurrentIndex()
if (activate) activateLimiter.restart()
}
function activate() {
currentItem.item.activated()
}
function accountSettings() {
if (! currentItem) incrementCurrentIndex()
pageLoader.showPage(
"AccountSettings/AccountSettings",
{userId: currentItem.item.delegateModel.user_id},
)
}
function addNewChat() {
if (! currentItem) incrementCurrentIndex()
pageLoader.showPage(
"AddChat/AddChat",
{userId: currentItem.item.delegateModel.user_id},
)
}
function toggleCollapseAccount() {
if (filter) return
if (! currentItem) incrementCurrentIndex()
if (currentItem.item.delegateModel.type === "Account") {
currentItem.item.toggleCollapse()
return
}
for (let i = 0; i < model.source.length; i++) {
let item = model.source[i]
if (item.type === "Account" && item.user_id ==
currentItem.item.delegateModel.user_id)
{
currentIndex = i
currentItem.item.toggleCollapse()
}
}
}
Timer {
id: activateLimiter
interval: 300
onTriggered: activate()
}
}

View File

@ -37,7 +37,7 @@ HDrawer {
HColumnLayout {
anchors.fill: parent
AccountRoomList {
AccountRoomsList {
id: mainPaneList
clip: true

View File

@ -9,7 +9,7 @@ HRowLayout {
// Hide filter field overflowing for a sec on size changes
clip: true
property AccountRoomList mainPaneList
property AccountRoomsList mainPaneList
readonly property alias addAccountButton: addAccountButton
readonly property alias filterField: filterField
property alias roomFilter: filterField.text

View File

@ -3,85 +3,48 @@
import QtQuick 2.12
import QtQuick.Layouts 1.12
import Clipboard 0.1
import ".."
import "../Base"
HTileDelegate {
id: roomDelegate
spacing: theme.spacing
backgroundColor: theme.mainPane.room.background
opacity: model.data.left ? theme.mainPane.room.leftRoomOpacity : 1
shouldBeCurrent:
window.uiState.page === "Pages/Chat/Chat.qml" &&
window.uiState.pageProperties.userId === model.user_id &&
window.uiState.pageProperties.roomId === model.data.room_id
setCurrentTimer.running:
! mainPaneList.activateLimiter.running && ! mainPane.hasFocus
Behavior on opacity { HNumberAnimation {} }
readonly property bool joined: ! invited && ! parted
readonly property bool invited: model.data.inviter_id && ! parted
readonly property bool parted: model.data.left
readonly property var lastEvent: model.data.last_event
onActivated: pageLoader.showRoom(model.user_id, model.data.room_id)
opacity: model.left ? theme.mainPane.room.leftRoomOpacity : 1
image: HRoomAvatar {
displayName: model.data.display_name
mxc: model.data.avatar_url
displayName: model.display_name
mxc: model.avatar_url
}
title.color: theme.mainPane.room.name
title.text: model.data.display_name || qsTr("Empty room")
title.text: model.display_name || qsTr("Empty room")
additionalInfo.children: HIcon {
svgName: "invite-received"
colorize: theme.colors.alertBackground
visible: invited
Layout.maximumWidth: invited ? implicitWidth : 0
Behavior on Layout.maximumWidth { HNumberAnimation {} }
}
rightInfo.color: theme.mainPane.room.lastEventDate
rightInfo.text: {
! lastEvent || ! lastEvent.date ?
"" :
utils.dateIsToday(lastEvent.date) ?
utils.formatTime(lastEvent.date, false) : // no seconds
lastEvent.date.getFullYear() === new Date().getFullYear() ?
Qt.formatDate(lastEvent.date, "d MMM") : // e.g. "5 Dec"
lastEvent.date.getFullYear()
}
subtitle.color: theme.mainPane.room.subtitle
subtitle.font.italic:
Boolean(lastEvent && lastEvent.event_type === "RoomMessageEmote")
subtitle.textFormat: Text.StyledText
subtitle.font.italic:
lastEvent && lastEvent.event_type === "RoomMessageEmote"
subtitle.text: {
if (! lastEvent) return ""
let isEmote = lastEvent.event_type === "RoomMessageEmote"
let isMsg = lastEvent.event_type.startsWith("RoomMessage")
let isUnknownMsg = lastEvent.event_type === "RoomMessageUnknown"
let isCryptMedia = lastEvent.event_type.startsWith("RoomEncrypted")
const isEmote = lastEvent.event_type === "RoomMessageEmote"
const isMsg = lastEvent.event_type.startsWith("RoomMessage")
const isUnknownMsg = lastEvent.event_type === "RoomMessageUnknown"
const isCryptMedia = lastEvent.event_type.startsWith("RoomEncrypted")
// If it's a general event
if (isEmote || isUnknownMsg || (! isMsg && ! isCryptMedia)) {
if (isEmote || isUnknownMsg || (! isMsg && ! isCryptMedia))
return utils.processedEventText(lastEvent)
}
let text = utils.coloredNameHtml(
const text = utils.coloredNameHtml(
lastEvent.sender_name, lastEvent.sender_id
) + ": " + lastEvent.inline_content
@ -91,26 +54,40 @@ HTileDelegate {
)
}
rightInfo.color: theme.mainPane.room.lastEventDate
rightInfo.text: {
model.last_event_date < new Date(1) ?
"" :
utils.dateIsToday(model.last_event_date) ?
utils.formatTime(model.last_event_date, false) : // no seconds
model.last_event_date.getFullYear() === new Date().getFullYear() ?
Qt.formatDate(model.last_event_date, "d MMM") : // e.g. "5 Dec"
model.last_event_date.getFullYear()
}
contextMenu: HMenu {
HMenuItemPopupSpawner {
visible: joined
enabled: model.data.can_invite
enabled: model.can_invite
icon.name: "room-send-invite"
text: qsTr("Invite members")
popup: "Popups/InviteToRoomPopup.qml"
properties: ({
userId: model.user_id,
roomId: model.data.room_id,
roomName: model.data.display_name,
invitingAllowed: Qt.binding(() => model.data.can_invite)
userId: userId,
roomId: model.id,
roomName: model.display_name,
invitingAllowed: Qt.binding(() => model.can_invite)
})
}
HMenuItem {
icon.name: "copy-room-id"
text: qsTr("Copy room ID")
onTriggered: Clipboard.text = model.data.room_id
onTriggered: Clipboard.text = model.id
}
HMenuItem {
@ -118,12 +95,12 @@ HTileDelegate {
icon.name: "invite-accept"
icon.color: theme.colors.positiveBackground
text: qsTr("Accept %1's invite").arg(utils.coloredNameHtml(
model.data.inviter_name, model.data.inviter_id
model.inviter_name, model.inviter_id
))
label.textFormat: Text.StyledText
onTriggered: py.callClientCoro(
model.user_id, "join", [model.data.room_id]
userId, "join", [model.id]
)
}
@ -135,9 +112,9 @@ HTileDelegate {
popup: "Popups/LeaveRoomPopup.qml"
properties: ({
userId: model.user_id,
roomId: model.data.room_id,
roomName: model.data.display_name,
userId: userId,
roomId: model.id,
roomName: model.display_name,
})
}
@ -149,10 +126,27 @@ HTileDelegate {
popup: "Popups/ForgetRoomPopup.qml"
autoDestruct: false
properties: ({
userId: model.user_id,
roomId: model.data.room_id,
roomName: model.data.display_name,
userId: userId,
roomId: model.id,
roomName: model.display_name,
})
}
}
onActivated: pageLoader.showRoom(userId, model.id)
property string userId
readonly property bool joined: ! invited && ! parted
readonly property bool invited: model.inviter_id && ! parted
readonly property bool parted: model.left
readonly property ListModel eventModel:
ModelStore.get(userId, model.id, "events")
readonly property QtObject lastEvent:
eventModel.count > 0 ? eventModel.get(0) : null
Behavior on opacity { HNumberAnimation {} }
}

37
src/gui/ModelStore.qml Normal file
View File

@ -0,0 +1,37 @@
// SPDX-License-Identifier: LGPL-3.0-or-later
pragma Singleton
import QtQuick 2.12
import "PythonBridge"
QtObject {
property QtObject privates: QtObject {
readonly property var store: ({})
readonly property PythonBridge py: PythonBridge {}
readonly property Component model: Component {
ListModel {
property var modelId
function find(id) {
for (let i = 0; i < count; i++)
if (get(i).id === id) return get(i)
return null
}
}
}
}
function get(...modelId) {
if (modelId.length === 1) modelId = modelId[0]
if (! privates.store[modelId])
privates.store[modelId] =
privates.model.createObject(this, {modelId})
return privates.store[modelId]
}
}

View File

@ -3,29 +3,29 @@
import QtQuick 2.12
import QtQuick.Controls 2.12
import QtQuick.Layouts 1.12
import "../.."
import "../../Base"
HPage {
id: accountSettings
hideHeaderUnderHeight: avatarPreferredSize
headerLabel.text: qsTr("Account settings for %1").arg(
utils.coloredNameHtml(headerName, userId)
)
property int avatarPreferredSize: 256 * theme.uiScale
property string userId: ""
readonly property bool ready:
accountInfo !== "waiting" && Boolean(accountInfo.profile_updated)
accountInfo !== null && accountInfo.profile_updated > new Date(1)
readonly property var accountInfo: utils.getItem(
modelSources["Account"] || [], "user_id", userId
) || "waiting"
readonly property QtObject accountInfo:
ModelStore.get("accounts").find(userId)
property string headerName: ready ? accountInfo.display_name : userId
hideHeaderUnderHeight: avatarPreferredSize
headerLabel.text: qsTr("Account settings for %1").arg(
utils.coloredNameHtml(headerName, userId)
)
HSpacer {}

View File

@ -2,6 +2,7 @@
import QtQuick 2.12
import QtQuick.Layouts 1.12
import "../.."
import "../../Base"
HPage {
@ -10,8 +11,7 @@ HPage {
property string userId
readonly property var account:
utils.getItem(modelSources["Account"] || [], "user_id", userId)
readonly property QtObject account: ModelStore.get("accounts").find(userId)
HTabContainer {

View File

@ -2,6 +2,7 @@
import QtQuick 2.12
import QtQuick.Layouts 1.12
import "../.."
import "../../Base"
import "RoomPane"
@ -13,16 +14,11 @@ Item {
property string userId: ""
property string roomId: ""
property QtObject userInfo: ModelStore.get("accounts").find(userId)
property QtObject roomInfo: ModelStore.get(userId, "rooms").find(roomId)
property bool loadingMessages: false
property bool ready: userInfo !== "waiting" && roomInfo !== "waiting"
readonly property var userInfo:
utils.getItem(modelSources["Account"] || [], "user_id", userId) ||
"waiting"
readonly property var roomInfo: utils.getItem(
modelSources[["Room", userId]] || [], "room_id", roomId
) || "waiting"
property bool ready: Boolean(userInfo && roomInfo)
readonly property alias loader: loader
readonly property alias roomPane: roomPaneLoader.item

View File

@ -3,18 +3,28 @@
import QtQuick 2.12
import QtQuick.Layouts 1.12
import Clipboard 0.1
import "../.."
import "../../Base"
import "../../Dialogs"
Rectangle {
id: composer
color: theme.chat.composer.background
Layout.fillWidth: true
Layout.minimumHeight: theme.baseElementsHeight
Layout.preferredHeight: areaScrollView.implicitHeight
Layout.maximumHeight: pageLoader.height / 2
property string indent: " "
property var aliases: window.settings.writeAliases
property string toSend: ""
property string writingUserId: chat.userId
readonly property var writingUserInfo:
utils.getItem(modelSources["Account"] || [], "user_id", writingUserId)
property QtObject writingUserInfo:
ModelStore.get("accounts").find(writingUserId)
property bool textChangedSinceLostFocus: false
@ -40,20 +50,9 @@ Rectangle {
lineTextUntilCursor.match(/ {1,4}/g).slice(-1)[0].length :
1
function takeFocus() { areaScrollView.forceActiveFocus() }
// property var pr: lineTextUntilCursor
// onPrChanged: print(
// "y", cursorY, "x", cursorX,
// "ltuc <" + lineTextUntilCursor + ">", "dob",
// deleteCharsOnBackspace, "m", lineTextUntilCursor.match(/^ +$/))
id: composer
Layout.fillWidth: true
Layout.minimumHeight: theme.baseElementsHeight
Layout.preferredHeight: areaScrollView.implicitHeight
Layout.maximumHeight: pageLoader.height / 2
color: theme.chat.composer.background
HRowLayout {
anchors.fill: parent
@ -61,8 +60,8 @@ Rectangle {
HUserAvatar {
id: avatar
userId: writingUserId
displayName: writingUserInfo.display_name
mxc: writingUserInfo.avatar_url
displayName: writingUserInfo ? writingUserInfo.display_name : ""
mxc: writingUserInfo ? writingUserInfo.avatar_url : ""
}
HScrollableTextArea {

View File

@ -1,6 +1,7 @@
// SPDX-License-Identifier: LGPL-3.0-or-later
import QtQuick 2.12
import "../../.."
import "../../../Base"
Rectangle {
@ -25,11 +26,7 @@ Rectangle {
id: transferList
anchors.fill: parent
model: HListModel {
keyField: "uuid"
source: modelSources[["Upload", chat.roomId]] || []
}
model: ModelStore.get(chat.roomId, "uploads")
delegate: Transfer { width: transferList.width }
}
}

View File

@ -11,7 +11,7 @@ HTileDelegate {
model.invited ? theme.chat.roomPane.member.invitedOpacity : 1
image: HUserAvatar {
userId: model.user_id
userId: model.id
displayName: model.display_name
mxc: model.avatar_url
powerLevel: model.power_level
@ -19,20 +19,20 @@ HTileDelegate {
invited: model.invited
}
title.text: model.display_name || model.user_id
title.text: model.display_name || model.id
title.color:
memberDelegate.hovered ?
utils.nameColor(model.display_name || model.user_id.substring(1)) :
utils.nameColor(model.display_name || model.id.substring(1)) :
theme.chat.roomPane.member.name
subtitle.text: model.display_name ? model.user_id : ""
subtitle.text: model.display_name ? model.id : ""
subtitle.color: theme.chat.roomPane.member.subtitle
contextMenu: HMenu {
HMenuItem {
icon.name: "copy-user-id"
text: qsTr("Copy user ID")
onTriggered: Clipboard.text = model.user_id
onTriggered: Clipboard.text = model.id
}
}

View File

@ -2,6 +2,7 @@
import QtQuick 2.12
import QtQuick.Layouts 1.12
import "../../.."
import "../../../Base"
HColumnLayout {
@ -9,37 +10,33 @@ HColumnLayout {
id: memberList
clip: true
Layout.fillWidth: true
Layout.fillHeight: true
model: ModelStore.get(chat.userId, chat.roomId, "members")
// model: HSortFilterProxy {
// model: ModelStore.get(chat.userId, chat.roomId, "members")
// comparator: (a, b) =>
// // Sort by power level, then by display name or user ID (no @)
// [
// a.invited,
// b.power_level,
// (a.display_name || a.id.substring(1)).toLocaleLowerCase(),
// ] < [
// b.invited,
// a.power_level,
// (b.display_name || b.id.substring(1)).toLocaleLowerCase(),
// ]
readonly property var originSource:
modelSources[["Member", chat.userId, chat.roomId]] || []
onOriginSourceChanged: filterLimiter.restart()
function filterSource() {
model.source =
utils.filterModelSource(originSource, filterField.text)
}
model: HListModel {
keyField: "user_id"
source: memberList.originSource
}
// filter: (item, index) => utils.filterMatchesAny(
// filterField.text, item.display_name, item.id,
// )
// }
delegate: MemberDelegate {
width: memberList.width
}
Timer {
id: filterLimiter
interval: 16
onTriggered: memberList.filterSource()
}
Layout.fillWidth: true
Layout.fillHeight: true
}
HRowLayout {
@ -56,7 +53,7 @@ HColumnLayout {
bordered: false
opacity: width >= 16 * theme.uiScale ? 1 : 0
onTextChanged: filterLimiter.restart()
onTextChanged: memberList.model.reFilter()
Layout.fillWidth: true
Layout.fillHeight: true

View File

@ -172,7 +172,7 @@ HRowLayout {
HRepeater {
id: linksRepeater
model: eventDelegate.currentModel.links
model: JSON.parse(eventDelegate.currentModel.links)
EventMediaLoader {
singleMediaInfo: eventDelegate.currentModel

View File

@ -3,6 +3,7 @@
import QtQuick 2.12
import QtQuick.Layouts 1.12
import Clipboard 0.1
import "../../.."
import "../../../Base"
HColumnLayout {
@ -65,13 +66,8 @@ HColumnLayout {
function json() {
return JSON.stringify(
{
"model": utils.getItem(
modelSources[[
"Event", chat.userId, chat.roomId
]],
"client_id",
model.client_id
),
"model": ModelStore.get(chat.userId, chat.roomId, "events")
.get(model.id),
"source": py.getattr(model.source, "__dict__"),
},
null, 4)

View File

@ -1,6 +1,7 @@
// SPDX-License-Identifier: LGPL-3.0-or-later
import QtQuick 2.12
import "../../.."
import "../../../Base"
Rectangle {
@ -157,12 +158,12 @@ Rectangle {
}
model: HListModel {
keyField: "client_id"
source: modelSources[[
"Event", chat.userId, chat.roomId
]] || []
}
model: ModelStore.get(chat.userId, chat.roomId, "events")
// model: HSortFilterProxy {
// model: ModelStore.get(chat.userId, chat.roomId, "events")
// comparator: "date"
// descendingSort: true
// }
delegate: EventDelegate {}
}

View File

@ -32,7 +32,7 @@ Rectangle {
textFormat: Text.StyledText
elide: Text.ElideRight
text: {
let tm = chat.roomInfo.typing_members
const tm = JSON.parse(chat.roomInfo.typing_members)
if (tm.length === 0) return ""
if (tm.length === 1) return qsTr("%1 is typing...").arg(tm[0])

View File

@ -21,7 +21,9 @@ BoxPopup {
window.uiState.pageProperties.userId === userId &&
window.uiState.pageProperties.roomId === roomId)
{
pageLoader.showPage("Default")
window.mainUI.pageLoader.showPrevious() ||
window.mainUI.pageLoader.showPage("Default")
Qt.callLater(popup.destroy)
}
})

View File

@ -1,6 +1,7 @@
// SPDX-License-Identifier: LGPL-3.0-or-later
import QtQuick 2.12
import ".."
BoxPopup {
id: popup
@ -28,7 +29,7 @@ BoxPopup {
ok: button => {
utils.makeObject(
"Dialogs/ExportKeys.qml",
mainUI,
window.mainUI,
{ userId },
obj => {
button.loading = Qt.binding(() => obj.exporting)
@ -44,10 +45,9 @@ BoxPopup {
okClicked = true
popup.ok()
if ((modelSources["Account"] || []).length < 2) {
pageLoader.showPage("AddAccount/AddAccount")
} else if (window.uiState.pageProperties.userId === userId) {
pageLoader.showPage("Default")
if (ModelStore.get("accounts").count < 2 ||
window.uiState.pageProperties.userId === userId) {
window.mainUI.pageLoader.showPage("AddAccount/AddAccount")
}
py.callCoro("logout_client", [userId])

View File

@ -1,6 +1,8 @@
// SPDX-License-Identifier: LGPL-3.0-or-later
import QtQuick 2.12
import ".."
import "../.."
QtObject {
function onExitRequested(exitCode) {
@ -16,10 +18,10 @@ QtObject {
function onCoroutineDone(uuid, result, error, traceback) {
let onSuccess = py.privates.pendingCoroutines[uuid].onSuccess
let onError = py.privates.pendingCoroutines[uuid].onError
let onSuccess = Globals.pendingCoroutines[uuid].onSuccess
let onError = Globals.pendingCoroutines[uuid].onError
delete py.privates.pendingCoroutines[uuid]
delete Globals.pendingCoroutines[uuid]
if (error) {
const type = py.getattr(py.getattr(error, "__class__"), "__name__")
@ -74,14 +76,29 @@ QtObject {
}
function onModelUpdated(syncId, data, serializedSyncId) {
if (serializedSyncId === "Account" || serializedSyncId[0] === "Room") {
py.callCoro("get_flat_mainpane_data", [], data => {
window.mainPaneModelSource = data
})
}
function onModelItemInserted(syncId, index, item) {
// print("insert", syncId, index, item)
ModelStore.get(syncId).insert(index, item)
}
window.modelSources[serializedSyncId] = data
window.modelSourcesChanged()
function onModelItemFieldChanged(syncId, oldIndex, newIndex, field, value){
// print("change", syncId, oldIndex, newIndex, field, value)
const model = ModelStore.get(syncId)
model.setProperty(oldIndex, field, value)
if (oldIndex !== newIndex) model.move(oldIndex, newIndex, 1)
}
function onModelItemDeleted(syncId, index) {
// print("del", syncId, index)
ModelStore.get(syncId).remove(index)
}
function onModelCleared(syncId) {
// print("clear", syncId)
ModelStore.get(syncId).clear()
}
}

View File

@ -0,0 +1,8 @@
// SPDX-License-Identifier: LGPL-3.0-or-later
pragma Singleton
import QtQuick 2.12
QtObject {
readonly property var pendingCoroutines: ({})
}

View File

@ -0,0 +1 @@
singleton Globals 0.1 Globals.qml

View File

@ -3,41 +3,16 @@
import QtQuick 2.12
import io.thp.pyotherside 1.5
import CppUtils 0.1
import "Privates"
Python {
id: py
Component.onCompleted: {
for (var func in privates.eventHandlers) {
if (! privates.eventHandlers.hasOwnProperty(func)) continue
setHandler(func.replace(/^on/, ""), privates.eventHandlers[func])
}
addImportPath("src")
addImportPath("qrc:/src")
importNames("backend.qml_bridge", ["BRIDGE"], () => {
loadSettings(() => {
callCoro("saved_accounts.any_saved", [], any => {
if (any) { py.callCoro("load_saved_accounts", []) }
py.startupAnyAccountsSaved = any
py.ready = true
})
})
})
}
property bool ready: false
property bool startupAnyAccountsSaved: false
readonly property QtObject privates: QtObject {
readonly property var pendingCoroutines: ({})
readonly property EventHandlers eventHandlers: EventHandlers {}
function makeFuture(callback) {
return Qt.createComponent("Future.qml")
.createObject(py, {bridge: py})
.createObject(py, { bridge: py })
}
}
@ -47,15 +22,10 @@ Python {
}
function callSync(name, args=[]) {
return call_sync("BRIDGE.backend." + name, args)
}
function callCoro(name, args=[], onSuccess=null, onError=null) {
let uuid = name + "." + CppUtils.uuid()
privates.pendingCoroutines[uuid] = {onSuccess, onError}
Globals.pendingCoroutines[uuid] = {onSuccess, onError}
let future = privates.makeFuture()
@ -75,7 +45,7 @@ Python {
callCoro("get_client", [accountId], () => {
let uuid = accountId + "." + name + "." + CppUtils.uuid()
privates.pendingCoroutines[uuid] = {onSuccess, onError}
Globals.pendingCoroutines[uuid] = {onSuccess, onError}
let call_args = [accountId, name, uuid, args]

View File

@ -0,0 +1,33 @@
// SPDX-License-Identifier: LGPL-3.0-or-later
import QtQuick 2.12
import "Privates"
PythonBridge {
Component.onCompleted: {
for (var func in eventHandlers) {
if (! eventHandlers.hasOwnProperty(func)) continue
setHandler(func.replace(/^on/, ""), eventHandlers[func])
}
addImportPath("src")
addImportPath("qrc:/src")
importNames("backend.qml_bridge", ["BRIDGE"], () => {
loadSettings(() => {
callCoro("saved_accounts.any_saved", [], any => {
if (any) { callCoro("load_saved_accounts", []) }
startupAnyAccountsSaved = any
ready = true
})
})
})
}
property bool ready: false
property bool startupAnyAccountsSaved: false
readonly property EventHandlers eventHandlers: EventHandlers {}
}

View File

@ -16,8 +16,7 @@ Item {
property bool accountsPresent:
(modelSources["Account"] || []).length > 0 ||
py.startupAnyAccountsSaved
ModelStore.get("accounts").count > 0 || py.startupAnyAccountsSaved
readonly property alias shortcuts: shortcuts
readonly property alias mainPane: mainPane

View File

@ -135,30 +135,29 @@ QtObject {
function processedEventText(ev) {
if (ev.event_type === "RoomMessageEmote")
return coloredNameHtml(ev.sender_name, ev.sender_id) + " " +
ev.content
const type = ev.event_type
const unknownMsg = type === "RoomMessageUnknown"
const sender = coloredNameHtml(ev.sender_name, ev.sender_id)
let unknown = ev.event_type === "RoomMessageUnknown"
if (type === "RoomMessageEmote")
return qsTr("%1 %2").arg(sender).arg(ev.content)
if (ev.event_type.startsWith("RoomMessage") && ! unknown)
if (type.startsWith("RoomMessage") && ! unknownMsg)
return ev.content
if (ev.event_type.startsWith("RoomEncrypted")) return ev.content
if (type.startsWith("RoomEncrypted"))
return ev.content
let text = qsTr(ev.content).arg(
coloredNameHtml(ev.sender_name, ev.sender_id)
)
if (ev.content.includes("%2")) {
const target = coloredNameHtml(ev.target_name, ev.target_id)
return qsTr(ev.content).arg(sender).arg(target)
}
if (text.includes("%2") && ev.target_id)
text = text.arg(coloredNameHtml(ev.target_name, ev.target_id))
return text
return qsTr(ev.content).arg(sender)
}
function filterMatches(filter, text) {
let filter_lower = filter.toLowerCase()
const filter_lower = filter.toLowerCase()
if (filter_lower === filter) {
// Consider case only if filter isn't all lowercase (smart case)
@ -175,17 +174,11 @@ QtObject {
}
function filterModelSource(source, filter_text, property="filter_string") {
if (! filter_text) return source
let results = []
for (let i = 0; i < source.length; i++) {
if (filterMatches(filter_text, source[i][property])) {
results.push(source[i])
}
function filterMatchesAny(filter, ...texts) {
for (let text of texts) {
if (filterMatches(filter, text)) return true
}
return results
return false
}
@ -257,14 +250,6 @@ QtObject {
}
function getItem(array, mainKey, value) {
for (let i = 0; i < array.length; i++) {
if (array[i][mainKey] === value) { return array[i] }
}
return undefined
}
function flickPages(flickable, pages) {
// Adapt velocity and deceleration for the number of pages to flick.
// If this is a repeated flicking, flick faster than a single flick.

View File

@ -28,7 +28,6 @@ ApplicationWindow {
// NOTE: For JS object variables, the corresponding method to notify
// key/value changes must be called manually, e.g. settingsChanged().
property var modelSources: ({})
property var mainPaneModelSource: []
property var mainUI: null
@ -46,8 +45,6 @@ ApplicationWindow {
property var hideErrorTypes: new Set()
readonly property alias py: py
function saveState(obj) {
if (! obj.saveName || ! obj.saveProperties ||
@ -75,7 +72,7 @@ ApplicationWindow {
}
PythonBridge { id: py }
PythonRootBridge { id: py }
Utils { id: utils }

1
src/gui/qmldir Normal file
View File

@ -0,0 +1 @@
singleton ModelStore 0.1 ModelStore.qml

1
submodules/gel Submodule

@ -0,0 +1 @@
Subproject commit 0e796aacc16388a164bab0bb0ce9dabc885ed7fa