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__ __pycache__
.mypy_cache .mypy_cache
build
dist
*.egg-info *.egg-info
*.pyc *.pyc
*.qmlc *.qmlc
*.jsc *.jsc
.pylintrc
tmp-* 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 asyncio
import signal
from concurrent.futures import Future from concurrent.futures import Future
from pathlib import Path from pathlib import Path
from threading import Thread from threading import Thread
@ -15,31 +16,16 @@ class App:
def __init__(self) -> None: def __init__(self) -> None:
self.appdirs = AppDirs(appname=__about__.__pkg_name__, roaming=True) 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 = asyncio.get_event_loop()
self.loop_thread = Thread(target=self._loop_starter) self.loop_thread = Thread(target=self._loop_starter)
self.loop_thread.start() self.loop_thread.start()
def start(self, cli_flags: Sequence[str] = ()) -> bool: def is_debug_on(self, cli_flags: Sequence[str] = ()) -> bool:
debug = False return "-d" in cli_flags or "--debug" in cli_flags
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 _loop_starter(self) -> None: def _loop_starter(self) -> None:
@ -74,7 +60,7 @@ class App:
name: str, name: str,
args: Optional[List[str]] = None, args: Optional[List[str]] = None,
kwargs: Optional[Dict[str, Any]] = None) -> str: 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( return self._call_coro(
getattr(client, name)(*args or [], **kwargs or {}) getattr(client, name)(*args or [], **kwargs or {})
) )
@ -85,14 +71,19 @@ class App:
ad = additional_data ad = additional_data
rl = self.run_in_loop rl = self.run_in_loop
ba = self.backend ba = self.backend
cl = self.backend.clients # type: ignore cl = self.backend.clients
tcl = lambda user: cl[f"@test_{user}:matrix.org"] tcl = lambda user: cl[f"@test_{user}:matrix.org"]
import json import json
jd = lambda obj: print(json.dumps(obj, indent=4, ensure_ascii=False)) jd = lambda obj: print(json.dumps(obj, indent=4, ensure_ascii=False))
import pdb print("\n=> Run `socat readline tcp:127.0.0.1:4444` in a terminal "
pdb.set_trace() "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() APP = App()

View File

@ -1,7 +1,7 @@
import asyncio import asyncio
import json import json
from pathlib import Path from pathlib import Path
from typing import Any, Dict, Optional, Tuple from typing import Dict, Optional, Tuple
from atomicfile import AtomicFile from atomicfile import AtomicFile
@ -29,13 +29,14 @@ class Backend:
user: str, user: str,
password: str, password: str,
device_id: Optional[str] = None, device_id: Optional[str] = None,
homeserver: str = "https://matrix.org") -> None: homeserver: str = "https://matrix.org") -> str:
client = MatrixClient( client = MatrixClient(
user=user, homeserver=homeserver, device_id=device_id user=user, homeserver=homeserver, device_id=device_id
) )
await client.login(password) await client.login(password)
self.clients[client.user_id] = client self.clients[client.user_id] = client
users.AccountUpdated(client.user_id) users.AccountUpdated(client.user_id)
return client.user_id
async def resume_client(self, 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({ await self._write_config({
**self.saved_accounts, **self.saved_accounts,
client.userId: { client.user_id: {
"hostname": client.nio.host, "homeserver": client.homeserver,
"token": client.nio.access_token, "token": client.access_token,
"device_id": client.nio.device_id, "device_id": client.device_id,
} }
}) })
@ -119,7 +122,7 @@ class Backend:
async def _write_config(self, accounts: SavedAccounts) -> None: async def _write_config(self, accounts: SavedAccounts) -> None:
js = json.dumps(accounts, indent=4, ensure_ascii=False, sort_keys=True) 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) self.saved_accounts_path.parent.mkdir(parents=True, exist_ok=True)
with AtomicFile(self.saved_accounts_path, "w") as new: 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 int dimension: HStyle.avatar.size
property bool hidden: false 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 var hue = 0
for (var i = 0; i < name.length; i++) { for (var i = 0; i < name.length; i++) {
hue += name.charCodeAt(i) * 99 hue += name.charCodeAt(i) * 99
@ -24,7 +31,7 @@ Rectangle {
color: name ? color: name ?
Qt.hsla( Qt.hsla(
hue_from_name(name), hueFromName(name),
HStyle.avatar.background.saturation, HStyle.avatar.background.saturation,
HStyle.avatar.background.lightness, HStyle.avatar.background.lightness,
HStyle.avatar.background.alpha HStyle.avatar.background.alpha

View File

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

View File

@ -1,85 +1,112 @@
import QtQuick 2.7 import QtQuick 2.7
import SortFilterProxyModel 0.2
ListModel { SortFilterProxyModel {
// To initialize a HListModel with items, // To initialize a HListModel with items,
// use `Component.onCompleted: extend([{"foo": 1, "bar": 2}, ...])` // 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) { function extend(new_items) {
for (var i = 0; i < new_items.length; i++) { 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 = [] var results = []
for (var i = 0; i < listModel.count; i++) { for (var i = 0; i < model.count; i++) {
if (listModel.get(i)[where_role] == is) { var item = model.get(i)
results.push(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 break
} }
} }
if (include) {
results.push(i)
if (max_results && results.length >= max_results) {
break
}
}
if (max_tries && i >= max_tries) {
break
}
} }
return results return results
} }
function getWhere(where_role, is, max) { function getWhere(roles_are, max_results, max_tries) {
var indices = getIndices(where_role, is, max) var indices = getIndices(roles_are, max_results, max_tries)
var results = [] var items = []
for (var i = 0; i < indices.length; i++) { 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) { function forEachWhere(roles_are, func, max_results, max_tries) {
var items = getWhere(where_role, is, max) var items = getWhere(roles_are, max_results, max_tries)
for (var i = 0; i < items.length; i++) { for (var i = 0; i < items.length; i++) {
func(item) func(items[i])
} }
} }
function upsert(where_role, is, new_item, update_if_exist) { function upsert(where_roles_are, new_item, update_if_exist, max_tries) {
// new_item can contain only the keys we're interested in updating var indices = getIndices(where_roles_are, 1, max_tries)
var indices = getIndices(where_role, is, 1)
if (indices.length == 0) { if (indices.length == 0) {
listModel.append(new_item) model.append(new_item)
return listModel.get(listModel.count) return model.get(model.count)
} }
if (update_if_exist != false) { 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) { function pop(index) {
var item = listModel.get(index) var item = model.get(index)
listModel.remove(index) model.remove(index)
return item return item
} }
function popWhere(where_role, is, max) { function popWhere(roles_are, max_results, max_tries) {
var indices = getIndices(where_role, is, max) var indices = getIndices(roles_are, max_results, max_tries)
var results = [] var items = []
for (var i = 0; i < indices.length; i++) { for (var i = 0; i < indices.length; i++) {
results.push(listModel.get(indices[i])) items.push(model.get(indices[i]))
listModel.remove(indices[i]) model.remove(indices[i])
} }
return results return items
} }
function toObject(item_list) { function toObject(item_list) {
item_list = item_list || listModel item_list = item_list || sortFilteredModel
var obj_list = [] var obj_list = []
for (var i = 0; i < item_list.count; i++) { for (var i = 0; i < item_list.count; i++) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,5 @@
import QtQuick 2.7 import QtQuick 2.7
import SortFilterProxyModel 0.2
import "../../Base" import "../../Base"
HRectangle { HRectangle {
@ -8,10 +9,19 @@ HRectangle {
HListView { HListView {
id: roomEventListView id: roomEventListView
delegate: RoomEventDelegate {}
model: Backend.roomEvents.get(chatPage.roomId)
clip: true clip: true
model: HListModel {
sourceModel: models.timelines
filters: ValueFilter {
roleName: "roomId"
value: chatPage.roomId
}
}
delegate: RoomEventDelegate {}
anchors.fill: parent anchors.fill: parent
anchors.leftMargin: space anchors.leftMargin: space
anchors.rightMargin: space anchors.rightMargin: space
@ -29,7 +39,7 @@ HRectangle {
onYPosChanged: { onYPosChanged: {
if (chatPage.category != "Invites" && yPos <= 0.1) { 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" import "../Base"
HRectangle { HRectangle {
property string displayName: "" property var displayName: ""
property string topic: "" property string topic: ""
property alias buttonsImplicitWidth: viewButtons.implicitWidth property alias buttonsImplicitWidth: viewButtons.implicitWidth
@ -22,7 +22,7 @@ HRectangle {
HAvatar { HAvatar {
id: avatar id: avatar
name: displayName name: stripRoomName(displayName) || qsTr("Empty room")
dimension: roomHeader.height dimension: roomHeader.height
Layout.alignment: Qt.AlignTop Layout.alignment: Qt.AlignTop
} }

View File

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

View File

@ -18,7 +18,7 @@ HRectangle {
HAvatar { HAvatar {
id: avatar id: avatar
name: chatPage.sender.displayName.value name: chatPage.sender.displayName || stripUserId(chatPage.userId)
dimension: root.Layout.minimumHeight 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, function onRoomUpdated(user_id, category, room_id, display_name, avatar_url,
topic, last_event_date, inviter, left_event) { topic, last_event_date, inviter, left_event) {
var client_id = clientId(user_id, category, room_id) models.roomCategories.upsert({"userId": user_id, "name": category}, {
"userId": user_id,
"name": category
})
var rooms = models.rooms var rooms = models.rooms
function roles(for_category) {
return {"userId": user_id, "roomId": room_id, "category": for_category}
}
if (category == "Invites") { if (category == "Invites") {
rooms.popWhere("clientId", clientId(user_id, "Rooms", room_id)) rooms.popWhere(roles("Rooms"), 1)
rooms.popWhere("clientId", clientId(user_id, "Left", room_id)) rooms.popWhere(roles("Left"), 1)
} }
else if (category == "Rooms") { else if (category == "Rooms") {
rooms.popWhere("clientId", clientId(user_id, "Invites", room_id)) rooms.popWhere(roles("Invites"), 1)
rooms.popWhere("clientId", clientId(user_id, "Left", room_id)) rooms.popWhere(roles("Left"), 1)
} }
else if (category == "Left") { else if (category == "Left") {
var old_room = var old_room = rooms.popWhere(roles("Invites"), 1)[0] ||
rooms.popWhere("clientId", clientId(user_id, "Rooms", room_id)) || rooms.popWhere(roles("Rooms"), 1)[0]
rooms.popWhere("clientId", clientId(user_id, "Invites", room_id))
if (old_room) { if (old_room) {
display_name = old_room.displayName 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 , { rooms.upsert(roles(category), {
"clientId": client_id,
"userId": user_id, "userId": user_id,
"category": category, "category": category,
"roomId": room_id, "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) { function onRoomDeleted(user_id, category, room_id) {
var client_id = clientId(user_id, category, room_id) var roles = {"userId": user_id, "roomId": room_id, "category": category}
return models.rooms.popWhere("clientId", client_id, 1) 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}) models.accounts.append({"userId": user_id})
} }
function AccountDeleted(user_id) { function onAccountDeleted(user_id) {
models.accounts.popWhere("userId", user_id, 1) models.accounts.popWhere({"userId": user_id}, 1)
} }
function onUserUpdated(user_id, display_name, avatar_url, status_message) { 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, "userId": user_id,
"displayName": display_name, "displayName": display_name,
"avatarUrl": avatar_url, "avatarUrl": avatar_url,
"statusMessage": status_message "statusMessage": status_message
}) })
} }
function onDeviceUpdated(user_id, device_id, ed25519_key, trust, display_name, function onDeviceUpdated(user_id, device_id, ed25519_key, trust, display_name,

View File

@ -1,4 +1,5 @@
import QtQuick 2.7 import QtQuick 2.7
import SortFilterProxyModel 0.2
import "Base" import "Base"
QtObject { QtObject {
@ -8,7 +9,7 @@ QtObject {
function getUser(as_account_id, wanted_user_id) { function getUser(as_account_id, wanted_user_id) {
wanted_user_id = wanted_user_id || as_account_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] } if (found.length > 0) { return found[0] }
users.append({ users.append({
@ -22,13 +23,20 @@ QtObject {
as_account_id, "request_user_update_event", [wanted_user_id] 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 devices: HListModel {}
property HListModel roomCategories: HListModel {}
property HListModel rooms: 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 { Item {
property string loginWith: "username" property string loginWith: "username"
property var client: null property string userId: ""
HInterfaceBox { HInterfaceBox {
id: rememberBox id: rememberBox
@ -20,10 +20,13 @@ Item {
buttonCallbacks: { buttonCallbacks: {
"yes": function(button) { "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") pageStack.showPage("Default")
}, },
"no": function(button) { pageStack.showPage("Default") },
} }
HLabel { HLabel {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,6 @@
import QtQuick 2.7 import QtQuick 2.7
import QtQuick.Layouts 1.3 import QtQuick.Layouts 1.3
import SortFilterProxyModel 0.2
import "../Base" import "../Base"
HListView { HListView {
@ -7,8 +8,37 @@ HListView {
property string category: "" property string category: ""
id: roomList id: roomList
spacing: accountList.spacing spacing: sidePane.normalSpacing
model:
Backend.accounts.get(userId).roomCategories.get(category).sortedRooms 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 {} delegate: RoomDelegate {}
} }

View File

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

View File

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

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