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"] [submodule "submodules/hsluv-c"]
path = submodules/hsluv-c path = submodules/hsluv-c
url = https://github.com/hsluv/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 # 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 ## Media
- nio ClientTimeout - nio ClientTimeout
- no thumb if bigger than original
- upload delay at the end? - upload delay at the end?
- Handle upload file size limit - Handle upload file size limit
- Handle set avatar upload errors - Handle set avatar upload errors

View File

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

View File

@ -5,16 +5,16 @@ import logging as log
import sys import sys
import traceback import traceback
from pathlib import Path from pathlib import Path
from typing import Any, DefaultDict, Dict, List, Optional, Tuple from typing import Any, DefaultDict, Dict, List, Optional
from appdirs import AppDirs
import nio import nio
from appdirs import AppDirs
from . import __app_name__ from . import __app_name__
from .errors import MatrixError from .errors import MatrixError
from .matrix_client import MatrixClient 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 from .models.model_store import ModelStore
# Logging configuration # Logging configuration
@ -116,8 +116,8 @@ class Backend:
await client.close() await client.close()
raise raise
self.clients[client.user_id] = client self.clients[client.user_id] = client
self.models[Account][client.user_id] = Account(client.user_id) self.models["accounts"][client.user_id] = Account(client.user_id)
return client.user_id return client.user_id
@ -133,13 +133,13 @@ class Backend:
user=user_id, homeserver=homeserver, device_id=device_id, user=user_id, homeserver=homeserver, device_id=device_id,
) )
self.clients[user_id] = client self.clients[user_id] = client
self.models[Account][user_id] = Account(user_id) self.models["accounts"][user_id] = Account(user_id)
await client.resume(user_id=user_id, token=token, device_id=device_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.""" """Call `resume_client` for all saved accounts in user config."""
async def resume(user_id: str, info: Dict[str, str]) -> str: async def resume(user_id: str, info: Dict[str, str]) -> str:
@ -162,7 +162,7 @@ class Backend:
client = self.clients.pop(user_id, None) client = self.clients.pop(user_id, None)
if client: if client:
self.models[Account].pop(user_id, None) self.models["accounts"].pop(user_id, None)
await client.logout() await client.logout()
await self.saved_accounts.delete(user_id) await self.saved_accounts.delete(user_id)
@ -256,25 +256,24 @@ class Backend:
return (settings, ui_state, history, theme) return (settings, ui_state, history, theme)
async def get_flat_mainpane_data(self) -> List[Dict[str, Any]]: async def await_model_item(
"""Return a flat list of accounts and their joined rooms for QML.""" 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()): failures = 0
data.append({
"type": "Account",
"id": account.user_id,
"user_id": account.user_id,
"data": account.serialized,
})
for room in sorted(self.models[Room, account.user_id].values()): while True:
data.append({ try:
"type": "Room", return self.models[model_id][item_id].serialized
"id": "/".join((account.user_id, room.room_id)), except KeyError:
"user_id": account.user_id, if failures and failures % 300 == 0:
"data": room.serialized, 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 .html_markdown import HTML_PROCESSOR as HTML
from .models.items import ( from .models.items import (
Account, Event, Member, Room, TypeSpecifier, Upload, UploadStatus, Event, Member, Room, TypeSpecifier, Upload, UploadStatus,
) )
from .models.model_store import ModelStore from .models.model_store import ModelStore
from .pyotherside_events import AlertRequested from .pyotherside_events import AlertRequested
@ -211,7 +211,7 @@ class MatrixClient(nio.AsyncClient):
return return
resp = future.result() resp = future.result()
account = self.models[Account][self.user_id] account = self.models["accounts"][self.user_id]
account.profile_updated = datetime.now() account.profile_updated = datetime.now()
account.display_name = resp.displayname or "" account.display_name = resp.displayname or ""
account.avatar_url = resp.avatar_url 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) await self._send_file(item_uuid, room_id, path)
except (nio.TransferCancelledError, asyncio.CancelledError): except (nio.TransferCancelledError, asyncio.CancelledError):
log.info("Deleting item for cancelled upload %s", item_uuid) 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( async def _send_file(
@ -310,7 +310,7 @@ class MatrixClient(nio.AsyncClient):
task = asyncio.Task.current_task() task = asyncio.Task.current_task()
monitor = nio.TransferMonitor(size) monitor = nio.TransferMonitor(size)
upload_item = Upload(item_uuid, task, monitor, path, total_size=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: def on_transferred(transferred: int) -> None:
upload_item.uploaded = transferred upload_item.uploaded = transferred
@ -452,7 +452,7 @@ class MatrixClient(nio.AsyncClient):
content["msgtype"] = "m.file" content["msgtype"] = "m.file"
content["filename"] = path.name 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( await self._local_echo(
room_id, room_id,
@ -499,12 +499,12 @@ class MatrixClient(nio.AsyncClient):
replace the local one we registered. 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( event = Event(
source = None, id = f"echo-{transaction_id}",
client_id = f"echo-{transaction_id}",
event_id = "", event_id = "",
source = None,
date = datetime.now(), date = datetime.now(),
sender_id = self.user_id, sender_id = self.user_id,
sender_name = our_info.display_name, sender_name = our_info.display_name,
@ -514,13 +514,10 @@ class MatrixClient(nio.AsyncClient):
**event_fields, **event_fields,
) )
for user_id in self.models[Account]: for user_id in self.models["accounts"]:
if user_id in self.models[Member, self.user_id, room_id]: if user_id in self.models[self.user_id, room_id, "members"]:
key = f"echo-{transaction_id}" key = f"echo-{transaction_id}"
self.models[Event, user_id, room_id][key] = event self.models[user_id, room_id, "events"][key] = event
if user_id == self.user_id:
self.models[Event, user_id, room_id].sync_now()
await self.set_room_last_event(room_id, 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: async def load_rooms_without_visible_events(self) -> None:
"""Call `_load_room_without_visible_events` for all joined rooms.""" """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( asyncio.ensure_future(
self._load_room_without_visible_events(room_id), 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. 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 more = True
while self.skipped_events[room_id] and not events and more: 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. 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) 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( async def room_mass_invite(
@ -843,7 +845,8 @@ class MatrixClient(nio.AsyncClient):
for sync_id, model in self.models.items(): for sync_id, model in self.models.items():
if not (isinstance(sync_id, tuple) and 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 continue
_, _, room_id = sync_id _, _, room_id = sync_id
@ -873,10 +876,9 @@ class MatrixClient(nio.AsyncClient):
""" """
self.cleared_events_rooms.add(room_id) 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: if model:
model.clear() model.clear()
model.sync_now()
# Functions to register data into models # 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. The `last_event` is notably displayed in the UI room subtitles.
""" """
model = self.models[Room, self.user_id] room = self.models[self.user_id, "rooms"][room_id]
room = model[room_id]
if room.last_event is None: if item.date > room.last_event_date:
room.last_event = item room.last_event_date = item.date
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()
async def register_nio_room( async def register_nio_room(
@ -939,18 +914,21 @@ class MatrixClient(nio.AsyncClient):
"""Register a `nio.MatrixRoom` as a `Room` object in our model.""" """Register a `nio.MatrixRoom` as a `Room` object in our model."""
# Add room # 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 "" inviter = getattr(room, "inviter", "") or ""
levels = room.power_levels levels = room.power_levels
can_send_state = partial(levels.can_user_send_state, self.user_id) can_send_state = partial(levels.can_user_send_state, self.user_id)
can_send_msg = partial(levels.can_user_send_message, 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( try:
room_id = room.room_id, 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 "", given_name = room.name or "",
display_name = room.display_name or "", display_name = room.display_name or "",
avatar_url = room.gen_avatar_url or "", avatar_url = room.gen_avatar_url or "",
@ -962,6 +940,8 @@ class MatrixClient(nio.AsyncClient):
(room.avatar_url(inviter) or "") if inviter else "", (room.avatar_url(inviter) or "") if inviter else "",
left = left, left = left,
typing_members = typing_members,
encrypted = room.encrypted, encrypted = room.encrypted,
invite_required = room.join_rule == "invite", invite_required = room.join_rule == "invite",
guests_allowed = room.guest_access == "can_join", 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_join_rules = can_send_state("m.room.join_rules"),
can_set_guest_access = can_send_state("m.room.guest_access"), 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 # List members that left the room, then remove them from our model
left_the_room = [ left_the_room = [
user_id 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 if user_id not in room.users
] ]
for user_id in left_the_room: 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 # Add the room members to the added room
new_dict = { new_dict = {
user_id: Member( user_id: Member(
user_id = user_id, id = user_id,
display_name = room.user_name(user_id) # disambiguated display_name = room.user_name(user_id) # disambiguated
if member.display_name else "", if member.display_name else "",
avatar_url = member.avatar_url or "", avatar_url = member.avatar_url or "",
@ -1000,7 +981,7 @@ class MatrixClient(nio.AsyncClient):
invited = member.invited, invited = member.invited,
) for user_id, member in room.users.items() ) 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( async def get_member_name_avatar(
@ -1013,7 +994,7 @@ class MatrixClient(nio.AsyncClient):
""" """
try: 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 except KeyError: # e.g. user is not anymore in the room
try: try:
@ -1030,11 +1011,7 @@ class MatrixClient(nio.AsyncClient):
async def register_nio_event( async def register_nio_event(
self, room: nio.MatrixRoom, ev: nio.Event, **fields, self, room: nio.MatrixRoom, ev: nio.Event, **fields,
) -> None: ) -> None:
"""Register a `nio.Event` as a `Event` object in our model. """Register a `nio.Event` as a `Event` object in our model."""
`MatrixClient.register_nio_room` is called for the passed `room`
if neccessary before.
"""
await self.register_nio_room(room) await self.register_nio_room(room)
@ -1049,9 +1026,9 @@ class MatrixClient(nio.AsyncClient):
# Create Event ModelItem # Create Event ModelItem
item = Event( item = Event(
source = ev, id = ev.event_id,
client_id = ev.event_id,
event_id = ev.event_id, event_id = ev.event_id,
source = ev,
date = datetime.fromtimestamp(ev.server_timestamp / 1000), date = datetime.fromtimestamp(ev.server_timestamp / 1000),
sender_id = ev.sender, sender_id = ev.sender,
sender_name = sender_name, sender_name = sender_name,
@ -1069,14 +1046,11 @@ class MatrixClient(nio.AsyncClient):
local_sender = ev.sender in self.backend.clients local_sender = ev.sender in self.backend.clients
if local_sender and tx_id: 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): if not local_sender and not await self.event_is_past(ev):
AlertRequested() 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) 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.""" """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 SyncId = Union[str, Tuple[str]]
# last one: Tuple[Union[Type[ModelItem], Tuple[Type[ModelItem]]], str...]
SyncId = Union[Type[ModelItem], Tuple[Type[ModelItem]], tuple]

View File

@ -11,31 +11,39 @@ from typing import Any, Dict, List, Optional, Tuple, Type, Union
from uuid import UUID from uuid import UUID
import lxml # nosec import lxml # nosec
import nio import nio
from ..html_markdown import HTML_PROCESSOR from ..html_markdown import HTML_PROCESSOR
from ..utils import AutoStrEnum, auto from ..utils import AutoStrEnum, auto
from .model_item import ModelItem from .model_item import ModelItem
ZeroDate = datetime.fromtimestamp(0)
OptionalExceptionType = Union[Type[None], Type[Exception]] 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 @dataclass
class Account(ModelItem): class Account(ModelItem):
"""A logged in matrix account.""" """A logged in matrix account."""
user_id: str = field() id: str = field()
display_name: str = "" display_name: str = ""
avatar_url: str = "" avatar_url: str = ""
first_sync_done: bool = False first_sync_done: bool = False
profile_updated: Optional[datetime] = None profile_updated: datetime = ZeroDate
def __lt__(self, other: "Account") -> bool: def __lt__(self, other: "Account") -> bool:
"""Sort by display name or user ID.""" """Sort by display name or user ID."""
name = self.display_name or self.user_id[1:] name = self.display_name or self.id[1:]
other_name = other.display_name or other.user_id[1:] other_name = other.display_name or other.id[1:]
return name < other_name return name.lower() < other_name.lower()
@property @property
def filter_string(self) -> str: def filter_string(self) -> str:
@ -47,18 +55,21 @@ class Account(ModelItem):
class Room(ModelItem): class Room(ModelItem):
"""A matrix room we are invited to, are or were member of.""" """A matrix room we are invited to, are or were member of."""
room_id: str = field() id: str = field()
given_name: str = "" given_name: str = ""
display_name: str = "" display_name: str = ""
avatar_url: str = "" main_alias: str = ""
plain_topic: str = "" avatar_url: str = ""
topic: str = "" plain_topic: str = ""
inviter_id: str = "" topic: str = ""
inviter_name: str = "" inviter_id: str = ""
inviter_avatar: str = "" inviter_name: str = ""
left: bool = False inviter_avatar: str = ""
left: bool = False
typing_members: List[str] = field(default_factory=list) typing_members: List[str] = field(default_factory=list)
federated: bool = True
encrypted: bool = False encrypted: bool = False
invite_required: bool = True invite_required: bool = True
guests_allowed: bool = True guests_allowed: bool = True
@ -72,7 +83,7 @@ class Room(ModelItem):
can_set_join_rules: bool = False can_set_join_rules: bool = False
can_set_guest_access: 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: def __lt__(self, other: "Room") -> bool:
"""Sort by join state, then descending last event date, then name. """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. # Left rooms may still have an inviter_id, so check left first.
return ( return (
self.left, self.left,
other.inviter_id, other.inviter_id,
other.last_event_date,
other.last_event.date if other.last_event else (self.display_name or self.id).lower(),
datetime.fromtimestamp(0),
self.display_name.lower() or self.room_id,
) < ( ) < (
other.left, other.left,
self.inviter_id, self.inviter_id,
self.last_event_date,
self.last_event.date if self.last_event else (other.display_name or other.id).lower(),
datetime.fromtimestamp(0),
other.display_name.lower() or other.room_id,
) )
@property @property
def filter_string(self) -> str: def filter_string(self) -> str:
"""Filter based on room display name, topic, and last event content.""" """Filter based on room display name, topic, and last event content."""
return " ".join(( return " ".join((self.display_name, self.topic))
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
@dataclass @dataclass
class Member(ModelItem): class Member(ModelItem):
"""A member in a matrix room.""" """A member in a matrix room."""
user_id: str = field() id: str = field()
display_name: str = "" display_name: str = ""
avatar_url: str = "" avatar_url: str = ""
typing: bool = False typing: bool = False
power_level: int = 0 power_level: int = 0
invited: bool = False invited: bool = False
profile_updated: datetime = ZeroDate
def __lt__(self, other: "Member") -> bool: def __lt__(self, other: "Member") -> bool:
"""Sort by power level, then by display name/user ID.""" """Sort by power level, then by display name/user ID."""
name = (self.display_name or self.user_id[1:]).lower() name = self.display_name or self.id[1:]
other_name = (other.display_name or other.user_id[1:]).lower() other_name = other.display_name or other.id[1:]
return ( 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): class Upload(ModelItem):
"""Represent a running or failed file upload operation.""" """Represent a running or failed file upload operation."""
uuid: UUID = field() id: UUID = field()
task: asyncio.Task = field() task: asyncio.Task = field()
monitor: nio.TransferMonitor = field() monitor: nio.TransferMonitor = field()
filepath: Path = field() filepath: Path = field()
total_size: int = 0 total_size: int = 0
uploaded: int = 0 uploaded: int = 0
speed: float = 0 speed: float = 0
time_left: Optional[timedelta] = None time_left: timedelta = timedelta(0)
status: UploadStatus = UploadStatus.Uploading status: UploadStatus = UploadStatus.Uploading
error: OptionalExceptionType = type(None) error: OptionalExceptionType = type(None)
@ -191,21 +179,13 @@ class Upload(ModelItem):
return self.start_date > other.start_date 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 @dataclass
class Event(ModelItem): class Event(ModelItem):
"""A matrix state event or message.""" """A matrix state event or message."""
source: Optional[nio.Event] = field() id: str = field()
client_id: str = field()
event_id: str = field() event_id: str = field()
source: Optional[nio.Event] = field()
date: datetime = field() date: datetime = field()
sender_id: str = field() sender_id: str = field()
sender_name: str = field() sender_name: str = field()
@ -213,8 +193,9 @@ class Event(ModelItem):
content: str = "" content: str = ""
inline_content: str = "" inline_content: str = ""
reason: str = ""
type_specifier: TypeSpecifier = TypeSpecifier.none type_specifier: TypeSpecifier = TypeSpecifier.Unset
target_id: str = "" target_id: str = ""
target_name: str = "" target_name: str = ""
@ -271,12 +252,19 @@ class Event(ModelItem):
return urls return urls
@property
def serialized(self) -> Dict[str, Any]:
dct = super().serialized
del dct["source"]
del dct["local_event_type"]
return dct
@dataclass @dataclass
class Device(ModelItem): class Device(ModelItem):
"""A matrix user's device. This class is currently unused.""" """A matrix user's device. This class is currently unused."""
device_id: str = field() id: str = field()
ed25519_key: str = field() ed25519_key: str = field()
trusted: bool = False trusted: bool = False
blacklisted: bool = False blacklisted: bool = False

View File

@ -1,12 +1,12 @@
# SPDX-License-Identifier: LGPL-3.0-or-later # SPDX-License-Identifier: LGPL-3.0-or-later
import logging as log from threading import Lock
import time
from threading import Lock, Thread
from typing import Any, Dict, Iterator, List, MutableMapping from typing import Any, Dict, Iterator, List, MutableMapping
from ..pyotherside_events import (
ModelCleared, ModelItemDeleted, ModelItemInserted,
)
from . import SyncId from . import SyncId
from ..pyotherside_events import ModelUpdated
from .model_item import ModelItem from .model_item import ModelItem
@ -30,13 +30,10 @@ class Model(MutableMapping):
""" """
def __init__(self, sync_id: SyncId) -> None: def __init__(self, sync_id: SyncId) -> None:
self.sync_id: SyncId = sync_id self.sync_id: SyncId = sync_id
self._data: Dict[Any, ModelItem] = {} self._data: Dict[Any, ModelItem] = {}
self._sorted_data: List[ModelItem] = []
self._changed: bool = False self._write_lock: Lock = Lock()
self._sync_lock: Lock = Lock()
self._sync_thread: Thread = Thread(target=self._sync_loop, daemon=True)
self._sync_thread.start()
def __repr__(self) -> str: def __repr__(self) -> str:
@ -47,27 +44,14 @@ class Model(MutableMapping):
except ImportError: except ImportError:
from pprint import pformat # type: ignore 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)" % ( 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: def __str__(self) -> str:
"""Provide a short "<sync_id>: <num> items" representation.""" """Provide a short "<sync_id>: <num> items" representation."""
return f"{self.sync_id}: {len(self)} items"
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"
def __getitem__(self, key): def __getitem__(self, key):
@ -81,37 +65,36 @@ class Model(MutableMapping):
updated with the passed `ModelItem`'s fields. updated with the passed `ModelItem`'s fields.
In other cases, the item is simply added to the model. In other cases, the item is simply added to the model.
This also sets the `ModelItem.parent_model` hidden attribute on the This also sets the `ModelItem.parent_model` hidden attributes on
passed item. the passed item.
""" """
new = value with self._write_lock:
existing = self._data.get(key)
new = value
if key in self: if existing:
existing = dict(self[key].serialized) # copy to not alter with pop for field in new.__dataclass_fields__: # type: ignore
merged = {**existing, **value.serialized} # The same shared item is in _sorted_data, no need to find
# and modify it explicitely.
existing.pop("parent_model", None) setattr(existing, field, getattr(new, field))
merged.pop("parent_model", None)
if merged == existing:
return return
merged_init_kwargs = {**vars(self[key]), **vars(value)} new.parent_model = self
merged_init_kwargs.pop("parent_model", None)
new = type(value)(**merged_init_kwargs)
new.parent_model = self
with self._sync_lock:
self._data[key] = new 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: def __delitem__(self, key) -> None:
with self._sync_lock: with self._write_lock:
del self._data[key] item = self._data.pop(key)
self._changed = True index = self._sorted_data.index(item)
del self._sorted_data[index]
ModelItemDeleted(self.sync_id, index)
def __iter__(self) -> Iterator: def __iter__(self) -> Iterator:
@ -122,31 +105,11 @@ class Model(MutableMapping):
return len(self._data) 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: def __lt__(self, other: "Model") -> bool:
"""Sort `Model` objects lexically by `sync_id`.""" """Sort `Model` objects lexically by `sync_id`."""
return str(self.sync_id) < str(other.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 typing import Any, Dict, Optional
from ..utils import serialize_value_for_qml
class ModelItem: class ModelItem:
"""Base class for items stored inside a `Model`. """Base class for items stored inside a `Model`.
@ -28,11 +26,23 @@ class ModelItem:
def __setattr__(self, name: str, value) -> None: def __setattr__(self, name: str, value) -> None:
"""If this item is in a `Model`, alert it of attribute changes.""" """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) super().__setattr__(name, value)
if name != "parent_model" and self.parent_model is not None: old_index = self.parent_model._sorted_data.index(self)
with self.parent_model._sync_lock: self.parent_model._sorted_data.sort()
self.parent_model._changed = True 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: def __delattr__(self, name: str) -> None:
@ -43,8 +53,10 @@ class ModelItem:
def serialized(self) -> Dict[str, Any]: def serialized(self) -> Dict[str, Any]:
"""Return this item as a dict ready to be passed to QML.""" """Return this item as a dict ready to be passed to QML."""
from ..utils import serialize_value_for_qml
return { 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) for name in dir(self)
if not ( if not (
name.startswith("_") or name in ("parent_model", "serialized") name.startswith("_") or name in ("parent_model", "serialized")

View File

@ -14,7 +14,7 @@ import nio
from . import utils from . import utils
from .html_markdown import HTML_PROCESSOR from .html_markdown import HTML_PROCESSOR
from .matrix_client import MatrixClient from .matrix_client import MatrixClient
from .models.items import Account, Room, TypeSpecifier from .models.items import TypeSpecifier
@dataclass @dataclass
@ -63,7 +63,6 @@ class NioCallbacks:
# TODO: handle in nio, these are rooms that were left before # TODO: handle in nio, these are rooms that were left before
# starting the client. # starting the client.
if room_id not in self.client.all_rooms: if room_id not in self.client.all_rooms:
log.warning("Left room not in MatrixClient.rooms: %r", room_id)
continue continue
# TODO: handle left events in nio async client # TODO: handle left events in nio async client
@ -85,7 +84,7 @@ class NioCallbacks:
self.client.first_sync_done.set() self.client.first_sync_done.set()
self.client.first_sync_date = datetime.now() 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 account.first_sync_done = True
@ -207,7 +206,7 @@ class NioCallbacks:
prev_membership = ev.prev_membership prev_membership = ev.prev_membership
ev_date = datetime.fromtimestamp(ev.server_timestamp / 1000) ev_date = datetime.fromtimestamp(ev.server_timestamp / 1000)
member_change = TypeSpecifier.membership_change member_change = TypeSpecifier.MembershipChange
# Membership changes # Membership changes
if not prev or membership != prev_membership: if not prev or membership != prev_membership:
@ -263,10 +262,9 @@ class NioCallbacks:
if changed: if changed:
# Update our account profile if the event is newer than last update # Update our account profile if the event is newer than last update
if ev.state_key == self.client.user_id: if ev.state_key == self.client.user_id:
account = self.client.models[Account][self.client.user_id] account = self.client.models["accounts"][self.client.user_id]
updated = account.profile_updated
if not updated or updated < ev_date: if account.profile_updated < ev_date:
account.profile_updated = ev_date account.profile_updated = ev_date
account.display_name = now["displayname"] or "" account.display_name = now["displayname"] or ""
account.avatar_url = now["avatar_url"] or "" account.avatar_url = now["avatar_url"] or ""
@ -276,7 +274,7 @@ class NioCallbacks:
return None return None
return ( return (
TypeSpecifier.profile_change, TypeSpecifier.ProfileChange,
"%1 changed their {}".format(" and ".join(changed)), "%1 changed their {}".format(" and ".join(changed)),
) )
@ -383,10 +381,11 @@ class NioCallbacks:
if not self.client.first_sync_done.is_set(): if not self.client.first_sync_done.is_set():
return return
if room.room_id not in self.client.models[Room, self.client.user_id]: await self.client.register_nio_room(room)
return
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_item.typing_members = sorted(
room.user_name(user_id) for user_id in ev.users 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 # SPDX-License-Identifier: LGPL-3.0-or-later
from abc import ABC
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Union from typing import Any, Optional
import pyotherside import pyotherside
from .models import SyncId from .models import SyncId
from .models.model_item import ModelItem
from .utils import serialize_value_for_qml from .utils import serialize_value_for_qml
@ -13,11 +15,13 @@ from .utils import serialize_value_for_qml
class PyOtherSideEvent: class PyOtherSideEvent:
"""Event that will be sent on instanciation to QML by PyOtherSide.""" """Event that will be sent on instanciation to QML by PyOtherSide."""
json_lists = False
def __post_init__(self) -> None: def __post_init__(self) -> None:
# CPython 3.6 or any Python implemention >= 3.7 is required for correct # CPython 3.6 or any Python implemention >= 3.7 is required for correct
# __dataclass_fields__ dict order. # __dataclass_fields__ dict order.
args = [ 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 for field in self.__dataclass_fields__ # type: ignore
] ]
pyotherside.send(type(self).__name__, *args) pyotherside.send(type(self).__name__, *args)
@ -59,20 +63,32 @@ class LoopException(PyOtherSideEvent):
@dataclass @dataclass
class ModelUpdated(PyOtherSideEvent): class ModelEvent(ABC, PyOtherSideEvent):
"""Indicate that a backend `Model`'s data changed.""" 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 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 p = print # pdb's `p` doesn't print a class's __str__ # noqa
try: try:
from pprintpp import pprint as pp # noqa from pprintpp import pprint as pp # noqa

View File

@ -6,6 +6,7 @@ import collections
import html import html
import inspect import inspect
import io import io
import json
import xml.etree.cElementTree as xml_etree # FIXME: bandit warning import xml.etree.cElementTree as xml_etree # FIXME: bandit warning
from datetime import timedelta from datetime import timedelta
from enum import Enum from enum import Enum
@ -17,10 +18,11 @@ from uuid import UUID
import filetype import filetype
from aiofiles.threadpool.binary import AsyncBufferedReader from aiofiles.threadpool.binary import AsyncBufferedReader
from nio.crypto import AsyncDataT as File from nio.crypto import AsyncDataT as File
from nio.crypto import async_generator_from_data from nio.crypto import async_generator_from_data
from .models.model_item import ModelItem
Size = Tuple[int, int] Size = Tuple[int, int]
auto = autostr auto = autostr
@ -125,7 +127,7 @@ def plain2html(text: str) -> str:
.replace("\t", "&nbsp;" * 4) .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. """Convert a value to make it easier to use from QML.
Returns: Returns:
@ -135,11 +137,18 @@ def serialize_value_for_qml(value: Any) -> Any:
- Strings for `UUID` objects - Strings for `UUID` objects
- A number of milliseconds for `datetime.timedelta` objects - A number of milliseconds for `datetime.timedelta` objects
- The class `__name__` for class types. - 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): if hasattr(value, "__class__") and issubclass(value.__class__, Enum):
return value.value return value.value
if isinstance(value, ModelItem):
return value.serialized
if isinstance(value, Path): if isinstance(value, Path):
return f"file://{value!s}" 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 visible: listView.interactive || ! listView.allowDragging
} }
// property bool debug: false
// Make sure to handle when a previous transition gets interrupted // Make sure to handle when a previous transition gets interrupted
add: Transition { add: Transition {
ParallelAnimation { ParallelAnimation {
// ScriptAction { script: print("add") } // ScriptAction { script: if (listView.debug) print("add") }
HNumberAnimation { property: "opacity"; from: 0; to: 1 } HNumberAnimation { property: "opacity"; from: 0; to: 1 }
HNumberAnimation { property: "scale"; from: 0; to: 1 } HNumberAnimation { property: "scale"; from: 0; to: 1 }
} }
@ -37,7 +39,7 @@ ListView {
move: Transition { move: Transition {
ParallelAnimation { ParallelAnimation {
// ScriptAction { script: print("move") } // ScriptAction { script: if (listView.debug) print("move") }
HNumberAnimation { property: "opacity"; to: 1 } HNumberAnimation { property: "opacity"; to: 1 }
HNumberAnimation { property: "scale"; to: 1 } HNumberAnimation { property: "scale"; to: 1 }
HNumberAnimation { properties: "x,y" } HNumberAnimation { properties: "x,y" }
@ -46,16 +48,15 @@ ListView {
remove: Transition { remove: Transition {
ParallelAnimation { ParallelAnimation {
// ScriptAction { script: print("remove") } // ScriptAction { script: if (listView.debug) print("remove") }
HNumberAnimation { property: "opacity"; to: 0 } HNumberAnimation { property: "opacity"; to: 0 }
HNumberAnimation { property: "scale"; to: 0 } HNumberAnimation { property: "scale"; to: 0 }
} }
} }
// displaced: move
displaced: Transition { displaced: Transition {
ParallelAnimation { ParallelAnimation {
// ScriptAction { script: print("displaced") } // ScriptAction { script: if (listView.debug) print("displaced") }
HNumberAnimation { property: "opacity"; to: 1 } HNumberAnimation { property: "opacity"; to: 1 }
HNumberAnimation { property: "scale"; to: 1 } HNumberAnimation { property: "scale"; to: 1 }
HNumberAnimation { properties: "x,y" } 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() signal activated()
property HListView view: ListView.view 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" import "../Base"
HTileDelegate { HTileDelegate {
id: accountDelegate id: account
spacing: 0 spacing: 0
topPadding: model.index > 0 ? theme.spacing / 2 : 0 topPadding: model.index > 0 ? theme.spacing / 2 : 0
bottomPadding: topPadding bottomPadding: topPadding
backgroundColor: theme.mainPane.account.background backgroundColor: theme.mainPane.account.background
opacity: collapsed && ! forceExpand ? opacity: collapsed && ! anyFilter ?
theme.mainPane.account.collapsedOpacity : 1 theme.mainPane.account.collapsedOpacity : 1
shouldBeCurrent: title.color: theme.mainPane.account.name
window.uiState.page === "Pages/AccountSettings/AccountSettings.qml" && title.text: model.display_name || model.id
window.uiState.pageProperties.userId === model.data.user_id title.font.pixelSize: theme.fontSize.big
title.leftPadding: theme.spacing
setCurrentTimer.running: image: HUserAvatar {
! mainPaneList.activateLimiter.running && ! mainPane.hasFocus 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 {} } HMenuItemPopupSpawner {
icon.name: "sign-out"
icon.color: theme.colors.negativeBackground
readonly property bool forceExpand: Boolean(mainPaneList.filter) text: qsTr("Sign out")
// Hide harmless error when a filter matches nothing
readonly property bool collapsed: try {
return mainPaneList.collapseAccounts[model.data.user_id] || false
} catch (err) {}
popup: "Popups/SignOutPopup.qml"
properties: { "userId": model.id }
}
}
onActivated: pageLoader.showPage( 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() { function toggleCollapse() {
window.uiState.collapseAccounts[model.data.user_id] = ! collapsed window.uiState.collapseAccounts[model.id] = ! collapsed
window.uiStateChanged() window.uiStateChanged()
} }
image: HUserAvatar { Behavior on opacity { HNumberAnimation {} }
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
HButton { HButton {
id: addChat id: addChat
@ -62,7 +69,7 @@ HTileDelegate {
backgroundColor: "transparent" backgroundColor: "transparent"
toolTip.text: qsTr("Add new chat") toolTip.text: qsTr("Add new chat")
onClicked: pageLoader.showPage( onClicked: pageLoader.showPage(
"AddChat/AddChat", {userId: model.data.user_id}, "AddChat/AddChat", {userId: model.id},
) )
leftPadding: theme.spacing / 2 leftPadding: theme.spacing / 2
@ -73,7 +80,7 @@ HTileDelegate {
Layout.fillHeight: true Layout.fillHeight: true
Layout.maximumWidth: Layout.maximumWidth:
accountDelegate.width >= 100 * theme.uiScale ? implicitWidth : 0 account.width >= 100 * theme.uiScale ? implicitWidth : 0
Behavior on Layout.maximumWidth { HNumberAnimation {} } Behavior on Layout.maximumWidth { HNumberAnimation {} }
Behavior on opacity { HNumberAnimation {} } Behavior on opacity { HNumberAnimation {} }
@ -81,22 +88,23 @@ HTileDelegate {
HButton { HButton {
id: expand 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 iconItem.small: true
icon.name: "expand" icon.name: "expand"
backgroundColor: "transparent" backgroundColor: "transparent"
toolTip.text: collapsed ? qsTr("Expand") : qsTr("Collapse") toolTip.text: collapsed ? qsTr("Expand") : qsTr("Collapse")
onClicked: accountDelegate.toggleCollapse() onClicked: account.toggleCollapse()
leftPadding: theme.spacing / 2 leftPadding: theme.spacing / 2
rightPadding: leftPadding rightPadding: leftPadding
opacity: ! loading && accountDelegate.forceExpand ? 0 : 1 opacity: ! loading && account.anyFilter ? 0 : 1
visible: opacity > 0 && Layout.maximumWidth > 0 visible: opacity > 0 && Layout.maximumWidth > 0
Layout.fillHeight: true Layout.fillHeight: true
Layout.maximumWidth: Layout.maximumWidth:
accountDelegate.width >= 120 * theme.uiScale ? implicitWidth : 0 account.width >= 120 * theme.uiScale ? implicitWidth : 0
iconItem.transform: Rotation { iconItem.transform: Rotation {
@ -110,21 +118,4 @@ HTileDelegate {
Behavior on Layout.maximumWidth { HNumberAnimation {} } Behavior on Layout.maximumWidth { HNumberAnimation {} }
Behavior on opacity { 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 { HColumnLayout {
anchors.fill: parent anchors.fill: parent
AccountRoomList { AccountRoomsList {
id: mainPaneList id: mainPaneList
clip: true clip: true

View File

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

View File

@ -3,85 +3,48 @@
import QtQuick 2.12 import QtQuick 2.12
import QtQuick.Layouts 1.12 import QtQuick.Layouts 1.12
import Clipboard 0.1 import Clipboard 0.1
import ".."
import "../Base" import "../Base"
HTileDelegate { HTileDelegate {
id: roomDelegate
spacing: theme.spacing spacing: theme.spacing
backgroundColor: theme.mainPane.room.background backgroundColor: theme.mainPane.room.background
opacity: model.data.left ? theme.mainPane.room.leftRoomOpacity : 1 opacity: model.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)
image: HRoomAvatar { image: HRoomAvatar {
displayName: model.data.display_name displayName: model.display_name
mxc: model.data.avatar_url mxc: model.avatar_url
} }
title.color: theme.mainPane.room.name 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 { additionalInfo.children: HIcon {
svgName: "invite-received" svgName: "invite-received"
colorize: theme.colors.alertBackground colorize: theme.colors.alertBackground
visible: invited
Layout.maximumWidth: invited ? implicitWidth : 0 Layout.maximumWidth: invited ? implicitWidth : 0
Behavior on Layout.maximumWidth { HNumberAnimation {} } 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.color: theme.mainPane.room.subtitle
subtitle.font.italic:
Boolean(lastEvent && lastEvent.event_type === "RoomMessageEmote")
subtitle.textFormat: Text.StyledText subtitle.textFormat: Text.StyledText
subtitle.font.italic:
lastEvent && lastEvent.event_type === "RoomMessageEmote"
subtitle.text: { subtitle.text: {
if (! lastEvent) return "" if (! lastEvent) return ""
let isEmote = lastEvent.event_type === "RoomMessageEmote" const isEmote = lastEvent.event_type === "RoomMessageEmote"
let isMsg = lastEvent.event_type.startsWith("RoomMessage") const isMsg = lastEvent.event_type.startsWith("RoomMessage")
let isUnknownMsg = lastEvent.event_type === "RoomMessageUnknown" const isUnknownMsg = lastEvent.event_type === "RoomMessageUnknown"
let isCryptMedia = lastEvent.event_type.startsWith("RoomEncrypted") const isCryptMedia = lastEvent.event_type.startsWith("RoomEncrypted")
// If it's a general event // If it's a general event
if (isEmote || isUnknownMsg || (! isMsg && ! isCryptMedia)) { if (isEmote || isUnknownMsg || (! isMsg && ! isCryptMedia))
return utils.processedEventText(lastEvent) return utils.processedEventText(lastEvent)
}
let text = utils.coloredNameHtml( const text = utils.coloredNameHtml(
lastEvent.sender_name, lastEvent.sender_id lastEvent.sender_name, lastEvent.sender_id
) + ": " + lastEvent.inline_content ) + ": " + 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 { contextMenu: HMenu {
HMenuItemPopupSpawner { HMenuItemPopupSpawner {
visible: joined visible: joined
enabled: model.data.can_invite enabled: model.can_invite
icon.name: "room-send-invite" icon.name: "room-send-invite"
text: qsTr("Invite members") text: qsTr("Invite members")
popup: "Popups/InviteToRoomPopup.qml" popup: "Popups/InviteToRoomPopup.qml"
properties: ({ properties: ({
userId: model.user_id, userId: userId,
roomId: model.data.room_id, roomId: model.id,
roomName: model.data.display_name, roomName: model.display_name,
invitingAllowed: Qt.binding(() => model.data.can_invite) invitingAllowed: Qt.binding(() => model.can_invite)
}) })
} }
HMenuItem { HMenuItem {
icon.name: "copy-room-id" icon.name: "copy-room-id"
text: qsTr("Copy room ID") text: qsTr("Copy room ID")
onTriggered: Clipboard.text = model.data.room_id onTriggered: Clipboard.text = model.id
} }
HMenuItem { HMenuItem {
@ -118,12 +95,12 @@ HTileDelegate {
icon.name: "invite-accept" icon.name: "invite-accept"
icon.color: theme.colors.positiveBackground icon.color: theme.colors.positiveBackground
text: qsTr("Accept %1's invite").arg(utils.coloredNameHtml( 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 label.textFormat: Text.StyledText
onTriggered: py.callClientCoro( onTriggered: py.callClientCoro(
model.user_id, "join", [model.data.room_id] userId, "join", [model.id]
) )
} }
@ -135,9 +112,9 @@ HTileDelegate {
popup: "Popups/LeaveRoomPopup.qml" popup: "Popups/LeaveRoomPopup.qml"
properties: ({ properties: ({
userId: model.user_id, userId: userId,
roomId: model.data.room_id, roomId: model.id,
roomName: model.data.display_name, roomName: model.display_name,
}) })
} }
@ -149,10 +126,27 @@ HTileDelegate {
popup: "Popups/ForgetRoomPopup.qml" popup: "Popups/ForgetRoomPopup.qml"
autoDestruct: false autoDestruct: false
properties: ({ properties: ({
userId: model.user_id, userId: userId,
roomId: model.data.room_id, roomId: model.id,
roomName: model.data.display_name, 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 2.12
import QtQuick.Controls 2.12 import QtQuick.Controls 2.12
import QtQuick.Layouts 1.12 import QtQuick.Layouts 1.12
import "../.."
import "../../Base" import "../../Base"
HPage { HPage {
id: accountSettings id: accountSettings
hideHeaderUnderHeight: avatarPreferredSize
headerLabel.text: qsTr("Account settings for %1").arg(
utils.coloredNameHtml(headerName, userId)
)
property int avatarPreferredSize: 256 * theme.uiScale property int avatarPreferredSize: 256 * theme.uiScale
property string userId: "" property string userId: ""
readonly property bool ready: readonly property bool ready:
accountInfo !== "waiting" && Boolean(accountInfo.profile_updated) accountInfo !== null && accountInfo.profile_updated > new Date(1)
readonly property var accountInfo: utils.getItem( readonly property QtObject accountInfo:
modelSources["Account"] || [], "user_id", userId ModelStore.get("accounts").find(userId)
) || "waiting"
property string headerName: ready ? accountInfo.display_name : userId property string headerName: ready ? accountInfo.display_name : userId
hideHeaderUnderHeight: avatarPreferredSize
headerLabel.text: qsTr("Account settings for %1").arg(
utils.coloredNameHtml(headerName, userId)
)
HSpacer {} HSpacer {}

View File

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

View File

@ -2,6 +2,7 @@
import QtQuick 2.12 import QtQuick 2.12
import QtQuick.Layouts 1.12 import QtQuick.Layouts 1.12
import "../.."
import "../../Base" import "../../Base"
import "RoomPane" import "RoomPane"
@ -13,16 +14,11 @@ Item {
property string userId: "" property string userId: ""
property string roomId: "" 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 loadingMessages: false
property bool ready: userInfo !== "waiting" && roomInfo !== "waiting" property bool ready: Boolean(userInfo && roomInfo)
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"
readonly property alias loader: loader readonly property alias loader: loader
readonly property alias roomPane: roomPaneLoader.item readonly property alias roomPane: roomPaneLoader.item

View File

@ -3,18 +3,28 @@
import QtQuick 2.12 import QtQuick 2.12
import QtQuick.Layouts 1.12 import QtQuick.Layouts 1.12
import Clipboard 0.1 import Clipboard 0.1
import "../.."
import "../../Base" import "../../Base"
import "../../Dialogs" import "../../Dialogs"
Rectangle { 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 string indent: " "
property var aliases: window.settings.writeAliases property var aliases: window.settings.writeAliases
property string toSend: "" property string toSend: ""
property string writingUserId: chat.userId property string writingUserId: chat.userId
readonly property var writingUserInfo: property QtObject writingUserInfo:
utils.getItem(modelSources["Account"] || [], "user_id", writingUserId) ModelStore.get("accounts").find(writingUserId)
property bool textChangedSinceLostFocus: false property bool textChangedSinceLostFocus: false
@ -40,20 +50,9 @@ Rectangle {
lineTextUntilCursor.match(/ {1,4}/g).slice(-1)[0].length : lineTextUntilCursor.match(/ {1,4}/g).slice(-1)[0].length :
1 1
function takeFocus() { areaScrollView.forceActiveFocus() } 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 { HRowLayout {
anchors.fill: parent anchors.fill: parent
@ -61,8 +60,8 @@ Rectangle {
HUserAvatar { HUserAvatar {
id: avatar id: avatar
userId: writingUserId userId: writingUserId
displayName: writingUserInfo.display_name displayName: writingUserInfo ? writingUserInfo.display_name : ""
mxc: writingUserInfo.avatar_url mxc: writingUserInfo ? writingUserInfo.avatar_url : ""
} }
HScrollableTextArea { HScrollableTextArea {

View File

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

View File

@ -11,7 +11,7 @@ HTileDelegate {
model.invited ? theme.chat.roomPane.member.invitedOpacity : 1 model.invited ? theme.chat.roomPane.member.invitedOpacity : 1
image: HUserAvatar { image: HUserAvatar {
userId: model.user_id userId: model.id
displayName: model.display_name displayName: model.display_name
mxc: model.avatar_url mxc: model.avatar_url
powerLevel: model.power_level powerLevel: model.power_level
@ -19,20 +19,20 @@ HTileDelegate {
invited: model.invited invited: model.invited
} }
title.text: model.display_name || model.user_id title.text: model.display_name || model.id
title.color: title.color:
memberDelegate.hovered ? 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 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 subtitle.color: theme.chat.roomPane.member.subtitle
contextMenu: HMenu { contextMenu: HMenu {
HMenuItem { HMenuItem {
icon.name: "copy-user-id" icon.name: "copy-user-id"
text: qsTr("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 2.12
import QtQuick.Layouts 1.12 import QtQuick.Layouts 1.12
import "../../.."
import "../../../Base" import "../../../Base"
HColumnLayout { HColumnLayout {
@ -9,37 +10,33 @@ HColumnLayout {
id: memberList id: memberList
clip: true clip: true
Layout.fillWidth: true model: ModelStore.get(chat.userId, chat.roomId, "members")
Layout.fillHeight: true // 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: // filter: (item, index) => utils.filterMatchesAny(
modelSources[["Member", chat.userId, chat.roomId]] || [] // filterField.text, item.display_name, item.id,
// )
// }
onOriginSourceChanged: filterLimiter.restart()
function filterSource() {
model.source =
utils.filterModelSource(originSource, filterField.text)
}
model: HListModel {
keyField: "user_id"
source: memberList.originSource
}
delegate: MemberDelegate { delegate: MemberDelegate {
width: memberList.width width: memberList.width
} }
Timer { Layout.fillWidth: true
id: filterLimiter Layout.fillHeight: true
interval: 16
onTriggered: memberList.filterSource()
}
} }
HRowLayout { HRowLayout {
@ -56,7 +53,7 @@ HColumnLayout {
bordered: false bordered: false
opacity: width >= 16 * theme.uiScale ? 1 : 0 opacity: width >= 16 * theme.uiScale ? 1 : 0
onTextChanged: filterLimiter.restart() onTextChanged: memberList.model.reFilter()
Layout.fillWidth: true Layout.fillWidth: true
Layout.fillHeight: true Layout.fillHeight: true

View File

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

View File

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

View File

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

View File

@ -32,7 +32,7 @@ Rectangle {
textFormat: Text.StyledText textFormat: Text.StyledText
elide: Text.ElideRight elide: Text.ElideRight
text: { text: {
let tm = chat.roomInfo.typing_members const tm = JSON.parse(chat.roomInfo.typing_members)
if (tm.length === 0) return "" if (tm.length === 0) return ""
if (tm.length === 1) return qsTr("%1 is typing...").arg(tm[0]) 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.userId === userId &&
window.uiState.pageProperties.roomId === roomId) window.uiState.pageProperties.roomId === roomId)
{ {
pageLoader.showPage("Default") window.mainUI.pageLoader.showPrevious() ||
window.mainUI.pageLoader.showPage("Default")
Qt.callLater(popup.destroy) Qt.callLater(popup.destroy)
} }
}) })

View File

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

View File

@ -1,6 +1,8 @@
// SPDX-License-Identifier: LGPL-3.0-or-later // SPDX-License-Identifier: LGPL-3.0-or-later
import QtQuick 2.12 import QtQuick 2.12
import ".."
import "../.."
QtObject { QtObject {
function onExitRequested(exitCode) { function onExitRequested(exitCode) {
@ -16,10 +18,10 @@ QtObject {
function onCoroutineDone(uuid, result, error, traceback) { function onCoroutineDone(uuid, result, error, traceback) {
let onSuccess = py.privates.pendingCoroutines[uuid].onSuccess let onSuccess = Globals.pendingCoroutines[uuid].onSuccess
let onError = py.privates.pendingCoroutines[uuid].onError let onError = Globals.pendingCoroutines[uuid].onError
delete py.privates.pendingCoroutines[uuid] delete Globals.pendingCoroutines[uuid]
if (error) { if (error) {
const type = py.getattr(py.getattr(error, "__class__"), "__name__") const type = py.getattr(py.getattr(error, "__class__"), "__name__")
@ -74,14 +76,29 @@ QtObject {
} }
function onModelUpdated(syncId, data, serializedSyncId) { function onModelItemInserted(syncId, index, item) {
if (serializedSyncId === "Account" || serializedSyncId[0] === "Room") { // print("insert", syncId, index, item)
py.callCoro("get_flat_mainpane_data", [], data => { ModelStore.get(syncId).insert(index, item)
window.mainPaneModelSource = data }
})
}
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 QtQuick 2.12
import io.thp.pyotherside 1.5 import io.thp.pyotherside 1.5
import CppUtils 0.1 import CppUtils 0.1
import "Privates"
Python { Python {
id: py 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 QtObject privates: QtObject {
readonly property var pendingCoroutines: ({})
readonly property EventHandlers eventHandlers: EventHandlers {}
function makeFuture(callback) { function makeFuture(callback) {
return Qt.createComponent("Future.qml") 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) { function callCoro(name, args=[], onSuccess=null, onError=null) {
let uuid = name + "." + CppUtils.uuid() let uuid = name + "." + CppUtils.uuid()
privates.pendingCoroutines[uuid] = {onSuccess, onError} Globals.pendingCoroutines[uuid] = {onSuccess, onError}
let future = privates.makeFuture() let future = privates.makeFuture()
@ -75,7 +45,7 @@ Python {
callCoro("get_client", [accountId], () => { callCoro("get_client", [accountId], () => {
let uuid = accountId + "." + name + "." + CppUtils.uuid() let uuid = accountId + "." + name + "." + CppUtils.uuid()
privates.pendingCoroutines[uuid] = {onSuccess, onError} Globals.pendingCoroutines[uuid] = {onSuccess, onError}
let call_args = [accountId, name, uuid, args] 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: property bool accountsPresent:
(modelSources["Account"] || []).length > 0 || ModelStore.get("accounts").count > 0 || py.startupAnyAccountsSaved
py.startupAnyAccountsSaved
readonly property alias shortcuts: shortcuts readonly property alias shortcuts: shortcuts
readonly property alias mainPane: mainPane readonly property alias mainPane: mainPane

View File

@ -135,30 +135,29 @@ QtObject {
function processedEventText(ev) { function processedEventText(ev) {
if (ev.event_type === "RoomMessageEmote") const type = ev.event_type
return coloredNameHtml(ev.sender_name, ev.sender_id) + " " + const unknownMsg = type === "RoomMessageUnknown"
ev.content 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 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( if (ev.content.includes("%2")) {
coloredNameHtml(ev.sender_name, ev.sender_id) const target = coloredNameHtml(ev.target_name, ev.target_id)
) return qsTr(ev.content).arg(sender).arg(target)
}
if (text.includes("%2") && ev.target_id) return qsTr(ev.content).arg(sender)
text = text.arg(coloredNameHtml(ev.target_name, ev.target_id))
return text
} }
function filterMatches(filter, text) { function filterMatches(filter, text) {
let filter_lower = filter.toLowerCase() const filter_lower = filter.toLowerCase()
if (filter_lower === filter) { if (filter_lower === filter) {
// Consider case only if filter isn't all lowercase (smart case) // Consider case only if filter isn't all lowercase (smart case)
@ -175,17 +174,11 @@ QtObject {
} }
function filterModelSource(source, filter_text, property="filter_string") { function filterMatchesAny(filter, ...texts) {
if (! filter_text) return source for (let text of texts) {
let results = [] if (filterMatches(filter, text)) return true
for (let i = 0; i < source.length; i++) {
if (filterMatches(filter_text, source[i][property])) {
results.push(source[i])
}
} }
return false
return results
} }
@ -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) { function flickPages(flickable, pages) {
// Adapt velocity and deceleration for the number of pages to flick. // Adapt velocity and deceleration for the number of pages to flick.
// If this is a repeated flicking, flick faster than a single 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 // NOTE: For JS object variables, the corresponding method to notify
// key/value changes must be called manually, e.g. settingsChanged(). // key/value changes must be called manually, e.g. settingsChanged().
property var modelSources: ({})
property var mainPaneModelSource: [] property var mainPaneModelSource: []
property var mainUI: null property var mainUI: null
@ -46,8 +45,6 @@ ApplicationWindow {
property var hideErrorTypes: new Set() property var hideErrorTypes: new Set()
readonly property alias py: py
function saveState(obj) { function saveState(obj) {
if (! obj.saveName || ! obj.saveProperties || if (! obj.saveName || ! obj.saveProperties ||
@ -75,7 +72,7 @@ ApplicationWindow {
} }
PythonBridge { id: py } PythonRootBridge { id: py }
Utils { id: utils } 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