Capitalization, list model and room header work

- Standardized capitalization for variables and file names everywhere in
  QML and JS, get rid of mixed camelCase/snakeCase,
  use camelCase like everywhere in Qt

- ListModel items are now stored and returned as real QObjects with
  PyQt properties and signals.
  This makes dynamic property binding a lot easier and eliminates the need
  for many hacks.

- New update(), updateOrAppendWhere() methods and roles property
  for ListModel

- RoomHeader now properly updates when the room title or topic changes

- Add Backend.pdb(), to make it easier to start the debugger from QML
This commit is contained in:
miruka 2019-04-20 17:36:21 -04:00
parent b33f5f1d34
commit 8f35e60801
34 changed files with 304 additions and 250 deletions

View File

@ -6,12 +6,14 @@ PKG_DIR = harmonyqml
PYTHON = python3 PYTHON = python3
PIP = pip3 PIP = pip3
PYLINT = pylint PYLINT = pylint
MYPY = mypy
VULTURE = vulture VULTURE = vulture
CLOC = cloc CLOC = cloc
ARCHIVE_FORMATS = gztar ARCHIVE_FORMATS = gztar
INSTALL_FLAGS = --user --editable INSTALL_FLAGS = --user --editable
PYLINT_FLAGS = --output-format colorized PYLINT_FLAGS = --output-format colorized
MYPY_FLAGS = --ignore-missing-imports
VULTURE_FLAGS = --min-confidence 100 VULTURE_FLAGS = --min-confidence 100
CLOC_FLAGS = --ignore-whitespace CLOC_FLAGS = --ignore-whitespace
@ -47,6 +49,8 @@ upload: dist
test: test:
- ${PYLINT} ${PYLINT_FLAGS} ${PKG_DIR} *.py - ${PYLINT} ${PYLINT_FLAGS} ${PKG_DIR} *.py
@echo @echo
- ${MYPY} ${PKG_DIR} ${MYPY_FLAGS}
@echo
- ${VULTURE} ${PKG_DIR} ${VULTURE_FLAGS} - ${VULTURE} ${PKG_DIR} ${VULTURE_FLAGS}
@echo @echo
${CLOC} ${CLOC_FLAGS} ${PKG_DIR} ${CLOC} ${CLOC_FLAGS} ${PKG_DIR}

View File

@ -1,6 +1,5 @@
- Separate categories for invited, group and direct rooms - Separate categories for invited, group and direct rooms
- Invited → Accept/Deny dialog - Invited → Accept/Deny dialog
- Keep the room header name and topic updated
- Merge login page - Merge login page
- When inviting someone to direct chat, room is "Empty room" until accepted, - When inviting someone to direct chat, room is "Empty room" until accepted,
@ -14,7 +13,7 @@
- Use Loader? for MessageDelegate to show sub-components based on condition - Use Loader? for MessageDelegate to show sub-components based on condition
- Better names and organization for the Message components - Better names and organization for the Message components
- Migrate more JS functions to their own files - Migrate more JS functions to their own files / Implement in Python instead
- Set Qt parents for all QObject - Set Qt parents for all QObject
@ -40,3 +39,5 @@
- Verify E2E working - Verify E2E working
- Multiaccount aliases - Multiaccount aliases
- Fix tooltip hide()

View File

@ -3,7 +3,7 @@
import hashlib import hashlib
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from typing import Dict, Set from typing import Dict, Sequence, Set
from PyQt5.QtCore import QObject, pyqtProperty, pyqtSlot from PyQt5.QtCore import QObject, pyqtProperty, pyqtSlot
@ -96,3 +96,15 @@ class Backend(QObject):
break break
else: else:
raise ValueError(f"Room not found in any client: {room_id}") raise ValueError(f"Room not found in any client: {room_id}")
@pyqtSlot()
@pyqtSlot(list)
def pdb(self, additional_data: Sequence = ()) -> None:
# pylint: disable=all
ad = additional_data
re = self.models.roomEvents.get(ad[1])
import pdb
from PyQt5.QtCore import pyqtRemoveInputHook
pyqtRemoveInputHook()
pdb.set_trace()

View File

@ -67,11 +67,11 @@ class Client(QObject):
def __repr__(self) -> str: def __repr__(self) -> str:
return "%s(host=%r, port=%r, user_id=%r)" % \ return "%s(host=%r, port=%r, user_id=%r)" % \
(type(self).__name__, self.host, self.port, self.userID) (type(self).__name__, self.host, self.port, self.userId)
@pyqtProperty(str, constant=True) @pyqtProperty(str, constant=True)
def userID(self) -> str: def userId(self) -> str:
return self.nio.user_id return self.nio.user_id

View File

@ -63,7 +63,7 @@ class ClientManager(QObject):
def _on_connected(self, client: Client) -> None: def _on_connected(self, client: Client) -> None:
self.clients[client.userID] = client self.clients[client.userId] = client
self.clientAdded.emit(client) self.clientAdded.emit(client)
client.startSyncing() client.startSyncing()
@ -130,7 +130,7 @@ class ClientManager(QObject):
def configAdd(self, client: Client) -> None: def configAdd(self, client: Client) -> None:
self._write_config({ self._write_config({
**self.configAccounts(), **self.configAccounts(),
**{client.userID: { **{client.userId: {
"hostname": client.nio.host, "hostname": client.nio.host,
"token": client.nio.access_token, "token": client.nio.access_token,
"device_id": client.nio.device_id, "device_id": client.nio.device_id,

View File

@ -1,7 +1,7 @@
# Copyright 2019 miruka # Copyright 2019 miruka
# This file is part of harmonyqml, licensed under GPLv3. # This file is part of harmonyqml, licensed under GPLv3.
from . import items
from .list_model import ListModel from .list_model import ListModel
from .list_model_map import ListModelMap from .list_model_map import ListModelMap
from .qml_models import QMLModels from .qml_models import QMLModels
from . import enums, items

View File

@ -1,31 +0,0 @@
# Copyright 2019 miruka
# This file is part of harmonyqml, licensed under GPLv3.
from enum import Enum
class Activity(Enum):
none = 0
focus = 1
paused_typing = 2
typing = 3
class Presence(Enum):
none = 0
offline = 1
invisible = 2
away = 3
busy = 4
online = 5
class MessageKind(Enum):
audio = "m.audio"
emote = "m.emote"
file = "m.file"
image = "m.image"
location = "m.location"
notice = "m.notice"
text = "m.text"
video = "m.video"

View File

@ -1,29 +1,83 @@
# Copyright 2019 miruka from typing import Any, Callable, Optional, Tuple, Union
# This file is part of harmonyqml, licensed under GPLv3.
from typing import Dict, List, NamedTuple, Optional from PyQt5.QtCore import QObject, pyqtProperty, pyqtSignal
from PyQt5.QtCore import QDateTime
from ..pyqt_future import PyQtFuture
class User(NamedTuple): class ListItem(QObject):
user_id: str roles: Tuple[str, ...] = ()
display_name: PyQtFuture
avatar_url: Optional[str] = None def __init__(self, *args, **kwargs):
status_message: Optional[str] = None super().__init__()
for role, value in zip(self.roles, args):
setattr(self, role, value)
for role, value in kwargs.items():
setattr(self, role, value)
class Room(NamedTuple): def __repr__(self) -> str:
room_id: str return "%s(%s)" % (
display_name: Optional[str] type(self).__name__,
description: str = "" ", ".join((f"{r}={getattr(self, r)!r}" for r in self.roles)),
typing_users: List[str] = [] )
class RoomEvent(NamedTuple): @pyqtProperty(str, constant=True)
type: str def repr(self) -> str:
date_time: QDateTime return repr(self)
dict: Dict[str, str]
is_local_echo: bool = False
def prop(qt_type: Union[str, Callable],
name: str,
signal: Optional[pyqtSignal] = None,
default_value: Any = None) -> pyqtProperty:
def fget(self, name=name, default_value=default_value):
if not hasattr(self, f"_{name}"):
setattr(self, f"_{name}", default_value)
return getattr(self, f"_{name}")
def fset(self, value, name=name, signal=signal):
setattr(self, f"_{name}", value)
if signal:
getattr(self, f"{name}Changed").emit(value)
kws = {"notify": signal} if signal else {"constant": True}
return pyqtProperty(qt_type, fget=fget, fset=fset, **kws)
class User(ListItem):
roles = ("userId", "displayName", "avatarUrl", "statusMessage")
displayNameChanged = pyqtSignal("QVariant")
avatarUrlChanged = pyqtSignal("QVariant")
statusMessageChanged = pyqtSignal(str)
userId = prop(str, "userId")
displayName = prop("QVariant", "displayName", displayNameChanged)
avatarUrl = prop(str, "avatarUrl", avatarUrlChanged)
statusMessage = prop(str, "statusMessage", statusMessageChanged, "")
class Room(ListItem):
roles = ("roomId", "displayName", "topic", "typingUsers")
displayNameChanged = pyqtSignal("QVariant")
topicChanged = pyqtSignal(str)
typingUsersChanged = pyqtSignal("QVariantList")
roomId = prop(str, "roomId")
displayName = prop(str, "displayName", displayNameChanged)
topic = prop(str, "topic", topicChanged, "")
typingUsers = prop(list, "typingUsers", typingUsersChanged, [])
class RoomEvent(ListItem):
roles = ("type", "dateTime", "dict", "isLocalEcho")
type = prop(str, "type")
dateTime = prop("QVariant", "dateTime")
dict = prop("QVariantMap", "dict")
isLocalEcho = prop(bool, "isLocalEcho", None, False)

View File

@ -4,27 +4,25 @@ from typing import (
Sequence, Tuple, Union Sequence, Tuple, Union
) )
from namedlist import namedlist
from PyQt5.QtCore import ( from PyQt5.QtCore import (
QAbstractListModel, QModelIndex, QObject, Qt, pyqtProperty, pyqtSignal, QAbstractListModel, QModelIndex, QObject, Qt, pyqtProperty, pyqtSignal,
pyqtSlot pyqtSlot
) )
NewValue = Union[Mapping[str, Any], Sequence] from .items import ListItem
ReturnItem = Dict[str, Any]
NewItem = Union[ListItem, Mapping[str, Any], Sequence]
class ListModel(QAbstractListModel): class ListModel(QAbstractListModel):
changed = pyqtSignal() changed = pyqtSignal()
def __init__(self, def __init__(self,
initial_data: Optional[List[NewValue]] = None, initial_data: Optional[List[NewItem]] = None,
container: Callable[..., MutableSequence] = list, container: Callable[..., MutableSequence] = list,
parent: Optional[QObject] = None) -> None: parent: Optional[QObject] = None) -> None:
super().__init__(parent) super().__init__(parent)
self._ref_namedlist = None self._data: MutableSequence[ListItem] = container()
self._roles: Tuple[str, ...] = ()
self._data: MutableSequence = container()
if initial_data: if initial_data:
self.extend(initial_data) self.extend(initial_data)
@ -50,57 +48,64 @@ class ListModel(QAbstractListModel):
return self.rowCount() return self.rowCount()
@pyqtProperty(list)
def roles(self) -> Tuple[str, ...]:
return self._data[0].roles if self._data else () # type: ignore
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(f, "utf-8")
for i, f in enumerate(self._roles, 1)} for i, f in enumerate(self.roles, 1)} \
if self._data else {}
def data(self, index: QModelIndex, role: int = Qt.DisplayRole) -> Any: def data(self, index: QModelIndex, role: int = Qt.DisplayRole) -> Any:
if role <= Qt.UserRole: if role <= Qt.UserRole:
return None return None
return self._data[index.row()][role - Qt.UserRole - 1] return getattr(self._data[index.row()],
str(self.roleNames()[role], "utf8"))
def rowCount(self, _: QModelIndex = QModelIndex()) -> int: def rowCount(self, _: QModelIndex = QModelIndex()) -> int:
return len(self._data) return len(self._data)
def _convert_new_value(self, value: NewValue) -> Any: def _convert_new_value(self, value: NewItem) -> ListItem:
if isinstance(value, Mapping): def convert() -> ListItem:
if not self._ref_namedlist: if self._data and isinstance(value, Mapping):
self._ref_namedlist = namedlist("ListItem", value.keys()) assert set(value.keys()) <= set(self.roles), \
self._roles = tuple(value.keys()) f"{value}: must have all these keys: {self.roles}"
return self._ref_namedlist(**value) # type: ignore return type(self._data[0])(**value)
if isinstance(value, Sequence): if not self._data and isinstance(value, Mapping):
if not self._ref_namedlist: raise NotImplementedError("First item must be set from Python")
try:
self._ref_namedlist = namedlist(
value.__class__.__name__, value._fields # type: ignore
)
self._roles = tuple(value._fields) # type: ignore
except AttributeError:
raise TypeError(
"Need a mapping/dict, namedtuple or namedlist as "
"first value to set allowed keys/fields."
)
return self._ref_namedlist(*value) # type: ignore if self._data and isinstance(value, type(self._data[0])):
return value
if not self._data and isinstance(value, ListItem):
return value
raise TypeError("Value must be a mapping or sequence.") raise TypeError("%r: must be mapping or %s" % (
value,
type(self._data[0]).__name__ if self._data else "ListItem"
))
value = convert()
value.setParent(self)
return value
@pyqtProperty(int, constant=True) @pyqtProperty(int, constant=True)
def count(self) -> int: # pylint: disable=arguments-differ def count(self) -> int:
return self.rowCount() return self.rowCount()
@pyqtSlot(int, result="QVariantMap") @pyqtSlot(int, result="QVariant")
def get(self, index: int) -> ReturnItem: def get(self, index: int) -> ListItem:
return self._data[index]._asdict() return self._data[index]
@pyqtSlot(str, "QVariant", result=int) @pyqtSlot(str, "QVariant", result=int)
@ -109,17 +114,17 @@ class ListModel(QAbstractListModel):
if getattr(item, prop) == is_value: if getattr(item, prop) == is_value:
return i return i
raise ValueError(f"No {type(self._ref_namedlist)} in list with " raise ValueError(f"No item in model data with "
f"property {prop!r} set to {is_value!r}.") f"property {prop!r} set to {is_value!r}.")
@pyqtSlot(str, "QVariant", result="QVariantMap") @pyqtSlot(str, "QVariant", result="QVariant")
def getWhere(self, prop: str, is_value: Any) -> ReturnItem: def getWhere(self, prop: str, is_value: Any) -> ListItem:
return self.get(self.indexWhere(prop, is_value)) return self.get(self.indexWhere(prop, is_value))
@pyqtSlot(int, list) @pyqtSlot(int, "QVariantMap")
def insert(self, index: int, value: NewValue) -> None: def insert(self, index: int, value: NewItem) -> None:
value = self._convert_new_value(value) value = self._convert_new_value(value)
self.beginInsertRows(QModelIndex(), index, index) self.beginInsertRows(QModelIndex(), index, index)
self._data.insert(index, value) self._data.insert(index, value)
@ -127,19 +132,44 @@ class ListModel(QAbstractListModel):
self.changed.emit() self.changed.emit()
@pyqtSlot(list) @pyqtSlot("QVariantMap")
def append(self, value: NewValue) -> None: def append(self, value: NewItem) -> None:
self.insert(self.rowCount(), value) self.insert(self.rowCount(), value)
@pyqtSlot(list) @pyqtSlot(list)
def extend(self, values: Iterable[NewValue]) -> None: def extend(self, values: Iterable[NewItem]) -> None:
for val in values: for val in values:
self.append(val) self.append(val)
@pyqtSlot("QVariantMap")
def update(self, index: int, value: NewItem) -> None:
value = self._convert_new_value(value)
for role in self.roles:
setattr(self._data[index], role, getattr(value, role))
qidx = QAbstractListModel.index(self, index, 0)
self.dataChanged.emit(qidx, qidx, self.roleNames())
self.changed.emit()
@pyqtSlot(str, "QVariant", "QVariantMap")
def updateOrAppendWhere(
self, prop: str, is_value: Any, update_with: NewItem
) -> None:
try:
index = self.indexWhere(prop, is_value)
self.update(index, update_with)
except ValueError:
index = self.rowCount()
self.append(update_with)
@pyqtSlot(int, list) @pyqtSlot(int, list)
def set(self, index: int, value: NewValue) -> None: def set(self, index: int, value: NewItem) -> None:
qidx = QAbstractListModel.index(self, index, 0) qidx = QAbstractListModel.index(self, index, 0)
value = self._convert_new_value(value) value = self._convert_new_value(value)
self._data[index] = value self._data[index] = value
@ -149,16 +179,16 @@ class ListModel(QAbstractListModel):
@pyqtSlot(int, str, "QVariant") @pyqtSlot(int, str, "QVariant")
def setProperty(self, index: int, prop: str, value: Any) -> None: def setProperty(self, index: int, prop: str, value: Any) -> None:
self._data[index][self._roles.index(prop)] = value setattr(self._data[index], prop, value)
qidx = QAbstractListModel.index(self, index, 0) qidx = QAbstractListModel.index(self, index, 0)
self.dataChanged.emit(qidx, qidx, self.roleNames()) self.dataChanged.emit(qidx, qidx, self.roleNames())
self.changed.emit() self.changed.emit()
# pylint: disable=invalid-name
@pyqtSlot(int, int) @pyqtSlot(int, int)
@pyqtSlot(int, int, int) @pyqtSlot(int, int, int)
def move(self, from_: int, to: int, n: int = 1) -> None: def move(self, from_: int, to: int, n: int = 1) -> None:
# pylint: disable=invalid-name
qlast = from_ + n - 1 qlast = from_ + n - 1
if (n <= 0) or (from_ == to) or (qlast == to) or \ if (n <= 0) or (from_ == to) or (qlast == to) or \
@ -186,7 +216,7 @@ class ListModel(QAbstractListModel):
@pyqtSlot(int) @pyqtSlot(int)
def remove(self, index: int) -> None: # pylint: disable=arguments-differ def remove(self, index: int) -> None:
self.beginRemoveRows(QModelIndex(), index, index) self.beginRemoveRows(QModelIndex(), index, index)
del self._data[index] del self._data[index]
self.endRemoveRows() self.endRemoveRows()

View File

@ -132,8 +132,6 @@ class NetworkManager:
response = self.read(sock) response = self.read(sock)
except (OSError, RemoteTransportError) as err: except (OSError, RemoteTransportError) as err:
logging.error("Connection error for %s: %s",
nio_func.__name__, str(err))
self._close_socket(sock) self._close_socket(sock)
self.http_disconnect() self.http_disconnect()
retry.sleep(max_time=2) retry.sleep(max_time=2)

View File

@ -31,14 +31,14 @@ class SignalManager(QObject):
def onClientAdded(self, client: Client) -> None: def onClientAdded(self, client: Client) -> None:
self.connectClient(client) self.connectClient(client)
self.backend.models.accounts.append(User( self.backend.models.accounts.append(User(
user_id = client.userID, userId = client.userId,
display_name = self.backend.getUserDisplayName(client.userID), displayName = self.backend.getUserDisplayName(client.userId),
)) ))
def onClientDeleted(self, user_id: str) -> None: def onClientDeleted(self, user_id: str) -> None:
accs = self.backend.models.accounts accs = self.backend.models.accounts
del accs[accs.indexWhere("user_id", user_id)] del accs[accs.indexWhere("userId", user_id)]
def connectClient(self, client: Client) -> None: def connectClient(self, client: Client) -> None:
@ -58,7 +58,7 @@ class SignalManager(QObject):
def onRoomJoined(self, client: Client, room_id: str) -> None: def onRoomJoined(self, client: Client, room_id: str) -> None:
model = self.backend.models.rooms[client.userID] model = self.backend.models.rooms[client.userId]
room = client.nio.rooms[room_id] room = client.nio.rooms[room_id]
def group_name() -> Optional[str]: def group_name() -> Optional[str]:
@ -66,22 +66,17 @@ class SignalManager(QObject):
return None if name == "Empty room?" else name return None if name == "Empty room?" else name
item = Room( item = Room(
room_id = room_id, roomId = room_id,
display_name = room.name or room.canonical_alias or group_name(), displayName = room.name or room.canonical_alias or group_name(),
description = room.topic, topic = room.topic,
) )
try: model.updateOrAppendWhere("roomId", room_id, item)
index = model.indexWhere("room_id", room_id)
except ValueError:
model.append(item)
else:
model[index] = item
def onRoomLeft(self, client: Client, room_id: str) -> None: def onRoomLeft(self, client: Client, room_id: str) -> None:
rooms = self.backend.models.rooms[client.userID] rooms = self.backend.models.rooms[client.userId]
del rooms[rooms.indexWhere("room_id", room_id)] del rooms[rooms.indexWhere("roomId", room_id)]
def onRoomSyncPrevBatchTokenReceived( def onRoomSyncPrevBatchTokenReceived(
@ -116,15 +111,15 @@ class SignalManager(QObject):
model = self.backend.models.roomEvents[room_id] model = self.backend.models.roomEvents[room_id]
date_time = QDateTime\ date_time = QDateTime\
.fromMSecsSinceEpoch(edict["server_timestamp"]) .fromMSecsSinceEpoch(edict["server_timestamp"])
new_event = RoomEvent(type=etype, date_time=date_time, dict=edict) new_event = RoomEvent(type=etype, dateTime=date_time, dict=edict)
if self._events_in_transfer: if self._events_in_transfer:
local_echoes_met: int = 0 local_echoes_met: int = 0
replace_at: Optional[int] = None update_at: Optional[int] = None
# Find if any locally echoed event corresponds to new_event # Find if any locally echoed event corresponds to new_event
for i, event in enumerate(model): for i, event in enumerate(model):
if not event.is_local_echo: if not event.isLocalEcho:
continue continue
sb = (event.dict["sender"], event.dict["body"]) sb = (event.dict["sender"], event.dict["body"])
@ -132,23 +127,23 @@ class SignalManager(QObject):
if sb == new_sb: if sb == new_sb:
# The oldest matching local echo shall be replaced # The oldest matching local echo shall be replaced
replace_at = max(replace_at or 0, i) update_at = max(update_at or 0, i)
local_echoes_met += 1 local_echoes_met += 1
if local_echoes_met >= self._events_in_transfer: if local_echoes_met >= self._events_in_transfer:
break break
if replace_at is not None: if update_at is not None:
model[replace_at] = new_event model.update(update_at, new_event)
self._events_in_transfer -= 1 self._events_in_transfer -= 1
return return
for i, event in enumerate(model): for i, event in enumerate(model):
if event.is_local_echo: if event.isLocalEcho:
continue continue
# Model is sorted from newest to oldest message # Model is sorted from newest to oldest message
if new_event.date_time > event.date_time: if new_event.dateTime > event.dateTime:
model.insert(i, new_event) model.insert(i, new_event)
return return
@ -159,8 +154,8 @@ class SignalManager(QObject):
self, client: Client, room_id: str, users: List[str] self, client: Client, room_id: str, users: List[str]
) -> None: ) -> None:
rooms = self.backend.models.rooms[client.userID] rooms = self.backend.models.rooms[client.userId]
rooms[rooms.indexWhere("room_id", room_id)].typing_users = users rooms[rooms.indexWhere("roomId", room_id)].typingUsers = users
def onMessageAboutToBeSent( def onMessageAboutToBeSent(
@ -171,15 +166,15 @@ class SignalManager(QObject):
model = self.backend.models.roomEvents[room_id] model = self.backend.models.roomEvents[room_id]
nio_event = nio.events.RoomMessage.parse_event({ nio_event = nio.events.RoomMessage.parse_event({
"event_id": "", "event_id": "",
"sender": client.userID, "sender": client.userId,
"origin_server_ts": timestamp, "origin_server_ts": timestamp,
"content": content, "content": content,
}) })
event = RoomEvent( event = RoomEvent(
type = type(nio_event).__name__, type = type(nio_event).__name__,
date_time = QDateTime.fromMSecsSinceEpoch(timestamp), dateTime = QDateTime.fromMSecsSinceEpoch(timestamp),
dict = nio_event.__dict__, dict = nio_event.__dict__,
is_local_echo = True, isLocalEcho = True,
) )
model.insert(0, event) model.insert(0, event)
self._events_in_transfer += 1 self._events_in_transfer += 1

View File

@ -1,7 +1,8 @@
import QtQuick 2.7
import QtQuick.Controls 1.4 as Controls1 import QtQuick.Controls 1.4 as Controls1
import QtQuick.Controls 2.2 import QtQuick.Controls 2.2
import QtQuick.Layouts 1.4 import QtQuick.Layouts 1.4
import "side_pane" as SidePane import "sidePane" as SidePane
import "chat" as Chat import "chat" as Chat
//https://doc.qt.io/qt-5/qml-qtquick-controls-splitview.html //https://doc.qt.io/qt-5/qml-qtquick-controls-splitview.html
@ -14,12 +15,9 @@ Controls1.SplitView {
} }
StackView { StackView {
function show_page(componentName) { function showRoom(userId, roomId) {
pageStack.replace(componentName + ".qml")
}
function show_room(user_id, room_obj) {
pageStack.replace( pageStack.replace(
"chat/Root.qml", { user_id: user_id, room: room_obj } "chat/Root.qml", { userId: userId, roomId: roomId }
) )
console.log("replaced") console.log("replaced")
} }
@ -28,6 +26,12 @@ Controls1.SplitView {
onCurrentItemChanged: currentItem.forceActiveFocus() onCurrentItemChanged: currentItem.forceActiveFocus()
initialItem: MouseArea { // TODO: (test, remove)
onClicked: pageStack.showRoom(
"@test_mary:matrix.org", "!VDSsFIzQnXARSCVNxS:matrix.org"
)
}
// Buggy // Buggy
replaceExit: null replaceExit: null
popExit: null popExit: null

View File

@ -8,7 +8,7 @@ Item {
property var imageSource: null property var imageSource: null
property int dimmension: 48 property int dimmension: 48
readonly property string resolved_name: readonly property string resolvedName:
! name ? "?" : ! name ? "?" :
typeof(name) == "string" ? name : typeof(name) == "string" ? name :
(name.value ? name.value : "?") (name.value ? name.value : "?")
@ -21,13 +21,13 @@ Item {
id: "letterRectangle" id: "letterRectangle"
anchors.fill: parent anchors.fill: parent
visible: ! invisible && imageSource === null visible: ! invisible && imageSource === null
color: resolved_name === "?" ? color: resolvedName === "?" ?
Qt.hsla(0, 0, 0.22, 1) : Qt.hsla(0, 0, 0.22, 1) :
Qt.hsla(Backend.hueFromString(resolved_name), 0.22, 0.5, 1) Qt.hsla(Backend.hueFromString(resolvedName), 0.22, 0.5, 1)
HLabel { HLabel {
anchors.centerIn: parent anchors.centerIn: parent
text: resolved_name.charAt(0) text: resolvedName.charAt(0)
color: "white" color: "white"
font.pixelSize: letterRectangle.height / 1.4 font.pixelSize: letterRectangle.height / 1.4
} }

View File

@ -14,7 +14,7 @@ ToolButton {
onClicked: toolTip.hide() onClicked: toolTip.hide()
ToolTip { ToolTip {
id: "toolTip" id: toolTip
text: tooltip text: tooltip
delay: Qt.styleHints.mousePressAndHoldInterval delay: Qt.styleHints.mousePressAndHoldInterval
visible: text ? toolTipZone.containsMouse : false visible: text ? toolTipZone.containsMouse : false

View File

@ -2,11 +2,14 @@ import QtQuick 2.7
import QtQuick.Controls 2.0 import QtQuick.Controls 2.0
HLabel { HLabel {
property string toolTipText: ""
id: text id: text
ToolTip { ToolTip {
delay: Qt.styleHints.mousePressAndHoldInterval delay: Qt.styleHints.mousePressAndHoldInterval
visible: text ? toolTipZone.containsMouse : false visible: text ? toolTipZone.containsMouse : false
text: user_id text: toolTipText
} }
MouseArea { MouseArea {
id: toolTipZone id: toolTipZone

View File

@ -2,12 +2,13 @@ import QtQuick 2.7
import "../base" as Base import "../base" as Base
Base.HLabel { Base.HLabel {
text: date_time.toLocaleDateString()
width: messageDelegate.width width: messageDelegate.width
horizontalAlignment: Text.AlignHCenter
topPadding: messageDelegate.isFirstMessage ? topPadding: messageDelegate.isFirstMessage ?
0 : messageDelegate.standardSpacing 0 : messageDelegate.standardSpacing
bottomPadding: messageDelegate.standardSpacing bottomPadding: messageDelegate.standardSpacing
text: dateTime.toLocaleDateString()
horizontalAlignment: Text.AlignHCenter
font.pixelSize: normalSize * 1.1 font.pixelSize: normalSize * 1.1
color: "darkolivegreen" color: "darkolivegreen"
} }

View File

@ -11,7 +11,7 @@ RowLayout {
anchors.right: isOwn ? parent.right : undefined anchors.right: isOwn ? parent.right : undefined
readonly property string contentText: readonly property string contentText:
isMessage ? "" : ChatJS.get_event_text(type, dict) isMessage ? "" : ChatJS.getEventText(type, dict)
Base.Avatar { Base.Avatar {
id: avatar id: avatar
@ -26,7 +26,7 @@ RowLayout {
(isUndecryptableEvent ? "darkred" : "gray") + "'>" + (isUndecryptableEvent ? "darkred" : "gray") + "'>" +
(displayName.value || dict.sender) + " " + contentText + (displayName.value || dict.sender) + " " + contentText +
"&nbsp;&nbsp;<font size=" + smallSize + "px color='gray'>" + "&nbsp;&nbsp;<font size=" + smallSize + "px color='gray'>" +
Qt.formatDateTime(date_time, "hh:mm:ss") + Qt.formatDateTime(dateTime, "hh:mm:ss") +
"</font></font>" "</font></font>"
textFormat: Text.RichText textFormat: Text.RichText
background: Rectangle {color: "#DDD"} background: Rectangle {color: "#DDD"}

View File

@ -32,19 +32,13 @@ Row {
Base.RichLabel { Base.RichLabel {
id: contentLabel id: contentLabel
//text: (isOwn ? "" : content + "&nbsp;&nbsp;") +
//"<font size=" + smallSize + "px color=gray>" +
//Qt.formatDateTime(date_time, "hh:mm:ss") +
//"</font>" +
// (isOwn ? "&nbsp;&nbsp;" + content : "")
//
text: (dict.formatted_body ? text: (dict.formatted_body ?
Backend.htmlFilter.filter(dict.formatted_body) : Backend.htmlFilter.filter(dict.formatted_body) :
dict.body) + dict.body) +
"&nbsp;&nbsp;<font size=" + smallSize + "px color=gray>" + "&nbsp;&nbsp;<font size=" + smallSize + "px color=gray>" +
Qt.formatDateTime(date_time, "hh:mm:ss") + Qt.formatDateTime(dateTime, "hh:mm:ss") +
"</font>" + "</font>" +
(is_local_echo ? (isLocalEcho ?
"&nbsp;<font size=" + smallSize + "px>⏳</font>" : "") "&nbsp;<font size=" + smallSize + "px>⏳</font>" : "")
textFormat: Text.RichText textFormat: Text.RichText
background: Rectangle {color: "#DDD"} background: Rectangle {color: "#DDD"}

View File

@ -7,22 +7,22 @@ import "utils.js" as ChatJS
Column { Column {
id: "messageDelegate" id: "messageDelegate"
function mins_between(date1, date2) { function minsBetween(date1, date2) {
return Math.round((((date2 - date1) % 86400000) % 3600000) / 60000) return Math.round((((date2 - date1) % 86400000) % 3600000) / 60000)
} }
function is_message(type_) { return type_.startsWith("RoomMessage") } function getIsMessage(type_) { return type_.startsWith("RoomMessage") }
function get_previous_item() { function getPreviousItem() {
return index < messageListView.model.count - 1 ? return index < messageListView.model.count - 1 ?
messageListView.model.get(index + 1) : null messageListView.model.get(index + 1) : null
} }
property var previousItem: get_previous_item() property var previousItem: getPreviousItem()
signal reloadPreviousItem() signal reloadPreviousItem()
onReloadPreviousItem: previousItem = get_previous_item() onReloadPreviousItem: previousItem = getPreviousItem()
readonly property bool isMessage: is_message(type) readonly property bool isMessage: getIsMessage(type)
readonly property bool isUndecryptableEvent: readonly property bool isUndecryptableEvent:
type === "OlmEvent" || type === "MegolmEvent" type === "OlmEvent" || type === "MegolmEvent"
@ -31,7 +31,7 @@ Column {
Backend.getUserDisplayName(dict.sender) Backend.getUserDisplayName(dict.sender)
readonly property bool isOwn: readonly property bool isOwn:
chatPage.user_id === dict.sender chatPage.userId === dict.sender
readonly property bool isFirstEvent: type == "RoomCreateEvent" readonly property bool isFirstEvent: type == "RoomCreateEvent"
@ -39,19 +39,19 @@ Column {
previousItem && previousItem &&
! talkBreak && ! talkBreak &&
! dayBreak && ! dayBreak &&
is_message(previousItem.type) === isMessage && getIsMessage(previousItem.type) === isMessage &&
previousItem.dict.sender === dict.sender && previousItem.dict.sender === dict.sender &&
mins_between(previousItem.date_time, date_time) <= 5 minsBetween(previousItem.dateTime, dateTime) <= 5
readonly property bool dayBreak: readonly property bool dayBreak:
isFirstEvent || isFirstEvent ||
previousItem && previousItem &&
date_time.getDay() != previousItem.date_time.getDay() dateTime.getDay() != previousItem.dateTime.getDay()
readonly property bool talkBreak: readonly property bool talkBreak:
previousItem && previousItem &&
! dayBreak && ! dayBreak &&
mins_between(previousItem.date_time, date_time) >= 20 minsBetween(previousItem.dateTime, dateTime) >= 20
property int standardSpacing: 16 property int standardSpacing: 16
@ -59,8 +59,8 @@ Column {
property int verticalPadding: 5 property int verticalPadding: 5
ListView.onAdd: { ListView.onAdd: {
var next_delegate = messageListView.contentItem.children[index] var nextDelegate = messageListView.contentItem.children[index]
if (next_delegate) { next_delegate.reloadPreviousItem() } if (nextDelegate) { nextDelegate.reloadPreviousItem() }
} }
width: parent.width width: parent.width

View File

@ -14,8 +14,7 @@ Rectangle {
id: messageListView id: messageListView
anchors.fill: parent anchors.fill: parent
delegate: MessageDelegate {} delegate: MessageDelegate {}
model: Backend.models.roomEvents.get(chatPage.room.room_id) model: Backend.models.roomEvents.get(chatPage.roomId)
//highlight: Rectangle {color: "lightsteelblue"; radius: 5}
clip: true clip: true
topMargin: space topMargin: space
@ -31,7 +30,7 @@ Rectangle {
onYPosChanged: { onYPosChanged: {
if (yPos <= 0.1) { if (yPos <= 0.1) {
Backend.loadPastEvents(chatPage.room.room_id) Backend.loadPastEvents(chatPage.roomId)
} }
} }
} }

View File

@ -4,7 +4,10 @@ import QtQuick.Layouts 1.4
import "../base" as Base import "../base" as Base
Rectangle { Rectangle {
id: root property string displayName: ""
property string topic: ""
id: "root"
Layout.fillWidth: true Layout.fillWidth: true
Layout.minimumHeight: 36 Layout.minimumHeight: 36
Layout.maximumHeight: Layout.minimumHeight Layout.maximumHeight: Layout.minimumHeight
@ -19,21 +22,23 @@ Rectangle {
id: "avatar" id: "avatar"
Layout.alignment: Qt.AlignTop Layout.alignment: Qt.AlignTop
dimmension: root.Layout.minimumHeight dimmension: root.Layout.minimumHeight
name: chatPage.room.display_name name: displayName
} }
Base.HLabel { Base.HLabel {
id: "roomName" id: "roomName"
text: chatPage.room.display_name text: displayName
font.pixelSize: bigSize font.pixelSize: bigSize
elide: Text.ElideRight elide: Text.ElideRight
maximumLineCount: 1 maximumLineCount: 1
Layout.maximumWidth: row.width - row.spacing * (row.children.length - 1) - avatar.width Layout.maximumWidth:
row.width - row.spacing * (row.children.length - 1) -
avatar.width
} }
Base.HLabel { Base.HLabel {
id: "roomDescription" id: "roomTopic"
text: chatPage.room.description || "" text: topic
font.pixelSize: smallSize font.pixelSize: smallSize
elide: Text.ElideRight elide: Text.ElideRight
maximumLineCount: 1 maximumLineCount: 1

View File

@ -3,16 +3,23 @@ import QtQuick.Controls 2.2
import QtQuick.Layouts 1.4 import QtQuick.Layouts 1.4
ColumnLayout { ColumnLayout {
property var user_id: null property var userId: null
property var room: null property var roomId: null
property var roomInfo:
Backend.models.rooms.get(userId).getWhere("roomId", roomId)
id: chatPage id: "chatPage"
spacing: 0 spacing: 0
onFocusChanged: sendBox.setFocus() onFocusChanged: sendBox.setFocus()
RoomHeader {} RoomHeader {
id: "roomHeader"
displayName: roomInfo.displayName
topic: roomInfo.topic
}
MessageList {} MessageList {}
TypingUsersBar {} TypingUsersBar {}
SendBox { id: sendBox } SendBox { id: "sendBox" }
} }

View File

@ -20,7 +20,7 @@ Rectangle {
Base.Avatar { Base.Avatar {
id: "avatar" id: "avatar"
name: Backend.getUserDisplayName(chatPage.user_id) name: Backend.getUserDisplayName(chatPage.userId)
dimmension: root.Layout.minimumHeight dimmension: root.Layout.minimumHeight
//visible: textArea.text === "" //visible: textArea.text === ""
visible: textArea.height <= root.Layout.minimumHeight visible: textArea.height <= root.Layout.minimumHeight
@ -43,13 +43,13 @@ Rectangle {
font.pixelSize: 16 font.pixelSize: 16
focus: true focus: true
function set_typing(typing) { function setTyping(typing) {
Backend.clientManager.clients[chatPage.user_id] Backend.clientManager.clients[chatPage.userId]
.setTypingState(chatPage.room.room_id, typing) .setTypingState(chatPage.roomId, typing)
} }
onTypedTextChanged: set_typing(Boolean(text)) onTypedTextChanged: setTyping(Boolean(text))
onEditingFinished: set_typing(false) // when lost focus onEditingFinished: setTyping(false) // when lost focus
Keys.onReturnPressed: { Keys.onReturnPressed: {
event.accepted = true event.accepted = true
@ -62,8 +62,8 @@ Rectangle {
} }
if (textArea.text === "") { return } if (textArea.text === "") { return }
Backend.clientManager.clients[chatPage.user_id] Backend.clientManager.clients[chatPage.userId]
.sendMarkdown(chatPage.room.room_id, textArea.text) .sendMarkdown(chatPage.roomId, textArea.text)
textArea.clear() textArea.clear()
} }

View File

@ -11,19 +11,12 @@ Rectangle {
Layout.maximumHeight: Layout.minimumHeight Layout.maximumHeight: Layout.minimumHeight
color: "#BBB" color: "#BBB"
property var typingUsers: chatPage.roomInfo.typingUsers
Base.HLabel { Base.HLabel {
id: "usersLabel" id: "usersLabel"
anchors.fill: parent anchors.fill: parent
text: ChatJS.getTypingUsersText(typingUsers, chatPage.userId)
Timer {
interval: 500
repeat: true
running: true
triggeredOnStart: true
onTriggered: usersLabel.text = ChatJS.get_typing_users_text(
chatPage.user_id, chatPage.room.room_id
)
}
elide: Text.ElideMiddle elide: Text.ElideMiddle
maximumLineCount: 1 maximumLineCount: 1

View File

@ -1,4 +1,4 @@
function get_event_text(type, dict) { function getEventText(type, dict) {
switch (type) { switch (type) {
case "RoomCreateEvent": case "RoomCreateEvent":
return (dict.federate ? "allowed" : "blocked") + return (dict.federate ? "allowed" : "blocked") +
@ -18,14 +18,14 @@ function get_event_text(type, dict) {
break break
case "RoomHistoryVisibilityEvent": case "RoomHistoryVisibilityEvent":
return get_history_visibility_event_text(dict) return getHistoryVisibilityEventText(dict)
break break
case "PowerLevelsEvent": case "PowerLevelsEvent":
return "changed the room's permissions." return "changed the room's permissions."
case "RoomMemberEvent": case "RoomMemberEvent":
return get_member_event_text(dict) return getMemberEventText(dict)
break break
case "RoomAliasEvent": case "RoomAliasEvent":
@ -58,7 +58,7 @@ function get_event_text(type, dict) {
} }
function get_history_visibility_event_text(dict) { function getHistoryVisibilityEventText(dict) {
switch (dict.history_visibility) { switch (dict.history_visibility) {
case "shared": case "shared":
var end = "all room members." var end = "all room members."
@ -81,7 +81,7 @@ function get_history_visibility_event_text(dict) {
} }
function get_member_event_text(dict) { function getMemberEventText(dict) {
var info = dict.content, prev = dict.prev_content var info = dict.content, prev = dict.prev_content
if (! prev || (info.membership != prev.membership)) { if (! prev || (info.membership != prev.membership)) {
@ -127,15 +127,12 @@ function get_member_event_text(dict) {
} }
function get_typing_users_text(account_id, room_id) { function getTypingUsersText(users, ourAccountId) {
var names = [] var names = []
var room = Backend.models.rooms.get(account_id)
.getWhere("room_id", room_id)
for (var i = 0; i < room.typing_users.length; i++) { for (var i = 0; i < users.length; i++) {
if (room.typing_users[i] !== account_id) { if (users[i] !== ourAccountId) {
names.push(Backend.getUserDisplayName(room.typing_users[i], false) names.push(Backend.getUserDisplayName(users[i], false).result())
.result())
} }
} }

View File

@ -1,9 +0,0 @@
import QtQuick 2.7
import "../base" as Base
Rectangle {
Base.HLabel {
anchors.centerIn: parent
text: "Home page"
}
}

View File

@ -12,7 +12,7 @@ ColumnLayout {
id: "row" id: "row"
spacing: 0 spacing: 0
Base.Avatar { id: "avatar"; name: display_name; dimmension: 36 } Base.Avatar { id: "avatar"; name: displayName; dimmension: 36 }
ColumnLayout { ColumnLayout {
Layout.fillWidth: true Layout.fillWidth: true
@ -21,7 +21,7 @@ ColumnLayout {
Base.HLabel { Base.HLabel {
id: "accountLabel" id: "accountLabel"
text: display_name.value || user_id text: displayName.value || userId
elide: Text.ElideRight elide: Text.ElideRight
maximumLineCount: 1 maximumLineCount: 1
Layout.fillWidth: true Layout.fillWidth: true
@ -31,7 +31,7 @@ ColumnLayout {
TextField { TextField {
id: "statusEdit" id: "statusEdit"
text: status_message || "" text: statusMessage || ""
placeholderText: qsTr("Set status message") placeholderText: qsTr("Set status message")
background: null background: null
color: "black" color: "black"
@ -44,7 +44,7 @@ ColumnLayout {
rightPadding: leftPadding rightPadding: leftPadding
onEditingFinished: { onEditingFinished: {
Backend.setStatusMessage(user_id, text) Backend.setStatusMessage(userId, text)
pageStack.forceActiveFocus() pageStack.forceActiveFocus()
} }
} }
@ -67,7 +67,7 @@ ColumnLayout {
id: "roomList" id: "roomList"
visible: true visible: true
interactive: false // no scrolling interactive: false // no scrolling
for_user_id: user_id forUserId: userId
Layout.minimumHeight: Layout.minimumHeight:
roomList.visible ? roomList.visible ?

View File

@ -9,24 +9,21 @@ MouseArea {
width: roomList.width width: roomList.width
height: roomList.childrenHeight height: roomList.childrenHeight
onClicked: pageStack.show_room( onClicked: pageStack.showRoom(roomList.forUserId, roomId)
roomList.for_user_id,
roomList.model.get(index)
)
RowLayout { RowLayout {
anchors.fill: parent anchors.fill: parent
id: row id: row
spacing: 1 spacing: 1
Base.Avatar { id: avatar; name: display_name; dimmension: root.height } Base.Avatar { id: avatar; name: displayName; dimmension: root.height }
ColumnLayout { ColumnLayout {
spacing: 0 spacing: 0
Base.HLabel { Base.HLabel {
id: roomLabel id: roomLabel
text: display_name ? display_name : "<i>Empty room</i>" text: displayName ? displayName : "<i>Empty room</i>"
textFormat: Text.StyledText textFormat: Text.StyledText
elide: Text.ElideRight elide: Text.ElideRight
maximumLineCount: 1 maximumLineCount: 1
@ -39,18 +36,18 @@ MouseArea {
rightPadding: leftPadding rightPadding: leftPadding
} }
Base.HLabel { Base.HLabel {
function get_text() { function getText() {
return SidePaneJS.get_last_room_event_text(room_id) return SidePaneJS.getLastRoomEventText(roomId)
} }
Connections { Connections {
target: Backend.models.roomEvents.get(room_id) target: Backend.models.roomEvents.get(roomId)
onChanged: subtitleLabel.text = subtitleLabel.get_text() onChanged: subtitleLabel.text = subtitleLabel.getText()
} }
id: subtitleLabel id: subtitleLabel
visible: text !== "" visible: text !== ""
text: get_text() text: getText()
textFormat: Text.StyledText textFormat: Text.StyledText
font.pixelSize: smallSize font.pixelSize: smallSize

View File

@ -4,7 +4,7 @@ import QtQuick.Layouts 1.4
import "../base" as Base import "../base" as Base
ListView { ListView {
property var for_user_id: null property var forUserId: null
property int childrenHeight: 36 property int childrenHeight: 36
property int contentHeight: 0 property int contentHeight: 0
@ -16,6 +16,6 @@ ListView {
id: "roomList" id: "roomList"
spacing: 8 spacing: 8
model: Backend.models.rooms.get(for_user_id) model: Backend.models.rooms.get(forUserId)
delegate: RoomDelegate {} delegate: RoomDelegate {}
} }

View File

@ -1,8 +1,8 @@
.import "../chat/utils.js" as ChatJS .import "../chat/utils.js" as ChatJS
function get_last_room_event_text(room_id) { function getLastRoomEventText(roomId) {
var eventsModel = Backend.models.roomEvents.get(room_id) var eventsModel = Backend.models.roomEvents.get(roomId)
for (var i = 0; i < eventsModel.count; i++) { for (var i = 0; i < eventsModel.count; i++) {
var ev = eventsModel.get(i) var ev = eventsModel.get(i)
@ -19,7 +19,7 @@ function get_last_room_event_text(room_id) {
var undecryptable = ev.type === "OlmEvent" || ev.type === "MegolmEvent" var undecryptable = ev.type === "OlmEvent" || ev.type === "MegolmEvent"
if (undecryptable || ev.type.startsWith("RoomMessage")) { if (undecryptable || ev.type.startsWith("RoomMessage")) {
var color = ev.dict.sender === roomList.for_user_id ? var color = ev.dict.sender === roomList.forUserId ?
"darkblue" : "purple" "darkblue" : "purple"
return "<font color='" + return "<font color='" +
@ -36,7 +36,7 @@ function get_last_room_event_text(room_id) {
"'>" + "'>" +
name + name +
" " + " " +
ChatJS.get_event_text(ev.type, ev.dict) + ChatJS.getEventText(ev.type, ev.dict) +
"</font>" "</font>"
} }
} }