From 6564720d0d6ad44da4e0326601abe3324725f167 Mon Sep 17 00:00:00 2001 From: Vladyslav Hnatiuk Date: Wed, 18 Aug 2021 06:29:26 +0200 Subject: [PATCH 01/12] Event, RoomEvent, StateBaseEvent bindings. --- CMakeLists.txt | 16 +++++++++++++++ PyQuotient/bindings.h | 9 ++++++++- .../typesystems/events/typesystem_event.xml | 9 +++++++++ .../events/typesystem_roomevent.xml | 10 ++++++++++ .../events/typesystem_stateevent.xml | 8 ++++++++ .../typesystems/typesystem_eventitem.xml | 20 +++++++++++++++++++ PyQuotient/typesystems/typesystem_events.xml | 5 +++++ PyQuotient/typesystems/typesystem_index.xml | 8 ++++++++ tests/test_events.py | 17 ++++++++++++++++ 9 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 PyQuotient/typesystems/events/typesystem_event.xml create mode 100644 PyQuotient/typesystems/events/typesystem_roomevent.xml create mode 100644 PyQuotient/typesystems/events/typesystem_stateevent.xml create mode 100644 PyQuotient/typesystems/typesystem_eventitem.xml create mode 100644 PyQuotient/typesystems/typesystem_events.xml create mode 100644 tests/test_events.py diff --git a/CMakeLists.txt b/CMakeLists.txt index 690d042..870e238 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -421,6 +421,22 @@ quotient_joinroomjob_wrapper.h quotient_querykeysjob_deviceinformation_wrapper.h quotient_requesttokento3pidmsisdnjob_wrapper.h quotient_upgraderoomjob_wrapper.h +quotient_stateeventbase_wrapper.cpp +quotient_stateeventbase_wrapper.h +quotient_event_wrapper.cpp +quotient_event_wrapper.h +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 +quotient_timelineitem_wrapper.h +quotient_pendingeventitem_wrapper.cpp +quotient_pendingeventitem_wrapper.h ) # generated wrappers end diff --git a/PyQuotient/bindings.h b/PyQuotient/bindings.h index 79ab217..908d225 100644 --- a/PyQuotient/bindings.h +++ b/PyQuotient/bindings.h @@ -9,7 +9,7 @@ #include "libQuotient/lib/jobs/basejob.h" // jobs/typesystem_requestdata #include "libQuotient/lib/jobs/requestdata.h" - +#include "libQuotient/lib/quotient_common.h" // typesystem_avatar // #include "libQuotient/lib/avatar.h" // typesystem_connectiondata @@ -26,6 +26,8 @@ #include "libQuotient/lib/ssosession.h" // typesystem_user #include "libQuotient/lib/user.h" +// typesystem_eventitem +#include "libQuotient/lib/eventitem.h" // /* // * This part is autogenerated and is replaced on each API update, @@ -106,4 +108,9 @@ #include "libQuotient/lib/csapi/definitions/wellknown/identity_server.h" /* CSAPI end */ /* End of autogenerated part */ + +#include "libQuotient/lib/events/event.h" +#include "libQuotient/lib/events/roomevent.h" +#include "libQuotient/lib/events/stateevent.h" + #endif // BINDINGS_H diff --git a/PyQuotient/typesystems/events/typesystem_event.xml b/PyQuotient/typesystems/events/typesystem_event.xml new file mode 100644 index 0000000..36d8e5c --- /dev/null +++ b/PyQuotient/typesystems/events/typesystem_event.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/PyQuotient/typesystems/events/typesystem_roomevent.xml b/PyQuotient/typesystems/events/typesystem_roomevent.xml new file mode 100644 index 0000000..fb338bf --- /dev/null +++ b/PyQuotient/typesystems/events/typesystem_roomevent.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/PyQuotient/typesystems/events/typesystem_stateevent.xml b/PyQuotient/typesystems/events/typesystem_stateevent.xml new file mode 100644 index 0000000..a295efd --- /dev/null +++ b/PyQuotient/typesystems/events/typesystem_stateevent.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/PyQuotient/typesystems/typesystem_eventitem.xml b/PyQuotient/typesystems/typesystem_eventitem.xml new file mode 100644 index 0000000..4ad94da --- /dev/null +++ b/PyQuotient/typesystems/typesystem_eventitem.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/PyQuotient/typesystems/typesystem_events.xml b/PyQuotient/typesystems/typesystem_events.xml new file mode 100644 index 0000000..329ba2d --- /dev/null +++ b/PyQuotient/typesystems/typesystem_events.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/PyQuotient/typesystems/typesystem_index.xml b/PyQuotient/typesystems/typesystem_index.xml index 9704f8c..6910cfd 100644 --- a/PyQuotient/typesystems/typesystem_index.xml +++ b/PyQuotient/typesystems/typesystem_index.xml @@ -1,4 +1,9 @@ + + + + + @@ -10,4 +15,7 @@ + + + diff --git a/tests/test_events.py b/tests/test_events.py new file mode 100644 index 0000000..64402e4 --- /dev/null +++ b/tests/test_events.py @@ -0,0 +1,17 @@ +from PyQuotient import Quotient +from __feature__ import snake_case, true_property + + +def test_event_init(): + event = Quotient.Event(1, {'a': 'b'}) + assert isinstance(event, Quotient.Event) + + +def test_roomevent_init(): + room_event = Quotient.RoomEvent(1, {'a': 'b'}) + assert isinstance(room_event, Quotient.RoomEvent) + + +def test_stateeventbase_init(): + state_event_base = Quotient.StateEventBase(1, {'a': 'b'}) + assert isinstance(state_event_base, Quotient.StateEventBase) From 89ea564771315764de6f1341d2d78906398d62e8 Mon Sep 17 00:00:00 2001 From: Vladyslav Hnatiuk Date: Wed, 18 Aug 2021 06:34:49 +0200 Subject: [PATCH 02/12] Fix server edit slot in login window. --- demo/logindialog.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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 From c9adec6df73b837c5287593ca3aa26c355cde03d Mon Sep 17 00:00:00 2001 From: Vladyslav Hnatiuk Date: Wed, 18 Aug 2021 06:39:17 +0200 Subject: [PATCH 03/12] Subclass quotient room(in tests and demo). --- demo/pyquaternionroom.py | 19 +++++++++++++++++++ tests/test_room.py | 17 +++++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 demo/pyquaternionroom.py create mode 100644 tests/test_room.py diff --git a/demo/pyquaternionroom.py b/demo/pyquaternionroom.py new file mode 100644 index 0000000..898fb77 --- /dev/null +++ b/demo/pyquaternionroom.py @@ -0,0 +1,19 @@ +from PySide6 import QtCore +from PyQuotient import Quotient +from __feature__ import snake_case, true_property + + +class PyquaternionRoom(Quotient.Room): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self._html_safe_display_name_value = '' + + def _html_safe_display_name(self): + return self._html_safe_display_name_value + + @QtCore.Signal + def htmlSafeDisplayNameChanged(self): + ... + + html_safe_display_name = QtCore.Property(str, _html_safe_display_name, notify=htmlSafeDisplayNameChanged) 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) From 02addf099324aa49c4e2b9a9be53e1f8f274d9c2 Mon Sep 17 00:00:00 2001 From: Vladyslav Hnatiuk Date: Wed, 18 Aug 2021 07:15:18 +0200 Subject: [PATCH 04/12] Room list dock widget. --- demo/roomlistdock.py | 112 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 demo/roomlistdock.py 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) From 2485fe21b223ac42a23b4baf1d1bc581b63459e2 Mon Sep 17 00:00:00 2001 From: Vladyslav Hnatiuk Date: Wed, 18 Aug 2021 07:17:55 +0200 Subject: [PATCH 05/12] Account registry. --- demo/accountregistry.py | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 demo/accountregistry.py 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 From 6b7a46ef94877a147fff7b324f5027846590def9 Mon Sep 17 00:00:00 2001 From: Vladyslav Hnatiuk Date: Wed, 18 Aug 2021 10:38:14 +0200 Subject: [PATCH 06/12] Add room list in demo. --- demo/mainwindow.py | 88 ++++++++- demo/models/abstractroomordering.py | 43 ++++ demo/models/orderbytag.py | 214 ++++++++++++++++++++ demo/models/roomlistmodel.py | 296 ++++++++++++++++++++++++++++ 4 files changed, 636 insertions(+), 5 deletions(-) create mode 100644 demo/models/abstractroomordering.py create mode 100644 demo/models/orderbytag.py create mode 100644 demo/models/roomlistmodel.py 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..37d4eab --- /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_then(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 + + # 3a. 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..d7cf118 --- /dev/null +++ b/demo/models/roomlistmodel.py @@ -0,0 +1,296 @@ +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, lambda index: self.do_remove_room(index)) + + 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.do_set_order(order) + + def do_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) From 8e4bff9287e60b337d01a09b24dc1763bf826158 Mon Sep 17 00:00:00 2001 From: Vladyslav Hnatiuk Date: Wed, 18 Aug 2021 10:39:53 +0200 Subject: [PATCH 07/12] Add bindigs for settings. Remove commented code in connection bindings. --- .../typesystems/typesystem_connection.xml | 19 +----------------- .../typesystems/typesystem_settings.xml | 6 ++++++ tests/test_settings.py | 20 +++++++++++++++++++ 3 files changed, 27 insertions(+), 18 deletions(-) create mode 100644 tests/test_settings.py 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_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/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) From 60debf605df5ae44fdf230d83d2454794d3bb47e Mon Sep 17 00:00:00 2001 From: Vladyslav Hnatiuk Date: Wed, 18 Aug 2021 10:45:47 +0200 Subject: [PATCH 08/12] Add new files in source list. --- CMakeLists.txt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CMakeLists.txt b/CMakeLists.txt index 870e238..5942597 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 From 63e5dd11cfdcc3bb4c5775f37a8b5e99eddd1072 Mon Sep 17 00:00:00 2001 From: Hnatiuk Vladyslav Date: Sat, 28 Aug 2021 16:55:24 +0200 Subject: [PATCH 09/12] Apply suggestions from code review Co-authored-by: Alexey Rusakov --- demo/models/orderbytag.py | 4 ++-- demo/models/roomlistmodel.py | 3 --- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/demo/models/orderbytag.py b/demo/models/orderbytag.py index 37d4eab..33a8ac9 100644 --- a/demo/models/orderbytag.py +++ b/demo/models/orderbytag.py @@ -171,7 +171,7 @@ def get_filtered_tags(self, room: Quotient.Room) -> List[str]: result.append(tag) # Only copy tags that are not disabled return result - def room_less_then(self, group_key: str, room1: Quotient.Room, room2: Quotient.Room): + 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 @@ -206,7 +206,7 @@ def room_less_then(self, group_key: str, room1: Quotient.Room, room2: Quotient.R if connection1.user_id != connection2.user_id: return connection1.user_id < connection2.user_id - # 3a. Two logins under the same userid: pervert, but technically correct + # 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 diff --git a/demo/models/roomlistmodel.py b/demo/models/roomlistmodel.py index d7cf118..74d64f5 100644 --- a/demo/models/roomlistmodel.py +++ b/demo/models/roomlistmodel.py @@ -189,9 +189,6 @@ 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.do_set_order(order) - - def do_set_order(self, order: AbstractRoomOrdering) -> None: self.begin_reset_model() self.room_groups.clear() self.room_indices.clear() From f6e3179e09b5413d33c19028e9d12fd08da6d3c8 Mon Sep 17 00:00:00 2001 From: Vladyslav Hnatiuk Date: Sat, 28 Aug 2021 16:57:49 +0200 Subject: [PATCH 10/12] Remove unused custom room class. --- demo/pyquaternionroom.py | 19 ------------------- 1 file changed, 19 deletions(-) delete mode 100644 demo/pyquaternionroom.py diff --git a/demo/pyquaternionroom.py b/demo/pyquaternionroom.py deleted file mode 100644 index 898fb77..0000000 --- a/demo/pyquaternionroom.py +++ /dev/null @@ -1,19 +0,0 @@ -from PySide6 import QtCore -from PyQuotient import Quotient -from __feature__ import snake_case, true_property - - -class PyquaternionRoom(Quotient.Room): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - self._html_safe_display_name_value = '' - - def _html_safe_display_name(self): - return self._html_safe_display_name_value - - @QtCore.Signal - def htmlSafeDisplayNameChanged(self): - ... - - html_safe_display_name = QtCore.Property(str, _html_safe_display_name, notify=htmlSafeDisplayNameChanged) From 995c55fe121e45f21aff644b87ce26bcdfb8bb34 Mon Sep 17 00:00:00 2001 From: Hnatiuk Vladyslav Date: Sat, 28 Aug 2021 17:02:22 +0200 Subject: [PATCH 11/12] Small refactor in demo/models/roomlistmodel.py Co-authored-by: Alexey Rusakov --- demo/models/roomlistmodel.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/demo/models/roomlistmodel.py b/demo/models/roomlistmodel.py index 74d64f5..7d39c86 100644 --- a/demo/models/roomlistmodel.py +++ b/demo/models/roomlistmodel.py @@ -126,7 +126,7 @@ def connect_room_signals(self, room: Quotient.Room) -> None: @QtCore.Slot(Quotient.Room) def delete_room(self, room: Quotient.Room) -> None: - self.visit_room(room, lambda index: self.do_remove_room(index)) + 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): From 1237c980f42074ae4752acfda67c9cf24bbe2f41 Mon Sep 17 00:00:00 2001 From: Vladyslav Hnatiuk Date: Sat, 28 Aug 2021 17:14:03 +0200 Subject: [PATCH 12/12] Update bindings for EventStatus. --- CMakeLists.txt | 2 -- PyQuotient/typesystems/typesystem_eventitem.xml | 4 ++-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 5942597..4ce1eed 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -433,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_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 @@ - + - +