diff --git a/CMakeLists.txt b/CMakeLists.txt
index 870e238..4ce1eed 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -94,6 +94,7 @@ quotient_ispushruleenabledjob_wrapper.h
quotient_redirecttoidpjob_wrapper.cpp
pyquotient_module_wrapper.cpp
quotient_getprotocolmetadatajob_wrapper.h
+quotient_settingsgroup_wrapper.h
quotient_requesttokentoresetpasswordemailjob_wrapper.h
quotient_getroomeventsjob_wrapper.cpp
quotient_getversionsjob_wrapper.cpp
@@ -101,6 +102,7 @@ quotient_querypublicroomsjob_wrapper.cpp
quotient_querykeysjob_deviceinformation_wrapper.cpp
quotient_bind3pidjob_wrapper.cpp
quotient_pushruleset_wrapper.cpp
+quotient_accountsettings_wrapper.h
quotient_user_wrapper.cpp
quotient_searchjob_groupings_wrapper.h
quotient_requestopenidtokenjob_wrapper.h
@@ -171,9 +173,11 @@ quotient_setaccountdataperroomjob_wrapper.cpp
quotient_getuserprofilejob_wrapper.cpp
quotient_gettokenownerjob_wrapper.cpp
quotient_getpublicroomsjob_wrapper.cpp
+quotient_settingsgroup_wrapper.cpp
quotient_geturlpreviewjob_wrapper.h
quotient_getpushersjob_pusherdata_wrapper.h
quotient_getpushruleactionsjob_wrapper.cpp
+quotient_accountsettings_wrapper.cpp
quotient_querykeysjob_unsigneddeviceinfo_wrapper.cpp
quotient_getroomtagsjob_wrapper.cpp
quotient_getmembersbyroomjob_wrapper.h
@@ -429,8 +433,6 @@ quotient_roomevent_wrapper.cpp
quotient_roomevent_wrapper.h
quotient_roomeventptr_wrapper.cpp
quotient_roomeventptr_wrapper.h
-quotient_eventstatus_wrapper.cpp
-quotient_eventstatus_wrapper.h
quotient_eventitembase_wrapper.cpp
quotient_eventitembase_wrapper.h
quotient_timelineitem_wrapper.cpp
diff --git a/PyQuotient/typesystems/typesystem_connection.xml b/PyQuotient/typesystems/typesystem_connection.xml
index c1fb63f..e02e353 100644
--- a/PyQuotient/typesystems/typesystem_connection.xml
+++ b/PyQuotient/typesystems/typesystem_connection.xml
@@ -4,23 +4,6 @@
-
-
-
-
-
-
-
+
diff --git a/PyQuotient/typesystems/typesystem_eventitem.xml b/PyQuotient/typesystems/typesystem_eventitem.xml
index 4ad94da..4b11137 100644
--- a/PyQuotient/typesystems/typesystem_eventitem.xml
+++ b/PyQuotient/typesystems/typesystem_eventitem.xml
@@ -3,9 +3,9 @@
-
+
-
+
diff --git a/PyQuotient/typesystems/typesystem_settings.xml b/PyQuotient/typesystems/typesystem_settings.xml
index 5dced89..a0154d7 100644
--- a/PyQuotient/typesystems/typesystem_settings.xml
+++ b/PyQuotient/typesystems/typesystem_settings.xml
@@ -3,5 +3,11 @@
+
+
+
+
+
+
diff --git a/demo/accountregistry.py b/demo/accountregistry.py
new file mode 100644
index 0000000..bf5fb3c
--- /dev/null
+++ b/demo/accountregistry.py
@@ -0,0 +1,37 @@
+from typing import List
+from PySide6 import QtCore
+from PyQuotient import Quotient
+from __feature__ import snake_case, true_property
+
+
+class Account(Quotient.Connection):
+ ...
+
+
+class AccountRegistry(QtCore.QObject):
+ addedAccount = QtCore.Signal(Account)
+ aboutToDropAccount = QtCore.Signal(Account)
+
+ def __init__(self) -> None:
+ super().__init__()
+ self.accounts: List[Account] = []
+
+ def __len__(self):
+ return len(self.accounts)
+
+ def __getitem__(self, position):
+ return self.accounts[position]
+
+ def add(self, account: Account) -> None:
+ if (account in self.accounts):
+ return
+
+ self.accounts.append(account)
+ self.addedAccount.emit(account)
+
+ def drop(self, account: Account) -> None:
+ self.aboutToDropAccount.emit(account)
+ self.accounts.remove(account)
+
+ def is_logged_in(self, user_id: str) -> bool:
+ return next((user for user in self.accounts if user.user_id == user_id), None) is not None
diff --git a/demo/logindialog.py b/demo/logindialog.py
index df33137..c3c9056 100644
--- a/demo/logindialog.py
+++ b/demo/logindialog.py
@@ -1,7 +1,7 @@
from PySide6 import QtCore, QtWidgets, QtGui
+from PyQuotient import Quotient
from __feature__ import snake_case, true_property
-from PyQuotient import Quotient
from .dialog import Dialog
@@ -157,8 +157,9 @@ def on_user_edit_editing_finished(self):
self.buttons.button(QtWidgets.QDialogButtonBox.Ok).enabled = False
self.connection.resolve_server(user_id)
- @QtCore.Slot(str)
- def on_server_edit_editing_finished(self, server: str):
+ @QtCore.Slot()
+ def on_server_edit_editing_finished(self):
+ server = self.server_edit.text
hs_url = QtCore.QUrl(server)
if hs_url.is_valid():
self.connection.homerserver = server
diff --git a/demo/mainwindow.py b/demo/mainwindow.py
index 4803c22..10ac8bf 100644
--- a/demo/mainwindow.py
+++ b/demo/mainwindow.py
@@ -1,24 +1,72 @@
import math
from PySide6 import QtCore, QtWidgets, QtGui
-from __feature__ import snake_case, true_property
-
from PyQuotient import Quotient
+from demo.accountregistry import AccountRegistry
from demo.logindialog import LoginDialog
+from demo.roomlistdock import RoomListDock
+from demo.pyquaternionroom import PyquaternionRoom
+from __feature__ import snake_case, true_property
class MainWindow(QtWidgets.QMainWindow):
def __init__(self):
super().__init__()
- self.text_label = QtWidgets.QLabel("Welcome to PyQuotient demo!")
self.login_dialog = None
self.connection_menu = None
self.logout_menu = None
+ # FIXME: This will be a problem when we get ability to show
+ # several rooms at once.
+ self.current_room = None
- self.set_central_widget(self.text_label)
+ self.account_registry = AccountRegistry()
+ self.room_list_dock = RoomListDock(self)
+ self.room_list_dock.roomSelected.connect(self.select_room)
+ self.add_dock_widget(QtCore.Qt.LeftDockWidgetArea, self.room_list_dock)
self.create_menu()
+ # Only GUI, account settings will be loaded in invoke_login
+ self.load_settings()
+
+ timer = QtCore.QTimer(self)
+ timer.single_shot = True
+ timer.timeout.connect(self.invoke_login)
+ timer.start(0)
+
+ def __del__(self):
+ self.save_settings()
+
+ def load_settings(self):
+ sg = Quotient.SettingsGroup("UI/MainWindow")
+ # TODO: fix rect value, is None
+ # if sg.contains("normal_geometry"):
+ # self.geometry = sg.value("normal_geometry")
+ if sg.value("maximized"):
+
+ self.show_maximized()
+ # TODO: fix value, is None
+ # if sg.contains("window_parts_state"):
+ # self.restore_state(sg.value("window_parts_state"))
+
+ def save_settings(self):
+ sg = Quotient.SettingsGroup("UI/MainWindow")
+ sg.set_value("normal_geometry", self.normal_geometry)
+ sg.set_value("maximized", self.maximized)
+ sg.set_value("window_parts_state", self.save_state())
+ sg.sync()
+
+ @QtCore.Slot()
+ def invoke_login(self):
+ accounts = Quotient.SettingsGroup("Accounts").child_groups()
+ auto_logged_in = False
+ for account_id in accounts:
+ account = Quotient.AccountSettings()
+ if account.homeserver:
+ access_token = self.load_access_token(account)
+
+ def load_access_token(self, account: Quotient.AccountSettings):
+ ...
@QtCore.Slot()
def open_login_window(self):
@@ -44,6 +92,8 @@ def quit(self):
def add_connection(self, connection: Quotient.Connection, device_name: str):
connection.lazy_loading = True
+ self.account_registry.add(connection)
+ self.room_list_dock.add_connection(connection)
connection.syncLoop(30000)
logout_action = self.logout_menu.add_action(connection.local_user_id, lambda: self.logout(connection))
@@ -148,6 +198,7 @@ def on_reconnection_timer_timeout(self, connection: Quotient.Connection):
def on_logged_out(self, connection: Quotient.Connection):
self.status_bar().show_message(f'Logged out as {connection.local_user_id}', 3000)
+ self.account_registry.drop(connection)
self.drop_connection(connection)
def create_menu(self):
@@ -166,4 +217,31 @@ def show_millis_to_recon(self, connection: Quotient.Connection):
"""
self.status_bar().show_message('Couldn\'t connect to the server as {user}; will retry within {seconds} seconds'.format(
user=connection.local_user_id, seconds=math.ceil(connection.millis_to_reconnect)
- ))
\ No newline at end of file
+ ))
+
+ @QtCore.Slot(PyquaternionRoom)
+ def select_room(self, room: PyquaternionRoom) -> None:
+ if room is not None:
+ print(f'Opening room {room.object_name()}')
+ elif self.current_room is not None:
+ print(f'Closing room {self.current_room.object_name()}')
+
+ if self.current_room is not None:
+ self.current_room.displaynameChanged.disconnect(self.current_room_displayname_changed)
+
+ self.current_room = room
+ new_window_title = ''
+ if self.current_room:
+ new_window_title = self.current_room.display_name()
+ self.current_room.displaynameChanged.connect(self.current_room_displayname_changed)
+
+ self.window_title = new_window_title
+ self.room_list_dock.set_selected_room(self.current_room)
+
+ if room is not None and not self.is_active_window():
+ self.show()
+ self.activate_window()
+
+ @QtCore.Slot()
+ def current_room_displayname_changed(self):
+ self.window_title = self.current_room.displayName()
diff --git a/demo/models/abstractroomordering.py b/demo/models/abstractroomordering.py
new file mode 100644
index 0000000..9e77289
--- /dev/null
+++ b/demo/models/abstractroomordering.py
@@ -0,0 +1,43 @@
+from __future__ import annotations
+from typing import List, Optional, TYPE_CHECKING
+
+
+from PySide6 import QtCore
+from PyQuotient import Quotient
+if TYPE_CHECKING:
+ from demo.models.roomlistmodel import RoomListModel
+from __feature__ import snake_case, true_property
+
+
+class RoomGroup:
+ SystemPrefix = "im.quotient."
+ LegacyPrefix = "org.qmatrixclient."
+
+ def __init__(self, key: str, rooms: Optional[List[Quotient.Room]] = None):
+ self.key = key
+ self.rooms: List[Quotient.Room] = []
+ if rooms is not None:
+ self.rooms = rooms
+
+ def __eq__(self, o: object) -> bool:
+ if isinstance(o, RoomGroup):
+ return self.key == o.key
+ return self.key == o
+
+ def __repr__(self) -> str:
+ return f"RoomGroup(key='{self.key}', len(rooms)={len(self.rooms)})"
+
+
+RoomGroups = List[RoomGroup]
+
+
+class AbstractRoomOrdering(QtCore.QObject):
+ def __init__(self, model: RoomListModel) -> None:
+ super().__init__(model)
+ self.model = model
+
+ def room_groups(self, room: Quotient.Room) -> RoomGroups:
+ return []
+
+ def update_groups(self, room: Quotient.Room) -> None:
+ self.model.update_groups(room)
diff --git a/demo/models/orderbytag.py b/demo/models/orderbytag.py
new file mode 100644
index 0000000..33a8ac9
--- /dev/null
+++ b/demo/models/orderbytag.py
@@ -0,0 +1,214 @@
+from typing import Any, List, Union
+from demo.models.abstractroomordering import AbstractRoomOrdering, RoomGroup
+from PyQuotient import Quotient
+from __feature__ import snake_case, true_property
+
+
+Invite = RoomGroup.SystemPrefix + "invite"
+DirectChat = RoomGroup.SystemPrefix + "direct"
+Untagged = RoomGroup.SystemPrefix + "none"
+Left = RoomGroup.SystemPrefix + "left"
+
+InvitesLabel = "The caption for invitations"
+FavouritesLabel = "Favourites"
+LowPriorityLabel = "Low priority"
+ServerNoticeLabel = "Server notices"
+DirectChatsLabel = "The caption for direct chats"
+UngroupedRoomsLabel = "Ungrouped rooms"
+LeftLabel = "The caption for left rooms"
+
+
+def tag_to_caption(tag: str) -> str:
+ if tag == Quotient.FavouriteTag:
+ return FavouritesLabel
+ elif tag == Quotient.LowPriorityTag:
+ return LowPriorityLabel
+ elif Quotient.ServerNoticeTag:
+ return ServerNoticeLabel
+ elif tag.startswith('u.'):
+ return tag[2:]
+ return tag
+
+
+def caption_to_tag(caption: str):
+ if caption == FavouritesLabel:
+ return Quotient.FavouriteTag
+ elif caption == LowPriorityLabel:
+ return Quotient.LowPriorityTag
+ elif caption == ServerNoticeLabel:
+ return Quotient.ServerNoticeTag
+ elif caption.startswith('m.') or caption.startswith('u.'):
+ return caption
+ return f'u.{caption}'
+
+
+def find_index(item_list: List[Any], value):
+ try:
+ return item_list[item_list.index(value)]
+ except ValueError:
+ return item_list[-1]
+
+
+def find_index_with_wildcards(item_list: List[str], value: str):
+ if len(item_list) == 0 or len(value) == 0:
+ return len(item_list)
+
+ index = item_list.index(value)
+ # Try namespace groupings (".*" in the list), from right to left
+ dot_pos = 0
+ i = 0
+ while not (i == len(item_list)):
+ i = find_index(item_list, value[:dot_pos + 1] + '*')
+ try:
+ dot_pos = value.rindex('.', dot_pos - 1)
+ except ValueError:
+ break;
+ return i
+
+
+class OrderByTag(AbstractRoomOrdering):
+ def __init__(self, *args, **kwargs) -> None:
+ super().__init__(*args, **kwargs)
+ self.tags_order: List[str] = self.init_tags_order()
+
+ def init_tags_order(self):
+ return [
+ Invite,
+ Quotient.FavouriteTag,
+ "u.*",
+ DirectChat,
+ Untagged,
+ Quotient.LowPriorityTag,
+ Left
+ ]
+
+ def connect_signals(self, obj: Union[Quotient.Connection, Quotient.Room]) -> None:
+ if isinstance(obj, Quotient.Connection):
+ obj.directChatsListChanged.connect(lambda additions, removals: self.on_conn_direct_chats_list_changed(obj, additions, removals))
+ elif isinstance(obj, Quotient.Room):
+ obj.displaynameChanged.connect(lambda: self.update_groups(obj))
+ obj.tagsChanged.connect(lambda: self.update_groups(obj))
+ obj.joinStateChanged.connect(lambda: self.update_groups(obj))
+
+ def on_conn_direct_chats_list_changed(self, conn, additions, removals):
+ # The same room may show up in removals and in additions if it
+ # moves from one userid to another (pretty weird but encountered
+ # in the wild). Therefore process removals first.
+ for room_id in removals:
+ room = conn.room(room_id)
+ if room:
+ self.update_groups(room)
+
+ for room_id in additions:
+ room = conn.room(room_id)
+ if room:
+ self.update_groups(room)
+
+ def update_groups(self, room: Quotient.Room) -> None:
+ super().update_groups(room)
+
+ # As the room may shadow predecessors, need to update their groups too.
+ pred_room = room.predecessor() # TODO: 'Quotient.JoinState.Join' error: predecessor takes no arguments
+ if pred_room:
+ self.update_groups(pred_room)
+
+ def group_label(group: RoomGroup) -> str:
+ caption = tag_to_caption(group.key)
+ if group.key == Untagged:
+ caption = UngroupedRoomsLabel
+ elif group.key == Invite:
+ caption = InvitesLabel
+ elif group.key == DirectChat:
+ caption = DirectChatsLabel
+ elif group.key == Left:
+ caption = LeftLabel
+
+ return f"{caption} ({len(group.rooms)} room(s))"
+
+ def group_less_than(self, group1: RoomGroup, group2_key: str) -> bool:
+ lkey = group1.key
+ rkey = group2_key
+ li = find_index_with_wildcards(self.tags_order, lkey)
+ ri = find_index_with_wildcards(self.tags_order, rkey)
+ return li < ri or (li == ri and lkey < rkey)
+
+ def room_groups(self, room: Quotient.Room):
+ if room.join_state == Quotient.JoinState.Invite:
+ return [Invite]
+ if room.join_state == Quotient.JoinState.Leave:
+ return [Left]
+
+ tags = self.get_filtered_tags(room)
+ if len(tags) == 0:
+ tags.insert(0, Untagged)
+ # Check successors, reusing room as the current frame, and for each group
+ # shadow this room if there's already any of its successors in the group
+ successor_room = room.successor() # TODO: Quotient.JoinState.Join
+ while successor_room is not None:
+ successor_tags = self.get_filtered_tags(successor_room)
+
+ if len(successor_tags) == 0:
+ tags.remove(Untagged)
+ else:
+ for tag in successor_tags:
+ if tag in tags:
+ tags.remove(tag)
+
+ if len(tags) == 0:
+ return [] # No remaining groups, hide the room
+
+ successor_room = room.successor() # TODO: Quotient.JoinState.Join
+ return tags
+
+ def get_filtered_tags(self, room: Quotient.Room) -> List[str]:
+ all_tags = room.tag_names
+ if room.is_direct_chat():
+ all_tags.append(DirectChat)
+
+ result: List[str] = []
+ for tag in all_tags:
+ if find_index_with_wildcards(self.tags_order, '-' + tag) == len(self.tags_order):
+ result.append(tag) # Only copy tags that are not disabled
+ return result
+
+ def room_less_than(self, group_key: str, room1: Quotient.Room, room2: Quotient.Room):
+ if room1 == room2:
+ return False # 0. Short-circuit for coinciding room objects
+
+ # 1. Compare tag order values
+ tag = group_key
+ order1 = room1.tag(tag).order
+ order2 = room2.tag(tag).order
+ if type(order2) != type(order1):
+ return not order2 == None
+
+ if order1 and order2:
+ # Compare floats; fallthrough if neither is smaller
+ if order1 < order2:
+ return True
+
+ if order1 > order2:
+ return False
+
+ # 2. Neither tag order is less than the other; compare room display names
+ if room1.display_name != room2.display_name:
+ return room1.display_name < room2.display_name
+
+ # 3. Within the same display name, order by room id
+ # (typically the case when both display names are completely empty)
+ if room1.id != room2.id:
+ return room1.id < room2.id
+
+ # 4. Room ids are equal; order by connections (=userids)
+ connection1 = room1.connection
+ connection2 = room2.connection
+ if connection1 != connection2:
+ if connection1.user_id != connection2.user_id:
+ return connection1.user_id < connection2.user_id
+
+ # 4a. Two logins under the same userid: pervert, but technically correct
+ return connection1.access_token < connection2.access_token
+
+ # 5. Assume two incarnations of the room with the different join state
+ # (by design, join states are distinct within one connection+roomid)
+ return room1.join_state < room2.join_state
diff --git a/demo/models/roomlistmodel.py b/demo/models/roomlistmodel.py
new file mode 100644
index 0000000..7d39c86
--- /dev/null
+++ b/demo/models/roomlistmodel.py
@@ -0,0 +1,293 @@
+import functools
+from enum import Enum, auto
+from typing import Callable, Dict, List, Optional
+from PySide6 import QtCore
+from PyQuotient import Quotient
+from demo.models.abstractroomordering import AbstractRoomOrdering, RoomGroup, RoomGroups
+from __feature__ import snake_case, true_property
+
+
+Visitor = Callable[[QtCore.QModelIndex], None]
+
+
+class Roles(Enum):
+ HasUnreadRole = QtCore.Qt.UserRole + 1
+ HighlightCountRole = auto()
+ JoinStateRole = auto()
+ ObjectRole = auto()
+
+
+class RoomListModel(QtCore.QAbstractItemModel):
+ saveCurrentSelection = QtCore.Signal()
+ restoreCurrentSelection = QtCore.Signal()
+ groupAdded = QtCore.Signal(int)
+
+ def __init__(self, *args, **kwargs) -> None:
+ super().__init__(*args, **kwargs)
+ self.room_groups: RoomGroups = []
+ self.connections: List[Quotient.Connection] = []
+ self.room_order: Optional[AbstractRoomOrdering] = None
+ self.room_indices: Dict[Quotient.Room, QtCore.QPersistentModelIndex] = {}
+
+ self.modelAboutToBeReset.connect(self.saveCurrentSelection)
+ self.modelReset.connect(self.restoreCurrentSelection)
+
+ def column_count(self, index: QtCore.QModelIndex) -> int:
+ return 1
+
+ def row_count(self, parent: QtCore.QModelIndex) -> int:
+ if not parent.is_valid():
+ return len(self.room_groups)
+
+ if self.is_valid_group_index(parent):
+ return len(self.room_groups[parent.row].rooms)
+
+ return 0
+
+ def is_valid_group_index(self, index: QtCore.QModelIndex) -> bool:
+ return index.is_valid() and not index.parent().is_valid() and index.row < len(self.room_groups)
+
+ def is_valid_room_index(self, index: QtCore.QModelIndex) -> bool:
+ return index.is_valid() and self.is_valid_group_index(index.parent()) and index.row < len(self.room_groups[index.parent.row].rooms)
+
+ def add_connection(self, connection: Quotient.Connection) -> None:
+ self.connections.append(connection)
+ connection.loggedOut.connect(lambda: self.delete_connection(connection))
+ connection.newRoom.connect(self.add_room)
+ self.room_order.connect_signals(connection)
+
+ for room in connection.all_rooms():
+ self.add_room(room)
+
+ def delete_connection(self, connection: Quotient.Connection):
+ conn = next((conn for conn in self.connections if conn == connection), None)
+ if conn == None:
+ print('Connection is missing in the rooms model')
+ return
+
+ for room in connection.all_rooms():
+ self.delete_room(room)
+ self.connections.remove(connection)
+
+ @QtCore.Slot(Quotient.Room)
+ def add_room(self, room: Quotient.Room) -> None:
+ self.add_room_to_groups(room)
+ self.connect_room_signals(room)
+
+ def add_room_to_groups(self, room: Quotient.Room, groups_keys: List[str] = None) -> None:
+ if groups_keys is None:
+ groups_keys = []
+
+ if len(groups_keys) == 0:
+ groups_keys = self.room_order.room_groups(room)
+
+ for group_key in groups_keys:
+ inserted_group = self.try_insert_group(group_key)
+ lower_bound_room = self.lower_bound_room(inserted_group, room)
+ if lower_bound_room == room:
+ print("RoomListModel: room is already listed under group")
+ continue
+
+ group_index = self.index(len(self.room_groups), 0)
+ try:
+ room_index = inserted_group.rooms.index(room)
+ except ValueError:
+ room_index = 0
+
+ self.begin_insert_rows(group_index, room_index, room_index)
+ inserted_group.rooms.insert(room_index, room)
+ self.end_insert_rows()
+ self.room_indices[room] = self.index(room_index, 0, group_index)
+ print(f"RoomListModel: Added {room.object_name} to group {group_key}")
+
+ def try_insert_group(self, group_key: str) -> RoomGroup:
+ lower_bound_group = self.lower_bound_group(group_key)
+ if lower_bound_group is None:
+ group_index = len(self.room_groups)
+ # TODO: const auto affectedIdxs = preparePersistentIndexChange(gPos, 1);
+ self.begin_insert_rows(QtCore.QModelIndex(), group_index, group_index)
+ room_group = RoomGroup(group_key)
+ self.room_groups.append(room_group) # TODO: insert on correct position
+ lower_bound_group = room_group
+ self.end_insert_rows()
+ # TODO: changePersistentIndexList(affectedIdxs.first, affectedIdxs.second);
+ self.groupAdded.emit(self.room_groups.index(room_group))
+ # TODO
+ return lower_bound_group
+
+
+ def connect_room_signals(self, room: Quotient.Room) -> None:
+ room.beforeDestruction.connect(self.delete_room)
+ self.room_order.connect_signals(room)
+ room.displaynameChanged.connect(lambda: self.refresh(room))
+ room.unreadMessagesChanged.connect(lambda: self.refresh(room))
+ room.notificationCountChanged.connect(lambda: self.refresh(room))
+ room.avatarChanged.connect(lambda: self.refresh(room, [QtCore.Qt.DecorationRole]))
+
+ @QtCore.Slot(Quotient.Room)
+ def delete_room(self, room: Quotient.Room) -> None:
+ self.visit_room(room, self.do_remove_room)
+
+ def do_remove_room(self, index: QtCore.QModelIndex) -> None:
+ if not self.is_valid_room_index(index):
+ print(f'Attempt to remove a room at invalid index {index}')
+
+ group_position = index.parent.row
+ group = self.room_groups[group_position]
+ room = group.rooms[index.row]
+ print(f'RoomListModel: Removing room {room.object_name} from group {group.key}')
+ try:
+ del self.room_indices[room]
+ except KeyError:
+ print(f'Index {index} for room {room.object_name} not found in the index registry')
+
+ self.begin_remove_rows(index.parent, index.row, index.row)
+ group.rooms.remove(room)
+ self.end_remove_rows()
+
+ if len(group.rooms) == 0:
+ # Update persistent indices with parents after the deleted one
+ affected_indexes = self.prepare_persistent_index_change(group_position + 1, -1)
+
+ self.begin_remove_rows([], group_position, group_position)
+ del self.room_groups[group_position]
+ self.end_remove_rows()
+
+ self.change_persistent_index_list(affected_indexes[0], affected_indexes[1])
+
+ def refresh(self, room: Quotient.Room, roles: List[int] = None) -> None:
+ if roles is None:
+ roles = []
+
+ # The problem here is that the change might cause the room to change
+ # its groups. Assume for now that such changes are processed elsewhere
+ # where details about the change are available (e.g. in tagsChanged).
+ def refresh_visitor(index: QtCore.QModelIndex) -> None:
+ self.dataChanged.emit(index, index, roles)
+ self.dataChanged.emit(index.parent, index.parent, roles)
+
+ self.visit_room(room, refresh_visitor)
+
+ def visit_room(self, room: Quotient.Room, visitor: Visitor) -> None:
+ # Copy persistent indices because visitors may alter m_roomIndices
+ indices = self.room_indices.values()
+ for index in indices:
+ room_at_index = self.room_at(index)
+ if room_at_index == room:
+ visitor(index)
+ elif room_at_index is not None:
+ print(f'Room at {index} is {self.room_at(index).object_name} instead of {room.object_name}')
+ else:
+ print(f'Room at index {index} not found')
+
+ def room_at(self, index: QtCore.QModelIndex) -> Optional[Quotient.Room]:
+ if self.is_valid_room_index(index):
+ return self.room_groups[index.parent.row].rooms[index.row]
+ return None
+
+ def total_rooms(self) -> int:
+ return functools.reduce(lambda c1, c2: len(c1.all_rooms()) + len(c2.all_rooms()), self.connections)
+
+ def set_order(self, order: AbstractRoomOrdering) -> None:
+ self.begin_reset_model()
+ self.room_groups.clear()
+ self.room_indices.clear()
+ self.room_order = order
+ self.end_reset_model()
+
+ for connection in self.connections:
+ self.room_order.connect_signals(connection)
+ for room in connection.all_rooms():
+ self.add_room_to_groups(room)
+ self.room_order.connect_signals(room)
+
+ def update_groups(self, room: Quotient.Room) -> None:
+ groups = self.room_order.room_groups(room)
+ old_room_index = self.room_indices[room] # TODO: should be multiple?
+
+ group_index = old_room_index.parent()
+ group = self.room_groups[group_index.row()]
+ try:
+ groups.remove(group.key)
+ # The room still in this group but may need to move around
+ # TODO: move rows if needed
+
+ assert self.room_at(old_room_index) == room
+ except ValueError:
+ self.do_remove_room(old_room_index)
+
+ if len(groups) > 0:
+ self.add_room_to_groups(room, groups) # Groups the room wasn't before
+ print(f"RoomListModel: groups for {room.object_name()} updated")
+
+ def lower_bound_group(self, group_key: str, room = '') -> Optional[RoomGroup]:
+ found_group = None
+ for room_group in self.room_groups:
+ if not self.room_order.group_less_than(room_group, group_key):
+ found_group = room_group
+ break
+ if found_group is not None:
+ return found_group
+ else:
+ if len(self.room_groups) == 0:
+ return None
+ return self.room_groups[len(self.room_groups) - 1]
+
+
+ def lower_bound_room(self, group: RoomGroup, room: Quotient.Room):
+ found_room = None
+ for group_room in group.rooms:
+ if not self.room_order.room_less_than(group_room, group.key):
+ found_room = group_room
+ break
+ if found_room:
+ return found_room
+ else:
+ if len(group.rooms) == 0:
+ return None
+ return group.rooms[len(group.rooms) - 1]
+
+ def index(self, row: int, column: int, parent: QtCore.QModelIndex = QtCore.QModelIndex()):
+ if not self.has_index(row, column, parent):
+ return QtCore.QModelIndex()
+
+ # Groups get internalId() == -1, rooms get the group ordinal number
+ parent_row = -1
+ if parent:
+ parent_row = parent.row
+ return self.create_index(row, column, parent_row)
+
+ def parent(self, child: QtCore.QModelIndex):
+ parent_pos = int(child.internal_id())
+ # TODO: fix OverflowError (point to unexisting data?)
+ # if child.is_valid() and parent_pos > -1:
+ # return self.index(parent_pos, 0)
+ return QtCore.QModelIndex()
+
+ def room_group_at(self, index: QtCore.QModelIndex):
+ assert index.is_valid() # Root item shouldn't come here
+ # If we're on a room, find its group; otherwise just take the index
+ group_index = index
+ if index.parent().is_valid():
+ group_index = index.parent()
+ try:
+ return self.room_groups[group_index.row()].key
+ except ValueError:
+ return ''
+
+ def index_of(self, group_key, room = None):
+ if room is not None:
+ index = self.room_indices.get(room, None)
+ if group_key == '' and index != None:
+ return index;
+
+ for room_index in self.room_indices.keys():
+ if self.room_groups[room_index.parent().row()].key == group_key:
+ return room_index
+ return QtCore.QModelIndex()
+ else:
+ group = self.lower_bound_group(group_key)
+ if group not in self.room_groups:
+ # Group not found
+ return QtCore.QModelIndex()
+ return self.index(self.room_groups.index(group), 0)
diff --git a/demo/roomlistdock.py b/demo/roomlistdock.py
new file mode 100644
index 0000000..5295efe
--- /dev/null
+++ b/demo/roomlistdock.py
@@ -0,0 +1,112 @@
+from demo.models.abstractroomordering import RoomGroup
+from PySide6 import QtCore, QtWidgets, QtGui
+from PyQuotient import Quotient
+from demo.pyquaternionroom import PyquaternionRoom
+from demo.models.roomlistmodel import RoomListModel, Roles
+from demo.models.orderbytag import OrderByTag
+from __feature__ import snake_case, true_property
+
+
+class RoomListItemDelegate(QtWidgets.QStyledItemDelegate):
+ def paint(self, painter: QtGui.QPainter, option: QtWidgets.QStyleOptionViewItem, index: QtCore.QModelIndex):
+ new_option = QtWidgets.QStyleOptionViewItem(option)
+ if not index.parent().is_valid():
+ # Group captions
+ new_option.display_alignment = QtCore.Qt.AlignHCenter
+ new_option.font.bold = True
+
+ if index.data(Roles.HasUnreadRole) is not None:
+ new_option.font.bold = True
+
+ if int(index.data(Roles.HighlightCountRole)) > 0:
+ highlight_color = QtGui.QColor("orange")
+ new_option.palette.set_color(QtGui.QPalette.Text, highlight_color)
+ # Highlighting the text may not work out on monochrome colour schemes,
+ # hence duplicating with italic font.
+ new_option.font.italic = True
+
+ join_state = index.data(Roles.JoinStateRole)
+ if join_state == "invite":
+ new_option.font.italic = True
+ elif join_state == "leave" or join_state == "upgraded":
+ new_option.font.strike_out = True
+
+ super().paint(painter, new_option, index)
+
+class RoomListDock(QtWidgets.QDockWidget):
+ roomSelected = QtCore.Signal(PyquaternionRoom)
+
+ def __init__(self, parent=None) -> None:
+ super().__init__("Rooms", parent)
+ self.selected_group_cache = None
+ self.selected_room_cache = None
+
+ self.object_name = "RoomsDock"
+ self.view = QtWidgets.QTreeView(self)
+ self.model = RoomListModel(self.view)
+ self.update_sorting_mode()
+ self.view.set_model(self.model)
+ self.view.set_item_delegate(RoomListItemDelegate(self))
+ self.view.animated = True
+ self.view.uniform_row_heights = True
+ self.view.selection_behavior = QtWidgets.QTreeView.SelectRows
+ self.view.header_hidden = True
+ self.view.indentation = 0
+ self.view.root_is_decorated = False
+
+ self.view.activated.connect(self.row_selected) # See Quaternion #608
+ self.view.clicked.connect(self.row_selected)
+ self.model.rowsInserted.connect(self.refresh_title)
+ self.model.rowsRemoved.connect(self.refresh_title)
+ self.model.saveCurrentSelection.connect(self.save_current_selection)
+
+ self.set_widget(self.view)
+
+ def add_connection(self, connection: Quotient.Connection):
+ self.model.add_connection(connection)
+
+ @QtCore.Slot()
+ def set_selected_room(self, room: PyquaternionRoom):
+ if self.get_selected_room() == room:
+ return
+
+ # First try the current group; if that fails, try the entire list
+ index = None
+ current_group = self.get_selected_group()
+ if current_group is not None:
+ index = self.model.index_of(current_group, room)
+ if not index.is_valid():
+ index = self.model.index_of(RoomGroup(''), room)
+ if index.is_valid():
+ self.view.current_index = index
+ self.view.scroll_to(index)
+
+ @QtCore.Slot()
+ def update_sorting_mode(self):
+ self.model.set_order(OrderByTag(self.model))
+
+ @QtCore.Slot(QtCore.QModelIndex)
+ def row_selected(self, index: QtCore.QModelIndex):
+ if self.model.is_valid_room_index(index):
+ self.roomSelected.emit(self.model.room_at(index))
+
+ @QtCore.Slot()
+ def refresh_title(self):
+ self.window_title = f"Rooms ({self.model.total_rooms()})"
+
+ @QtCore.Slot()
+ def save_current_selection(self):
+ self.selected_room_cache = self.get_selected_room()
+ self.selected_group_cache = self.get_selected_group()
+
+ def get_selected_room(self):
+ index = self.view.current_index()
+ if not index.is_valid() or not index.parent().is_valid():
+ return None
+ return self.model.room_at(index)
+
+ def get_selected_group(self):
+ index = self.view.current_index()
+ if not index.is_valid():
+ return None
+ return self.model.room_group_at(index)
diff --git a/tests/test_room.py b/tests/test_room.py
new file mode 100644
index 0000000..9761a6e
--- /dev/null
+++ b/tests/test_room.py
@@ -0,0 +1,17 @@
+from PyQuotient import Quotient
+from __feature__ import snake_case, true_property
+
+
+def test_init():
+ connection = Quotient.Connection()
+ room = Quotient.Room(connection, 'room1', Quotient.JoinState.Join)
+ assert isinstance(room, Quotient.Room)
+
+
+def test_subclass():
+ class PyRoom(Quotient.Room):
+ ...
+
+ connection = Quotient.Connection()
+ room = PyRoom(connection, 'room1', Quotient.JoinState.Join)
+ assert isinstance(room, PyRoom)
diff --git a/tests/test_settings.py b/tests/test_settings.py
new file mode 100644
index 0000000..94b48bb
--- /dev/null
+++ b/tests/test_settings.py
@@ -0,0 +1,20 @@
+from PyQuotient import Quotient
+from __feature__ import snake_case, true_property
+
+
+class TestSettings:
+ def test_init(self):
+ settings = Quotient.Settings()
+ assert isinstance(settings, Quotient.Settings)
+
+
+class TestSettingsGroup:
+ def test_init(self):
+ settings_group = Quotient.SettingsGroup('group1')
+ assert isinstance(settings_group, Quotient.SettingsGroup)
+
+
+class TestAccountSettings:
+ def test_init(self):
+ account_settings = Quotient.AccountSettings('account1')
+ assert isinstance(account_settings, Quotient.AccountSettings)