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:
parent
2ce5e20efa
commit
9990fecc74
3
.gitmodules
vendored
3
.gitmodules
vendored
|
@ -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
|
||||||
|
|
6
TODO.md
6
TODO.md
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
@ -117,7 +117,7 @@ class Backend:
|
||||||
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
|
||||||
|
|
||||||
|
|
||||||
|
@ -134,12 +134,12 @@ class Backend:
|
||||||
)
|
)
|
||||||
|
|
||||||
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
|
||||||
|
|
|
@ -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.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
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)
|
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()
|
|
||||||
|
|
|
@ -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]
|
|
||||||
|
|
|
@ -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,9 +55,10 @@ 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 = ""
|
||||||
|
main_alias: str = ""
|
||||||
avatar_url: str = ""
|
avatar_url: str = ""
|
||||||
plain_topic: str = ""
|
plain_topic: str = ""
|
||||||
topic: str = ""
|
topic: str = ""
|
||||||
|
@ -57,8 +66,10 @@ class Room(ModelItem):
|
||||||
inviter_name: str = ""
|
inviter_name: str = ""
|
||||||
inviter_avatar: str = ""
|
inviter_avatar: str = ""
|
||||||
left: bool = False
|
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,7 +156,7 @@ 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()
|
||||||
|
@ -176,7 +164,7 @@ class Upload(ModelItem):
|
||||||
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
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
@ -32,11 +32,8 @@ 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.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
with self._write_lock:
|
||||||
|
existing = self._data.get(key)
|
||||||
new = value
|
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)}
|
|
||||||
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._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)
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ModelItemInserted(ModelEvent):
|
||||||
sync_id: SyncId = field()
|
sync_id: SyncId = field()
|
||||||
data: List[Dict[str, Any]] = field()
|
index: int = field()
|
||||||
|
item: ModelItem = field()
|
||||||
|
|
||||||
serialized_sync_id: Union[str, List[str]] = field(init=False)
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
@dataclass
|
||||||
if isinstance(self.sync_id, tuple):
|
class ModelItemFieldChanged(ModelEvent):
|
||||||
self.serialized_sync_id = [
|
sync_id: SyncId = field()
|
||||||
e.__name__ if isinstance(e, type) else e for e in self.sync_id
|
item_index_then: int = field()
|
||||||
]
|
item_index_now: int = field()
|
||||||
else:
|
changed_field: str = field()
|
||||||
self.serialized_sync_id = self.sync_id.__name__
|
field_value: Any = field()
|
||||||
|
|
||||||
super().__post_init__()
|
|
||||||
|
@dataclass
|
||||||
|
class ModelItemDeleted(ModelEvent):
|
||||||
|
sync_id: SyncId = field()
|
||||||
|
index: int = field()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ModelCleared(ModelEvent):
|
||||||
|
sync_id: SyncId = field()
|
||||||
|
|
|
@ -110,8 +110,6 @@ class QMLBridge:
|
||||||
|
|
||||||
rc = lambda c: asyncio.run_coroutine_threadsafe(c, self._loop) # noqa
|
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
|
||||||
|
|
|
@ -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", " " * 4)
|
.replace("\t", " " * 4)
|
||||||
|
|
||||||
|
|
||||||
def serialize_value_for_qml(value: Any) -> Any:
|
def serialize_value_for_qml(value: Any, json_lists: bool = False) -> Any:
|
||||||
"""Convert a value to make it easier to use from QML.
|
"""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}"
|
||||||
|
|
||||||
|
|
81
src/gui/Base/HAccordionView.qml
Normal file
81
src/gui/Base/HAccordionView.qml
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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" }
|
||||||
|
|
10
src/gui/Base/HSortFilterProxy.qml
Normal file
10
src/gui/Base/HSortFilterProxy.qml
Normal 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()
|
||||||
|
}
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
|
@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
66
src/gui/MainPane/AccountRoomsDelegate.qml
Normal file
66
src/gui/MainPane/AccountRoomsDelegate.qml
Normal 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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
88
src/gui/MainPane/AccountRoomsList.qml
Normal file
88
src/gui/MainPane/AccountRoomsList.qml
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
|
@ -37,7 +37,7 @@ HDrawer {
|
||||||
HColumnLayout {
|
HColumnLayout {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
|
|
||||||
AccountRoomList {
|
AccountRoomsList {
|
||||||
id: mainPaneList
|
id: mainPaneList
|
||||||
clip: true
|
clip: true
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
37
src/gui/ModelStore.qml
Normal 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]
|
||||||
|
}
|
||||||
|
}
|
|
@ -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 {}
|
||||||
|
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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 {}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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])
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
@ -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])
|
||||||
|
|
|
@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
8
src/gui/PythonBridge/Privates/Globals.qml
Normal file
8
src/gui/PythonBridge/Privates/Globals.qml
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
// SPDX-License-Identifier: LGPL-3.0-or-later
|
||||||
|
|
||||||
|
pragma Singleton
|
||||||
|
import QtQuick 2.12
|
||||||
|
|
||||||
|
QtObject {
|
||||||
|
readonly property var pendingCoroutines: ({})
|
||||||
|
}
|
1
src/gui/PythonBridge/Privates/qmldir
Normal file
1
src/gui/PythonBridge/Privates/qmldir
Normal file
|
@ -0,0 +1 @@
|
||||||
|
singleton Globals 0.1 Globals.qml
|
|
@ -3,38 +3,13 @@
|
||||||
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]
|
||||||
|
|
||||||
|
|
33
src/gui/PythonBridge/PythonRootBridge.qml
Normal file
33
src/gui/PythonBridge/PythonRootBridge.qml
Normal 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 {}
|
||||||
|
}
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
|
||||||
text = text.arg(coloredNameHtml(ev.target_name, ev.target_id))
|
|
||||||
|
|
||||||
return text
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return qsTr(ev.content).arg(sender)
|
||||||
|
}
|
||||||
|
|
||||||
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.
|
||||||
|
|
|
@ -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
1
src/gui/qmldir
Normal file
|
@ -0,0 +1 @@
|
||||||
|
singleton ModelStore 0.1 ModelStore.qml
|
1
submodules/gel
Submodule
1
submodules/gel
Submodule
|
@ -0,0 +1 @@
|
||||||
|
Subproject commit 0e796aacc16388a164bab0bb0ce9dabc885ed7fa
|
Loading…
Reference in New Issue
Block a user