Compare commits

...

47 Commits

Author SHA1 Message Date
4c8bc0eb1a Not sure how I didn't put that in yet 2024-04-12 20:53:31 +10:00
72809d024e merge is almost retarded but fails to be cute 2024-04-12 20:52:41 +10:00
gridtime
59b928d4f7 adds emoji presentation selector 2024-03-27 17:01:33 +01:00
Maze
12902d126c Add solution for missing libimagequant.so.0 2024-03-17 12:28:02 +00:00
Stefan Hansson
c096e239e7 Allow pymediainfo 6.x
Seems to work okay.
2024-03-08 14:19:50 +01:00
Stefan Hansson
abbe00d70f Allow lxml 5.x
Seems to work okay.
2024-03-08 14:18:43 +01:00
Maze
e3ea5f8051 Bump version to 0.7.5 2024-03-07 14:47:07 +01:00
Maze
c656956365 Update changelog for 0.7.5 2024-03-07 14:47:02 +01:00
Tim Clifford
bab99443b6 Minor fixes for OpenBSD 2024-03-04 11:31:30 +01:00
gridtime
5959e6e35a raises mistune version in requirements 2024-03-04 11:17:11 +01:00
gridtime
6a4b8d9199 fixes mistune version condition 2024-03-04 11:16:56 +01:00
gridtime
473205007f fixes mistune plugin for mistune v3 2024-03-04 10:45:20 +01:00
Tim Clifford
e99b7584d2 Add emoji processing 2024-02-26 19:01:38 +01:00
Tim Clifford
294bd887ba Catch some reaction failure modes, make spoilers black bars 2024-02-26 19:00:14 +01:00
Tim Clifford
8eb1afb91c pep8 fix 2024-02-26 19:00:14 +01:00
Tim Clifford
160670bea3 Add /spoiler and /unspoiler 2024-02-26 19:00:14 +01:00
Tim Clifford
022df56c9e add /react 2024-02-26 19:00:14 +01:00
Tim Clifford
7d6fba5ac7 Refactor /cmd handling to be more extensible 2024-02-26 19:00:14 +01:00
Stefan Hansson
4de8e87f06 Replace custom Purism::form_factor with display_length
See https://github.com/ximion/appstream/issues/476
2024-02-26 18:54:50 +01:00
f2de3d9584 Forgot to actually commit that fix 2024-02-14 16:55:31 +11:00
a9eecd1c05 Merge branch 'main' of https://gitlab.com/mx-moment/moment 2024-02-14 16:49:07 +11:00
gridtime
8521182a09 removes unneccessary set casts 2024-02-06 12:12:56 +01:00
gridtime
456525aa25 raises allowed html_sanitizer version in requirements.txt 2024-02-06 11:49:02 +01:00
gridtime
0e6e55a920 adjusts html saniziter settings to match expected types 2024-02-06 11:48:03 +01:00
gridtime
bd1bc59859 fixes content overlay in add chat tabs 2024-01-26 15:29:26 +01:00
d00a991b84 update submodule or something 2024-01-22 22:30:14 +11:00
4ec8ab50e7 fuck merge conflicts 2024-01-22 22:28:00 +11:00
b248771619 Merged with 0.7.4 2024-01-22 22:14:11 +11:00
85e78bae0e amend! Merged with 0.7.4 release
Merged with 0.7.4 release
2024-01-22 19:27:12 +11:00
442a1aafee amend! Merged with 0.7/4 release
Merged with 0.7/4 release
2024-01-22 19:26:43 +11:00
c7287c861c Merged with 0.7/4 release 2024-01-22 19:21:52 +11:00
gridtime
6e6b54c4c8 fixes flickering of account avatar overlay 2024-01-07 16:41:36 +01:00
gridtime
09b31c881e fixes undefined TabBar.width 2024-01-07 16:04:29 +01:00
gridtime
de4bd2c4a6 fixes account tab content overlay 2024-01-07 15:47:51 +01:00
Maze
1e61d1c538 Revet commit fd1fa516cb
Readding -name dev
2024-01-07 14:34:49 +01:00
Maze
fb4501e9b3 Bump version to 0.7.4 2024-01-04 10:28:48 +01:00
Maze
9302731734 Update changelog for 0.7.4 2024-01-04 10:23:35 +01:00
Maze
71db456ced Merge branch 'alexdev' 2024-01-04 09:57:24 +01:00
Maze
8d82431a22 Merge branch 'autoreload-qmake' into 'main'
Use qmake-qt5 in autoreload if it exists

See merge request mx-moment/moment!27
2024-01-03 20:15:00 +00:00
Maze
fd1fa516cb Remove -name from autoreload.py
(since this argument no longer works on Moment)
2024-01-03 11:34:15 +01:00
Newbyte
132b45f670 Use qmake-qt5 in autoreload if it exists
On Fedora 39, the Qt5 qmake is installed as qmake-qt5. As such, use it
when autoreloading instead of qmake if it exists.
2024-01-02 20:28:51 -06:00
gridtime
e5c136a32f forces normal background for history diff 2023-12-15 04:08:07 +01:00
gridtime
ef3ee1cdf6 forces left alignment for history diff 2023-12-15 04:07:35 +01:00
gridtime
75fc44e34f removes mx-reply block for history diff 2023-12-15 04:06:53 +01:00
gridtime
fc23274c94 adds edits 2023-12-08 21:27:11 +01:00
gridtime
f5691fd8be adds reactions 2023-12-08 09:44:52 +01:00
Maze
565508b217 Use servers.joinmatrix.org/servers.json
Switching because joinmatrix.org/servers.json is deprecated
2023-11-30 12:47:24 +01:00
31 changed files with 1983 additions and 90 deletions

1
.gitignore vendored
View File

@@ -3,6 +3,7 @@ __pycache__
*.egg-info *.egg-info
*.pyc *.pyc
venv venv
sitecustomize.py
*.qmlc *.qmlc
*.jsc *.jsc

79
PKGBUILD Normal file
View File

@@ -0,0 +1,79 @@
# Maintainer: DrRac27 <drrac27 at riseup.net>
pkgname=moment-git
_name=moment
pkgver=v0.7.3.r32.2af33fce
pkgrel=1
pkgdesc='A customizable, keyboard-operable Matrix client. Fork of Mirage'
arch=('x86_64' 'i686' 'aarch64')
url='https://mx-moment.xyz/'
license=('LGPL3')
depends=(
'qt5-base'
'qt5-declarative'
'qt5-quickcontrols2'
'qt5-svg'
'qt5-graphicaleffects'
'qt5-imageformats'
'python'
'python-pyotherside'
'libolm'
'libjpeg-turbo'
'zlib'
'libtiff'
'libwebp'
'openjpeg2'
'libmediainfo'
'python-pillow'
'python-pymediainfo'
'python-cairosvg'
'python-aiofiles'
'python-appdirs'
'python-filetype'
'python-html-sanitizer'
'python-lxml'
'python-mistune>=2'
'python-matrix-nio'
'libxss'
'python-plyer'
'python-sortedcontainers'
'python-watchgod'
'python-redbaron'
'dbus-python'
'python-hsluv'
'python-pycryptodome'
'python-simpleaudio'
'python-olm'
'python-cachetools'
'python-atomicwrites'
'python-peewee'
)
makedepends=('cmake' 'git')
provides=('moment')
conflicts=('moment')
source=('git+https://gitlab.com/mx-moment/moment.git')
sha256sums=('SKIP')
prepare() {
cd "${srcdir}/${_name}"
git submodule update --init --recursive
}
pkgver() {
cd "${srcdir}/${_name}"
local tag=$(git tag --sort=-v:refname | head -1)
local commits_since=$(git rev-list $tag..HEAD --count)
echo "$tag.r$commits_since.$(git log --pretty=format:'%h' -n 1)"
}
build() {
cd "${srcdir}/${_name}"
make clean || true
qmake PREFIX=/usr moment.pro
make
}
package() {
cd "${srcdir}/${_name}"
make INSTALL_ROOT="${pkgdir}" install
}

View File

@@ -11,6 +11,7 @@ Use `pip3 install --user -U requirements-dev.txt` before running this."""
import os import os
import subprocess import subprocess
import shutil
import sys import sys
from contextlib import suppress from contextlib import suppress
from pathlib import Path from pathlib import Path
@@ -49,8 +50,13 @@ def cmd(*parts) -> subprocess.CompletedProcess:
def run_app(args=sys.argv[1:]) -> None: def run_app(args=sys.argv[1:]) -> None:
print("\n\x1b[36m", "" * term_size().columns, "\x1b[0m\n", sep="") print("\n\x1b[36m", "" * term_size().columns, "\x1b[0m\n", sep="")
if shutil.which("qmake-qt5"):
QMAKE_CMD = "qmake-qt5"
else:
QMAKE_CMD = "qmake"
with suppress(KeyboardInterrupt): with suppress(KeyboardInterrupt):
cmd("qmake", "moment.pro", "CONFIG+=dev") cmd(QMAKE_CMD, "moment.pro", "CONFIG+=dev")
cmd("make") cmd("make")
cmd("./moment", "-name", "dev", *args) cmd("./moment", "-name", "dev", *args)

View File

@@ -6,6 +6,8 @@ The format is based on
and this project adheres to and this project adheres to
[Semantic Versioning](https://semver.org/spec/v2.0.0.html). [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- [0.7.5 (2024-03-07)](#075-2024-03-07)
- [0.7.4 (2024-01-04)](#074-2024-01-04)
- [0.7.3 (2022-01-31)](#073-2022-01-31) - [0.7.3 (2022-01-31)](#073-2022-01-31)
- [0.7.2 (2021-07-26)](#072-2021-07-26) - [0.7.2 (2021-07-26)](#072-2021-07-26)
- [0.7.1 (2021-03-04)](#071-2021-03-04) - [0.7.1 (2021-03-04)](#071-2021-03-04)
@@ -24,6 +26,58 @@ and this project adheres to
- [0.4.0 (2020-03-21)](#040-2020-03-21) - [0.4.0 (2020-03-21)](#040-2020-03-21)
## 0.7.5 (2024-03-07)
### Added
- Added emoji processing, for example `:eyes:`
- Added /react command, to add (emoji) reactions
- Added displaying of spoiler tags
- Added /spoiler and /unspoiler commands
### Changed
- Now using Mistune 3
### Fixed
- Fixed profile picture flickering in account settings UI
- Fixed overlaying tabs glitch in many UIs
- Fixed some issues when running on OpenBSD
## 0.7.4 (2024-01-04)
### Added
- Display emoji reactions on messages
- Display edited messages properly
### Changed
- Changed command line argument processing
- Changed server listing to use servers.joinmatrix.org
- Now using Mistune 2.0.2
- Now using upstream matrix-nio (instead of fork)
### Removed
- Almost all UI animations were removed
### Fixed
- Fixed restoring from tray
- Updated installation instructions
## 0.7.3 (2022-01-31) ## 0.7.3 (2022-01-31)
### Added ### Added

View File

@@ -25,7 +25,8 @@ but compiling on Windows and macOS should be possible with the right tools.
- [Common issues](#common-issues) - [Common issues](#common-issues)
- [cffi version mismatch](#cffi-version-mismatch) - [cffi version mismatch](#cffi-version-mismatch)
- [Type XYZ unavailable](#type-xyz-unavailable) - [Type XYZ unavailable](#type-xyz-unavailable)
- [libimagequant.so.0: cannot open shared object file: No such file or directory](#libimagequantso0-cannot-open-shared-object-file-no-such-file-or-directory)
## Packages ## Packages
@@ -379,3 +380,12 @@ sudo chmod 755 /usr/lib/qt5/qml/io/thp/pyotherside/*.so
Note that the Qt lib path might be `/usr/lib/qt/` instead of `/usr/lib/qt5/`, Note that the Qt lib path might be `/usr/lib/qt/` instead of `/usr/lib/qt5/`,
depending on the distro. depending on the distro.
#### libimagequant.so.0: cannot open shared object file: No such file or directory
Solution from [here](https://stackoverflow.com/questions/77499381/libimagequant-so-0-cannot-open-shared-object-file-no-such-file-or-directory) works.
```sh
sudo ln /lib/libimagequant.so.0.4 /lib/libimagequant.so.0
```

View File

@@ -23,6 +23,9 @@
<provides> <provides>
<binary>moment</binary> <binary>moment</binary>
</provides> </provides>
<requires>
<display_length compare="ge">360</display_length>
</requires>
<project_license>LGPL-3.0-or-later</project_license> <project_license>LGPL-3.0-or-later</project_license>
<screenshots> <screenshots>
<screenshot type="default"> <screenshot type="default">
@@ -56,6 +59,8 @@
</screenshots> </screenshots>
<metadata_license>FSFAP</metadata_license> <metadata_license>FSFAP</metadata_license>
<releases> <releases>
<release version="0.7.5" date="2024-03-07"/>
<release version="0.7.4" date="2024-01-04"/>
<release version="0.7.3" date="2022-01-31"/> <release version="0.7.3" date="2022-01-31"/>
<release version="0.7.2" date="2021-07-26"/> <release version="0.7.2" date="2021-07-26"/>
<release version="0.7.1" date="2021-03-04"/> <release version="0.7.1" date="2021-03-04"/>
@@ -73,8 +78,4 @@
<release version="0.4.1" date="2020-03-23"/> <release version="0.4.1" date="2020-03-23"/>
<release version="0.4.0" date="2020-03-21"/> <release version="0.4.0" date="2020-03-21"/>
</releases> </releases>
<custom>
<value key="Purism::form_factor">workstation</value>
<value key="Purism::form_factor">mobile</value>
</custom>
</component> </component>

View File

@@ -1,15 +1,15 @@
remote_pdb >= 2.0.0, < 3 remote_pdb >= 2.0.0, < 3
pdbpp >= 0.10.2, < 0.11 pdbpp >= 0.10.2, < 0.11
devtools >= 0.4.0, < 0.5 devtools >= 0.12.0, < 0.13
mypy >= 0.812, < 0.900 mypy >= 1.7.0, < 1.8
flake8 >= 3.8.4, < 4 flake8 >= 6.1.0, < 7
flake8-isort >= 4.0.0, < 5 flake8-isort >= 6.1.0, < 7
flake8-bugbear >= 20.1.4, < 21 flake8-bugbear >= 23.12.0, < 24
flake8-commas >= 2.0.0, < 3 flake8-commas >= 2.0.0, < 3
flake8-comprehensions >= 3.3.0, < 4 flake8-comprehensions >= 3.3.0, < 4
flake8-executable >= 2.0.4, < 3 flake8-executable >= 2.0.4, < 3
flake8-logging-format >= 0.6.0, < 0.7 flake8-logging-format >= 0.9.0, < 1
flake8-pie >= 0.6.1, < 0.7 flake8-pie >= 0.16.0, < 1
flake8-quotes >= 3.2.0, < 4 flake8-quotes >= 3.2.0, < 4
flake8-colors >= 0.1.6, < 0.2 flake8-colors >= 0.1.6, < 0.2

View File

@@ -2,11 +2,12 @@ Pillow >= 7.0.0, < 9
aiofiles >= 0.4.0, < 24.0.0 aiofiles >= 0.4.0, < 24.0.0
appdirs >= 1.4.4, < 2 appdirs >= 1.4.4, < 2
cairosvg >= 2.4.2, < 3 cairosvg >= 2.4.2, < 3
emoji >= 2.0, < 3.0
filetype >= 1.0.7, < 2 filetype >= 1.0.7, < 2
html_sanitizer >= 1.9.1, < 2 html_sanitizer >= 1.9.1, < 3
lxml >= 4.5.1, < 5 lxml >= 4.5.1, < 6
mistune >= 2.0.0, < 3.0 mistune >= 2.0.0, < 4.0
pymediainfo >= 4.2.1, < 5 pymediainfo >= 4.2.1, < 7
plyer >= 1.4.3, < 2 plyer >= 1.4.3, < 2
sortedcontainers >= 2.2.2, < 3 sortedcontainers >= 2.2.2, < 3
watchgod >= 0.7, < 0.8 watchgod >= 0.7, < 0.8
@@ -14,7 +15,7 @@ redbaron >= 0.9.2, < 1
hsluv >= 5.0.0, < 6 hsluv >= 5.0.0, < 6
simpleaudio >= 1.0.4, < 2 simpleaudio >= 1.0.4, < 2
dbus-python >= 1.2.16, < 2; platform_system == "Linux" dbus-python >= 1.2.16, < 2; platform_system == "Linux"
matrix-nio[e2e] >= 0.20.1, < 1.0.0 matrix-nio[e2e] >= 0.22.0, < 0.24
async_generator >= 1.10, < 2; python_version < "3.7" async_generator >= 1.10, < 2; python_version < "3.7"
dataclasses >= 0.6, < 0.7; python_version < "3.7" dataclasses >= 0.6, < 0.7; python_version < "3.7"

View File

@@ -16,4 +16,4 @@ documentation in the following modules first:
__app_name__ = "moment" __app_name__ = "moment"
__display_name__ = "Moment" __display_name__ = "Moment"
__reverse_dns__ = "xyz.mx-moment" __reverse_dns__ = "xyz.mx-moment"
__version__ = "0.7.3" __version__ = "0.7.5"

View File

@@ -20,7 +20,16 @@ import aiohttp
import nio import nio
import plyer import plyer
import pyotherside import pyotherside
import simpleaudio
has_simpleaudio = True
try:
import simpleaudio
except ImportError as e:
trace = traceback.format_exc().rstrip()
log.error("Importing simpleaudio failed\n%s", trace)
has_simpleaudio = False
from appdirs import AppDirs from appdirs import AppDirs
from nio.client.async_client import client_session from nio.client.async_client import client_session
@@ -533,7 +542,7 @@ class Backend:
connector = session.connector, connector = session.connector,
) )
api_list = "https://joinmatrix.org/servers.json" api_list = "https://servers.joinmatrix.org/servers.json"
try: try:
response = await session.get(api_list) response = await session.get(api_list)
except: except:
@@ -543,17 +552,19 @@ class Backend:
coros = [] coros = []
for server in (await response.json()): for server in (await response.json())["public_servers"]:
homeserver_url = "https://" + server["domain"] homeserver_url = "https://" + server["client_domain"]
if not server["open"]: # ignore closed servers http_s_re = re.compile("^https?://")
continue # remove https from homepage because it will be re-added later
site_url = (http_s_re.sub("", server["homepage"])
if "homepage" in server else server["client_domain"])
self.models["homeservers"][homeserver_url] = Homeserver( self.models["homeservers"][homeserver_url] = Homeserver(
id = homeserver_url, id = homeserver_url,
name = server["name"], name = server["name"],
site_url = server["domain"], site_url = site_url,
country = server["jurisdiction"], country = server["staff_jur"],
stability = 0, stability = 0,
downtimes_ms = 0, downtimes_ms = 0,
# austin's list doesn't have stability/downtime # austin's list doesn't have stability/downtime
@@ -588,12 +599,19 @@ class Backend:
async def sound_notify(self) -> None: async def sound_notify(self) -> None:
if not has_simpleaudio:
if self.audio_working:
log.error("Playing audio not supported as python-simpleaudio is not installed")
self.audio_working = False
return
path = self.settings.Notifications.default_sound path = self.settings.Notifications.default_sound
path = str(Path(path).expanduser()) path = str(Path(path).expanduser())
if path == "default.wav": if path == "default.wav":
path = "src/sounds/default.wav" path = "src/sounds/default.wav"
try: try:
content = pyotherside.qrc_get_file_contents(path) content = pyotherside.qrc_get_file_contents(path)
except ValueError: except ValueError:
@@ -601,7 +619,7 @@ class Backend:
else: else:
wave_read = wave.open(io.BytesIO(content)) wave_read = wave.open(io.BytesIO(content))
sa = simpleaudio.WaveObject.from_wave_read(wave_read) sa = simpleaudio.WaveObject.from_wave_read(wave_read)
try: try:
sa.play() sa.play()
self.audio_working = True self.audio_working = True

View File

@@ -10,6 +10,7 @@ from urllib.parse import unquote
import html_sanitizer.sanitizer as sanitizer import html_sanitizer.sanitizer as sanitizer
import lxml.html # nosec import lxml.html # nosec
import emoji
import mistune import mistune
import nio import nio
from html_sanitizer.sanitizer import Sanitizer from html_sanitizer.sanitizer import Sanitizer
@@ -40,7 +41,10 @@ def plugin_matrix(md):
# Mark colour as high priority as otherwise e.g. <red>(hi) matches the # Mark colour as high priority as otherwise e.g. <red>(hi) matches the
# inline_html rule instead of the colour rule. # inline_html rule instead of the colour rule.
md.inline.rules.insert(1, "colour") md.inline.rules.insert(1, "colour")
md.inline.register_rule("colour", colour, parse_colour) if mistune.__version__.startswith("2."): # v2
md.inline.register_rule("colour", colour, parse_colour)
else:
md.inline.register("colour", colour, parse_colour)
if md.renderer.NAME == "html": if md.renderer.NAME == "html":
md.renderer.register("colour", render_html_colour) md.renderer.register("colour", render_html_colour)
@@ -215,6 +219,14 @@ class HTMLProcessor:
if not outgoing: if not outgoing:
self._matrix_to_links_add_classes(a_tag) self._matrix_to_links_add_classes(a_tag)
for node in tree.iterdescendants():
if node.tag != "code" and node.text:
node.text = emoji.emojize(
node.text, language="alias", variant="emoji_type")
if node.getparent() and node.getparent().tag != "code" and node.tail:
node.tail = emoji.emojize(
node.tail, language="alias", variant="emoji_type")
html = etree.tostring(tree, encoding="utf-8", method="html").decode() html = etree.tostring(tree, encoding="utf-8", method="html").decode()
html = sanit.sanitize(html).rstrip("\n") html = sanit.sanitize(html).rstrip("\n")
@@ -223,6 +235,27 @@ class HTMLProcessor:
# Client-side modifications # Client-side modifications
# re-parsing, will sanitize again but allowing style
tree = etree.fromstring(
html, parser=etree.HTMLParser(encoding="utf-8"),
)
for span_tag in tree.iterfind(".//span[@data-mx-spoiler]"):
# if there are sub-elements, their styles also need to be set or
# background-color doesn't seem to apply
for tag in span_tag.iter():
tag.set(
"style",
"color: black !important; background-color: black !important;"
+ (tag.get("style") or "")
)
html = etree.tostring(tree, encoding="utf-8", method="html").decode()
html = Sanitizer(self.sanitize_settings(
inline, outgoing, mentions, extra_attributes={"style"}
)).sanitize(html).rstrip("\n")
html = self.quote_regex.sub(r'\1<span class="quote">\2</span>\3', html) html = self.quote_regex.sub(r'\1<span class="quote">\2</span>\3', html)
if not inline: if not inline:
@@ -238,6 +271,7 @@ class HTMLProcessor:
inline: bool = False, inline: bool = False,
outgoing: bool = False, outgoing: bool = False,
display_name_mentions: Optional[Dict[str, str]] = None, display_name_mentions: Optional[Dict[str, str]] = None,
extra_attributes: set = set(),
) -> dict: ) -> dict:
"""Return an html_sanitizer configuration.""" """Return an html_sanitizer configuration."""
@@ -250,16 +284,21 @@ class HTMLProcessor:
"font": {"color"}, "font": {"color"},
"a": {"href", "class", "data-mention"}, "a": {"href", "class", "data-mention"},
"code": {"class"}, "code": {"class"},
"span": {"data-mx-spoiler", "data-mx-color"},
} }
attributes = {**inlines_attributes, **{ attributes = {**inlines_attributes, **{
"ol": {"start"}, "ol": {"start"},
"hr": {"width"}, "hr": {"width"},
"span": {"data-mx-color"},
"img": { "img": {
"data-mx-emote", "src", "alt", "title", "width", "height", "data-mx-emote", "src", "alt", "title", "width", "height",
}, },
}} }}
for key in inlines_attributes:
inlines_attributes[key] |= extra_attributes
for key in attributes:
attributes[key] |= extra_attributes
username_link_regexes = [re.compile(r) for r in [ username_link_regexes = [re.compile(r) for r in [
rf"(?<!\w)(?P<body>{re.escape(name or user_id)})(?!\w)(?P<host>)" rf"(?<!\w)(?P<body>{re.escape(name or user_id)})(?!\w)(?P<host>)"
for user_id, name in (display_name_mentions or {}).items() for user_id, name in (display_name_mentions or {}).items()
@@ -268,11 +307,11 @@ class HTMLProcessor:
return { return {
"tags": inline_tags if inline else all_tags, "tags": inline_tags if inline else all_tags,
"attributes": inlines_attributes if inline else attributes, "attributes": inlines_attributes if inline else attributes,
"empty": {} if inline else {"hr", "br", "img"}, "empty": set() if inline else {"hr", "br", "img"},
"separate": {"a"} if inline else { "separate": {"a"} if inline else {
"a", "p", "li", "table", "tr", "th", "td", "br", "hr", "img", "a", "p", "li", "table", "tr", "th", "td", "br", "hr", "img",
}, },
"whitespace": {}, "whitespace": set(),
"keep_typographic_whitespace": True, "keep_typographic_whitespace": True,
"add_nofollow": False, "add_nofollow": False,
"autolink": { "autolink": {

View File

@@ -26,6 +26,7 @@ from urllib.parse import urlparse
from uuid import UUID, uuid4 from uuid import UUID, uuid4
import cairosvg import cairosvg
import emoji
import nio import nio
from nio.crypto import AsyncDataT as UploadData from nio.crypto import AsyncDataT as UploadData
from nio.crypto import async_generator_from_data from nio.crypto import async_generator_from_data
@@ -150,7 +151,6 @@ class MatrixClient(nio.AsyncClient):
"m.call.*", "m.call.*",
"m.room.third_party_invite", "m.room.third_party_invite",
"m.room.tombstone", "m.room.tombstone",
"m.reaction",
], ],
}, },
}, },
@@ -178,6 +178,24 @@ class MatrixClient(nio.AsyncClient):
if host in ("127.0.0.1", "localhost", "::1"): if host in ("127.0.0.1", "localhost", "::1"):
proxy = None proxy = None
# self is passed in explicitly, so that it will also be passed to
# handlers in the user config
self.cmd_handler_map = {
"me ": MatrixClient.handle_cmd_emote,
"react ": MatrixClient.handle_cmd_react,
"spoiler ": MatrixClient.handle_cmd_spoiler,
"unspoiler": MatrixClient.handle_cmd_unspoiler,
}
try:
self.cmd_handler_map = {
**self.cmd_handler_map,
**backend.settings.Commands.get_cmd_handler_map(),
}
except (AttributeError, KeyError):
# make sure we don't break older configs
pass
super().__init__( super().__init__(
homeserver = homeserver, homeserver = homeserver,
user = user, user = user,
@@ -225,14 +243,18 @@ class MatrixClient(nio.AsyncClient):
self.unassigned_event_last_read_by: DefaultDict[str, Dict[str, int]] =\ self.unassigned_event_last_read_by: DefaultDict[str, Dict[str, int]] =\
DefaultDict(dict) DefaultDict(dict)
# {reacted_event_id: {emoji: [user_id]}}
self.unassigned_reaction_events: Dict[str, Dict[str, List[str]]] = {}
# {replaced_event_id: [replace_event]}}
self.unassigned_replace_events: Dict[str, List[Dict[str, str]]] = {}
self.push_rules: nio.PushRulesEvent = nio.PushRulesEvent() self.push_rules: nio.PushRulesEvent = nio.PushRulesEvent()
self.ignored_user_ids: Set[str] = set() self.ignored_user_ids: Set[str] = set()
# {room_id: event} # {room_id: event}
self.power_level_events: Dict[str, nio.PowerLevelsEvent] = {} self.power_level_events: Dict[str, nio.PowerLevelsEvent] = {}
self.invalid_disconnecting: bool = False
self.nio_callbacks = NioCallbacks(self) self.nio_callbacks = NioCallbacks(self)
@@ -241,7 +263,6 @@ class MatrixClient(nio.AsyncClient):
type(self).__name__, self.user_id, self.homeserver, self.device_id, type(self).__name__, self.user_id, self.homeserver, self.device_id,
) )
@property @property
def default_device_name(self) -> str: def default_device_name(self) -> str:
"""Device name to set at login if the user hasn't set a custom one.""" """Device name to set at login if the user hasn't set a custom one."""
@@ -359,7 +380,7 @@ class MatrixClient(nio.AsyncClient):
timeout = 10, timeout = 10,
) )
except asyncio.TimeoutError: except asyncio.TimeoutError:
log.warn("%s timed out", self.user_id) log.warning("%s timed out", self.user_id)
await self.close() await self.close()
@@ -377,7 +398,7 @@ class MatrixClient(nio.AsyncClient):
account.max_upload_size = future.result() or 0 account.max_upload_size = future.result() or 0
except MatrixError: except MatrixError:
trace = traceback.format_exc().rstrip() trace = traceback.format_exc().rstrip()
log.warn( log.warning(
"On %s server config retrieval: %s", self.user_id, trace, "On %s server config retrieval: %s", self.user_id, trace,
) )
self.server_config_task = asyncio.ensure_future( self.server_config_task = asyncio.ensure_future(
@@ -603,7 +624,6 @@ class MatrixClient(nio.AsyncClient):
return {**self.invited_rooms, **self.rooms} return {**self.invited_rooms, **self.rooms}
async def send_text( async def send_text(
self, self,
room_id: str, room_id: str,
@@ -611,22 +631,46 @@ class MatrixClient(nio.AsyncClient):
display_name_mentions: Optional[Dict[str, str]] = None, # {id: name} display_name_mentions: Optional[Dict[str, str]] = None, # {id: name}
reply_to_event_id: Optional[str] = None, reply_to_event_id: Optional[str] = None,
) -> None: ) -> None:
"""Send a markdown `m.text` or `m.notice` (with `/me`) message .""" if text.startswith("//") or text.startswith(r"\/"):
await self._send_text(
room_id, text[1:], display_name_mentions, reply_to_event_id)
elif text.startswith("/"):
for k, v in self.cmd_handler_map.items():
if text.startswith("/" + k):
await v(self, room_id, text[len("/" + k):],
display_name_mentions, reply_to_event_id)
break
else:
await self.send_fake_notice(
room_id,
r"That command was not recognised. "
r"To send a message starting with /, use //",
)
else:
await self._send_text(
room_id, text, display_name_mentions, reply_to_event_id)
async def _send_text(
self,
room_id: str,
text: str,
display_name_mentions: Optional[Dict[str, str]] = None, # {id: name}
reply_to_event_id: Optional[str] = None,
override_to_html: Optional[str] = None,
override_echo_body: Optional[str] = None,
emote: bool = False,
) -> None:
"""Send a markdown `m.text` or `m.emote` message ."""
from_md = partial( from_md = partial(
HTML.from_markdown, display_name_mentions=display_name_mentions, HTML.from_markdown, display_name_mentions=display_name_mentions,
) )
escape = False
if text.startswith("//") or text.startswith(r"\/"):
escape = True
text = text[1:]
content: Dict[str, Any] content: Dict[str, Any]
if text.startswith("/me ") and not escape: if emote:
event_type = nio.RoomMessageEmote event_type = nio.RoomMessageEmote
text = text[len("/me "):]
content = {"body": text, "msgtype": "m.emote"} content = {"body": text, "msgtype": "m.emote"}
to_html = from_md(text, inline=True, outgoing=True) to_html = from_md(text, inline=True, outgoing=True)
echo_body = from_md(text, inline=True) echo_body = from_md(text, inline=True)
@@ -636,6 +680,12 @@ class MatrixClient(nio.AsyncClient):
to_html = from_md(text, outgoing=True) to_html = from_md(text, outgoing=True)
echo_body = from_md(text) echo_body = from_md(text)
# override_echo_body will not be effective if it is a reply.
# echo_body is only shown before the event is received back from the
# server, so this is fine if not ideal
to_html = override_to_html or to_html
echo_body = override_echo_body or echo_body
if to_html not in (html.escape(text), f"<p>{html.escape(text)}</p>"): if to_html not in (html.escape(text), f"<p>{html.escape(text)}</p>"):
content["format"] = "org.matrix.custom.html" content["format"] = "org.matrix.custom.html"
content["formatted_body"] = to_html content["formatted_body"] = to_html
@@ -691,6 +741,171 @@ class MatrixClient(nio.AsyncClient):
await self.pause_while_offline() await self.pause_while_offline()
await self._send_message(room_id, content, tx_id) await self._send_message(room_id, content, tx_id)
async def handle_cmd_emote(
self,
room_id: str,
text: str,
display_name_mentions: Optional[Dict[str, str]] = None, # {id: name}
reply_to_event_id: Optional[str] = None,
):
await self._send_text(
room_id,
text,
display_name_mentions,
reply_to_event_id,
emote=True,
)
async def handle_cmd_react(
self,
room_id: str,
text: str,
display_name_mentions: Optional[Dict[str, str]] = None, # {id: name}
reply_to_event_id: Optional[str] = None,
) -> None:
if reply_to_event_id is None or reply_to_event_id == "":
await self.send_fake_notice(
room_id, "Please reply to a message to react to it")
else:
reaction = emoji.emojize(
text, language="alias", variant="emoji_type")
await self.send_reaction(room_id, reaction, reply_to_event_id)
async def handle_cmd_spoiler(
self,
room_id: str,
text: str,
display_name_mentions: Optional[Dict[str, str]] = None, # {id: name}
reply_to_event_id: Optional[str] = None,
) -> None:
from_md = partial(
HTML.from_markdown, display_name_mentions=display_name_mentions,
)
to_html = from_md(text, outgoing=True)
echo_body = from_md(text)
if to_html.startswith("<p>") and to_html.endswith("</p>"):
# we want to make sure the <span> is inside the <p>, otherwise the
# black bar will be too wide
inner_html = to_html[len('<p>'):-len('</p>')]
to_html = f"<p><span data-mx-spoiler>{inner_html}</span></p>"
else:
to_html = f"<span data-mx-spoiler>{to_html}</span>"
if echo_body.startswith("<p>") and echo_body.endswith("</p>"):
inner_html = echo_body[len('<p>'):-len('</p>')]
echo_body = f"<p><span data-mx-spoiler>{inner_html}</span></p>"
else:
echo_body = f"<span data-mx-spoiler>{echo_body}</span>"
await self._send_text(
room_id,
text,
display_name_mentions,
reply_to_event_id,
override_to_html = to_html,
override_echo_body = echo_body,
)
async def handle_cmd_unspoiler(
self,
room_id: str,
text: str,
display_name_mentions: Optional[Dict[str, str]] = None, # {id: name}
reply_to_event_id: Optional[str] = None,
) -> None:
if reply_to_event_id is None or reply_to_event_id == "":
await self.send_fake_notice(
room_id,
"Please reply to a message with /unspoiler to unspoiler it",
)
else:
spoiler_event: Event = \
self.models[self.user_id, room_id, "events"][reply_to_event_id]
# get formatted_body, fallback to body,
spoiler = getattr(spoiler_event.source, "formatted_body", None) \
or getattr(spoiler_event.source, "body", "")
unspoiler = re.sub(
r"<span[^>]+data-mx-spoiler[^>]*>(.*?)</?span>", r"\1",
spoiler,
)
await self.send_fake_notice(room_id, unspoiler)
async def send_reaction(
self,
room_id: str,
key: str,
reacts_to: str,
) -> None:
# local event id in model isn't necessarily the actual event id
reacts_to_event = self.models[
self.user_id, room_id, "events"][reacts_to]
reacts_to_event_id = reacts_to_event.event_id
if self.user_id in reacts_to_event.reactions.get(key, {}).get('users', []):
await self.send_fake_notice(
room_id,
"Can't send the same reaction more than once",
)
return
elif reacts_to_event_id.startswith("echo-"):
await self.send_fake_notice(
room_id,
"Can't react to that, it's not a real event",
)
return
item_uuid = uuid4()
content: Dict[str, Any] = {
"m.relates_to": {
"rel_type": "m.annotation",
"event_id": reacts_to_event_id,
"key": key,
},
}
tx_id = uuid4()
content[f"{__reverse_dns__}.transaction_id"] = str(tx_id)
await self.pause_while_offline()
try:
await self._send_message(
room_id, content, item_uuid, message_type = "m.reaction")
except MatrixError as err:
if err.m_code == "M_DUPLICATE_ANNOTATION":
# potentially possible if the new reaction is
# sent before the existing reaction is loaded
await self.send_fake_notice(
room_id,
"Can't send the same reaction more than once",
)
return
if err.m_code == "M_UNKNOWN":
await self.send_fake_notice(
room_id,
"Failed to send reaction. Has the event you are reacting to fully sent yet?",
)
else:
raise err
# only update the UI after the reaction is sent, to not be misleading
await self._register_reaction(
self.all_rooms[room_id],
reacts_to_event_id,
key,
self.user_id,
)
async def toggle_pause_transfer( async def toggle_pause_transfer(
self, room_id: str, uuid: Union[str, UUID], self, room_id: str, uuid: Union[str, UUID],
@@ -742,7 +957,6 @@ class MatrixClient(nio.AsyncClient):
reply_to_event_id: Optional[str] = None, reply_to_event_id: Optional[str] = None,
) -> None: ) -> None:
"""Send a `m.file`, `m.image`, `m.audio` or `m.video` message. """Send a `m.file`, `m.image`, `m.audio` or `m.video` message.
The Matrix client-server API states that media messages can't have a The Matrix client-server API states that media messages can't have a
reply attached. reply attached.
Thus, if a `reply_to_event_id` is passed, we send a pseudo-reply as two Thus, if a `reply_to_event_id` is passed, we send a pseudo-reply as two
@@ -1016,6 +1230,7 @@ class MatrixClient(nio.AsyncClient):
room_id: str, room_id: str,
transaction_id: UUID, transaction_id: UUID,
event_type: Type[nio.Event], event_type: Type[nio.Event],
fake_event: bool = False,
**event_fields, **event_fields,
) -> None: ) -> None:
"""Register a local model `Event` while waiting for the server. """Register a local model `Event` while waiting for the server.
@@ -1043,13 +1258,13 @@ class MatrixClient(nio.AsyncClient):
event = Event( event = Event(
id = f"echo-{transaction_id}", id = f"echo-{transaction_id}",
event_id = "", event_id = f"echo-{transaction_id}" if fake_event else "",
event_type = event_type, event_type = event_type,
date = datetime.now(), date = datetime.now(),
sender_id = self.user_id, sender_id = self.user_id,
sender_name = our_info.display_name, sender_name = our_info.display_name,
sender_avatar = our_info.avatar_url, sender_avatar = our_info.avatar_url,
is_local_echo = True, is_local_echo = not fake_event,
links = Event.parse_links(content), links = Event.parse_links(content),
**event_fields, **event_fields,
) )
@@ -1061,9 +1276,18 @@ class MatrixClient(nio.AsyncClient):
await self.set_room_last_event(room_id, event) await self.set_room_last_event(room_id, event)
async def send_fake_notice(self, room_id, msg):
await self._local_echo(
room_id,
uuid4(),
nio.RoomMessageNotice,
content = msg,
fake_event = True,
)
async def _send_message( async def _send_message(
self, room_id: str, content: dict, transaction_id: UUID, self, room_id: str, content: dict, transaction_id: UUID,
message_type: str = "m.room.message",
) -> None: ) -> None:
"""Send a message event with `content` dict to a room.""" """Send a message event with `content` dict to a room."""
@@ -1073,7 +1297,7 @@ class MatrixClient(nio.AsyncClient):
async with self.backend.send_locks[room_id]: async with self.backend.send_locks[room_id]:
await self.room_send( await self.room_send(
room_id = room_id, room_id = room_id,
message_type = "m.room.message", message_type = message_type,
content = content, content = content,
ignore_unverified_devices = True, ignore_unverified_devices = True,
) )
@@ -2422,6 +2646,122 @@ class MatrixClient(nio.AsyncClient):
self.backend.notification_avatar_cache[mxc] = path self.backend.notification_avatar_cache[mxc] = path
return path return path
async def register_reaction(
self,
room: nio.MatrixRoom,
ev: nio.ReactionEvent,
event_id: str = "",
**fields,
) -> None:
await self._register_reaction(room, ev.reacts_to, ev.key, ev.sender)
await self.register_nio_event(
room, ev, event_id, type_specifier=TypeSpecifier.Reaction,
content=ev.key, hidden=True, **fields,
)
async def _register_reaction(
self,
room: nio.MatrixRoom,
reacts_to: str,
key: str,
sender: str,
) -> None:
"""Register/update a reaction."""
model = self.models[self.user_id, room.room_id, "events"]
reacts_to_event = model.get(reacts_to)
if not reacts_to_event: # local echo
for item in model.values():
if item.event_id == reacts_to:
reacts_to_event = item
# message is already loaded: update reactions instantly
if reacts_to_event:
reactions = reacts_to_event.reactions
if key not in reactions:
reactions[key] = {
"hint": emoji.demojize(key, language="alias"), "users": []}
if sender not in reactions[key]["users"]:
reactions[key]["users"].append(sender)
reacts_to_event.set_fields(reactions=reactions)
reacts_to_event.notify_change("reactions")
# message is not loaded yet: register the reaction for later update
else:
registry = self.unassigned_reaction_events
if reacts_to not in registry:
registry[reacts_to] = {}
if key not in registry[reacts_to]:
registry[reacts_to][key] = []
if sender not in registry[reacts_to][key]:
registry[reacts_to][key].append(sender)
async def register_message_replacement(
self,
room: nio.MatrixRoom,
ev: Union[nio.Event, nio.BadEvent],
event_id: str = "",
override_fetch_profile: Optional[bool] = None,
**fields,
) -> Event:
"""Register/update a message replacement."""
event_id = event_id or ev.event_id
relates_to = ev.source.get("content", {}).get("m.relates_to", {})
replaced_event_id = relates_to.get("event_id")
model = self.models[self.user_id, room.room_id, "events"]
replaced_event = model.get(replaced_event_id)
if not replaced_event: # local echo
for item in model.values():
if item.event_id == replaced_event_id:
replaced_event = item
# content
content = fields.get("content", "").strip()
inline_content = fields.get("inline_content", "").strip()
if content and "inline_content" not in fields:
inline_content = HTML.filter(content, inline=True)
content_history = {
"id": event_id,
"date": datetime.fromtimestamp(ev.server_timestamp / 1000),
"content": content,
"content_diff": "",
"inline_content": inline_content,
"body": ev.source.get("content", {})
.get("m.new_content", {})
.get("body") or inline_content,
"links": Event.parse_links(content),
}
# message is already loaded: update message instantly
if replaced_event:
history = replaced_event.content_history or []
if history:
content_history["content_diff"] = utils.diff_body(
history[-1]["content"], content_history["content"])
history.append(content_history)
replaced_event.set_fields(
replaced = True,
content = content,
inline_content = inline_content,
content_history = history,
)
replaced_event.source.body = content_history["body"]
replaced_event.notify_change(
"replaced", "content", "inline_content", "content_history")
# message not loaded yet: register the replacement for later update
else:
if replaced_event_id not in self.unassigned_replace_events:
self.unassigned_replace_events[replaced_event_id] = []
self.unassigned_replace_events[replaced_event_id].append(
content_history)
await self.register_nio_event(
room, ev, event_id, override_fetch_profile,
type_specifier=TypeSpecifier.MessageReplace,
hidden=True, **fields,
)
async def register_nio_event( async def register_nio_event(
self, self,
@@ -2485,6 +2825,15 @@ class MatrixClient(nio.AsyncClient):
**fields, **fields,
) )
item.content_history = [{
"id": item.id,
"date": item.date,
"content": item.content,
"content_diff": item.content,
"inline_content": item.inline_content,
"body": ev.source.get("content", {}).get("body", item.content),
"links": item.links,
}]
# Add the Event to model # Add the Event to model
@@ -2499,6 +2848,33 @@ class MatrixClient(nio.AsyncClient):
item.id = f"echo-{tx_id}" item.id = f"echo-{tx_id}"
self.event_to_echo_ids[ev.event_id] = item.id self.event_to_echo_ids[ev.event_id] = item.id
reactions = self.unassigned_reaction_events.get(item.id, {})
for key, senders in reactions.items(): # update reactions
if key not in item.reactions:
item.reactions[key] = {
"hint": emoji.demojize(key),
"users": [],
}
item.reactions[key]["users"] += senders
if ev.source.get("type") == "m.reaction" \
and ev.source.get("unsigned", {}).get("redacted_by"):
item.type_specifier = TypeSpecifier.ReactionRedaction
item.hidden = True
replace_events = self.unassigned_replace_events.get(item.id)
if replace_events:
item.replaced = True
item.content_history += sorted(
replace_events, key=lambda r: r["date"])
for index in range(1, len(item.content_history)):
item.content_history[index]["content_diff"] = utils.diff_body(
item.content_history[index - 1]["body"],
item.content_history[index]["body"])
item.content = item.content_history[-1]["content"]
item.inline_content = item.content_history[-1]["inline_content"]
item.source.body = item.content_history[-1]["body"]
del self.unassigned_replace_events[item.id]
model[item.id] = item model[item.id] = item
await self.set_room_last_event(room.room_id, item) await self.set_room_last_event(room.room_id, item)
@@ -2537,8 +2913,8 @@ class MatrixClient(nio.AsyncClient):
) )
sender = item.sender_name or item.sender_id sender = item.sender_name or item.sender_id
is_linux = platform.system() == "Linux" is_nux = platform.system() == "Linux" or "BSD" in platform.system()
use_html = is_linux and self.backend.settings.Notifications.use_html use_html = is_nux and self.backend.settings.Notifications.use_html
content = item.inline_content if use_html else item.plain_content content = item.inline_content if use_html else item.plain_content
if isinstance(ev, nio.RoomMessageEmote) and use_html: if isinstance(ev, nio.RoomMessageEmote) and use_html:
@@ -2572,4 +2948,4 @@ class MatrixClient(nio.AsyncClient):
) if item.sender_avatar else "", ) if item.sender_avatar else "",
) )
return item return item

View File

@@ -14,7 +14,7 @@ import lxml # nosec
import nio import nio
from ..presence import Presence from ..presence import Presence
from ..utils import AutoStrEnum, auto, strip_html_tags from ..utils import AutoStrEnum, auto, strip_html_tags, serialize_value_for_qml
from .model_item import ModelItem from .model_item import ModelItem
OptionalExceptionType = Union[Type[None], Type[Exception]] OptionalExceptionType = Union[Type[None], Type[Exception]]
@@ -28,6 +28,9 @@ class TypeSpecifier(AutoStrEnum):
Unset = auto() Unset = auto()
ProfileChange = auto() ProfileChange = auto()
MembershipChange = auto() MembershipChange = auto()
Reaction = auto()
ReactionRedaction = auto()
MessageReplace = auto()
class PingStatus(AutoStrEnum): class PingStatus(AutoStrEnum):
@@ -349,6 +352,7 @@ class Event(ModelItem):
sender_name: str = field() sender_name: str = field()
sender_avatar: str = field() sender_avatar: str = field()
fetch_profile: bool = False fetch_profile: bool = False
hidden: bool = False
content: str = "" content: str = ""
inline_content: str = "" inline_content: str = ""
@@ -356,6 +360,9 @@ class Event(ModelItem):
links: List[str] = field(default_factory=list) links: List[str] = field(default_factory=list)
mentions: List[Tuple[str, str]] = field(default_factory=list) mentions: List[Tuple[str, str]] = field(default_factory=list)
reactions: Dict[str, Dict[str, Any]] = field(default_factory=dict)
replaced: bool = False
content_history: List[Dict[str, Any]] = field(default_factory=list)
type_specifier: TypeSpecifier = TypeSpecifier.Unset type_specifier: TypeSpecifier = TypeSpecifier.Unset
target_id: str = "" target_id: str = ""
@@ -433,5 +440,6 @@ class Event(ModelItem):
if field == "source": if field == "source":
source_dict = asdict(self.source) if self.source else {} source_dict = asdict(self.source) if self.source else {}
return json.dumps(source_dict) return json.dumps(source_dict)
if field == "content_history":
return serialize_value_for_qml(self.content_history)
return super().serialized_field(field) return super().serialized_field(field)

View File

@@ -619,7 +619,7 @@ class NioCallbacks:
await self.client.register_nio_event(room, ev, content=co) await self.client.register_nio_event(room, ev, content=co)
# Room events, invite events and misc events callbacks
async def onRoomNameEvent( async def onRoomNameEvent(
self, room: nio.MatrixRoom, ev: nio.RoomNameEvent, self, room: nio.MatrixRoom, ev: nio.RoomNameEvent,
) -> None: ) -> None:
@@ -630,6 +630,468 @@ class NioCallbacks:
await self.client.register_nio_event(room, ev, content=co) await self.client.register_nio_event(room, ev, content=co)
async def onRoomMessageText(
self, room: nio.MatrixRoom, ev: nio.RoomMessageText,
) -> None:
co = HTML_PROCESSOR.filter(
ev.formatted_body
if ev.format == "org.matrix.custom.html" else
plain2html(ev.body),
)
mention_list = HTML_PROCESSOR.mentions_in_html(co)
# message replacement
relates_to = ev.source.get("content", {}).get("m.relates_to", {})
if relates_to.get("rel_type") == "m.replace":
await self.client.register_message_replacement(
room, ev, content=co, mentions=mention_list,
)
return
await self.client.register_nio_event(
room, ev, content=co, mentions=mention_list,
)
async def onRoomMessageNotice(
self, room: nio.MatrixRoom, ev: nio.RoomMessageNotice,
) -> None:
await self.onRoomMessageText(room, ev)
async def onRoomMessageEmote(
self, room: nio.MatrixRoom, ev: nio.RoomMessageEmote,
) -> None:
await self.onRoomMessageText(room, ev)
async def onRoomMessageUnknown(
self, room: nio.MatrixRoom, ev: nio.RoomMessageUnknown,
) -> None:
co = f"%1 sent an unsupported <b>{escape(ev.msgtype)}</b> message"
await self.client.register_nio_event(room, ev, content=co)
async def onRoomMessageMedia(
self, room: nio.MatrixRoom, ev: nio.RoomMessageMedia,
) -> None:
info = ev.source["content"].get("info", {})
media_crypt_dict = ev.source["content"].get("file", {})
thumb_info = info.get("thumbnail_info", {})
thumb_crypt_dict = info.get("thumbnail_file", {})
try:
media_local_path: Union[Path, str] = await Media(
cache = self.client.backend.media_cache,
client_user_id = self.user_id,
mxc = ev.url,
title = ev.body,
room_id = room.room_id,
filesize = info.get("size") or 0,
crypt_dict = media_crypt_dict,
).get_local()
except FileNotFoundError:
media_local_path = ""
item = await self.client.register_nio_event(
room,
ev,
content = "",
inline_content = ev.body,
media_url = ev.url,
media_http_url = await self.client.mxc_to_http(ev.url),
media_title = ev.body,
media_width = info.get("w") or 0,
media_height = info.get("h") or 0,
media_duration = info.get("duration") or 0,
media_size = info.get("size") or 0,
media_mime = info.get("mimetype") or "",
media_crypt_dict = media_crypt_dict,
media_local_path = media_local_path,
thumbnail_url =
info.get("thumbnail_url") or thumb_crypt_dict.get("url") or "",
thumbnail_width = thumb_info.get("w") or 0,
thumbnail_height = thumb_info.get("h") or 0,
thumbnail_mime = thumb_info.get("mimetype") or "",
thumbnail_crypt_dict = thumb_crypt_dict,
)
self.client.backend.mxc_events[ev.url].append(item)
async def onRoomEncryptedMedia(
self, room: nio.MatrixRoom, ev: nio.RoomEncryptedMedia,
) -> None:
await self.onRoomMessageMedia(room, ev)
async def onReactionEvent(
self, room: nio.MatrixRoom, ev: nio.ReactionEvent,
) -> None:
await self.client.register_reaction(room, ev, ev.event_id)
async def onRedactionEvent(
self, room: nio.MatrixRoom, ev: nio.RedactionEvent,
) -> None:
model = self.models[self.user_id, room.room_id, "events"]
event = None
for existing in model._sorted_data:
if existing.event_id == ev.redacts:
event = existing
break
if not (
event and
(event.event_type is not nio.RedactedEvent or event.is_local_echo)
):
await self.client.register_nio_room(room)
return
event_type = event.source.source.get("type")
if not event_type == "m.reaction":
event.source.source["content"] = {}
event.source.source["unsigned"] = {
"redacted_by": ev.event_id,
"redacted_because": ev.source,
}
# Remove reactions
if event_type == "m.reaction":
relates_to = event.source.source.get(
"content", {}).get("m.relates_to", {})
reacted_event_id = relates_to.get("event_id")
reacted_event = model.get(reacted_event_id)
key = relates_to.get("key")
sender = ev.source.get("sender")
# Remove reactions from registry
reg = self.client.unassigned_reaction_events.get(
reacted_event_id)
if reg and key in reg and sender in reg[key]:
reg[key].remove(sender)
# Remove reactions from loaded messages
if reacted_event and key in reacted_event.reactions:
if sender in reacted_event.reactions[key]:
reacted_event.reactions[key].remove(sender)
if not reacted_event.reactions[key]:
del reacted_event.reactions[key]
reacted_event.notify_change('reactions')
await self.onRedactedEvent(
room,
nio.RedactedEvent.from_dict(event.source.source),
event_id = event.id,
)
async def onRedactedEvent(
self, room: nio.MatrixRoom, ev: nio.RedactedEvent, event_id: str = "",
) -> None:
redacter_name, _, must_fetch_redacter = \
await self.client.get_member_profile(room.room_id, ev.redacter) \
if ev.redacter else ("", "", False)
await self.client.register_nio_event(
room,
ev,
event_id = event_id,
reason = ev.reason or "",
content = await self.client.get_redacted_event_content(
type(ev), ev.redacter, ev.sender, ev.reason,
),
mentions = [],
type_specifier = TypeSpecifier.Unset,
media_url = "",
media_http_url = "",
media_title = "",
media_local_path = "",
thumbnail_url = "",
redacter_id = ev.redacter or "",
redacter_name = redacter_name,
override_fetch_profile = True,
)
async def onRoomCreateEvent(
self, room: nio.MatrixRoom, ev: nio.RoomCreateEvent,
) -> None:
co = "%1 allowed users on other matrix servers to join this room" \
if ev.federate else \
"%1 blocked users on other matrix servers from joining this room"
await self.client.register_nio_event(room, ev, content=co)
async def onRoomGuestAccessEvent(
self, room: nio.MatrixRoom, ev: nio.RoomGuestAccessEvent,
) -> None:
allowed = "allowed" if ev.guest_access == "can_join" else "forbad"
co = f"%1 {allowed} guests to join the room"
await self.client.register_nio_event(room, ev, content=co)
async def onRoomJoinRulesEvent(
self, room: nio.MatrixRoom, ev: nio.RoomJoinRulesEvent,
) -> None:
access = "public" if ev.join_rule == "public" else "invite-only"
co = f"%1 made the room {access}"
await self.client.register_nio_event(room, ev, content=co)
async def onRoomHistoryVisibilityEvent(
self, room: nio.MatrixRoom, ev: nio.RoomHistoryVisibilityEvent,
) -> None:
if ev.history_visibility == "shared":
to = "all room members"
elif ev.history_visibility == "world_readable":
to = "any member or outsider"
elif ev.history_visibility == "joined":
to = "all room members, since the time they joined"
elif ev.history_visibility == "invited":
to = "all room members, since the time they were invited"
else:
to = "???"
log.warning("Invalid visibility - %s",
json.dumps(vars(ev), indent=4))
co = f"%1 made future room history visible to {to}"
await self.client.register_nio_event(room, ev, content=co)
async def onPowerLevelsEvent(
self, room: nio.MatrixRoom, ev: nio.PowerLevelsEvent,
) -> None:
levels = ev.power_levels
stored = self.client.power_level_events.get(room.room_id)
if not stored or ev.server_timestamp > stored.server_timestamp:
self.client.power_level_events[room.room_id] = ev
try:
previous = ev.source["unsigned"]["prev_content"]
except KeyError:
previous = {}
users_previous = previous.get("users", {})
events_previous = previous.get("events", {})
changes: List[Tuple[str, int, int]] = []
event_changes: List[Tuple[str, int, int]] = []
user_changes: List[Tuple[str, int, int]] = []
def lvl(level: int) -> str:
return (
f"Admin ({level})" if level == 100 else
f"Moderator ({level})" if level >= 50 else
f"User ({level})" if level >= 0 else
f"Muted ({level})"
)
def format_defaults_dict(
levels: Dict[str, Union[int, dict]],
previous: Dict[str, Union[int, dict]],
prefix: str = "",
) -> None:
default_0 = ("users_default", "events_default", "invite")
for name in set({**levels, **previous}):
if not prefix and name in ("users", "events"):
continue
old_level = previous.get(
name, 0 if not prefix and name in default_0 else 50,
)
level = levels.get(
name, 0 if not prefix and name in default_0 else 50,
)
if isinstance(level, dict):
if not isinstance(old_level, dict):
old_level = {}
format_defaults_dict(level, old_level, f"{prefix}{name}.")
continue
if not isinstance(old_level, int):
old_level = 50
if old_level != level or not previous:
changes.append((f"{prefix}{name}", old_level, level))
format_defaults_dict(ev.source["content"], previous)
# Minimum level to send event changes
for ev_type in set({**levels.events, **events_previous}):
old_level = events_previous.get(
ev_type,
levels.defaults.state_default
if ev_type.startswith("m.room.") else
levels.defaults.events_default,
)
level = levels.events.get(
ev_type,
levels.defaults.state_default
if ev_type.startswith("m.room.") else
levels.defaults.events_default,
)
if old_level != level or not previous:
event_changes.append((ev_type, old_level, level))
# User level changes
for user_id in set({**levels.users, **users_previous}):
old_level = \
users_previous.get(user_id, levels.defaults.users_default)
level = levels.users.get(user_id, levels.defaults.users_default)
if old_level != level or not previous:
user_changes.append((user_id, old_level, level))
if user_id in room.users:
await self.client.add_member(room, user_id)
# Gather and format changes
if changes or event_changes or user_changes:
changes.sort(key=lambda c: (c[2], c[0]))
event_changes.sort(key=lambda c: (c[2], c[0]))
user_changes.sort(key=lambda c: (c[2], c[0]))
all_changes = changes + event_changes + user_changes
if len(all_changes) == 1:
co = HTML_PROCESSOR.from_markdown(
"%%1 changed the level for **%s**: %s%s " % (
all_changes[0][0],
lvl(all_changes[0][1]).lower(),
lvl(all_changes[0][2]).lower(),
),
inline = True,
)
else:
co = HTML_PROCESSOR.from_markdown("\n".join([
"%1 changed the room's permissions",
"",
"Change | Previous | Current ",
"--- | --- | ---",
*[
f"{name} | {lvl(old)} | {lvl(now)}"
for name, old, now in all_changes
],
]))
else:
co = "%1 didn't change the room's permissions"
await self.client.register_nio_event(room, ev, content=co)
async def process_room_member_event(
self, room: nio.MatrixRoom, ev: nio.RoomMemberEvent,
) -> Optional[Tuple[TypeSpecifier, str]]:
"""Return a `TypeSpecifier` and string describing a member event.
Matrix member events can represent many actions:
a user joined the room, a user banned another, a user changed their
display name, etc.
"""
if ev.prev_content == ev.content:
return None
prev = ev.prev_content
now = ev.content
membership = ev.membership
prev_membership = ev.prev_membership
ev_date = datetime.fromtimestamp(ev.server_timestamp / 1000)
member_change = TypeSpecifier.MembershipChange
# Membership changes
if not prev or membership != prev_membership:
if not self.client.backend.settings.Chat.show_membership_events:
return None
reason = escape(
f", reason: {now['reason']}" if now.get("reason") else "",
)
if membership == "join":
return (
member_change,
"%1 accepted their invitation"
if prev and prev_membership == "invite" else
"%1 joined the room",
)
if membership == "invite":
return (member_change, "%1 invited %2 to the room")
if membership == "leave":
if ev.state_key == ev.sender:
return (
member_change,
f"%1 declined their invitation{reason}"
if prev and prev_membership == "invite" else
f"%1 left the room{reason}",
)
return (
member_change,
f"%1 withdrew %2's invitation{reason}"
if prev and prev_membership == "invite" else
f"%1 unbanned %2 from the room{reason}"
if prev and prev_membership == "ban" else
f"%1 kicked %2 out from the room{reason}",
)
if membership == "ban":
return (member_change, f"%1 banned %2 from the room{reason}")
# Profile changes
changed = []
if prev and now.get("avatar_url") != prev.get("avatar_url"):
changed.append("profile picture") # TODO: <img>s
if prev and now.get("displayname") != prev.get("displayname"):
changed.append('display name from "{}" to "{}"'.format(
escape(prev.get("displayname") or ev.state_key),
escape(now.get("displayname") or ev.state_key),
))
if changed:
# Update our account profile if the event is newer than last update
if ev.state_key == self.user_id:
account = self.models["accounts"][self.user_id]
if account.profile_updated < ev_date:
account.set_fields(
profile_updated = ev_date,
display_name = now.get("displayname") or "",
avatar_url = now.get("avatar_url") or "",
)
if not self.client.backend.settings.Chat.show_profile_changes:
return None
return (
TypeSpecifier.ProfileChange,
"%1 changed their {}".format(" and ".join(changed)),
)
async def onRoomAvatarEvent( async def onRoomAvatarEvent(
self, room: nio.MatrixRoom, ev: nio.RoomAvatarEvent, self, room: nio.MatrixRoom, ev: nio.RoomAvatarEvent,

View File

@@ -14,6 +14,7 @@ import xml.etree.cElementTree as xml_etree
from concurrent.futures import ProcessPoolExecutor from concurrent.futures import ProcessPoolExecutor
from contextlib import suppress from contextlib import suppress
from datetime import date, datetime, time, timedelta from datetime import date, datetime, time, timedelta
from difflib import SequenceMatcher
from enum import Enum from enum import Enum
from enum import auto as autostr from enum import auto as autostr
from pathlib import Path from pathlib import Path
@@ -206,6 +207,28 @@ def strip_html_tags(text: str) -> str:
return re.sub(r"<\/?[^>]+(>|$)", "", text) return re.sub(r"<\/?[^>]+(>|$)", "", text)
def remove_reply(text: str):
return re.sub(r"<mx-reply.*?>.*?<\/mx-reply>", "", text)
def diff_body(a: str, b: str):
sm = SequenceMatcher(None, remove_reply(a), remove_reply(b))
output = []
for opcode, a0, a1, b0, b1 in sm.get_opcodes():
if opcode == "equal":
output.append(sm.a[a0:a1])
elif opcode == "insert":
output.append(f"<ins>{sm.b[b0:b1]}</ins>")
elif opcode == "delete":
output.append(f"<del>{sm.a[a0:a1]}</del>")
elif opcode == "replace":
output.append(f"<del>{sm.a[a0:a1]}</del>")
output.append(f"<ins>{sm.b[b0:b1]}</ins>")
else:
raise RuntimeError(f"unexpected opcode: {opcode}")
return "".join(output)
def serialize_value_for_qml( def serialize_value_for_qml(
value: Any, json_list_dicts: bool = False, reject_unknown: bool = False, value: Any, json_list_dicts: bool = False, reject_unknown: bool = False,
) -> Any: ) -> Any:

View File

@@ -541,3 +541,36 @@ class Keys:
# Sign out checked sessions if any, else sign out all sessions. # Sign out checked sessions if any, else sign out all sessions.
sign_out = ["Alt+S", "Delete"] sign_out = ["Alt+S", "Delete"]
class Commands:
# If you are an advanced user, here you can define new /commands
#
# get_cmd_handler_map should return a dictionary of the form
# {
# "command": command_handling_function,
# "another_command": ...
# }
#
# each command_handling_function should have the signature:
#
# async def command_handling_function(
# matrix_client: moment.backend.MatrixClient,
# room_id: str,
# text: str, # text after the end of /command
# display_name_mentions: Optional[Dict[str, str]] = None, # {id: name}
# reply_to_event_id: Optional[str] = None,
# ) -> None:
# Example:
def get_cmd_handler_map(self):
return {
# "rot13 ": self.handle_cmd_rot13,
}
# @staticmethod
# def handle_cmd_rot13(matrix_client, room_id, text, display_name_mentions, reply_to_event_id):
# import codecs
# matrix_client._send_text(
# room_id, codecs.encode(text, 'rot_13'), display_name_mentions, reply_to_event_id
# )

View File

@@ -35,7 +35,9 @@ TextEdit {
focus: false focus: false
selectByMouse: true selectByMouse: true
onLinkActivated: if (enableLinkActivation && link !== '#state-text') onLinkActivated: if (enableLinkActivation
&& link !== '#state-text'
&& link !== '#replaced-text')
Qt.openUrlExternally(link) Qt.openUrlExternally(link)
MouseArea { MouseArea {

View File

@@ -28,8 +28,17 @@ HPage {
HTabButton { text: qsTr("Security") } HTabButton { text: qsTr("Security") }
} }
General { userId: page.userId } General {
Notifications { userId: page.userId } userId: page.userId
Security { userId: page.userId } implicitWidth: 0
}
Notifications {
userId: page.userId
implicitWidth: 0
}
Security {
userId: page.userId
implicitWidth: 0
}
} }
} }

View File

@@ -132,6 +132,8 @@ HFlickableColumnPage {
// Layout.preferredWidth: 256 * theme.uiScale // Layout.preferredWidth: 256 * theme.uiScale
Layout.preferredHeight: width Layout.preferredHeight: width
HoverHandler { id: overlayHover }
Rectangle { Rectangle {
anchors.fill: parent anchors.fill: parent
z: 10 z: 10
@@ -154,8 +156,6 @@ HFlickableColumnPage {
Behavior on opacity { HNumberAnimation {} } Behavior on opacity { HNumberAnimation {} }
Behavior on color { HColorAnimation {} } Behavior on color { HColorAnimation {} }
HoverHandler { id: overlayHover }
MouseArea { MouseArea {
anchors.fill: parent anchors.fill: parent
enabled: ready && account.presence !== "offline" enabled: ready && account.presence !== "offline"

View File

@@ -26,8 +26,17 @@ HPage {
HTabButton { text: qsTr("Create group") } HTabButton { text: qsTr("Create group") }
} }
DirectChat { userId: page.userId } DirectChat {
JoinRoom { userId: page.userId } userId: page.userId
CreateRoom { userId: page.userId } implicitWidth: 0
}
JoinRoom {
userId: page.userId
implicitWidth: 0
}
CreateRoom {
userId: page.userId
implicitWidth: 0
}
} }
} }

View File

@@ -49,6 +49,18 @@ HRowLayout {
">" ">"
) + "</font></font></a>" ) + "</font></font></a>"
readonly property var reactions: model.reactions
readonly property var contentHistory: model.content_history
readonly property string replacedText:
`<a href="#replaced-text" style="text-decoration: none">` +
`<font size=${theme.fontSize.small}px><font ` + (
model.replaced ?
`color="${theme.chat.message.readCounter}">&nbsp;🖉` : // U+1F589
">"
) + "</font></font></a>"
readonly property bool pureMedia: ! contentText && linksRepeater.count readonly property bool pureMedia: ! contentText && linksRepeater.count
readonly property bool hoveredSelectable: contentHover.hovered readonly property bool hoveredSelectable: contentHover.hovered
@@ -123,6 +135,13 @@ HRowLayout {
id: contentLabel id: contentLabel
visible: ! pureMedia visible: ! pureMedia
enableLinkActivation: ! eventList.selectedCount enableLinkActivation: ! eventList.selectedCount
onLinkActivated:
if(link === "#replaced-text") window.makePopup(
"Popups/MessageReplaceHistoryPopup.qml",
{
contentHistory: contentHistory
},
)
selectByMouse: selectByMouse:
eventList.selectedCount <= 1 && eventList.selectedCount <= 1 &&
@@ -163,6 +182,7 @@ HRowLayout {
timeText + timeText +
"</font>" + "</font>" +
replacedText +
stateText stateText
transform: Translate { x: xOffset } transform: Translate { x: xOffset }
@@ -298,6 +318,8 @@ HRowLayout {
linksRepeater.summedWidth + linksRepeater.summedWidth +
(pureMedia ? 0 : parent.leftPadding + parent.rightPadding), (pureMedia ? 0 : parent.leftPadding + parent.rightPadding),
reactionsRow.width
) )
height: contentColumn.height height: contentColumn.height
radius: theme.chat.message.radius radius: theme.chat.message.radius
@@ -361,6 +383,94 @@ HRowLayout {
Layout.preferredHeight: item ? item.height : -1 Layout.preferredHeight: item ? item.height : -1
} }
} }
Row {
id: reactionsRow
spacing: 10
bottomPadding: 7
leftPadding: 10
rightPadding: 10
Layout.alignment: onRight ? Qt.AlignRight : Qt.AlignLeft
Repeater {
id: reactionsRepeater
model: {
const reactions = Object.entries(
JSON.parse(eventDelegate.currentModel.reactions));
return reactions;
}
Rectangle {
id: reactionItem
required property var modelData
readonly property var icon: modelData[0]
readonly property var hint: modelData[1]["hint"]
readonly property var users: modelData[1]["users"]
width: reactionContent.width
height: theme.fontSize.normal + 10
radius: width / 2
color: theme.colors.strongBackground
border.color: theme.colors.accentBackground
border.width: 1
Row {
id: reactionContent
spacing: 5
topPadding: 3
leftPadding: 10
rightPadding: 10
Text {
id: reactionIcon
color: theme.colors.brightText
font.pixelSize: theme.fontSize.normal
font.family: theme.fontFamily.sans
text: parent.parent.icon
}
Text {
id: reactionCounter
color: theme.colors.brightText
font.pixelSize: theme.fontSize.normal
font.family: theme.fontFamily.sans
text: parent.parent.users.length
}
}
MouseArea {
id: reactionItemMouseArea
anchors.fill: parent
onEntered: { reactionTooltip.visible = true }
onExited: { reactionTooltip.visible = false }
hoverEnabled: true
}
HToolTip {
id: reactionTooltip
visible: false
label.textFormat: HLabel.StyledText
text: {
const members =
ModelStore.get(chat.userId, chat.roomId, "members")
const lines = [parent.hint]
for (const userId of parent.users) {
const member = members.find(userId)
const by = utils.coloredNameHtml(
member ? member.display_name: userId, userId,
)
lines.push(qsTr("%1").arg(by))
}
return lines.join("<br>")
}
}
}
}
}
} }
HSpacer {} HSpacer {}

View File

@@ -72,20 +72,26 @@ HColumnLayout {
eventList.toggleCheck(model.index) eventList.toggleCheck(model.index)
} }
visible: !model.hidden
width: eventList.width - eventList.leftMargin - eventList.rightMargin width: eventList.width - eventList.leftMargin - eventList.rightMargin
// Needed because of eventList's MouseArea which steals the // Needed because of eventList's MouseArea which steals the
// HSelectableLabel's MouseArea hover events // HSelectableLabel's MouseArea hover events
onCursorShapeChanged: eventList.cursorShape = cursorShape onCursorShapeChanged: eventList.cursorShape = cursorShape
Component.onCompleted: if (model.fetch_profile) Component.onCompleted: {
fetchProfilesFutureId = py.callClientCoro( if (model.fetch_profile)
chat.userId, fetchProfilesFutureId = py.callClientCoro(
"get_event_profiles", chat.userId,
[chat.roomId, model.id], "get_event_profiles",
// The if avoids segfault if eventDelegate is already destroyed [chat.roomId, model.id],
() => { if (eventDelegate) fetchProfilesFutureId = "" } // The if avoids segfault if eventDelegate is already destroyed
) () => { if (eventDelegate) fetchProfilesFutureId = "" }
)
// Workaround for hiding messages of certain types
if (!eventDelegate.visible)
eventDelegate.height = 0
}
Component.onDestruction: Component.onDestruction:
if (fetchProfilesFutureId) py.cancelCoro(fetchProfilesFutureId) if (fetchProfilesFutureId) py.cancelCoro(fetchProfilesFutureId)

View File

@@ -266,10 +266,28 @@ Rectangle {
highlightRangeMode = previous highlightRangeMode = previous
} }
function focusPreviousVisibleMessage() {
incrementCurrentIndex()
let lastIndex = -1
while ( currentIndex != lastIndex && model.get(currentIndex).hidden ) {
lastIndex = currentIndex
incrementCurrentIndex()
}
}
function focusPreviousMessage() { function focusPreviousMessage() {
currentIndex === -1 && visibleEnd.y < contentHeight - height / 4 ? currentIndex === -1 && visibleEnd.y < contentHeight - height / 4 ?
focusCenterMessage() : focusCenterMessage() :
incrementCurrentIndex() focusPreviousVisibleMessage()
}
function focusNextVisibleMessage() {
decrementCurrentIndex()
while ( currentIndex > -1 && model.get(currentIndex).hidden ) {
if ( currentIndex === 0 )
currentIndex = -1;
decrementCurrentIndex()
}
} }
function focusNextMessage() { function focusNextMessage() {
@@ -279,7 +297,7 @@ Rectangle {
eventList.currentIndex === 0 ? eventList.currentIndex === 0 ?
eventList.currentIndex = -1 : eventList.currentIndex = -1 :
decrementCurrentIndex() focusNextVisibleMessage()
} }
function copySelectedDelegates() { function copySelectedDelegates() {
@@ -332,7 +350,7 @@ Rectangle {
} }
function canCombine(item, itemAfter) { function canCombine(item, itemAfter) {
if (! item || ! itemAfter) return false if (! item || ! itemAfter || item.hidden) return false
return Boolean( return Boolean(
! canTalkBreak(item, itemAfter) && ! canTalkBreak(item, itemAfter) &&

View File

@@ -0,0 +1,260 @@
// Copyright Mirage authors & contributors <https://github.com/mirukana/mirage>
// SPDX-License-Identifier: LGPL-3.0-or-later
import QtQuick 2.12
import QtQuick.Layouts 1.12
import "../../../Base"
import "../../.."
HRowLayout {
id: historyContent
readonly property var mentions: []
readonly property string mentionsCSS: {
const lines = []
for (const [name, link] of mentions) {
if (! link.match(/^https?:\/\/matrix.to\/#\/@.+/)) continue
lines.push(
`.mention[data-mention='${utils.escapeHtml(name)}'] ` +
`{ color: ${utils.nameColor(name)} }`
)
}
return "<style type='text/css'>" + lines.join("\n") + "</style>"
}
readonly property string diffCSS: {
const lines = [
"del { background-color: #f8d7da; color: #721c24; text-decoration: line-through; }",
"ins { background-color: #d4edda; color: #155724; text-decoration: underline; }",
]
return "<style type='text/css'>" + lines.join("\n") + "</style>"
}
readonly property string senderText: ""
property string contentText: model.content_diff
readonly property string timeText: utils.formatTime(model.date, false)
readonly property bool pureMedia: false
readonly property bool hoveredSelectable: contentHover.hovered
readonly property string hoveredLink:
linksRepeater.lastHovered && linksRepeater.lastHovered.hovered ?
linksRepeater.lastHovered.mediaUrl :
contentLabel.hoveredLink
readonly property alias contentLabel: contentLabel
readonly property int xOffset: 0
readonly property int maxMessageWidth:
contentText.includes("<pre>") || contentText.includes("<table>") ?
-1 :
window.settings.Chat.max_messages_line_length < 0 ?
-1 :
Math.ceil(
mainUI.fontMetrics.averageCharacterWidth *
window.settings.Chat.max_messages_line_length
)
readonly property alias selectedText: contentLabel.selectedPlainText
spacing: theme.chat.message.horizontalSpacing
layoutDirection: Qt.LeftToRight
HColumnLayout {
id: contentColumn
Layout.fillWidth: true
Layout.alignment: Qt.AlignVCenter
HSelectableLabel {
id: contentLabel
visible: ! pureMedia
enableLinkActivation: ! historyList.selectedCount
selectByMouse:
historyList.selectedCount <= 1 &&
historyDelegate.checked &&
textSelectionBlocker.point.scenePosition === Qt.point(0, 0)
topPadding: theme.chat.message.verticalSpacing
bottomPadding: topPadding
leftPadding: historyContent.spacing
rightPadding: leftPadding
color: theme.chat.message.body
font.italic: false
wrapMode: TextEdit.Wrap
textFormat: Text.RichText
text:
// CSS
theme.chat.message.styleInclude + mentionsCSS + diffCSS +
// Sender name & message body
(
compact && contentText.match(/^\s*<(p|h[1-6])>/) ?
contentText.replace(
/(^\s*<(p|h[1-6])>)/, "$1" + senderText,
) :
senderText + contentText
) +
// Time
// For some reason, if there's only one space,
// times will be on their own lines most of the time.
" " +
`<font size=${theme.fontSize.small}px ` +
`color=${theme.chat.message.date}>` +
timeText +
"</font>"
transform: Translate { x: xOffset }
Layout.maximumWidth: historyContent.maxMessageWidth
Layout.fillWidth: true
onSelectedTextChanged: if (selectedPlainText) {
historyList.delegateWithSelectedText = model.id
historyList.selectedText = selectedPlainText
} else if (historyList.delegateWithSelectedText === model.id) {
historyList.delegateWithSelectedText = ""
historyList.selectedText = ""
}
Connections {
target: historyList
onCheckedChanged: contentLabel.deselect()
onDelegateWithSelectedTextChanged: {
if (historyList.delegateWithSelectedText !== model.id)
contentLabel.deselect()
}
}
HoverHandler { id: contentHover }
PointHandler {
id: mousePointHandler
property bool checkedNow: false
acceptedButtons: Qt.LeftButton
acceptedModifiers: Qt.NoModifier
acceptedPointerTypes:
PointerDevice.GenericPointer | PointerDevice.Eraser
onActiveChanged: {
if (active &&
! historyDelegate.checked &&
(! parent.hoveredLink ||
! parent.enableLinkActivation)) {
historyList.check(model.index)
checkedNow = true
}
if (! active && historyDelegate.checked) {
checkedNow ?
checkedNow = false :
historyList.uncheck(model.index)
}
}
}
PointHandler {
id: mouseShiftPointHandler
acceptedButtons: Qt.LeftButton
acceptedModifiers: Qt.ShiftModifier
acceptedPointerTypes:
PointerDevice.GenericPointer | PointerDevice.Eraser
onActiveChanged: {
if (active &&
! historyDelegate.checked &&
(! parent.hoveredLink ||
! parent.enableLinkActivation)) {
historyList.checkFromLastToHere(model.index)
}
}
}
TapHandler {
id: touchTapHandler
acceptedButtons: Qt.LeftButton
acceptedPointerTypes: PointerDevice.Finger | PointerDevice.Pen
onTapped:
if (! parent.hoveredLink || ! parent.enableLinkActivation)
historyDelegate.toggleChecked()
}
TapHandler {
id: textSelectionBlocker
acceptedPointerTypes: PointerDevice.Finger | PointerDevice.Pen
}
Rectangle {
id: contentBackground
width: Math.max(
parent.paintedWidth +
parent.leftPadding + parent.rightPadding,
linksRepeater.summedWidth +
(pureMedia ? 0 : parent.leftPadding + parent.rightPadding),
)
height: contentColumn.height
radius: theme.chat.message.radius
z: -100
color: historyDelegate.checked &&
! contentLabel.selectedPlainText &&
! mousePointHandler.active &&
! mouseShiftPointHandler.active ?
theme.chat.message.checkedBackground :
theme.chat.message.background
}
}
HRepeater {
id: linksRepeater
property EventMediaLoader lastHovered: null
model: {
const links = historyDelegate.currentModel.links
if (historyDelegate.currentModel.media_url)
links.push(historyDelegate.currentModel.media_url)
return links
}
EventMediaLoader {
singleMediaInfo: historyDelegate.currentModel
mediaUrl: modelData
showSender: pureMedia ? senderText : ""
showDate: pureMedia ? timeText : ""
showLocalEcho: pureMedia && (
singleMediaInfo.is_local_echo ||
singleMediaInfo.read_by_count
) ? stateText : ""
transform: Translate { x: xOffset }
onHoveredChanged: if (hovered) linksRepeater.lastHovered = this
Layout.bottomMargin: pureMedia ? 0 : contentLabel.bottomPadding
Layout.leftMargin: pureMedia ? 0 : historyContent.spacing
Layout.rightMargin: pureMedia ? 0 : historyContent.spacing
Layout.preferredWidth: item ? item.width : -1
Layout.preferredHeight: item ? item.height : -1
}
}
}
HSpacer {}
}

View File

@@ -0,0 +1,93 @@
// Copyright Mirage authors & contributors <https://github.com/mirukana/mirage>
// SPDX-License-Identifier: LGPL-3.0-or-later
import QtQuick 2.12
import QtQuick.Layouts 1.12
import Clipboard 0.1
import "../../.."
import "../../../Base"
HColumnLayout {
id: historyDelegate
// Remember timeline goes from newest message at index 0 to oldest
readonly property var previousModel: historyList.model.get(model.index + 1)
readonly property var nextModel: historyList.model.get(model.index - 1)
readonly property QtObject currentModel: model
readonly property bool isFocused: model.index === historyList.currentIndex
readonly property bool compact: window.settings.General.compact
readonly property bool checked: model.id in historyList.checked
readonly property bool isOwn: true
readonly property bool isRedacted: false
readonly property bool onRight: ! historyList.ownEventsOnLeft && isOwn
readonly property bool combine: false
readonly property bool talkBreak: false
readonly property bool dayBreak:
model.index === 0 ? true : historyList.canDayBreak(previousModel, model)
readonly property bool hideNameLine: true
readonly property int cursorShape:
historyContent.hoveredLink ? Qt.PointingHandCursor :
historyContent.hoveredSelectable ? Qt.IBeamCursor :
Qt.ArrowCursor
readonly property int separationSpacing: theme.spacing * (
dayBreak ? 4 :
talkBreak ? 6 :
combine && compact ? 0.25 :
combine ? 0.5 :
compact ? 1 :
2
)
readonly property alias historyContent: historyContent
function toggleChecked() {
historyList.toggleCheck(model.index)
}
width: historyList.width - historyList.leftMargin - historyList.rightMargin
// Needed because of historyList's MouseArea which steals the
// HSelectableLabel's MouseArea hover events
onCursorShapeChanged: historyList.cursorShape = cursorShape
ListView.onRemove: historyList.uncheck(model.id)
DelegateTransitionFixer {}
Item {
Layout.fillWidth: true
visible: model.index !== 0
Layout.preferredHeight: separationSpacing
}
DayBreak {
visible: dayBreak
Layout.fillWidth: true
Layout.minimumWidth: parent.width
Layout.bottomMargin: separationSpacing
}
HistoryContent {
id: historyContent
Layout.fillWidth: true
}
TapHandler {
acceptedButtons: Qt.LeftButton
acceptedModifiers: Qt.NoModifier
onTapped: toggleChecked()
}
TapHandler {
acceptedButtons: Qt.LeftButton
acceptedModifiers: Qt.ShiftModifier
onTapped: historyList.checkFromLastToHere(model.index)
}
}

View File

@@ -0,0 +1,206 @@
// Copyright Mirage authors & contributors <https://github.com/mirukana/mirage>
// SPDX-License-Identifier: LGPL-3.0-or-later
import QtQuick 2.12
import QtQuick.Layouts 1.12
import QtQuick.Window 2.12
import Clipboard 0.1
import "../../.."
import "../../../Base"
import "../../../PythonBridge"
import "../../../ShortcutBundles"
Rectangle {
readonly property alias historyList: historyList
color: theme.chat.eventList.background
HShortcut {
sequences: window.settings.Keys.Messages.unfocus_or_deselect
onActivated: {
historyList.selectedCount ?
historyList.checked = {} :
historyList.currentIndex = -1
}
}
HShortcut {
sequences: window.settings.Keys.Messages.previous
onActivated: historyList.focusPreviousMessage()
}
HShortcut {
sequences: window.settings.Keys.Messages.next
onActivated: historyList.focusNextMessage()
}
HShortcut {
active: historyList.currentItem
sequences: window.settings.Keys.Messages.select
onActivated: historyList.toggleCheck(historyList.currentIndex)
}
HShortcut {
active: historyList.currentItem
sequences: window.settings.Keys.Messages.select_until_here
onActivated:
historyList.checkFromLastToHere(historyList.currentIndex)
}
HShortcut {
sequences: window.settings.Keys.Messages.open_links_files
onActivated: {
const indice =
historyList.getFocusedOrSelectedOrLastMediaEvents(true)
for (const i of Array.from(indice).sort().reverse()) {
const event = historyList.model.get(i)
for (const url of JSON.parse(event.links)) {
utils.getLinkType(url) === Utils.Media.Image ?
historyList.openImageViewer(event, url) :
Qt.openUrlExternally(url)
}
}
}
}
HShortcut {
sequences: window.settings.Keys.Messages.open_links_files_externally
onActivated: {
const indice =
historyList.getFocusedOrSelectedOrLastMediaEvents(true)
for (const i of Array.from(indice).sort().reverse()) {
const event = historyList.model.get(i)
for (const url of JSON.parse(event.links))
Qt.openUrlExternally(url)
}
}
}
HListView {
id: historyList
property bool ownEventsOnLeft: false
property string delegateWithSelectedText: ""
property string selectedText: ""
property bool showFocusedSeenTooltips: false
property alias cursorShape: cursorShapeArea.cursorShape
function focusCenterMessage() {
const previous = highlightRangeMode
highlightRangeMode = HListView.NoHighlightRange
currentIndex = indexAt(0, contentY + height / 2)
highlightRangeMode = previous
}
function focusPreviousMessage() {
currentIndex === -1 && visibleEnd.y < contentHeight - height / 4 ?
focusCenterMessage() :
incrementCurrentIndex()
}
function focusNextMessage() {
currentIndex === -1 && visibleEnd.y < contentHeight - height / 4 ?
focusCenterMessage() :
historyList.currentIndex === 0 ?
historyList.currentIndex = -1 :
decrementCurrentIndex()
}
function copySelectedDelegates() {
if (historyList.selectedText) {
Clipboard.text = historyList.selectedText
return
}
if (! historyList.selectedCount && historyList.currentIndex !== -1) {
const model = historyList.model.get(historyList.currentIndex)
const source = JSON.parse(model.source)
Clipboard.text =
model.media_http_url &&
utils.isEmptyObject(JSON.parse(model.media_crypt_dict)) ?
model.media_http_url :
"body" in source ?
source.body :
utils.stripHtmlTags(utils.processedEventText(model))
return
}
const contents = []
for (const model of historyList.getSortedChecked()) {
const source = JSON.parse(model.source)
contents.push(
model.media_http_url &&
utils.isEmptyObject(JSON.parse(model.media_crypt_dict)) ?
model.media_http_url :
"body" in source ?
source.body :
utils.stripHtmlTags(utils.processedEventText(model))
)
}
Clipboard.text = contents.join("\n\n")
}
function canDayBreak(item, itemAfter) {
if (! item || ! itemAfter || ! item.date || ! itemAfter.date)
return false
return item.date.getDate() !== itemAfter.date.getDate()
}
function getFocusedOrSelectedOrLastMediaEvents(acceptLinks=false) {
if (historyList.selectedCount) return historyList.checkedIndice
if (historyList.currentIndex !== -1) return [historyList.currentIndex]
// Find most recent event that's a media or contains links
for (let i = 0; i < historyList.model.count && i <= 1000; i++) {
const ev = historyList.model.get(i)
const links = JSON.parse(ev.links)
if (ev.media_url || (acceptLinks && links.length)) return [i]
}
}
anchors.fill: parent
clip: true
keyNavigationWraps: false
leftMargin: theme.spacing
rightMargin: theme.spacing
topMargin: theme.spacing
bottomMargin: theme.spacing
// model: ModelStore.get(chat.userRoomId[0], chat.userRoomId[1], "events")
model: []
delegate: HistoryDelegate {}
highlight: Rectangle {
color: theme.chat.message.focusedHighlight
opacity: theme.chat.message.focusedHighlightOpacity
}
MouseArea {
id: cursorShapeArea
anchors.fill: parent
acceptedButtons: Qt.NoButton
}
}
}

View File

@@ -0,0 +1,44 @@
// Copyright Mirage authors & contributors <https://github.com/mirukana/mirage>
// SPDX-License-Identifier: LGPL-3.0-or-later
import QtQuick 2.12
import QtQuick.Controls 2.12
import QtQuick.Layouts 1.12
import "../Base"
import "../Base/Buttons"
import "../Pages/Chat/Timeline"
HColumnPopup {
id: popup
contentWidthLimit:
window.settings.Chat.max_messages_line_length < 0 ?
theme.controls.popup.defaultWidth * 2 :
Math.ceil(
mainUI.fontMetrics.averageCharacterWidth *
window.settings.Chat.max_messages_line_length
)
property var contentHistory
page.footer: AutoDirectionLayout {
CancelButton {
id: cancelButton
onClicked: popup.close()
}
}
onOpened: cancelButton.forceActiveFocus()
SummaryLabel {
text: qsTr("Message History")
textFormat: Text.StyledText
}
HistoryList {
id: historyList
historyList.model: contentHistory
height: 400
Layout.fillWidth: true
Layout.fillHeight: true
}
}

View File

@@ -11,13 +11,13 @@ QtObject {
property bool keyboardFlicking: false property bool keyboardFlicking: false
readonly property var imageExtensions: [ readonly property var imageExtensions: [
"bmp", "gif", "jpg", "jpeg", "png", "pbm", "pgm", "ppm", "xbm", "xpm", "bmp", "gif", "jpg", "jpeg", "png", "pbm", "pgm", "ppm", "xbm", "xpm",
"tiff", "webp", "svg", "tiff", "webp", "svg",
] ]
readonly property var videoExtensions: [ readonly property var videoExtensions: [
"3gp", "avi", "flv", "m4p", "m4v", "mkv", "mov", "mp4", "3gp", "avi", "flv", "m4p", "m4v", "mkv", "mov", "mp4",
"mpeg", "mpg", "ogv", "qt", "vob", "webm", "wmv", "yuv", "mpeg", "mpg", "ogv", "qt", "vob", "webm", "wmv", "yuv",
] ]
readonly property var audioExtensions: [ readonly property var audioExtensions: [
@@ -214,6 +214,31 @@ QtObject {
const unknownMsg = type === "RoomMessageUnknown" const unknownMsg = type === "RoomMessageUnknown"
const sender = coloredNameHtml(ev.sender_name, ev.sender_id) const sender = coloredNameHtml(ev.sender_name, ev.sender_id)
if (ev.type_specifier === "Reaction") {
let name = coloredNameHtml(
ev.sender_name, ev.sender_id, "", true,
)
let reaction = ev.content
return qsTr(
`<font color="${theme.chat.message.noticeBody}">` +
name + ": " + reaction +
"</font>"
)
}
if (ev.type_specifier === "ReactionRedaction") {
let name = coloredNameHtml(
ev.sender_name, ev.sender_id, "", true,
)
let reaction = ev.content
return qsTr(
`<font color="${theme.chat.message.noticeBody}">` +
name + " removed a reaction" +
"</font>"
)
}
if (type === "RoomMessageEmote") if (type === "RoomMessageEmote")
return ev.content.match(/^\s*<(p|h[1-6])>/) ? return ev.content.match(/^\s*<(p|h[1-6])>/) ?
ev.content.replace(/(^\s*<(p|h[1-6])>)/, `$1${sender} `) : ev.content.replace(/(^\s*<(p|h[1-6])>)/, `$1${sender} `) :

View File

@@ -367,7 +367,7 @@ int main(int argc, char *argv[]) {
QApplication::setOrganizationName("moment"); QApplication::setOrganizationName("moment");
QApplication::setApplicationName("moment"); QApplication::setApplicationName("moment");
QApplication::setApplicationDisplayName("Moment"); QApplication::setApplicationDisplayName("Moment");
QApplication::setApplicationVersion("0.7.3"); QApplication::setApplicationVersion("0.7.5");
QApplication::setAttribute(Qt::AA_EnableHighDpiScaling); QApplication::setAttribute(Qt::AA_EnableHighDpiScaling);
// app needs to be constructed before attempting to migrate // app needs to be constructed before attempting to migrate