Compare commits
47 Commits
b6543b09cc
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 4c8bc0eb1a | |||
| 72809d024e | |||
|
|
59b928d4f7 | ||
|
|
12902d126c | ||
|
|
c096e239e7 | ||
|
|
abbe00d70f | ||
|
|
e3ea5f8051 | ||
|
|
c656956365 | ||
|
|
bab99443b6 | ||
|
|
5959e6e35a | ||
|
|
6a4b8d9199 | ||
|
|
473205007f | ||
|
|
e99b7584d2 | ||
|
|
294bd887ba | ||
|
|
8eb1afb91c | ||
|
|
160670bea3 | ||
|
|
022df56c9e | ||
|
|
7d6fba5ac7 | ||
|
|
4de8e87f06 | ||
| f2de3d9584 | |||
| a9eecd1c05 | |||
|
|
8521182a09 | ||
|
|
456525aa25 | ||
|
|
0e6e55a920 | ||
|
|
bd1bc59859 | ||
| d00a991b84 | |||
| 4ec8ab50e7 | |||
| b248771619 | |||
| 85e78bae0e | |||
| 442a1aafee | |||
| c7287c861c | |||
|
|
6e6b54c4c8 | ||
|
|
09b31c881e | ||
|
|
de4bd2c4a6 | ||
|
|
1e61d1c538 | ||
|
|
fb4501e9b3 | ||
|
|
9302731734 | ||
|
|
71db456ced | ||
|
|
8d82431a22 | ||
|
|
fd1fa516cb | ||
|
|
132b45f670 | ||
|
|
e5c136a32f | ||
|
|
ef3ee1cdf6 | ||
|
|
75fc44e34f | ||
|
|
fc23274c94 | ||
|
|
f5691fd8be | ||
|
|
565508b217 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,6 +3,7 @@ __pycache__
|
|||||||
*.egg-info
|
*.egg-info
|
||||||
*.pyc
|
*.pyc
|
||||||
venv
|
venv
|
||||||
|
sitecustomize.py
|
||||||
|
|
||||||
*.qmlc
|
*.qmlc
|
||||||
*.jsc
|
*.jsc
|
||||||
|
|||||||
79
PKGBUILD
Normal file
79
PKGBUILD
Normal 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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
# )
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}"> 🖉` : // 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 {}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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) &&
|
||||||
|
|||||||
260
src/gui/Pages/Chat/Timeline/HistoryContent.qml
Normal file
260
src/gui/Pages/Chat/Timeline/HistoryContent.qml
Normal 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 {}
|
||||||
|
}
|
||||||
93
src/gui/Pages/Chat/Timeline/HistoryDelegate.qml
Normal file
93
src/gui/Pages/Chat/Timeline/HistoryDelegate.qml
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
206
src/gui/Pages/Chat/Timeline/HistoryList.qml
Normal file
206
src/gui/Pages/Chat/Timeline/HistoryList.qml
Normal 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
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
44
src/gui/Popups/MessageReplaceHistoryPopup.qml
Normal file
44
src/gui/Popups/MessageReplaceHistoryPopup.qml
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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} `) :
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Submodule submodules/SortFilterProxyModel updated: 36befddf5d...5a930885b7
Submodule submodules/hsluv-c updated: 9e9be32d60...c0cb66d62f
Reference in New Issue
Block a user