Compare commits
	
		
			48 Commits
		
	
	
		
			bc20e47fb1
			...
			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 | ||
| b6543b09cc | 
							
								
								
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							@@ -3,6 +3,7 @@ __pycache__
 | 
			
		||||
*.egg-info
 | 
			
		||||
*.pyc
 | 
			
		||||
venv
 | 
			
		||||
sitecustomize.py
 | 
			
		||||
 | 
			
		||||
*.qmlc
 | 
			
		||||
*.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 subprocess
 | 
			
		||||
import shutil
 | 
			
		||||
import sys
 | 
			
		||||
from contextlib import suppress
 | 
			
		||||
from pathlib import Path
 | 
			
		||||
@@ -49,8 +50,13 @@ def cmd(*parts) -> subprocess.CompletedProcess:
 | 
			
		||||
def run_app(args=sys.argv[1:]) -> None:
 | 
			
		||||
	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):
 | 
			
		||||
        cmd("qmake", "moment.pro", "CONFIG+=dev")
 | 
			
		||||
		cmd(QMAKE_CMD, "moment.pro", "CONFIG+=dev")
 | 
			
		||||
		cmd("make")
 | 
			
		||||
		cmd("./moment", "-name", "dev", *args)
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -6,6 +6,8 @@ The format is based on
 | 
			
		||||
and this project adheres to
 | 
			
		||||
[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.2 (2021-07-26)](#072-2021-07-26)
 | 
			
		||||
- [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.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)
 | 
			
		||||
 | 
			
		||||
### Added
 | 
			
		||||
 
 | 
			
		||||
@@ -25,6 +25,7 @@ but compiling on Windows and macOS should be possible with the right tools.
 | 
			
		||||
  - [Common issues](#common-issues)
 | 
			
		||||
    - [cffi version mismatch](#cffi-version-mismatch)
 | 
			
		||||
    - [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
 | 
			
		||||
@@ -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/`,
 | 
			
		||||
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>
 | 
			
		||||
    <binary>moment</binary>
 | 
			
		||||
  </provides>
 | 
			
		||||
  <requires>
 | 
			
		||||
    <display_length compare="ge">360</display_length>
 | 
			
		||||
  </requires>
 | 
			
		||||
  <project_license>LGPL-3.0-or-later</project_license>
 | 
			
		||||
  <screenshots>
 | 
			
		||||
    <screenshot type="default">
 | 
			
		||||
@@ -56,6 +59,8 @@
 | 
			
		||||
  </screenshots>
 | 
			
		||||
  <metadata_license>FSFAP</metadata_license>
 | 
			
		||||
  <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.2" date="2021-07-26"/>
 | 
			
		||||
    <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.0" date="2020-03-21"/>
 | 
			
		||||
  </releases>
 | 
			
		||||
  <custom>
 | 
			
		||||
    <value key="Purism::form_factor">workstation</value>
 | 
			
		||||
    <value key="Purism::form_factor">mobile</value>
 | 
			
		||||
  </custom>
 | 
			
		||||
</component>
 | 
			
		||||
 
 | 
			
		||||
@@ -1,15 +1,15 @@
 | 
			
		||||
remote_pdb >= 2.0.0,  < 3
 | 
			
		||||
pdbpp      >= 0.10.2, < 0.11
 | 
			
		||||
devtools   >= 0.4.0,  < 0.5
 | 
			
		||||
devtools   >= 0.12.0,  < 0.13
 | 
			
		||||
 | 
			
		||||
mypy                  >= 0.812,  < 0.900
 | 
			
		||||
flake8                >= 3.8.4,  < 4
 | 
			
		||||
flake8-isort          >= 4.0.0,  < 5
 | 
			
		||||
flake8-bugbear        >= 20.1.4, < 21
 | 
			
		||||
mypy                  >= 1.7.0,   < 1.8
 | 
			
		||||
flake8                >= 6.1.0,   < 7
 | 
			
		||||
flake8-isort          >= 6.1.0,   < 7
 | 
			
		||||
flake8-bugbear        >= 23.12.0, < 24
 | 
			
		||||
flake8-commas         >= 2.0.0,   < 3
 | 
			
		||||
flake8-comprehensions >= 3.3.0,   < 4
 | 
			
		||||
flake8-executable     >= 2.0.4,   < 3
 | 
			
		||||
flake8-logging-format >= 0.6.0,  < 0.7
 | 
			
		||||
flake8-pie            >= 0.6.1,  < 0.7
 | 
			
		||||
flake8-logging-format >= 0.9.0,   < 1
 | 
			
		||||
flake8-pie            >= 0.16.0,  < 1
 | 
			
		||||
flake8-quotes         >= 3.2.0,   < 4
 | 
			
		||||
flake8-colors         >= 0.1.6,   < 0.2
 | 
			
		||||
 
 | 
			
		||||
@@ -2,11 +2,12 @@ Pillow            >= 7.0.0,  < 9
 | 
			
		||||
aiofiles          >= 0.4.0,  < 24.0.0
 | 
			
		||||
appdirs           >= 1.4.4,  < 2
 | 
			
		||||
cairosvg          >= 2.4.2,  < 3
 | 
			
		||||
emoji             >= 2.0,    < 3.0
 | 
			
		||||
filetype          >= 1.0.7,  < 2
 | 
			
		||||
html_sanitizer    >= 1.9.1,  < 2
 | 
			
		||||
lxml              >= 4.5.1,  < 5
 | 
			
		||||
mistune           >= 2.0.0,  < 3.0
 | 
			
		||||
pymediainfo       >= 4.2.1,  < 5
 | 
			
		||||
html_sanitizer    >= 1.9.1,  < 3
 | 
			
		||||
lxml              >= 4.5.1,  < 6
 | 
			
		||||
mistune           >= 2.0.0,  < 4.0
 | 
			
		||||
pymediainfo       >= 4.2.1,  < 7
 | 
			
		||||
plyer             >= 1.4.3,  < 2
 | 
			
		||||
sortedcontainers  >= 2.2.2,  < 3
 | 
			
		||||
watchgod          >= 0.7,    < 0.8
 | 
			
		||||
@@ -14,7 +15,7 @@ redbaron          >= 0.9.2,  < 1
 | 
			
		||||
hsluv             >= 5.0.0,  < 6
 | 
			
		||||
simpleaudio       >= 1.0.4,  < 2
 | 
			
		||||
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"
 | 
			
		||||
dataclasses       >= 0.6,   < 0.7; python_version < "3.7"
 | 
			
		||||
 
 | 
			
		||||
@@ -16,4 +16,4 @@ documentation in the following modules first:
 | 
			
		||||
__app_name__	 = "moment"
 | 
			
		||||
__display_name__ = "Moment"
 | 
			
		||||
__reverse_dns__  = "xyz.mx-moment"
 | 
			
		||||
__version__      = "0.7.3"
 | 
			
		||||
__version__      = "0.7.5"
 | 
			
		||||
@@ -20,7 +20,16 @@ import aiohttp
 | 
			
		||||
import nio
 | 
			
		||||
import plyer
 | 
			
		||||
import pyotherside
 | 
			
		||||
 | 
			
		||||
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 nio.client.async_client import client_session
 | 
			
		||||
 | 
			
		||||
@@ -533,7 +542,7 @@ class Backend:
 | 
			
		||||
			connector		= session.connector,
 | 
			
		||||
		)
 | 
			
		||||
 | 
			
		||||
        api_list = "https://joinmatrix.org/servers.json"
 | 
			
		||||
		api_list = "https://servers.joinmatrix.org/servers.json"
 | 
			
		||||
		try:
 | 
			
		||||
			response = await session.get(api_list)
 | 
			
		||||
		except:
 | 
			
		||||
@@ -543,17 +552,19 @@ class Backend:
 | 
			
		||||
 | 
			
		||||
		coros	= []
 | 
			
		||||
 | 
			
		||||
        for server in (await response.json()):
 | 
			
		||||
            homeserver_url = "https://" + server["domain"]
 | 
			
		||||
		for server in (await response.json())["public_servers"]:
 | 
			
		||||
			homeserver_url = "https://" + server["client_domain"]
 | 
			
		||||
 | 
			
		||||
            if not server["open"]: # ignore closed servers
 | 
			
		||||
                continue
 | 
			
		||||
			http_s_re  = re.compile("^https?://")
 | 
			
		||||
			# 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(
 | 
			
		||||
				id		   = homeserver_url,
 | 
			
		||||
				name		 = server["name"],
 | 
			
		||||
                site_url     = server["domain"],
 | 
			
		||||
                country      = server["jurisdiction"],
 | 
			
		||||
				site_url	 = site_url,
 | 
			
		||||
				country	  = server["staff_jur"],
 | 
			
		||||
				stability	= 0,
 | 
			
		||||
				downtimes_ms = 0,
 | 
			
		||||
				# austin's list doesn't have stability/downtime
 | 
			
		||||
@@ -588,6 +599,13 @@ class Backend:
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
	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 = str(Path(path).expanduser())
 | 
			
		||||
		
 | 
			
		||||
 
 | 
			
		||||
@@ -10,6 +10,7 @@ from urllib.parse import unquote
 | 
			
		||||
 | 
			
		||||
import html_sanitizer.sanitizer as sanitizer
 | 
			
		||||
import lxml.html  # nosec
 | 
			
		||||
import emoji
 | 
			
		||||
import mistune
 | 
			
		||||
import nio
 | 
			
		||||
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
 | 
			
		||||
	# inline_html rule instead of the colour rule.
 | 
			
		||||
	md.inline.rules.insert(1, "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":
 | 
			
		||||
		md.renderer.register("colour", render_html_colour)
 | 
			
		||||
@@ -215,6 +219,14 @@ class HTMLProcessor:
 | 
			
		||||
			if not outgoing:
 | 
			
		||||
				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 = sanit.sanitize(html).rstrip("\n")
 | 
			
		||||
 | 
			
		||||
@@ -223,6 +235,27 @@ class HTMLProcessor:
 | 
			
		||||
 | 
			
		||||
		# 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)
 | 
			
		||||
 | 
			
		||||
		if not inline:
 | 
			
		||||
@@ -238,6 +271,7 @@ class HTMLProcessor:
 | 
			
		||||
		inline:				bool					 = False,
 | 
			
		||||
		outgoing:			  bool					 = False,
 | 
			
		||||
		display_name_mentions: Optional[Dict[str, str]] = None,
 | 
			
		||||
		extra_attributes:	  set					  = set(),
 | 
			
		||||
	) -> dict:
 | 
			
		||||
		"""Return an html_sanitizer configuration."""
 | 
			
		||||
 | 
			
		||||
@@ -250,16 +284,21 @@ class HTMLProcessor:
 | 
			
		||||
			"font": {"color"},
 | 
			
		||||
			"a":	{"href", "class", "data-mention"},
 | 
			
		||||
			"code": {"class"},
 | 
			
		||||
			"span": {"data-mx-spoiler", "data-mx-color"},
 | 
			
		||||
		}
 | 
			
		||||
		attributes = {**inlines_attributes, **{
 | 
			
		||||
			"ol":   {"start"},
 | 
			
		||||
			"hr":   {"width"},
 | 
			
		||||
            "span": {"data-mx-color"},
 | 
			
		||||
			"img":  {
 | 
			
		||||
				"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 [
 | 
			
		||||
			rf"(?<!\w)(?P<body>{re.escape(name or user_id)})(?!\w)(?P<host>)"
 | 
			
		||||
			for user_id, name in (display_name_mentions or {}).items()
 | 
			
		||||
@@ -268,11 +307,11 @@ class HTMLProcessor:
 | 
			
		||||
		return {
 | 
			
		||||
			"tags": inline_tags if inline else all_tags,
 | 
			
		||||
			"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 {
 | 
			
		||||
				"a", "p", "li", "table", "tr", "th", "td", "br", "hr", "img",
 | 
			
		||||
			},
 | 
			
		||||
            "whitespace": {},
 | 
			
		||||
			"whitespace": set(),
 | 
			
		||||
			"keep_typographic_whitespace": True,
 | 
			
		||||
			"add_nofollow": False,
 | 
			
		||||
			"autolink": {
 | 
			
		||||
 
 | 
			
		||||
@@ -26,6 +26,7 @@ from urllib.parse import urlparse
 | 
			
		||||
from uuid import UUID, uuid4
 | 
			
		||||
 | 
			
		||||
import cairosvg
 | 
			
		||||
import emoji
 | 
			
		||||
import nio
 | 
			
		||||
from nio.crypto import AsyncDataT as UploadData
 | 
			
		||||
from nio.crypto import async_generator_from_data
 | 
			
		||||
@@ -150,7 +151,6 @@ class MatrixClient(nio.AsyncClient):
 | 
			
		||||
					"m.call.*",
 | 
			
		||||
					"m.room.third_party_invite",
 | 
			
		||||
					"m.room.tombstone",
 | 
			
		||||
                    "m.reaction",
 | 
			
		||||
				],
 | 
			
		||||
			},
 | 
			
		||||
		},
 | 
			
		||||
@@ -178,6 +178,24 @@ class MatrixClient(nio.AsyncClient):
 | 
			
		||||
		if host in ("127.0.0.1", "localhost", "::1"):
 | 
			
		||||
			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__(
 | 
			
		||||
			homeserver = homeserver,
 | 
			
		||||
			user	   = user,
 | 
			
		||||
@@ -225,14 +243,18 @@ class MatrixClient(nio.AsyncClient):
 | 
			
		||||
		self.unassigned_event_last_read_by: DefaultDict[str, Dict[str, int]] =\
 | 
			
		||||
			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.ignored_user_ids: Set[str]		   = set()
 | 
			
		||||
 | 
			
		||||
		# {room_id: event}
 | 
			
		||||
		self.power_level_events: Dict[str, nio.PowerLevelsEvent] = {}
 | 
			
		||||
 | 
			
		||||
        self.invalid_disconnecting: bool = False
 | 
			
		||||
 | 
			
		||||
		self.nio_callbacks = NioCallbacks(self)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@@ -241,7 +263,6 @@ class MatrixClient(nio.AsyncClient):
 | 
			
		||||
			type(self).__name__, self.user_id, self.homeserver, self.device_id,
 | 
			
		||||
		)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
	@property
 | 
			
		||||
	def default_device_name(self) -> str:
 | 
			
		||||
		"""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,
 | 
			
		||||
				)
 | 
			
		||||
			except asyncio.TimeoutError:
 | 
			
		||||
                log.warn("%s timed out", self.user_id)
 | 
			
		||||
				log.warning("%s timed out", self.user_id)
 | 
			
		||||
 | 
			
		||||
		await self.close()
 | 
			
		||||
 | 
			
		||||
@@ -377,7 +398,7 @@ class MatrixClient(nio.AsyncClient):
 | 
			
		||||
				account.max_upload_size = future.result() or 0
 | 
			
		||||
			except MatrixError:
 | 
			
		||||
				trace = traceback.format_exc().rstrip()
 | 
			
		||||
                log.warn(
 | 
			
		||||
				log.warning(
 | 
			
		||||
					"On %s server config retrieval: %s", self.user_id, trace,
 | 
			
		||||
				)
 | 
			
		||||
				self.server_config_task = asyncio.ensure_future(
 | 
			
		||||
@@ -603,7 +624,6 @@ class MatrixClient(nio.AsyncClient):
 | 
			
		||||
 | 
			
		||||
		return {**self.invited_rooms, **self.rooms}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
	async def send_text(
 | 
			
		||||
		self,
 | 
			
		||||
		room_id:			   str,
 | 
			
		||||
@@ -611,22 +631,46 @@ class MatrixClient(nio.AsyncClient):
 | 
			
		||||
		display_name_mentions: Optional[Dict[str, str]] = None,  # {id: name}
 | 
			
		||||
		reply_to_event_id:	 Optional[str]			= 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(
 | 
			
		||||
			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]
 | 
			
		||||
 | 
			
		||||
        if text.startswith("/me ") and not escape:
 | 
			
		||||
		if emote:
 | 
			
		||||
			event_type = nio.RoomMessageEmote
 | 
			
		||||
            text       = text[len("/me "):]
 | 
			
		||||
			content	= {"body": text, "msgtype": "m.emote"}
 | 
			
		||||
			to_html	= from_md(text, inline=True, outgoing=True)
 | 
			
		||||
			echo_body  = from_md(text, inline=True)
 | 
			
		||||
@@ -636,6 +680,12 @@ class MatrixClient(nio.AsyncClient):
 | 
			
		||||
			to_html	= from_md(text, outgoing=True)
 | 
			
		||||
			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>"):
 | 
			
		||||
			content["format"]		 = "org.matrix.custom.html"
 | 
			
		||||
			content["formatted_body"] = to_html
 | 
			
		||||
@@ -691,6 +741,171 @@ class MatrixClient(nio.AsyncClient):
 | 
			
		||||
		await self.pause_while_offline()
 | 
			
		||||
		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(
 | 
			
		||||
		self, room_id: str, uuid: Union[str, UUID],
 | 
			
		||||
@@ -742,7 +957,6 @@ class MatrixClient(nio.AsyncClient):
 | 
			
		||||
		reply_to_event_id: Optional[str] = None,
 | 
			
		||||
	) -> None:
 | 
			
		||||
		"""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
 | 
			
		||||
		reply attached.
 | 
			
		||||
		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,
 | 
			
		||||
		transaction_id: UUID,
 | 
			
		||||
		event_type:	 Type[nio.Event],
 | 
			
		||||
		fake_event:	 bool = False,
 | 
			
		||||
		**event_fields,
 | 
			
		||||
	) -> None:
 | 
			
		||||
		"""Register a local model `Event` while waiting for the server.
 | 
			
		||||
@@ -1043,13 +1258,13 @@ class MatrixClient(nio.AsyncClient):
 | 
			
		||||
 | 
			
		||||
		event = Event(
 | 
			
		||||
			id			= f"echo-{transaction_id}",
 | 
			
		||||
            event_id      = "",
 | 
			
		||||
			event_id	  = f"echo-{transaction_id}" if fake_event else "",
 | 
			
		||||
			event_type	= event_type,
 | 
			
		||||
			date		  = datetime.now(),
 | 
			
		||||
			sender_id	 = self.user_id,
 | 
			
		||||
			sender_name   = our_info.display_name,
 | 
			
		||||
			sender_avatar = our_info.avatar_url,
 | 
			
		||||
            is_local_echo = True,
 | 
			
		||||
			is_local_echo = not fake_event,
 | 
			
		||||
			links		 = Event.parse_links(content),
 | 
			
		||||
			**event_fields,
 | 
			
		||||
		)
 | 
			
		||||
@@ -1061,9 +1276,18 @@ class MatrixClient(nio.AsyncClient):
 | 
			
		||||
 | 
			
		||||
		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(
 | 
			
		||||
		self, room_id: str, content: dict, transaction_id: UUID,
 | 
			
		||||
		message_type: str = "m.room.message",
 | 
			
		||||
	) -> None:
 | 
			
		||||
		"""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]:
 | 
			
		||||
			await self.room_send(
 | 
			
		||||
				room_id				   = room_id,
 | 
			
		||||
                message_type              = "m.room.message",
 | 
			
		||||
				message_type			  = message_type,
 | 
			
		||||
				content				   = content,
 | 
			
		||||
				ignore_unverified_devices = True,
 | 
			
		||||
			)
 | 
			
		||||
@@ -1107,26 +1331,24 @@ class MatrixClient(nio.AsyncClient):
 | 
			
		||||
 | 
			
		||||
		Returns whether there are any messages left to load.
 | 
			
		||||
		"""
 | 
			
		||||
 | 
			
		||||
		log.info("loading events for %s",room_id)
 | 
			
		||||
		if room_id in self.fully_loaded_rooms or \
 | 
			
		||||
		   room_id in self.invited_rooms or \
 | 
			
		||||
		   room_id in self.cleared_events_rooms or \
 | 
			
		||||
		   self.models[self.user_id, "rooms"][room_id].left:
 | 
			
		||||
			return False
 | 
			
		||||
 | 
			
		||||
		log.info("waiting for first sync %s",room_id)
 | 
			
		||||
		await self.first_sync_done.wait()
 | 
			
		||||
 | 
			
		||||
        while not self.past_tokens.get(room_id):
 | 
			
		||||
		log.info("waiting for past token %s",room_id)
 | 
			
		||||
		i=0
 | 
			
		||||
		while i<100 and not self.past_tokens.get(room_id):
 | 
			
		||||
			# If a new room was added, wait for onSyncResponse to set the token
 | 
			
		||||
			await asyncio.sleep(0.1)
 | 
			
		||||
 | 
			
		||||
        response = await self.room_messages(
 | 
			
		||||
            room_id        = room_id,
 | 
			
		||||
            start          = self.past_tokens[room_id],
 | 
			
		||||
            limit          = 100 if room_id in self.loaded_once_rooms else 10,
 | 
			
		||||
            message_filter = self.lazy_load_filter,
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
			i+=1 # Give up waiting after 10 seconds and just fetch from newest instead. This fixes a bug where initial sync occasionally inserts None into the batch for some reason - I'm blaming nio currently. Also fixes a bug where the room just straight up doesn't seem to exist in initial sync, but somehow still ends up in room list.
 | 
			
		||||
		log.info("performing actual fetch %s",room_id)
 | 
			
		||||
		args={"room_id":room_id,"limit":100 if room_id in self.loaded_once_rooms else 10,"message_filter":self.lazy_load_filter,"start":self.past_tokens.get(room_id)}
 | 
			
		||||
		response = await self.room_messages(**args)
 | 
			
		||||
		log.info("data returned %s",room_id)
 | 
			
		||||
		self.loaded_once_rooms.add(room_id)
 | 
			
		||||
		more_to_load = True
 | 
			
		||||
 | 
			
		||||
@@ -1575,6 +1797,9 @@ class MatrixClient(nio.AsyncClient):
 | 
			
		||||
				resp = await self.get_presence(user_id)
 | 
			
		||||
		except (MatrixForbidden, MatrixUnrecognized):
 | 
			
		||||
			return
 | 
			
		||||
		except MatrixError as e:
 | 
			
		||||
			if e.http_code==500: return # Probably conduit which hasn't implemented it yet
 | 
			
		||||
			else: raise
 | 
			
		||||
 | 
			
		||||
		await self.nio_callbacks.onPresenceEvent(nio.PresenceEvent(
 | 
			
		||||
			user_id		  = resp.user_id,
 | 
			
		||||
@@ -1843,7 +2068,7 @@ class MatrixClient(nio.AsyncClient):
 | 
			
		||||
 | 
			
		||||
		auth = {
 | 
			
		||||
			"type":	 "m.login.password",
 | 
			
		||||
            "user":     self.user_id,
 | 
			
		||||
			"identifier": {"type":"m.id.user", "user":self.user_id},
 | 
			
		||||
			"password": password,
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
@@ -2317,7 +2542,7 @@ class MatrixClient(nio.AsyncClient):
 | 
			
		||||
			ev.set_fields(target_name=target_name, target_avatar=target_avatar)
 | 
			
		||||
 | 
			
		||||
		if ev.redacter_id and not ev.redacter_name:
 | 
			
		||||
            redacter_name, _, _ = await get_profile(ev.target_id)
 | 
			
		||||
			redacter_name, _, _ = await get_profile(ev.redacter_id)
 | 
			
		||||
			ev.redacter_name	= redacter_name
 | 
			
		||||
 | 
			
		||||
		ev.fetch_profile = False
 | 
			
		||||
@@ -2421,6 +2646,122 @@ class MatrixClient(nio.AsyncClient):
 | 
			
		||||
		self.backend.notification_avatar_cache[mxc] = 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(
 | 
			
		||||
		self,
 | 
			
		||||
@@ -2484,6 +2825,15 @@ class MatrixClient(nio.AsyncClient):
 | 
			
		||||
 | 
			
		||||
			**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
 | 
			
		||||
 | 
			
		||||
@@ -2498,6 +2848,33 @@ class MatrixClient(nio.AsyncClient):
 | 
			
		||||
			item.id							 = f"echo-{tx_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
 | 
			
		||||
		await self.set_room_last_event(room.room_id, item)
 | 
			
		||||
 | 
			
		||||
@@ -2536,8 +2913,8 @@ class MatrixClient(nio.AsyncClient):
 | 
			
		||||
			)
 | 
			
		||||
 | 
			
		||||
		sender   = item.sender_name or item.sender_id
 | 
			
		||||
        is_linux = platform.system() == "Linux"
 | 
			
		||||
        use_html = is_linux and self.backend.settings.Notifications.use_html
 | 
			
		||||
		is_nux = platform.system() == "Linux" or "BSD" in platform.system()
 | 
			
		||||
		use_html = is_nux and self.backend.settings.Notifications.use_html
 | 
			
		||||
		content  = item.inline_content if use_html else item.plain_content
 | 
			
		||||
 | 
			
		||||
		if isinstance(ev, nio.RoomMessageEmote) and use_html:
 | 
			
		||||
 
 | 
			
		||||
@@ -14,7 +14,7 @@ import lxml  # nosec
 | 
			
		||||
import nio
 | 
			
		||||
 | 
			
		||||
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
 | 
			
		||||
 | 
			
		||||
OptionalExceptionType = Union[Type[None], Type[Exception]]
 | 
			
		||||
@@ -28,6 +28,9 @@ class TypeSpecifier(AutoStrEnum):
 | 
			
		||||
	Unset			= auto()
 | 
			
		||||
	ProfileChange	= auto()
 | 
			
		||||
	MembershipChange = auto()
 | 
			
		||||
	Reaction          = auto()
 | 
			
		||||
	ReactionRedaction = auto()
 | 
			
		||||
	MessageReplace    = auto()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class PingStatus(AutoStrEnum):
 | 
			
		||||
@@ -349,6 +352,7 @@ class Event(ModelItem):
 | 
			
		||||
	sender_name:   str				 = field()
 | 
			
		||||
	sender_avatar: str				 = field()
 | 
			
		||||
	fetch_profile: bool				= False
 | 
			
		||||
	hidden:		bool				= False
 | 
			
		||||
 | 
			
		||||
	content:		   str				   = ""
 | 
			
		||||
	inline_content:	str				   = ""
 | 
			
		||||
@@ -356,6 +360,9 @@ class Event(ModelItem):
 | 
			
		||||
	links:			 List[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
 | 
			
		||||
 | 
			
		||||
	target_id:	 str = ""
 | 
			
		||||
@@ -433,5 +440,6 @@ class Event(ModelItem):
 | 
			
		||||
		if field == "source":
 | 
			
		||||
			source_dict = asdict(self.source) if self.source else {}
 | 
			
		||||
			return json.dumps(source_dict)
 | 
			
		||||
 | 
			
		||||
		if field == "content_history":
 | 
			
		||||
			return serialize_value_for_qml(self.content_history)
 | 
			
		||||
		return super().serialized_field(field)
 | 
			
		||||
 
 | 
			
		||||
@@ -82,9 +82,11 @@ class NioCallbacks:
 | 
			
		||||
			await self.client.register_nio_room(self.client.all_rooms[room_id])
 | 
			
		||||
 | 
			
		||||
		for room_id, info in resp.rooms.join.items():
 | 
			
		||||
			log.info("%s %a",room_id,room_id in self.client.past_tokens)
 | 
			
		||||
			await self.client.register_nio_room(self.client.rooms[room_id])
 | 
			
		||||
 | 
			
		||||
			if room_id not in self.client.past_tokens:
 | 
			
		||||
				log.info("adding %s to %s's past_tokens", info.timeline.prev_batch,room_id)
 | 
			
		||||
				self.client.past_tokens[room_id] = info.timeline.prev_batch
 | 
			
		||||
 | 
			
		||||
			for ev in info.state:
 | 
			
		||||
@@ -617,7 +619,7 @@ class NioCallbacks:
 | 
			
		||||
 | 
			
		||||
		await self.client.register_nio_event(room, ev, content=co)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
	# Room events, invite events and misc events callbacks
 | 
			
		||||
	async def onRoomNameEvent(
 | 
			
		||||
		self, room: nio.MatrixRoom, ev: nio.RoomNameEvent,
 | 
			
		||||
	) -> None:
 | 
			
		||||
@@ -628,6 +630,468 @@ class NioCallbacks:
 | 
			
		||||
 | 
			
		||||
		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(
 | 
			
		||||
		self, room: nio.MatrixRoom, ev: nio.RoomAvatarEvent,
 | 
			
		||||
 
 | 
			
		||||
@@ -14,6 +14,7 @@ import xml.etree.cElementTree as xml_etree
 | 
			
		||||
from concurrent.futures import ProcessPoolExecutor
 | 
			
		||||
from contextlib import suppress
 | 
			
		||||
from datetime import date, datetime, time, timedelta
 | 
			
		||||
from difflib import SequenceMatcher
 | 
			
		||||
from enum import Enum
 | 
			
		||||
from enum import auto as autostr
 | 
			
		||||
from pathlib import Path
 | 
			
		||||
@@ -206,6 +207,28 @@ def strip_html_tags(text: str) -> str:
 | 
			
		||||
	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(
 | 
			
		||||
	value: Any, json_list_dicts: bool = False, reject_unknown: bool = False,
 | 
			
		||||
) -> Any:
 | 
			
		||||
 
 | 
			
		||||
@@ -541,3 +541,36 @@ class Keys:
 | 
			
		||||
 | 
			
		||||
		# Sign out checked sessions if any, else sign out all sessions.
 | 
			
		||||
		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
 | 
			
		||||
    selectByMouse: true
 | 
			
		||||
 | 
			
		||||
    onLinkActivated: if (enableLinkActivation && link !== '#state-text')
 | 
			
		||||
    onLinkActivated: if (enableLinkActivation
 | 
			
		||||
            && link !== '#state-text'
 | 
			
		||||
            && link !== '#replaced-text')
 | 
			
		||||
        Qt.openUrlExternally(link)
 | 
			
		||||
 | 
			
		||||
    MouseArea {
 | 
			
		||||
 
 | 
			
		||||
@@ -28,8 +28,17 @@ HPage {
 | 
			
		||||
            HTabButton { text: qsTr("Security") }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        General { userId: page.userId }
 | 
			
		||||
        Notifications { userId: page.userId }
 | 
			
		||||
        Security { userId: page.userId }
 | 
			
		||||
        General {
 | 
			
		||||
            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.preferredHeight: width
 | 
			
		||||
 | 
			
		||||
        HoverHandler { id: overlayHover }
 | 
			
		||||
 | 
			
		||||
        Rectangle {
 | 
			
		||||
            anchors.fill: parent
 | 
			
		||||
            z: 10
 | 
			
		||||
@@ -154,8 +156,6 @@ HFlickableColumnPage {
 | 
			
		||||
            Behavior on opacity { HNumberAnimation {} }
 | 
			
		||||
            Behavior on color { HColorAnimation {} }
 | 
			
		||||
 | 
			
		||||
            HoverHandler { id: overlayHover }
 | 
			
		||||
 | 
			
		||||
            MouseArea {
 | 
			
		||||
                anchors.fill: parent
 | 
			
		||||
                enabled: ready && account.presence !== "offline"
 | 
			
		||||
 
 | 
			
		||||
@@ -26,8 +26,17 @@ HPage {
 | 
			
		||||
            HTabButton { text: qsTr("Create group") }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        DirectChat { userId: page.userId }
 | 
			
		||||
        JoinRoom { userId: page.userId }
 | 
			
		||||
        CreateRoom { userId: page.userId }
 | 
			
		||||
        DirectChat {
 | 
			
		||||
            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>"
 | 
			
		||||
 | 
			
		||||
    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 hoveredSelectable: contentHover.hovered
 | 
			
		||||
@@ -123,6 +135,13 @@ HRowLayout {
 | 
			
		||||
            id: contentLabel
 | 
			
		||||
            visible: ! pureMedia
 | 
			
		||||
            enableLinkActivation: ! eventList.selectedCount
 | 
			
		||||
            onLinkActivated:
 | 
			
		||||
                if(link === "#replaced-text") window.makePopup(
 | 
			
		||||
                    "Popups/MessageReplaceHistoryPopup.qml",
 | 
			
		||||
                    {
 | 
			
		||||
                        contentHistory: contentHistory
 | 
			
		||||
                    },
 | 
			
		||||
                )
 | 
			
		||||
 | 
			
		||||
            selectByMouse:
 | 
			
		||||
                eventList.selectedCount <= 1 &&
 | 
			
		||||
@@ -163,6 +182,7 @@ HRowLayout {
 | 
			
		||||
                timeText +
 | 
			
		||||
                "</font>" +
 | 
			
		||||
 | 
			
		||||
                replacedText +
 | 
			
		||||
                stateText
 | 
			
		||||
 | 
			
		||||
            transform: Translate { x: xOffset }
 | 
			
		||||
@@ -298,6 +318,8 @@ HRowLayout {
 | 
			
		||||
 | 
			
		||||
                    linksRepeater.summedWidth +
 | 
			
		||||
                    (pureMedia ? 0 : parent.leftPadding + parent.rightPadding),
 | 
			
		||||
 | 
			
		||||
                    reactionsRow.width
 | 
			
		||||
                )
 | 
			
		||||
                height: contentColumn.height
 | 
			
		||||
                radius: theme.chat.message.radius
 | 
			
		||||
@@ -361,6 +383,94 @@ HRowLayout {
 | 
			
		||||
                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 {}
 | 
			
		||||
 
 | 
			
		||||
@@ -72,13 +72,15 @@ HColumnLayout {
 | 
			
		||||
        eventList.toggleCheck(model.index)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    visible: !model.hidden
 | 
			
		||||
    width: eventList.width - eventList.leftMargin - eventList.rightMargin
 | 
			
		||||
 | 
			
		||||
    // Needed because of eventList's MouseArea which steals the
 | 
			
		||||
    // HSelectableLabel's MouseArea hover events
 | 
			
		||||
    onCursorShapeChanged: eventList.cursorShape = cursorShape
 | 
			
		||||
 | 
			
		||||
    Component.onCompleted: if (model.fetch_profile)
 | 
			
		||||
    Component.onCompleted: {
 | 
			
		||||
        if (model.fetch_profile)
 | 
			
		||||
            fetchProfilesFutureId = py.callClientCoro(
 | 
			
		||||
                chat.userId,
 | 
			
		||||
                "get_event_profiles",
 | 
			
		||||
@@ -86,6 +88,10 @@ HColumnLayout {
 | 
			
		||||
                // 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:
 | 
			
		||||
        if (fetchProfilesFutureId) py.cancelCoro(fetchProfilesFutureId)
 | 
			
		||||
 
 | 
			
		||||
@@ -266,10 +266,28 @@ Rectangle {
 | 
			
		||||
            highlightRangeMode = previous
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        function focusPreviousVisibleMessage() {
 | 
			
		||||
            incrementCurrentIndex()
 | 
			
		||||
            let lastIndex = -1
 | 
			
		||||
            while ( currentIndex != lastIndex && model.get(currentIndex).hidden ) {
 | 
			
		||||
                lastIndex = currentIndex
 | 
			
		||||
                incrementCurrentIndex()
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        function focusPreviousMessage() {
 | 
			
		||||
            currentIndex === -1 && visibleEnd.y < contentHeight - height / 4 ?
 | 
			
		||||
            focusCenterMessage() :
 | 
			
		||||
            incrementCurrentIndex()
 | 
			
		||||
            focusPreviousVisibleMessage()
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        function focusNextVisibleMessage() {
 | 
			
		||||
            decrementCurrentIndex()
 | 
			
		||||
            while ( currentIndex > -1 && model.get(currentIndex).hidden ) {
 | 
			
		||||
                if ( currentIndex === 0 )
 | 
			
		||||
                    currentIndex = -1;
 | 
			
		||||
                decrementCurrentIndex()
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        function focusNextMessage() {
 | 
			
		||||
@@ -279,7 +297,7 @@ Rectangle {
 | 
			
		||||
            eventList.currentIndex === 0 ?
 | 
			
		||||
            eventList.currentIndex = -1 :
 | 
			
		||||
 | 
			
		||||
            decrementCurrentIndex()
 | 
			
		||||
            focusNextVisibleMessage()
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        function copySelectedDelegates() {
 | 
			
		||||
@@ -332,7 +350,7 @@ Rectangle {
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        function canCombine(item, itemAfter) {
 | 
			
		||||
            if (! item || ! itemAfter) return false
 | 
			
		||||
            if (! item || ! itemAfter || item.hidden) return false
 | 
			
		||||
 | 
			
		||||
            return Boolean(
 | 
			
		||||
                ! 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
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -214,6 +214,31 @@ QtObject {
 | 
			
		||||
        const unknownMsg = type === "RoomMessageUnknown"
 | 
			
		||||
        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")
 | 
			
		||||
            return ev.content.match(/^\s*<(p|h[1-6])>/) ?
 | 
			
		||||
                   ev.content.replace(/(^\s*<(p|h[1-6])>)/, `$1${sender} `) :
 | 
			
		||||
 
 | 
			
		||||
@@ -367,7 +367,7 @@ int main(int argc, char *argv[]) {
 | 
			
		||||
    QApplication::setOrganizationName("moment");
 | 
			
		||||
    QApplication::setApplicationName("moment");
 | 
			
		||||
    QApplication::setApplicationDisplayName("Moment");
 | 
			
		||||
    QApplication::setApplicationVersion("0.7.3");
 | 
			
		||||
    QApplication::setApplicationVersion("0.7.5");
 | 
			
		||||
    QApplication::setAttribute(Qt::AA_EnableHighDpiScaling);
 | 
			
		||||
 | 
			
		||||
    // 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