Build system, messages support and more

This commit is contained in:
miruka 2019-07-02 13:59:52 -04:00
parent 933341b7e6
commit 06c823aa67
53 changed files with 2264 additions and 446 deletions

10
.gitignore vendored
View File

@ -1,12 +1,14 @@
__pycache__
.mypy_cache
build
dist
*.egg-info
*.pyc
*.qmlc
*.jsc
.pylintrc
tmp-*
build
dist
.qmake.stash
Makefile

3
.gitmodules vendored Normal file
View File

@ -0,0 +1,3 @@
[submodule "submodules/SortFilterProxyModel"]
path = submodules/SortFilterProxyModel
url = https://github.com/oKcerG/SortFilterProxyModel

362
.pylintrc Normal file
View File

@ -0,0 +1,362 @@
[MASTER]
# A comma-separated list of package or module names from where C extensions may
# be loaded. Extensions are loading into the active Python interpreter and may
# run arbitrary code
extension-pkg-whitelist=lxml.etree
# Add files or directories to the blacklist. They should be base names, not
# paths.
ignore=.git
# Add files or directories matching the regex patterns to the blacklist. The
# regex matches against base names, not paths.
ignore-patterns=
# Python code to execute, usually for sys.path manipulation such as
# pygtk.require().
#init-hook=
# Use multiple processes to speed up Pylint.
jobs=6
# List of plugins (as comma separated values of python modules names) to load,
# usually to register additional checkers.
load-plugins=
# Pickle collected data for later comparisons.
persistent=yes
# Specify a configuration file.
#rcfile=
# When enabled, pylint would attempt to guess common misconfiguration and emit
# user-friendly hints instead of false-positive error messages
suggestion-mode=yes
# Allow loading of arbitrary C extensions. Extensions are imported into the
# active Python interpreter and may run arbitrary code.
unsafe-load-any-extension=no
[MESSAGES CONTROL]
# Only show warnings with the listed confidence levels. Leave empty to show
# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED
confidence=
# Disable the message, report, category or checker with the given id(s). You
# can either give multiple identifiers separated by comma (,) or put this
# option multiple times (only on the command line, not in the configuration
# file where it should appear only once).You can also use "--disable=all" to
# disable everything first and then reenable specific checks. For example, if
# you want to run only the similarities checker, you can use "--disable=all
# --enable=similarities". If you want to run only the classes checker, but have
# no Warning level messages displayed, use"--disable=all --enable=classes
# --disable=W"
#
# C0111: Allow lack of literal """docstring""" (e.g. if assigning __doc__)
# C0326: Allow aligning things like variable assignements
# C0412: Remove annoying "imports are not grouped" because of :Isort with from
# W0123: Allow eval()
disable=C0111,C0326,C0412,W0123
# (useful for modules/projects where namespaces are manipulated during runtime
# and thus existing member attributes cannot be deduced by static analysis. It
# supports qualified module names, as well as Unix pattern matching.
ignored-modules=pyotherside
# Show a hint with possible names when a member name was not found. The aspect
# of finding the hint is based on edit distance.
missing-member-hint=yes
# The minimum edit distance a name should have in order to be considered a
# similar match for a missing member name.
missing-member-hint-distance=1
# The total number of similar names that should be taken in consideration when
# showing a hint for a missing member.
missing-member-max-choices=1
[SPELLING]
# Limits count of emitted suggestions for spelling mistakes
max-spelling-suggestions=4
# Spelling dictionary name. Available dictionaries: none. To make it working
# install pyenchant package.
spelling-dict=
# List of comma separated words that should not be checked.
spelling-ignore-words=
# A path to a file that contains private dictionary; one word per line.
spelling-private-dict-file=
# Tells whether to store unknown words to indicated private dictionary in
# --spelling-private-dict-file option instead of raising a message.
spelling-store-unknown-words=no
[SIMILARITIES]
# Ignore comments when computing similarities.
ignore-comments=yes
# Ignore docstrings when computing similarities.
ignore-docstrings=yes
# Ignore imports when computing similarities.
ignore-imports=no
# Minimum lines number of a similarity.
min-similarity-lines=4
[MISCELLANEOUS]
# List of note tags to take in consideration, separated by a comma.
notes=
[LOGGING]
# Logging modules to check that the string format arguments are in logging
# function parameter format
logging-modules=logging
[FORMAT]
# Expected format of line ending, e.g. empty (any line ending), LF or CRLF.
expected-line-ending-format=
# Regexp for a line that is allowed to be longer than the limit.
ignore-long-lines=^\s*(# )?<?https?://\S+>?$
# Number of spaces of indent required inside a hanging or continued line.
indent-after-paren=4
# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1
# tab).
indent-string=' '
# Maximum number of characters on a single line.
max-line-length=79
# Maximum number of lines in a module
max-module-lines=1000
# List of optional constructs for which whitespace checking is disabled. `dict-
# separator` is used to allow tabulation in dicts, etc.: {1 : 1,\n222: 2}.
# `trailing-comma` allows a space between comma and closing bracket: (a, ).
# `empty-line` allows space-only lines.
no-space-check=trailing-comma,
dict-separator
# Allow the body of a class to be on the same line as the declaration if body
# contains single statement.
single-line-class-stmt=no
# Allow the body of an if to be on the same line as the test if there is no
# else.
single-line-if-stmt=no
[BASIC]
# Naming style matching correct argument names
argument-naming-style=snake_case
# Regular expression matching correct argument names. Overrides argument-
# naming-style
argument-rgx=[a-z0-9_]{2,65}$
# Naming style matching correct attribute names
attr-naming-style=snake_case
# Regular expression matching correct attribute names. Overrides attr-naming-
# style
attr-rgx=[a-z0-9_]{2,65}$
# Bad variable names which should always be refused, separated by a comma
bad-names=
# Naming style matching correct class attribute names
class-attribute-naming-style=snake_case
# Regular expression matching correct class attribute names. Overrides class-
# attribute-naming-style
class-attribute-rgx=[a-z0-9_]{2,65}$
# Naming style matching correct class names
class-naming-style=PascalCase
# Regular expression matching correct class names. Overrides class-naming-style
#class-rgx=
# Naming style matching correct constant names
const-naming-style=UPPER_CASE
# Regular expression matching correct constant names. Overrides const-naming-
# style
const-rgx=(__[a-zA-Z0-9_]{2,65}__|[A-Z0-9_]{2,65})$
# Minimum line length for functions/classes that require docstrings, shorter
# ones are exempt.
docstring-min-length=-1
# Naming style matching correct function names
function-naming-style=snake_case
# Regular expression matching correct function names. Overrides function-
# naming-style
function-rgx=[a-z0-9_]{2,65}$
# Good variable names which should always be accepted, separated by a comma
good-names= _, a, b, i, j, k, v, w, h, x, y, z
# Include a hint for the correct naming format with invalid-name
include-naming-hint=no
# Naming style matching correct inline iteration names
inlinevar-naming-style=any
# Regular expression matching correct inline iteration names. Overrides
# inlinevar-naming-style
#inlinevar-rgx=
# Naming style matching correct method names
method-naming-style=snake_case
# Regular expression matching correct method names. Overrides method-naming-
# style
method-rgx=([a-z0-9_]{2,65}|on[A-Z][a-zA-Z0-9]{1,65})$
# Naming style matching correct module names
module-naming-style=snake_case
# Regular expression matching correct module names. Overrides module-naming-
# style
module-rgx=[a-z0-9_]{2,65}$
# Colon-delimited sets of names that determine each other's naming style when
# the name regexes allow several styles.
name-group=
# Regular expression which should only match function or class names that do
# not require a docstring.
no-docstring-rgx=^
# List of decorators that produce properties, such as abc.abstractproperty. Add
# to this list to register other decorators that produce valid properties.
property-classes=abc.abstractproperty
# Naming style matching correct variable names
variable-naming-style=snake_case
# Regular expression matching correct variable names. Overrides variable-
# naming-style
variable-rgx=[a-z0-9_]{2,65}$
[IMPORTS]
# Allow wildcard imports from modules that define __all__.
allow-wildcard-with-all=no
# Analyse import fallback blocks. This can be used to support both Python 2 and
# 3 compatible code, which means that the block might have code that exists
# only in one or another interpreter, leading to false positives when analysed.
analyse-fallback-blocks=no
# Deprecated modules which should not be used, separated by a comma
deprecated-modules=optparse,tkinter.tix
# Create a graph of external dependencies in the given file (report RP0402 must
# not be disabled)
ext-import-graph=
# Create a graph of every (i.e. internal and external) dependencies in the
# given file (report RP0402 must not be disabled)
import-graph=
# Create a graph of internal dependencies in the given file (report RP0402 must
# not be disabled)
int-import-graph=
# Force import order to recognize a module as part of the standard
# compatibility libraries.
known-standard-library=
# Force import order to recognize a module as part of a third party library.
known-third-party=enchant
[DESIGN]
# Maximum number of arguments for function / method
max-args=99
# Maximum number of attributes for a class (see R0902).
max-attributes=99
# Maximum number of boolean expressions in a if statement
max-bool-expr=99
# Maximum number of branch for function / method body
max-branches=99
# Maximum number of locals for function / method body
max-locals=99
# Maximum number of parents for a class (see R0901).
max-parents=99
# Maximum number of public methods for a class (see R0904).
max-public-methods=99
# Maximum number of return / yield for function / method body
max-returns=99
# Maximum number of statements in function / method body
max-statements=999
# Minimum number of public methods for a class (see R0903).
min-public-methods=0
[CLASSES]
# List of method names used to declare (i.e. assign) instance attributes.
defining-attr-methods=__init__,
__new__,
setUp
# List of member names, which should be excluded from the protected access
# warning.
exclude-protected=_asdict,
_fields,
_replace,
_source,
_make
# List of valid names for the first argument in a class method.
valid-classmethod-first-arg=cls
# List of valid names for the first argument in a metaclass class method.
valid-metaclass-classmethod-first-arg=mcs
[EXCEPTIONS]
# Exceptions that will emit a warning when being caught. Defaults to
# "Exception"
# Catching this is safe enough in Python 3.
#overgeneral-exceptions=Exception
overgeneral-exceptions=
[TYPECHECK]
generated-members=sh

1098
Makefile

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,16 @@
# harmonyqml
## Dependencies setup
### [pyotherside](https://github.com/thp/pyotherside)
git clone https://github.com/thp/pyotherside
cd pyotherside
qmake
make
sudo make install
After this, verify the permissions of the installed plugin files.
sudo chmod 644 /usr/lib/qt5/qml/io/thp/pyotherside/*
sudo chmod 755 /usr/lib/qt5/qml/io/thp/pyotherside/*.so

BIN
harmonyqml Executable file

Binary file not shown.

68
harmonyqml.pro Normal file
View File

@ -0,0 +1,68 @@
TEMPLATE = app
QT = quick
DEFINES += QT_DEPRECATED_WARNINGS
CONFIG += release warn_off c++11
BUILD_DIR = build
MOC_DIR = $$BUILD_DIR/moc
OBJECTS_DIR = $$BUILD_DIR/obj
RCC_DIR = $$BUILD_DIR/rcc
QRC_FILE = $$BUILD_DIR/resources.qrc
!no_embedded {
RESOURCES += $$QRC_FILE
}
SOURCES += src/main.cpp
TARGET = harmonyqml
# Libraries includes
include(submodules/SortFilterProxyModel/SortFilterProxyModel.pri)
# Custom functions
defineReplace(glob_filenames) {
for(pattern, ARGS) {
results *= $$files(src/$${pattern}, true)
}
return($$results)
}
# Generate resource file
RESOURCE_FILES *= $$glob_filenames(qmldir, *.qml, *.js, *.py)
RESOURCE_FILES *= $$glob_filenames( *.jpg, *.jpeg, *.png, *.svg)
file_content += '<!-- vim: set ft=xml : -->'
file_content += '<!DOCTYPE RCC>'
file_content += '<RCC version="1.0">'
file_content += '<qresource prefix="/">'
for(file, RESOURCE_FILES) {
alias = $$replace(file, src/, '')
file_content += ' <file alias="$$alias">../$$file</file>'
}
file_content += '</qresource>'
file_content += '</RCC>'
write_file($$QRC_FILE, file_content)
# Add stuff to `make clean`
# Allow cleaning folders instead of just files
win32:QMAKE_DEL_FILE = rmdir /q /s
unix:QMAKE_DEL_FILE = rm -rf
for(file, $$list($$glob_filenames(*.py))) {
PYCACHE_DIRS *= $$dirname(file)/__pycache__
}
QMAKE_CLEAN *= $$MOC_DIR $$OBJECTS_DIR $$RCC_DIR $$PYCACHE_DIRS $$QRC_FILE
QMAKE_CLEAN *= $$BUILD_DIR Makefile .qmake.stash
QMAKE_CLEAN *= $$glob_filenames(*.pyc, *.qmlc, *.jsc, *.egg-info)

10
live_reload.sh Executable file
View File

@ -0,0 +1,10 @@
#!/usr/bin/env sh
# pdb won't be usable with entr,
# use https://pypi.org/project/remote-pdb/ instead
# no_embedded (resources) is used to speed up the compilation
find src harmonyqml.pro -type f |
entr -cdnr sh -c \
'qmake CONFIG+=no_embedded && make && ./harmonyqml --debug'

3
run.sh
View File

@ -1,3 +0,0 @@
#!/usr/bin/env sh
qml src/qml/Window.qml

View File

@ -1,8 +0,0 @@
#!/usr/bin/env sh
while true; do
clear
qml src/qml/Window.qml -- --debug
exit_code="$?"
if [ "$exit_code" != 231 ]; then break; fi
done

View File

@ -1,8 +0,0 @@
from datetime import datetime
from typing import Dict, Optional
from dataclasses import dataclass, field
from .event import Event

23
src/main.cpp Normal file
View File

@ -0,0 +1,23 @@
#include <QGuiApplication>
#include <QQmlEngine>
#include <QQmlContext>
#include <QQmlComponent>
#include <QFileInfo>
#include <QUrl>
int main(int argc, char *argv[]) {
QGuiApplication app(argc, argv);
QQmlEngine engine;
QQmlContext *objectContext = new QQmlContext(engine.rootContext());
QQmlComponent component(
&engine,
QFileInfo::exists("qrc:/qml/Window.qml") ?
"qrc:/qml/Window.qml" : "src/qml/Window.qml"
);
component.create(objectContext);
return app.exec();
}

View File

@ -1,124 +0,0 @@
import asyncio
import inspect
import platform
from contextlib import suppress
from typing import Optional
import nio
from . import __about__
from .events import rooms, users
class MatrixClient(nio.AsyncClient):
def __init__(self,
user: str,
homeserver: str = "https://matrix.org",
device_id: Optional[str] = None) -> None:
# TODO: ensure homeserver starts with a scheme://
self.sync_task: Optional[asyncio.Task] = None
super().__init__(homeserver=homeserver, user=user, device_id=device_id)
self.connect_callbacks()
def __repr__(self) -> str:
return "%s(user_id=%r, homeserver=%r, device_id=%r)" % (
type(self).__name__, self.user_id, self.homeserver, self.device_id
)
def connect_callbacks(self) -> None:
for name in dir(nio.responses):
if name.startswith("_"):
continue
obj = getattr(nio.responses, name)
if inspect.isclass(obj) and issubclass(obj, nio.Response):
with suppress(AttributeError):
self.add_response_callback(getattr(self, f"on{name}"), obj)
async def start_syncing(self) -> None:
self.sync_task = asyncio.ensure_future( # type: ignore
self.sync_forever(timeout=10_000)
)
@property
def default_device_name(self) -> str:
os_ = f" on {platform.system()}".rstrip()
os_ = f"{os_} {platform.release()}".rstrip() if os_ != " on" else ""
return f"{__about__.__pretty_name__}{os_}"
async def login(self, password: str) -> None:
response = await super().login(password, self.default_device_name)
if isinstance(response, nio.LoginError):
print(response)
else:
await self.start_syncing()
async def resume(self, user_id: str, token: str, device_id: str) -> None:
self.receive_response(nio.LoginResponse(user_id, device_id, token))
await self.start_syncing()
async def logout(self) -> None:
if self.sync_task:
self.sync_task.cancel()
with suppress(asyncio.CancelledError):
await self.sync_task
await self.close()
async def request_user_update_event(self, user_id: str) -> None:
response = await self.get_profile(user_id)
users.UserUpdated(
user_id = user_id,
display_name = response.displayname,
avatar_url = response.avatar_url,
status_message = None, # TODO
)
# Callbacks for nio responses
async def onSyncResponse(self, response: nio.SyncResponse) -> None:
for room_id in response.rooms.invite:
room: nio.rooms.MatrixRoom = self.invited_rooms[room_id]
rooms.RoomUpdated(
user_id = self.user_id,
category = "Invites",
room_id = room_id,
display_name = room.display_name,
avatar_url = room.gen_avatar_url,
topic = room.topic,
inviter = room.inviter,
)
for room_id in response.rooms.join:
room = self.rooms[room_id]
rooms.RoomUpdated(
user_id = self.user_id,
category = "Rooms",
room_id = room_id,
display_name = room.display_name,
avatar_url = room.gen_avatar_url,
topic = room.topic,
)
for room_id in response.rooms.left:
rooms.RoomUpdated(
user_id = self.user_id,
category = "Left",
room_id = room_id,
# left_event TODO
)

View File

@ -1,4 +1,5 @@
import asyncio
import signal
from concurrent.futures import Future
from pathlib import Path
from threading import Thread
@ -15,31 +16,16 @@ class App:
def __init__(self) -> None:
self.appdirs = AppDirs(appname=__about__.__pkg_name__, roaming=True)
self.backend = None
from .backend import Backend
self.backend = Backend(app=self)
self.loop = asyncio.get_event_loop()
self.loop_thread = Thread(target=self._loop_starter)
self.loop_thread.start()
def start(self, cli_flags: Sequence[str] = ()) -> bool:
debug = False
if "-d" in cli_flags or "--debug" in cli_flags:
self.run_in_loop(self._exit_on_app_file_change())
debug = True
from .backend import Backend
self.backend = Backend(app=self) # type: ignore
return debug
async def _exit_on_app_file_change(self) -> None:
from watchgod import awatch
async for _ in awatch(Path(__file__).resolve().parent):
ExitRequested(231)
def is_debug_on(self, cli_flags: Sequence[str] = ()) -> bool:
return "-d" in cli_flags or "--debug" in cli_flags
def _loop_starter(self) -> None:
@ -74,7 +60,7 @@ class App:
name: str,
args: Optional[List[str]] = None,
kwargs: Optional[Dict[str, Any]] = None) -> str:
client = self.backend.clients[account_id] # type: ignore
client = self.backend.clients[account_id]
return self._call_coro(
getattr(client, name)(*args or [], **kwargs or {})
)
@ -85,14 +71,19 @@ class App:
ad = additional_data
rl = self.run_in_loop
ba = self.backend
cl = self.backend.clients # type: ignore
cl = self.backend.clients
tcl = lambda user: cl[f"@test_{user}:matrix.org"]
import json
jd = lambda obj: print(json.dumps(obj, indent=4, ensure_ascii=False))
import pdb
pdb.set_trace()
print("\n=> Run `socat readline tcp:127.0.0.1:4444` in a terminal "
"to connect to pdb.")
import remote_pdb
remote_pdb.RemotePdb("127.0.0.1", 4444).set_trace()
# Make CTRL-C work again
signal.signal(signal.SIGINT, signal.SIG_DFL)
APP = App()

View File

@ -1,7 +1,7 @@
import asyncio
import json
from pathlib import Path
from typing import Any, Dict, Optional, Tuple
from typing import Dict, Optional, Tuple
from atomicfile import AtomicFile
@ -29,13 +29,14 @@ class Backend:
user: str,
password: str,
device_id: Optional[str] = None,
homeserver: str = "https://matrix.org") -> None:
homeserver: str = "https://matrix.org") -> str:
client = MatrixClient(
user=user, homeserver=homeserver, device_id=device_id
)
await client.login(password)
self.clients[client.user_id] = client
users.AccountUpdated(client.user_id)
return client.user_id
async def resume_client(self,
@ -98,13 +99,15 @@ class Backend:
))
async def save_account(self, client: MatrixClient) -> None:
async def save_account(self, user_id: str) -> None:
client = self.clients[user_id]
await self._write_config({
**self.saved_accounts,
client.userId: {
"hostname": client.nio.host,
"token": client.nio.access_token,
"device_id": client.nio.device_id,
client.user_id: {
"homeserver": client.homeserver,
"token": client.access_token,
"device_id": client.device_id,
}
})
@ -119,7 +122,7 @@ class Backend:
async def _write_config(self, accounts: SavedAccounts) -> None:
js = json.dumps(accounts, indent=4, ensure_ascii=False, sort_keys=True)
with CONFIG_LOCK:
async with CONFIG_LOCK:
self.saved_accounts_path.parent.mkdir(parents=True, exist_ok=True)
with AtomicFile(self.saved_accounts_path, "w") as new:

View File

@ -0,0 +1,32 @@
from datetime import datetime
from enum import auto
from dataclasses import dataclass, field
from .event import AutoStrEnum, Event
class EventType(AutoStrEnum):
text = auto()
html = auto()
file = auto()
image = auto()
audio = auto()
video = auto()
location = auto()
notice = auto()
@dataclass
class TimelineEvent(Event):
type: EventType = field()
room_id: str = field()
event_id: str = field()
sender_id: str = field()
date: datetime = field()
is_local_echo: bool = field()
@dataclass
class HtmlMessageReceived(TimelineEvent):
content: str = field()

153
src/python/html_filter.py Normal file
View File

@ -0,0 +1,153 @@
# Copyright 2019 miruka
# This file is part of harmonyqml, licensed under GPLv3.
import re
import mistune
from lxml.html import HtmlElement, etree # nosec
import html_sanitizer.sanitizer as sanitizer
class HtmlFilter:
link_regexes = [re.compile(r, re.IGNORECASE) for r in [
(r"(?P<body>.+://(?P<host>[a-z0-9._-]+)(?:/[/\-_.,a-z0-9%&?;=~]*)?"
r"(?:\([/\-_.,a-z0-9%&?;=~]*\))?)"),
r"mailto:(?P<body>[a-z0-9._-]+@(?P<host>[a-z0-9_.-]+[a-z]))",
r"tel:(?P<body>[0-9+-]+)(?P<host>)",
r"(?P<body>magnet:\?xt=urn:[a-z0-9]+:.+)(?P<host>)",
]]
def __init__(self) -> None:
self._sanitizer = sanitizer.Sanitizer(self.sanitizer_settings)
# The whitespace remover doesn't take <pre> into account
sanitizer.normalize_overall_whitespace = lambda html: html
sanitizer.normalize_whitespace_in_text_or_tail = lambda el: el
# hard_wrap: convert all \n to <br> without required two spaces
self._markdown_to_html = mistune.Markdown(hard_wrap=True)
def from_markdown(self, text: str) -> str:
return self.filter(self._markdown_to_html(text))
def filter(self, html: str) -> str:
html = self._sanitizer.sanitize(html)
tree = etree.fromstring(html, parser=etree.HTMLParser())
if tree is None:
return ""
for el in tree.iter("img"):
el = self._wrap_img_in_a(el)
for el in tree.iter("a"):
el = self._append_img_to_a(el)
result = b"".join((etree.tostring(el, encoding="utf-8")
for el in tree[0].iterchildren()))
return str(result, "utf-8")
@property
def sanitizer_settings(self) -> dict:
# https://matrix.org/docs/spec/client_server/latest.html#m-room-message-msgtypes
return {
"tags": {
# TODO: mx-reply, audio, video
"font", "h1", "h2", "h3", "h4", "h5", "h6",
"blockquote", "p", "a", "ul", "ol", "sup", "sub", "li",
"b", "i", "s", "u", "code", "hr", "br",
"table", "thead", "tbody", "tr", "th", "td",
"pre", "img",
},
"attributes": {
# TODO: translate font attrs to qt html subset
"font": {"data-mx-bg-color", "data-mx-color"},
"a": {"href"},
"img": {"width", "height", "alt", "title", "src"},
"ol": {"start"},
"code": {"class"},
},
"empty": {"hr", "br", "img"},
"separate": {
"a", "p", "li", "table", "tr", "th", "td", "br", "hr"
},
"whitespace": {},
"add_nofollow": False,
"autolink": { # FIXME: arg dict not working
"link_regexes": self.link_regexes,
"avoid_hosts": [],
},
"sanitize_href": lambda href: href,
"element_preprocessors": [
sanitizer.bold_span_to_strong,
sanitizer.italic_span_to_em,
sanitizer.tag_replacer("strong", "b"),
sanitizer.tag_replacer("em", "i"),
sanitizer.tag_replacer("strike", "s"),
sanitizer.tag_replacer("del", "s"),
sanitizer.tag_replacer("span", "font"),
self._remove_empty_font,
sanitizer.tag_replacer("form", "p"),
sanitizer.tag_replacer("div", "p"),
sanitizer.tag_replacer("caption", "p"),
sanitizer.target_blank_noopener,
],
"element_postprocessors": [],
"is_mergeable": lambda e1, e2: e1.attrib == e2.attrib,
}
def _remove_empty_font(self, el: HtmlElement) -> HtmlElement:
if el.tag != "font":
return el
if not self.sanitizer_settings["attributes"]["font"] & set(el.keys()):
el.clear()
return el
def _wrap_img_in_a(self, el: HtmlElement) -> HtmlElement:
link = el.attrib.get("src", "")
width = el.attrib.get("width", "256")
height = el.attrib.get("height", "256")
if el.getparent().tag == "a" or el.tag != "img" or \
not self._is_image_path(link):
return el
el.tag = "a"
el.attrib.clear()
el.attrib["href"] = link
el.append(etree.Element("img", src=link, width=width, height=height))
return el
def _append_img_to_a(self, el: HtmlElement) -> HtmlElement:
link = el.attrib.get("href", "")
if not (el.tag == "a" and self._is_image_path(link)):
return el
for _ in el.iter("img"): # if the <a> already has an <img> child
return el
el.append(etree.Element("br"))
el.append(etree.Element("img", src=link, width="256", height="256"))
return el
@staticmethod
def _is_image_path(link: str) -> bool:
return bool(re.match(
r".+\.(jpg|jpeg|png|gif|bmp|webp|tiff|svg)$", link, re.IGNORECASE
))
HTML_FILTER = HtmlFilter()

182
src/python/matrix_client.py Normal file
View File

@ -0,0 +1,182 @@
import asyncio
import inspect
import logging as log
import platform
from contextlib import suppress
from datetime import datetime
from types import ModuleType
from typing import Dict, Optional, Type
import nio
from . import __about__
from .events import rooms, users
from .events.rooms_timeline import EventType, HtmlMessageReceived
from .html_filter import HTML_FILTER
class MatrixClient(nio.AsyncClient):
def __init__(self,
user: str,
homeserver: str = "https://matrix.org",
device_id: Optional[str] = None) -> None:
# TODO: ensure homeserver starts with a scheme://
self.sync_task: Optional[asyncio.Future] = None
super().__init__(homeserver=homeserver, user=user, device_id=device_id)
self.connect_callbacks()
def __repr__(self) -> str:
return "%s(user_id=%r, homeserver=%r, device_id=%r)" % (
type(self).__name__, self.user_id, self.homeserver, self.device_id
)
@staticmethod
def _classes_defined_in(module: ModuleType) -> Dict[str, Type]:
return {
m[0]: m[1] for m in inspect.getmembers(module, inspect.isclass)
if not m[0].startswith("_") and
m[1].__module__.startswith(module.__name__)
}
def connect_callbacks(self) -> None:
for name, class_ in self._classes_defined_in(nio.responses).items():
with suppress(AttributeError):
self.add_response_callback(getattr(self, f"on{name}"), class_)
# TODO: get this implemented in AsyncClient
# for name, class_ in self._classes_defined_in(nio.events).items():
# with suppress(AttributeError):
# self.add_event_callback(getattr(self, f"on{name}"), class_)
async def start_syncing(self) -> None:
self.sync_task = asyncio.ensure_future(
self.sync_forever(timeout=10_000)
)
def callback(task):
raise task.exception()
self.sync_task.add_done_callback(callback)
@property
def default_device_name(self) -> str:
os_ = f" on {platform.system()}".rstrip()
os_ = f"{os_} {platform.release()}".rstrip() if os_ != " on" else ""
return f"{__about__.__pretty_name__}{os_}"
async def login(self, password: str) -> None:
response = await super().login(password, self.default_device_name)
if isinstance(response, nio.LoginError):
print(response)
else:
await self.start_syncing()
async def resume(self, user_id: str, token: str, device_id: str) -> None:
response = nio.LoginResponse(user_id, device_id, token)
await self.receive_response(response)
await self.start_syncing()
async def logout(self) -> None:
if self.sync_task:
self.sync_task.cancel()
with suppress(asyncio.CancelledError):
await self.sync_task
await self.close()
async def request_user_update_event(self, user_id: str) -> None:
response = await self.get_profile(user_id)
if isinstance(response, nio.ProfileGetError):
log.warning("Error getting profile for %r: %s", user_id, response)
users.UserUpdated(
user_id = user_id,
display_name = getattr(response, "displayname", None),
avatar_url = getattr(response, "avatar_url", None),
status_message = None, # TODO
)
# Callbacks for nio responses
@staticmethod
def _get_room_name(room: nio.rooms.MatrixRoom) -> Optional[str]:
# FIXME: reimplanted because of nio's non-standard room.display_name
name = room.name or room.canonical_alias
if name:
return name
name = room.group_name()
return None if name == "Empty room?" else name
async def onSyncResponse(self, resp: nio.SyncResponse) -> None:
for room_id, info in resp.rooms.invite.items():
room: nio.rooms.MatrixRoom = self.invited_rooms[room_id]
rooms.RoomUpdated(
user_id = self.user_id,
category = "Invites",
room_id = room_id,
display_name = self._get_room_name(room),
avatar_url = room.gen_avatar_url,
topic = room.topic,
inviter = room.inviter,
)
for room_id, info in resp.rooms.join.items():
room = self.rooms[room_id]
rooms.RoomUpdated(
user_id = self.user_id,
category = "Rooms",
room_id = room_id,
display_name = self._get_room_name(room),
avatar_url = room.gen_avatar_url,
topic = room.topic,
)
asyncio.gather(*(
getattr(self, f"on{type(ev).__name__}")(room_id, ev)
for ev in info.timeline.events
if hasattr(self, f"on{type(ev).__name__}")
))
for room_id, info in resp.rooms.leave.items():
rooms.RoomUpdated(
user_id = self.user_id,
category = "Left",
room_id = room_id,
# left_event TODO
)
# Callbacks for nio events
async def onRoomMessageText(self, room_id: str, ev: nio.RoomMessageText
) -> None:
is_html = ev.format == "org.matrix.custom.html"
filter_ = HTML_FILTER.filter
HtmlMessageReceived(
type = EventType.html if is_html else EventType.text,
room_id = room_id,
event_id = ev.event_id,
sender_id = ev.sender,
date = datetime.fromtimestamp(ev.server_timestamp / 1000),
is_local_echo = False,
content = filter_(ev.formatted_body) if is_html else ev.body,
)

View File

@ -7,7 +7,14 @@ Rectangle {
property int dimension: HStyle.avatar.size
property bool hidden: false
function hue_from_name(name) {
function stripUserId(user_id) {
return user_id.substring(1) // Remove leading @
}
function stripRoomName(name) {
return name[0] == "#" ? name.substring(1) : name
}
function hueFromName(name) {
var hue = 0
for (var i = 0; i < name.length; i++) {
hue += name.charCodeAt(i) * 99
@ -24,7 +31,7 @@ Rectangle {
color: name ?
Qt.hsla(
hue_from_name(name),
hueFromName(name),
HStyle.avatar.background.saturation,
HStyle.avatar.background.lightness,
HStyle.avatar.background.alpha

View File

@ -32,11 +32,6 @@ Button {
signal pressed
signal released
function loadingUntilFutureDone(future) {
loading = true
future.onGotResult.connect(function() { loading = false })
}
id: button
background: Rectangle {

View File

@ -1,85 +1,112 @@
import QtQuick 2.7
import SortFilterProxyModel 0.2
ListModel {
SortFilterProxyModel {
// To initialize a HListModel with items,
// use `Component.onCompleted: extend([{"foo": 1, "bar": 2}, ...])`
id: listModel
id: sortFilteredModel
property var model: ListModel {}
sourceModel: model // Can't assign a "ListModel {}" directly here
function append(dict) { return model.append(dict) }
function clear() { return model.clear() }
function insert(index, dict) { return model.inset(index, dict) }
function move(from, to, n) { return model.move(from, to, n) }
function remove(index, count) { return model.remove(index, count) }
function set(index, dict) { return model.set(index, dict) }
function sync() { return model.sync() }
function setProperty(index, prop, value) {
return model.setProperty(index, prop, value)
}
function extend(new_items) {
for (var i = 0; i < new_items.length; i++) {
listModel.append(new_items[i])
model.append(new_items[i])
}
}
function getIndices(where_role, is, max) { // max: undefined or int
function getIndices(where_roles_are, max_results, max_tries) {
// max arguments: unefined or int
var results = []
for (var i = 0; i < listModel.count; i++) {
if (listModel.get(i)[where_role] == is) {
results.push(i)
for (var i = 0; i < model.count; i++) {
var item = model.get(i)
var include = true
if (max && results.length >= max) {
for (var role in where_roles_are) {
if (item[role] != where_roles_are[role]) {
include = false
break
}
}
if (include) {
results.push(i)
if (max_results && results.length >= max_results) {
break
}
}
if (max_tries && i >= max_tries) {
break
}
}
return results
}
function getWhere(where_role, is, max) {
var indices = getIndices(where_role, is, max)
var results = []
function getWhere(roles_are, max_results, max_tries) {
var indices = getIndices(roles_are, max_results, max_tries)
var items = []
for (var i = 0; i < indices.length; i++) {
results.push(listModel.get(indices[i]))
items.push(model.get(indices[i]))
}
return results
return items
}
function forEachWhere(where_role, is, max, func) {
var items = getWhere(where_role, is, max)
function forEachWhere(roles_are, func, max_results, max_tries) {
var items = getWhere(roles_are, max_results, max_tries)
for (var i = 0; i < items.length; i++) {
func(item)
func(items[i])
}
}
function upsert(where_role, is, new_item, update_if_exist) {
// new_item can contain only the keys we're interested in updating
var indices = getIndices(where_role, is, 1)
function upsert(where_roles_are, new_item, update_if_exist, max_tries) {
var indices = getIndices(where_roles_are, 1, max_tries)
if (indices.length == 0) {
listModel.append(new_item)
return listModel.get(listModel.count)
model.append(new_item)
return model.get(model.count)
}
if (update_if_exist != false) {
listModel.set(indices[0], new_item)
model.set(indices[0], new_item)
}
return listModel.get(indices[0])
return model.get(indices[0])
}
function pop(index) {
var item = listModel.get(index)
listModel.remove(index)
var item = model.get(index)
model.remove(index)
return item
}
function popWhere(where_role, is, max) {
var indices = getIndices(where_role, is, max)
var results = []
function popWhere(roles_are, max_results, max_tries) {
var indices = getIndices(roles_are, max_results, max_tries)
var items = []
for (var i = 0; i < indices.length; i++) {
results.push(listModel.get(indices[i]))
listModel.remove(indices[i])
items.push(model.get(indices[i]))
model.remove(indices[i])
}
return results
return items
}
function toObject(item_list) {
item_list = item_list || listModel
item_list = item_list || sortFilteredModel
var obj_list = []
for (var i = 0; i < item_list.count; i++) {

View File

@ -6,6 +6,7 @@ Banner {
color: HStyle.chat.inviteBanner.background
// TODO: get disp name from models.users, inviter = userid now
avatar.name: inviter ? inviter.displayname : ""
//avatar.imageUrl: inviter ? inviter.avatar_url : ""

View File

@ -10,27 +10,27 @@ HColumnLayout {
property string category: ""
property string roomId: ""
readonly property var roomInfo:
Backend.accounts.get(userId)
.roomCategories.get(category)
.rooms.get(roomId)
readonly property var roomInfo: models.rooms.getWhere(
{"userId": userId, "roomId": roomId, "category": category}, 1
)[0]
readonly property var sender: Backend.users.get(userId)
readonly property var sender:
models.users.getWhere({"userId": userId}, 1)[0]
readonly property bool hasUnknownDevices:
category == "Rooms" ?
Backend.clients.get(userId).roomHasUnknownDevices(roomId) : false
readonly property bool hasUnknownDevices: false
//category == "Rooms" ?
//Backend.clients.get(userId).roomHasUnknownDevices(roomId) : false
id: chatPage
onFocusChanged: sendBox.setFocus()
Component.onCompleted: Backend.signals.roomCategoryChanged.connect(
function(forUserId, forRoomId, previous, now) {
if (chatPage && forUserId == userId && forRoomId == roomId) {
chatPage.category = now
}
}
)
//Component.onCompleted: Backend.signals.roomCategoryChanged.connect(
//function(forUserId, forRoomId, previous, now) {
//if (chatPage && forUserId == userId && forRoomId == roomId) {
//chatPage.category = now
//}
//}
//)
RoomHeader {
id: roomHeader
@ -77,72 +77,72 @@ HColumnLayout {
}
}
RoomSidePane {
id: roomSidePane
// RoomSidePane {
//id: roomSidePane
activeView: roomHeader.activeButton
property int oldWidth: width
onActiveViewChanged:
activeView ? restoreAnimation.start() : hideAnimation.start()
//activeView: roomHeader.activeButton
//property int oldWidth: width
//onActiveViewChanged:
//activeView ? restoreAnimation.start() : hideAnimation.start()
NumberAnimation {
id: hideAnimation
target: roomSidePane
properties: "width"
duration: HStyle.animationDuration
from: target.width
to: 0
//NumberAnimation {
//id: hideAnimation
//target: roomSidePane
//properties: "width"
//duration: HStyle.animationDuration
//from: target.width
//to: 0
onStarted: {
target.oldWidth = target.width
target.Layout.minimumWidth = 0
}
}
//onStarted: {
//target.oldWidth = target.width
//target.Layout.minimumWidth = 0
//}
//}
NumberAnimation {
id: restoreAnimation
target: roomSidePane
properties: "width"
duration: HStyle.animationDuration
from: 0
to: target.oldWidth
//NumberAnimation {
//id: restoreAnimation
//target: roomSidePane
//properties: "width"
//duration: HStyle.animationDuration
//from: 0
//to: target.oldWidth
onStopped: target.Layout.minimumWidth = Qt.binding(
function() { return HStyle.avatar.size }
)
}
//onStopped: target.Layout.minimumWidth = Qt.binding(
//function() { return HStyle.avatar.size }
//)
//}
collapsed: width < HStyle.avatar.size + 8
//collapsed: width < HStyle.avatar.size + 8
property bool wasSnapped: false
property int referenceWidth: roomHeader.buttonsWidth
onReferenceWidthChanged: {
if (chatSplitView.canAutoSize || wasSnapped) {
if (wasSnapped) { chatSplitView.canAutoSize = true }
width = referenceWidth
}
}
//property bool wasSnapped: false
//property int referenceWidth: roomHeader.buttonsWidth
//onReferenceWidthChanged: {
//if (chatSplitView.canAutoSize || wasSnapped) {
//if (wasSnapped) { chatSplitView.canAutoSize = true }
//width = referenceWidth
//}
//}
property int currentWidth: width
onCurrentWidthChanged: {
if (referenceWidth != width &&
referenceWidth - 15 < width &&
width < referenceWidth + 15)
{
currentWidth = referenceWidth
width = referenceWidth
wasSnapped = true
currentWidth = Qt.binding(
function() { return roomSidePane.width }
)
} else {
wasSnapped = false
}
}
//property int currentWidth: width
//onCurrentWidthChanged: {
//if (referenceWidth != width &&
//referenceWidth - 15 < width &&
//width < referenceWidth + 15)
//{
//currentWidth = referenceWidth
//width = referenceWidth
//wasSnapped = true
//currentWidth = Qt.binding(
//function() { return roomSidePane.width }
//)
//} else {
//wasSnapped = false
//}
//}
width: referenceWidth // Initial width
Layout.minimumWidth: HStyle.avatar.size
Layout.maximumWidth: parent.width
}
//width: referenceWidth // Initial width
//Layout.minimumWidth: HStyle.avatar.size
//Layout.maximumWidth: parent.width
//}
}
}

View File

@ -2,7 +2,7 @@ import QtQuick 2.7
import "../../Base"
HNoticePage {
text: dateTime.toLocaleDateString()
text: model.date.toLocaleDateString()
color: HStyle.chat.daybreak.foreground
backgroundColor: HStyle.chat.daybreak.background
radius: HStyle.chat.daybreak.radius

View File

@ -16,7 +16,7 @@ Row {
HAvatar {
id: avatar
name: sender.displayName.value
name: sender.displayName || stripUserId(sender.userId)
hidden: combine
dimension: 28
}

View File

@ -10,7 +10,7 @@ Row {
HAvatar {
id: avatar
hidden: combine
name: sender.displayName.value
name: senderInfo.displayName || stripUserId(model.senderId)
dimension: 48
}
@ -38,8 +38,8 @@ Row {
visible: height > 0
id: nameLabel
text: sender.displayName.value
color: Qt.hsla(Backend.hueFromString(text),
text: senderInfo.displayName || model.senderId
color: Qt.hsla(avatar.hueFromName(avatar.name),
HStyle.displayName.saturation,
HStyle.displayName.lightness,
1)
@ -56,17 +56,16 @@ Row {
width: parent.width
id: contentLabel
text: (dict.formatted_body ?
Backend.htmlFilter.filter(dict.formatted_body) :
dict.body) +
text: model.content +
"&nbsp;&nbsp;<font size=" + HStyle.fontSize.small +
"px color=" + HStyle.chat.message.date + ">" +
Qt.formatDateTime(dateTime, "hh:mm:ss") +
Qt.formatDateTime(model.date, "hh:mm:ss") +
"</font>" +
(isLocalEcho ?
(model.isLocalEcho ?
"&nbsp;<font size=" + HStyle.fontSize.small +
"px>⏳</font>" : "")
textFormat: Text.RichText
textFormat: model.type == "text" ?
Text.PlainText : Text.RichText
color: HStyle.chat.message.body
wrapMode: Text.Wrap

View File

@ -1,7 +1,6 @@
import QtQuick 2.7
import QtQuick.Layouts 1.3
import "../../Base"
import "../utils.js" as ChatJS
Column {
id: roomEventDelegate
@ -10,46 +9,47 @@ Column {
return Math.round((((date2 - date1) % 86400000) % 3600000) / 60000)
}
function getIsMessage(type_) { return type_.startsWith("RoomMessage") }
function getPreviousItem() {
return index < roomEventListView.model.count - 1 ?
roomEventListView.model.get(index + 1) : null
}
function getIsMessage(type) {
return true
}
property var previousItem: getPreviousItem()
signal reloadPreviousItem()
onReloadPreviousItem: previousItem = getPreviousItem()
readonly property bool isMessage: getIsMessage(type)
property var senderInfo: null
Component.onCompleted:
senderInfo = models.users.getUser(chatPage.userId, senderId)
readonly property bool isUndecryptableEvent:
type === "OlmEvent" || type === "MegolmEvent"
//readonly property bool isMessage: ! model.type.match(/^event.*/)
readonly property bool isMessage: getIsMessage(model.type)
readonly property var sender: Backend.users.get(dict.sender)
readonly property bool isOwn: chatPage.userId === senderId
readonly property bool isOwn:
chatPage.userId === dict.sender
readonly property bool isFirstEvent: type == "RoomCreateEvent"
readonly property bool isFirstEvent: model.type == "eventCreate"
readonly property bool combine:
previousItem &&
! talkBreak &&
! dayBreak &&
getIsMessage(previousItem.type) === isMessage &&
previousItem.dict.sender === dict.sender &&
minsBetween(previousItem.dateTime, dateTime) <= 5
previousItem.senderId === senderId &&
minsBetween(previousItem.date, model.date) <= 5
readonly property bool dayBreak:
isFirstEvent ||
previousItem &&
dateTime.getDate() != previousItem.dateTime.getDate()
model.date.getDate() != previousItem.date.getDate()
readonly property bool talkBreak:
previousItem &&
! dayBreak &&
minsBetween(previousItem.dateTime, dateTime) >= 20
minsBetween(previousItem.date, model.date) >= 20
property int standardSpacing: 16

View File

@ -1,4 +1,5 @@
import QtQuick 2.7
import SortFilterProxyModel 0.2
import "../../Base"
HRectangle {
@ -8,10 +9,19 @@ HRectangle {
HListView {
id: roomEventListView
delegate: RoomEventDelegate {}
model: Backend.roomEvents.get(chatPage.roomId)
clip: true
model: HListModel {
sourceModel: models.timelines
filters: ValueFilter {
roleName: "roomId"
value: chatPage.roomId
}
}
delegate: RoomEventDelegate {}
anchors.fill: parent
anchors.leftMargin: space
anchors.rightMargin: space
@ -29,7 +39,7 @@ HRectangle {
onYPosChanged: {
if (chatPage.category != "Invites" && yPos <= 0.1) {
Backend.loadPastEvents(chatPage.roomId)
//Backend.loadPastEvents(chatPage.roomId)
}
}
}

View File

@ -3,7 +3,7 @@ import QtQuick.Layouts 1.3
import "../Base"
HRectangle {
property string displayName: ""
property var displayName: ""
property string topic: ""
property alias buttonsImplicitWidth: viewButtons.implicitWidth
@ -22,7 +22,7 @@ HRectangle {
HAvatar {
id: avatar
name: displayName
name: stripRoomName(displayName) || qsTr("Empty room")
dimension: roomHeader.height
Layout.alignment: Qt.AlignTop
}

View File

@ -15,7 +15,7 @@ MouseArea {
HAvatar {
id: memberAvatar
name: member.displayName.value
name: member.displayName || stripUserId(member.userId)
}
HColumnLayout {

View File

@ -18,7 +18,7 @@ HRectangle {
HAvatar {
id: avatar
name: chatPage.sender.displayName.value
name: chatPage.sender.displayName || stripUserId(chatPage.userId)
dimension: root.Layout.minimumHeight
}

View File

@ -1,26 +1,28 @@
function clientId(user_id, category, room_id) {
return user_id + " " + room_id + " " + category
}
function onRoomUpdated(user_id, category, room_id, display_name, avatar_url,
topic, last_event_date, inviter, left_event) {
var client_id = clientId(user_id, category, room_id)
var rooms = models.rooms
models.roomCategories.upsert({"userId": user_id, "name": category}, {
"userId": user_id,
"name": category
})
var rooms = models.rooms
function roles(for_category) {
return {"userId": user_id, "roomId": room_id, "category": for_category}
}
if (category == "Invites") {
rooms.popWhere("clientId", clientId(user_id, "Rooms", room_id))
rooms.popWhere("clientId", clientId(user_id, "Left", room_id))
rooms.popWhere(roles("Rooms"), 1)
rooms.popWhere(roles("Left"), 1)
}
else if (category == "Rooms") {
rooms.popWhere("clientId", clientId(user_id, "Invites", room_id))
rooms.popWhere("clientId", clientId(user_id, "Left", room_id))
rooms.popWhere(roles("Invites"), 1)
rooms.popWhere(roles("Left"), 1)
}
else if (category == "Left") {
var old_room =
rooms.popWhere("clientId", clientId(user_id, "Rooms", room_id)) ||
rooms.popWhere("clientId", clientId(user_id, "Invites", room_id))
var old_room = rooms.popWhere(roles("Invites"), 1)[0] ||
rooms.popWhere(roles("Rooms"), 1)[0]
if (old_room) {
display_name = old_room.displayName
@ -30,8 +32,7 @@ function onRoomUpdated(user_id, category, room_id, display_name, avatar_url,
}
}
rooms.upsert("clientId", client_id , {
"clientId": client_id,
rooms.upsert(roles(category), {
"userId": user_id,
"category": category,
"roomId": room_id,
@ -47,8 +48,8 @@ function onRoomUpdated(user_id, category, room_id, display_name, avatar_url,
function onRoomDeleted(user_id, category, room_id) {
var client_id = clientId(user_id, category, room_id)
return models.rooms.popWhere("clientId", client_id, 1)
var roles = {"userId": user_id, "roomId": room_id, "category": category}
models.rooms.popWhere(roles, 1)
}

View File

@ -0,0 +1,12 @@
function onHtmlMessageReceived(type, room_id, event_id, sender_id, date,
is_local_echo, content) {
models.timelines.upsert({"eventId": event_id}, {
"type": type,
"roomId": room_id,
"eventId": event_id,
"senderId": sender_id,
"date": date,
"isLocalEcho": is_local_echo,
"content": content,
}, true, 1000)
}

View File

@ -2,18 +2,17 @@ function onAccountUpdated(user_id) {
models.accounts.append({"userId": user_id})
}
function AccountDeleted(user_id) {
models.accounts.popWhere("userId", user_id, 1)
function onAccountDeleted(user_id) {
models.accounts.popWhere({"userId": user_id}, 1)
}
function onUserUpdated(user_id, display_name, avatar_url, status_message) {
models.users.upsert("userId", user_id, {
models.users.upsert({"userId": user_id}, {
"userId": user_id,
"displayName": display_name,
"avatarUrl": avatar_url,
"statusMessage": status_message
})
}
function onDeviceUpdated(user_id, device_id, ed25519_key, trust, display_name,

View File

@ -1,4 +1,5 @@
import QtQuick 2.7
import SortFilterProxyModel 0.2
import "Base"
QtObject {
@ -8,7 +9,7 @@ QtObject {
function getUser(as_account_id, wanted_user_id) {
wanted_user_id = wanted_user_id || as_account_id
var found = users.getWhere("userId", wanted_user_id, 1)
var found = users.getWhere({"userId": wanted_user_id}, 1)
if (found.length > 0) { return found[0] }
users.append({
@ -22,13 +23,20 @@ QtObject {
as_account_id, "request_user_update_event", [wanted_user_id]
)
return users.getWhere("userId", wanted_user_id, 1)[0]
return users.getWhere({"userId": wanted_user_id}, 1)[0]
}
}
property HListModel devices: HListModel {}
property HListModel roomCategories: HListModel {}
property HListModel rooms: HListModel {}
property HListModel timelines: HListModel {}
property HListModel timelines: HListModel {
sorters: RoleSorter {
roleName: "date"
sortOrder: Qt.DescendingOrder
}
}
}

View File

@ -4,7 +4,7 @@ import "../Base"
Item {
property string loginWith: "username"
property var client: null
property string userId: ""
HInterfaceBox {
id: rememberBox
@ -20,10 +20,13 @@ Item {
buttonCallbacks: {
"yes": function(button) {
Backend.clients.remember(client)
py.callCoro("save_account", [userId])
pageStack.showPage("Default")
},
"no": function(button) {
py.callCoro("forget_account", [userId])
pageStack.showPage("Default")
},
"no": function(button) { pageStack.showPage("Default") },
}
HLabel {

View File

@ -4,7 +4,7 @@ import "../Base"
Item {
property string loginWith: "username"
onFocusChanged: identifierField.forceActiveFocus()
onFocusChanged: idField.forceActiveFocus()
HInterfaceBox {
id: signInBox
@ -23,15 +23,15 @@ Item {
"register": function(button) {},
"login": function(button) {
var future = Backend.clients.new(
"matrix.org", identifierField.text, passwordField.text
)
button.loadingUntilFutureDone(future)
future.onGotResult.connect(function(client) {
button.loading = true
var args = [idField.text, passwordField.text]
py.callCoro("login_client", args, {}, function(user_id) {
pageStack.showPage(
"RememberAccount",
{"loginWith": loginWith, "client": client}
{"loginWith": loginWith, "userId": user_id}
)
button.loading = false
})
},
@ -58,7 +58,7 @@ Item {
}
HTextField {
id: identifierField
id: idField
placeholderText: qsTr(
loginWith === "email" ? "Email" :
loginWith === "phone" ? "Phone" :

View File

@ -9,6 +9,7 @@ Python {
property bool ready: false
property var pendingCoroutines: ({})
signal willLoadAccounts(bool will)
property bool loadingAccounts: false
function callCoro(name, args, kwargs, callback) {
@ -32,18 +33,20 @@ Python {
}
}
addImportPath("../..")
importNames("src", ["APP"], function() {
call("APP.start", [Qt.application.arguments], function(debug_on) {
window.debug = debug_on
addImportPath("src")
addImportPath("qrc:/")
importNames("python", ["APP"], function() {
call("APP.is_debug_on", [Qt.application.arguments], function(on) {
window.debug = on
callCoro("has_saved_accounts", [], {}, function(has) {
loadingAccounts = has
py.ready = true
willLoadAccounts(has)
if (has) {
py.loadingAccounts = true
py.callCoro("load_saved_accounts", [], {}, function() {
loadingAccounts = false
py.loadingAccounts = false
})
}
})

View File

@ -20,7 +20,7 @@ Column {
HAvatar {
id: avatar
name: user.displayName
name: user.displayName || stripUserId(user.userId)
}
HColumnLayout {

View File

@ -4,6 +4,8 @@ import "../Base"
HRowLayout {
id: toolBar
property alias roomFilter: filterField.text
Layout.fillWidth: true
Layout.preferredHeight: HStyle.bottomElementsHeight
@ -17,8 +19,6 @@ HRowLayout {
placeholderText: qsTr("Filter rooms")
backgroundColor: HStyle.sidePane.filterRooms.background
onTextChanged: Backend.setRoomFilter(text)
Layout.fillWidth: true
Layout.preferredHeight: parent.height
}

View File

@ -1,11 +1,20 @@
import QtQuick 2.7
import QtQuick.Layouts 1.3
import SortFilterProxyModel 0.2
import "../Base"
HListView {
property string userId: ""
id: roomCategoriesList
model: Backend.accounts.get(userId).roomCategories
model: SortFilterProxyModel {
sourceModel: models.roomCategories
filters: ValueFilter {
roleName: "userId"
value: userId
}
}
delegate: RoomCategoryDelegate {}
}

View File

@ -12,11 +12,11 @@ MouseArea {
HRowLayout {
width: parent.width
spacing: roomList.spacing
spacing: sidePane.normalSpacing
HAvatar {
id: roomAvatar
name: displayName
name: stripRoomName(displayName) || qsTr("Empty room")
}
HColumnLayout {
@ -35,27 +35,27 @@ MouseArea {
Layout.maximumWidth: parent.width
}
HLabel {
function getText() {
return SidePaneJS.getLastRoomEventText(
roomId, roomList.userId
)
}
//HLabel {
//function getText() {
//return SidePaneJS.getLastRoomEventText(
//roomId, roomList.userId
//)
//}
property var lastEvTime: lastEventDateTime
onLastEvTimeChanged: subtitleLabel.text = getText()
//property var lastEvTime: lastEventDateTime
//onLastEvTimeChanged: subtitleLabel.text = getText()
id: subtitleLabel
visible: text !== ""
text: getText()
textFormat: Text.StyledText
//id: subtitleLabel
//visible: text !== ""
//text: getText()
//textFormat: Text.StyledText
font.pixelSize: HStyle.fontSize.small
elide: Text.ElideRight
maximumLineCount: 1
//font.pixelSize: HStyle.fontSize.small
//elide: Text.ElideRight
//maximumLineCount: 1
Layout.maximumWidth: parent.width
}
//Layout.maximumWidth: parent.width
//}
}
}
}

View File

@ -1,5 +1,6 @@
import QtQuick 2.7
import QtQuick.Layouts 1.3
import SortFilterProxyModel 0.2
import "../Base"
HListView {
@ -7,8 +8,37 @@ HListView {
property string category: ""
id: roomList
spacing: accountList.spacing
model:
Backend.accounts.get(userId).roomCategories.get(category).sortedRooms
spacing: sidePane.normalSpacing
model: SortFilterProxyModel {
sourceModel: models.rooms
filters: AllOf {
ValueFilter {
roleName: "category"
value: category
}
ValueFilter {
roleName: "userId"
value: userId
}
ExpressionFilter {
expression: {
var filter = paneToolBar.roomFilter.toLowerCase()
var words = filter.split(" ")
var room_name = displayName.toLowerCase()
for (var i = 0; i < words.length; i++) {
if (words[i] && room_name.indexOf(words[i]) == -1) {
return false
}
}
return true
}
}
}
}
delegate: RoomDelegate {}
}

View File

@ -15,16 +15,18 @@ HRectangle {
Layout.fillWidth: true
Layout.fillHeight: true
spacing: collapsed ? 0 : normalSpacing
topMargin: spacing
bottomMargin: spacing
Layout.leftMargin: spacing
spacing: collapsed ? 0 : normalSpacing * 3
topMargin: normalSpacing
bottomMargin: normalSpacing
Layout.leftMargin: normalSpacing
Behavior on spacing {
NumberAnimation { duration: HStyle.animationDuration }
}
}
PaneToolBar {}
PaneToolBar {
id: paneToolBar
}
}
}

View File

@ -8,10 +8,15 @@ import "SidePane"
Item {
id: mainUI
Connections {
target: py
onWillLoadAccounts: function(will) {
pageStack.showPage(will ? "Default" : "SignIn")
}
}
property bool accountsPresent:
models.accounts.count > 0 || py.loadingAccounts
onAccountsPresentChanged:
pageStack.showPage(accountsPresent ? "Default" : "SignIn")
HImage {
id: mainUIBackground

@ -0,0 +1 @@
Subproject commit 770789ee484abf69c230cbf1b64f39823e79a181