Fix room sorting for good

Do it the right way with a QSortFilterProxyModel.
This commit is contained in:
miruka 2019-05-06 13:07:00 -04:00
parent 0e5c5619cf
commit cdf6190cba
11 changed files with 178 additions and 70 deletions

View File

@ -56,6 +56,7 @@
- Banner name color instead of bold - Banner name color instead of bold
- Missing nio support - Missing nio support
- Invite events are missing their timestamps
- Left room events - Left room events
- `org.matrix.room.preview_urls` event - `org.matrix.room.preview_urls` event
- `m.room.aliases` event - `m.room.aliases` event

View File

@ -105,9 +105,10 @@ class Backend(QObject):
@pyqtSlot(list) @pyqtSlot(list)
def pdb(self, additional_data: Sequence = ()) -> None: def pdb(self, additional_data: Sequence = ()) -> None:
# pylint: disable=all # pylint: disable=all
a = additional_data ad = additional_data
c = self.clients cl = self.clients
m = self.models ac = self.accounts
re = self.roomEvents
tcl = lambda user: c[f"@test_{user}:matrix.org"] tcl = lambda user: c[f"@test_{user}:matrix.org"]

View File

@ -1,6 +1,6 @@
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from PyQt5.QtCore import QDateTime from PyQt5.QtCore import QDateTime, QSortFilterProxyModel
from .list_item import ListItem from .list_item import ListItem
from .list_model import ListModel from .list_model import ListModel
@ -24,20 +24,22 @@ class Room(ListItem):
displayName: str = "" displayName: str = ""
topic: Optional[str] = None topic: Optional[str] = None
typingUsers: List[str] = [] typingUsers: List[str] = []
lastEventDateTime: Optional[QDateTime] = None
inviter: Optional[Dict[str, str]] = None inviter: Optional[Dict[str, str]] = None
leftEvent: Optional[Dict[str, str]] = None leftEvent: Optional[Dict[str, str]] = None
class RoomCategory(ListItem): class RoomCategory(ListItem):
_required_init_values = {"name", "rooms"} _required_init_values = {"name", "rooms", "sortedRooms"}
_constant = {"rooms"} _constant = {"rooms", "sortedRooms"}
name: str = "" name: str = ""
# Must be provided at init, else it will be the same object # Must be provided at init, else it will be the same object
# for every RoomCategory # for every RoomCategory
rooms: ListModel = ListModel() rooms: ListModel = ListModel()
sortedRooms: QSortFilterProxyModel = QSortFilterProxyModel()
class Account(ListItem): class Account(ListItem):

View File

@ -118,7 +118,7 @@ class ListItem(QObject, metaclass=_ListItemMeta):
# Set properties from provided positional arguments # Set properties from provided positional arguments
for prop, value in zip(self._props, args): for prop, value in zip(self._props, args):
setattr(self, f"_{prop}", value) setattr(self, f"_{prop}", self._set_parent(value))
already_set.add(prop) already_set.add(prop)
# Set properties from provided keyword arguments # Set properties from provided keyword arguments
@ -129,7 +129,7 @@ class ListItem(QObject, metaclass=_ListItemMeta):
if prop not in self._props: if prop not in self._props:
raise TypeError(f"{method} got an unexpected keyword " raise TypeError(f"{method} got an unexpected keyword "
f"argument {prop!r}") f"argument {prop!r}")
setattr(self, f"_{prop}", value) setattr(self, f"_{prop}", self._set_parent(value))
already_set.add(prop) already_set.add(prop)
# Check for required init arguments not provided # Check for required init arguments not provided
@ -140,7 +140,13 @@ class ListItem(QObject, metaclass=_ListItemMeta):
# Set default values for properties not provided in arguments # Set default values for properties not provided in arguments
for prop in set(self._props) - already_set: for prop in set(self._props) - already_set:
setattr(self, f"_{prop}", self._props[prop][1]) setattr(self, f"_{prop}", self._set_parent(self._props[prop][1]))
def _set_parent(self, value: Any) -> Any:
if isinstance(value, QObject):
value.setParent(self)
return value
def __repr__(self) -> str: def __repr__(self) -> str:

View File

@ -89,10 +89,15 @@ class ListModel(QAbstractListModel):
return self._data[0].mainKey if self._data else None return self._data[0].mainKey if self._data else None
def roleNumbers(self) -> Dict[str, int]:
return {name: Qt.UserRole + i
for i, name in enumerate(self.roles, 1)} \
if self._data else {}
def roleNames(self) -> Dict[int, bytes]: def roleNames(self) -> Dict[int, bytes]:
return {Qt.UserRole + i: bytes(f, "utf-8") return {Qt.UserRole + i: bytes(name, "utf-8")
for i, f in enumerate(self.roles, 1)} \ for i, name in enumerate(self.roles, 1)} \
if self._data else {} if self._data else {}
@ -220,7 +225,9 @@ class ListModel(QAbstractListModel):
pass pass
qidx = QAbstractListModel.index(self, i_index, 0) qidx = QAbstractListModel.index(self, i_index, 0)
self.dataChanged.emit(qidx, qidx, self.roleNames()) updated = [number for name, number in self.roleNumbers().items()
if name not in ignore_roles]
self.dataChanged.emit(qidx, qidx, updated)
self.changed.emit() self.changed.emit()
return i_index return i_index
@ -265,7 +272,7 @@ class ListModel(QAbstractListModel):
setattr(self[i_index], prop, value) setattr(self[i_index], prop, value)
qidx = QAbstractListModel.index(self, i_index, 0) qidx = QAbstractListModel.index(self, i_index, 0)
self.dataChanged.emit(qidx, qidx, self.roleNames()) self.dataChanged.emit(qidx, qidx, (self.roleNumbers()[prop],))
self.changed.emit() self.changed.emit()

View File

@ -0,0 +1,69 @@
from typing import Dict
from PyQt5.QtCore import (
QObject, QSortFilterProxyModel, Qt, pyqtProperty, pyqtSignal, pyqtSlot
)
from .list_model import ListModel
class SortFilterProxy(QSortFilterProxyModel):
sortByRoleChanged = pyqtSignal()
def __init__(self,
source_model: ListModel,
sort_by_role: str,
ascending: bool = True,
parent: QObject = None) -> None:
super().__init__(parent)
self.setDynamicSortFilter(False)
self.ascending = ascending
self.sortByRoleChanged.connect(self._set_sort_role)
self.setSourceModel(source_model)
source_model.rolesSet.connect(self._set_sort_role)
source_model.changed.connect(self._sort)
self._sort_by_role = sort_by_role
self._set_sort_role()
@pyqtProperty(str, notify=sortByRoleChanged)
def sortByRole(self) -> str:
return self._sort_by_role
@sortByRole.setter # type: ignore
def sortByRole(self, role: str) -> None:
self._sort_by_role = role
self.sortByRoleChanged.emit()
def _set_sort_role(self) -> None:
numbers = self.sourceModel().roleNumbers()
try:
self.setSortRole(numbers[self._sort_by_role])
except KeyError:
pass # Model doesn't have its roles set yet (empty model)
def __repr__(self) -> str:
return "%s(sortByRole=%r, sourceModel=%r)" % (
type(self).__name__, self.sortByRole, self.sourceModel(),
)
@pyqtSlot(result=str)
def repr(self) -> str:
return self.__repr__()
def roleNames(self) -> Dict[int, bytes]:
return self.sourceModel().roleNames()
def _sort(self) -> None:
order = Qt.AscendingOrder if self.ascending else Qt.DescendingOrder
self.sort(0, order)

View File

@ -12,6 +12,7 @@ from nio.rooms import MatrixRoom
from .backend import Backend from .backend import Backend
from .client import Client from .client import Client
from .model.items import Account, ListModel, Room, RoomCategory, RoomEvent from .model.items import Account, ListModel, Room, RoomCategory, RoomEvent
from .model.sort_filter_proxy import SortFilterProxy
Inviter = Optional[Dict[str, str]] Inviter = Optional[Dict[str, str]]
LeftEvent = Optional[Dict[str, str]] LeftEvent = Optional[Dict[str, str]]
@ -34,12 +35,24 @@ class SignalManager(QObject):
def onClientAdded(self, client: Client) -> None: def onClientAdded(self, client: Client) -> None:
self.connectClient(client) self.connectClient(client)
room_categories_kwargs: List[Dict[str, Any]] = [
{"name": "Invites", "rooms": ListModel()},
{"name": "Rooms", "rooms": ListModel()},
{"name": "Left", "rooms": ListModel()},
]
for i, _ in enumerate(room_categories_kwargs):
proxy = SortFilterProxy(
source_model = room_categories_kwargs[i]["rooms"],
sort_by_role = "lastEventDateTime",
ascending = False,
)
room_categories_kwargs[i]["sortedRooms"] = proxy
self.backend.accounts.append(Account( self.backend.accounts.append(Account(
userId = client.userId, userId = client.userId,
roomCategories = ListModel([ roomCategories = ListModel([
RoomCategory("Invites", ListModel()), RoomCategory(**kws) for kws in room_categories_kwargs
RoomCategory("Rooms", ListModel()),
RoomCategory("Left", ListModel()),
]), ]),
displayName = self.backend.getUserDisplayName(client.userId), displayName = self.backend.getUserDisplayName(client.userId),
)) ))
@ -82,12 +95,18 @@ class SignalManager(QObject):
categories["Rooms"].rooms.pop(room_id, None) categories["Rooms"].rooms.pop(room_id, None)
categories["Left"].rooms.pop(room_id, None) categories["Left"].rooms.pop(room_id, None)
categories["Invites"].rooms.upsert(room_id, Room( categories["Invites"].rooms.upsert(
where_main_key_is = room_id,
update_with = Room(
roomId = room_id, roomId = room_id,
displayName = self._get_room_displayname(nio_room), displayName = self._get_room_displayname(nio_room),
topic = nio_room.topic, topic = nio_room.topic,
inviter = inviter, inviter = inviter,
), 0, 0) lastEventDateTime = QDateTime.currentDateTime(), # FIXME
),
new_index_if_insert = 0,
ignore_roles = ("typingUsers"),
)
def onRoomJoined(self, client: Client, room_id: str) -> None: def onRoomJoined(self, client: Client, room_id: str) -> None:
@ -97,11 +116,16 @@ class SignalManager(QObject):
categories["Invites"].rooms.pop(room_id, None) categories["Invites"].rooms.pop(room_id, None)
categories["Left"].rooms.pop(room_id, None) categories["Left"].rooms.pop(room_id, None)
categories["Rooms"].rooms.upsert(room_id, Room( categories["Rooms"].rooms.upsert(
where_main_key_is = room_id,
update_with = Room(
roomId = room_id, roomId = room_id,
displayName = self._get_room_displayname(nio_room), displayName = self._get_room_displayname(nio_room),
topic = nio_room.topic, topic = nio_room.topic,
), 0, 0) ),
new_index_if_insert = 0,
ignore_roles = ("typingUsers", "lastEventDateTime"),
)
def onRoomLeft(self, def onRoomLeft(self,
@ -114,12 +138,23 @@ class SignalManager(QObject):
previous = previous or categories["Invites"].rooms.pop(room_id, None) previous = previous or categories["Invites"].rooms.pop(room_id, None)
previous = previous or categories["Left"].rooms.get(room_id, None) previous = previous or categories["Left"].rooms.get(room_id, None)
categories["Left"].rooms.upsert(0, Room( left_time = left_event.get("server_timestamp") if left_event else None
categories["Left"].rooms.upsert(
where_main_key_is = room_id,
update_with = Room(
roomId = room_id, roomId = room_id,
displayName = previous.displayName if previous else None, displayName = previous.displayName if previous else None,
topic = previous.topic if previous else None, topic = previous.topic if previous else None,
leftEvent = left_event, leftEvent = left_event,
), 0, 0) lastEventDateTime = (
QDateTime.fromMSecsSinceEpoch(left_time)
if left_time else QDateTime.currentDateTime()
),
),
new_index_if_insert = 0,
ignore_roles = ("typingUsers", "lastEventDateTime"),
)
def onRoomSyncPrevBatchTokenReceived(self, def onRoomSyncPrevBatchTokenReceived(self,
_: Client, _: Client,
@ -141,27 +176,14 @@ class SignalManager(QObject):
self.backend.past_tokens[room_id] = token self.backend.past_tokens[room_id] = token
def _move_room(self, account_id: str, room_id: str, new_event: RoomEvent def _set_room_last_event(self, user_id: str, room_id: str, event: RoomEvent
) -> None: ) -> None:
# Find in which category our room is for categ in self.backend.accounts[user_id].roomCategories:
for categ in self.backend.accounts[account_id].roomCategories: if room_id in categ.rooms:
if room_id not in categ.rooms: # Use setProperty to make sure to trigger model changed signals
continue categ.rooms.setProperty(
room_id, "lastEventDateTime", event.dateTime
# Found the category, now find before which room we must move to )
for index, room in enumerate(categ.rooms):
if not self.backend.roomEvents[room.roomId]:
# That other room has no events, move before it
categ.rooms.move(room_id, index)
return
other_room_last_event = self.backend.roomEvents[room.roomId][0]
if new_event.dateTime > other_room_last_event.dateTime:
# Our last event is newer than that other room, move before
categ.rooms.move(room_id, max(0, index - 1))
return
return
def onRoomEventReceived(self, def onRoomEventReceived(self,
@ -235,7 +257,7 @@ class SignalManager(QObject):
with self._lock: with self._lock:
new_event = process() new_event = process()
if new_event: if new_event:
self._move_room(client.userId, room_id, new_event) self._set_room_last_event(client.userId, room_id, new_event)
def onRoomTypingUsersUpdated(self, def onRoomTypingUsersUpdated(self,
@ -245,7 +267,7 @@ class SignalManager(QObject):
categories = self.backend.accounts[client.userId].roomCategories categories = self.backend.accounts[client.userId].roomCategories
for categ in categories: for categ in categories:
try: try:
categ.rooms[room_id].typingUsers = users categ.rooms.setProperty(room_id, "typingUsers", users)
break break
except ValueError: except ValueError:
pass pass
@ -272,7 +294,7 @@ class SignalManager(QObject):
model.insert(0, event) model.insert(0, event)
self._events_in_transfer += 1 self._events_in_transfer += 1
self._move_room(client.userId, room_id, event) self._set_room_last_event(client.userId, room_id, event)
def onRoomAboutToBeForgotten(self, client: Client, room_id: str) -> None: def onRoomAboutToBeForgotten(self, client: Client, room_id: str) -> None:

View File

@ -42,10 +42,8 @@ MouseArea {
) )
} }
Connections { property var lastEvTime: lastEventDateTime
target: Backend.roomEvents.get(roomId) onLastEvTimeChanged: subtitleLabel.text = getText()
onChanged: subtitleLabel.text = subtitleLabel.getText()
}
id: subtitleLabel id: subtitleLabel
visible: text !== "" visible: text !== ""

View File

@ -8,6 +8,7 @@ ListView {
id: roomList id: roomList
spacing: accountList.spacing spacing: accountList.spacing
model: Backend.accounts.get(userId).roomCategories.get(category).rooms model:
Backend.accounts.get(userId).roomCategories.get(category).sortedRooms
delegate: RoomDelegate {} delegate: RoomDelegate {}
} }

View File

@ -62,5 +62,7 @@ Item {
popExit: null popExit: null
pushExit: null pushExit: null
} }
Keys.onEscapePressed: Backend.pdb() // TODO: only if debug mode True
} }
} }

View File

@ -2,7 +2,6 @@
# This file is part of harmonyqml, licensed under GPLv3. # This file is part of harmonyqml, licensed under GPLv3.
import signal import signal
import sys
from pathlib import Path from pathlib import Path
from typing import Any, Dict, Generator from typing import Any, Dict, Generator