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
 | 
					*.egg-info
 | 
				
			||||||
*.pyc
 | 
					*.pyc
 | 
				
			||||||
venv
 | 
					venv
 | 
				
			||||||
 | 
					sitecustomize.py
 | 
				
			||||||
 | 
					
 | 
				
			||||||
*.qmlc
 | 
					*.qmlc
 | 
				
			||||||
*.jsc
 | 
					*.jsc
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										79
									
								
								PKGBUILD
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										79
									
								
								PKGBUILD
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,79 @@
 | 
				
			|||||||
 | 
					# Maintainer: DrRac27 <drrac27 at riseup.net>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					pkgname=moment-git
 | 
				
			||||||
 | 
					_name=moment
 | 
				
			||||||
 | 
					pkgver=v0.7.3.r32.2af33fce
 | 
				
			||||||
 | 
					pkgrel=1
 | 
				
			||||||
 | 
					pkgdesc='A customizable, keyboard-operable Matrix client. Fork of Mirage'
 | 
				
			||||||
 | 
					arch=('x86_64' 'i686' 'aarch64')
 | 
				
			||||||
 | 
					url='https://mx-moment.xyz/'
 | 
				
			||||||
 | 
					license=('LGPL3')
 | 
				
			||||||
 | 
					depends=(
 | 
				
			||||||
 | 
					    'qt5-base'
 | 
				
			||||||
 | 
					    'qt5-declarative'
 | 
				
			||||||
 | 
					    'qt5-quickcontrols2'
 | 
				
			||||||
 | 
					    'qt5-svg'
 | 
				
			||||||
 | 
					    'qt5-graphicaleffects'
 | 
				
			||||||
 | 
					    'qt5-imageformats'
 | 
				
			||||||
 | 
					    'python'
 | 
				
			||||||
 | 
					    'python-pyotherside'
 | 
				
			||||||
 | 
					    'libolm'
 | 
				
			||||||
 | 
					    'libjpeg-turbo'
 | 
				
			||||||
 | 
					    'zlib'
 | 
				
			||||||
 | 
					    'libtiff'
 | 
				
			||||||
 | 
					    'libwebp'
 | 
				
			||||||
 | 
					    'openjpeg2'
 | 
				
			||||||
 | 
					    'libmediainfo'
 | 
				
			||||||
 | 
					    'python-pillow'
 | 
				
			||||||
 | 
					    'python-pymediainfo'
 | 
				
			||||||
 | 
					    'python-cairosvg'
 | 
				
			||||||
 | 
					    'python-aiofiles'
 | 
				
			||||||
 | 
					    'python-appdirs'
 | 
				
			||||||
 | 
					    'python-filetype'
 | 
				
			||||||
 | 
					    'python-html-sanitizer'
 | 
				
			||||||
 | 
					    'python-lxml'
 | 
				
			||||||
 | 
					    'python-mistune>=2'
 | 
				
			||||||
 | 
					    'python-matrix-nio'
 | 
				
			||||||
 | 
					    'libxss'
 | 
				
			||||||
 | 
					    'python-plyer'
 | 
				
			||||||
 | 
					    'python-sortedcontainers'
 | 
				
			||||||
 | 
					    'python-watchgod'
 | 
				
			||||||
 | 
					    'python-redbaron'
 | 
				
			||||||
 | 
					    'dbus-python'
 | 
				
			||||||
 | 
					    'python-hsluv'
 | 
				
			||||||
 | 
					    'python-pycryptodome'
 | 
				
			||||||
 | 
					    'python-simpleaudio'
 | 
				
			||||||
 | 
					    'python-olm'
 | 
				
			||||||
 | 
					    'python-cachetools'
 | 
				
			||||||
 | 
					    'python-atomicwrites'
 | 
				
			||||||
 | 
					    'python-peewee'
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					makedepends=('cmake' 'git')
 | 
				
			||||||
 | 
					provides=('moment')
 | 
				
			||||||
 | 
					conflicts=('moment')
 | 
				
			||||||
 | 
					source=('git+https://gitlab.com/mx-moment/moment.git')
 | 
				
			||||||
 | 
					sha256sums=('SKIP')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					prepare() {
 | 
				
			||||||
 | 
					    cd "${srcdir}/${_name}"
 | 
				
			||||||
 | 
					    git submodule update --init --recursive
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					pkgver() {
 | 
				
			||||||
 | 
					    cd "${srcdir}/${_name}"
 | 
				
			||||||
 | 
					    local tag=$(git tag --sort=-v:refname | head -1)
 | 
				
			||||||
 | 
					    local commits_since=$(git rev-list $tag..HEAD --count)
 | 
				
			||||||
 | 
					    echo "$tag.r$commits_since.$(git log --pretty=format:'%h' -n 1)"
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					build() {
 | 
				
			||||||
 | 
					    cd "${srcdir}/${_name}"
 | 
				
			||||||
 | 
					    make clean || true
 | 
				
			||||||
 | 
					    qmake PREFIX=/usr moment.pro
 | 
				
			||||||
 | 
					    make
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					package() {
 | 
				
			||||||
 | 
					    cd "${srcdir}/${_name}"
 | 
				
			||||||
 | 
					    make INSTALL_ROOT="${pkgdir}" install
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -11,6 +11,7 @@ Use `pip3 install --user -U requirements-dev.txt` before running this."""
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
import os
 | 
					import os
 | 
				
			||||||
import subprocess
 | 
					import subprocess
 | 
				
			||||||
 | 
					import shutil
 | 
				
			||||||
import sys
 | 
					import sys
 | 
				
			||||||
from contextlib import suppress
 | 
					from contextlib import suppress
 | 
				
			||||||
from pathlib import Path
 | 
					from pathlib import Path
 | 
				
			||||||
@@ -49,8 +50,13 @@ def cmd(*parts) -> subprocess.CompletedProcess:
 | 
				
			|||||||
def run_app(args=sys.argv[1:]) -> None:
 | 
					def run_app(args=sys.argv[1:]) -> None:
 | 
				
			||||||
	print("\n\x1b[36m", "─" * term_size().columns, "\x1b[0m\n", sep="")
 | 
						print("\n\x1b[36m", "─" * term_size().columns, "\x1b[0m\n", sep="")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if shutil.which("qmake-qt5"):
 | 
				
			||||||
 | 
							QMAKE_CMD = "qmake-qt5"
 | 
				
			||||||
 | 
						else:
 | 
				
			||||||
 | 
							QMAKE_CMD = "qmake"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	with suppress(KeyboardInterrupt):
 | 
						with suppress(KeyboardInterrupt):
 | 
				
			||||||
        cmd("qmake", "moment.pro", "CONFIG+=dev")
 | 
							cmd(QMAKE_CMD, "moment.pro", "CONFIG+=dev")
 | 
				
			||||||
		cmd("make")
 | 
							cmd("make")
 | 
				
			||||||
		cmd("./moment", "-name", "dev", *args)
 | 
							cmd("./moment", "-name", "dev", *args)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -6,6 +6,8 @@ The format is based on
 | 
				
			|||||||
and this project adheres to
 | 
					and this project adheres to
 | 
				
			||||||
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 | 
					[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- [0.7.5 (2024-03-07)](#075-2024-03-07)
 | 
				
			||||||
 | 
					- [0.7.4 (2024-01-04)](#074-2024-01-04)
 | 
				
			||||||
- [0.7.3 (2022-01-31)](#073-2022-01-31)
 | 
					- [0.7.3 (2022-01-31)](#073-2022-01-31)
 | 
				
			||||||
- [0.7.2 (2021-07-26)](#072-2021-07-26)
 | 
					- [0.7.2 (2021-07-26)](#072-2021-07-26)
 | 
				
			||||||
- [0.7.1 (2021-03-04)](#071-2021-03-04)
 | 
					- [0.7.1 (2021-03-04)](#071-2021-03-04)
 | 
				
			||||||
@@ -24,6 +26,58 @@ and this project adheres to
 | 
				
			|||||||
- [0.4.0 (2020-03-21)](#040-2020-03-21)
 | 
					- [0.4.0 (2020-03-21)](#040-2020-03-21)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## 0.7.5 (2024-03-07)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Added
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  - Added emoji processing, for example `:eyes:`
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  - Added /react command, to add (emoji) reactions
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  - Added displaying of spoiler tags
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  - Added /spoiler and /unspoiler commands
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Changed
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  - Now using Mistune 3
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Fixed
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  - Fixed profile picture flickering in account settings UI
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  - Fixed overlaying tabs glitch in many UIs
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  - Fixed some issues when running on OpenBSD
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## 0.7.4 (2024-01-04)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Added
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  - Display emoji reactions on messages
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  - Display edited messages properly
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Changed
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  - Changed command line argument processing
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  - Changed server listing to use servers.joinmatrix.org
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  - Now using Mistune 2.0.2
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  - Now using upstream matrix-nio (instead of fork)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Removed
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  - Almost all UI animations were removed
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Fixed
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  - Fixed restoring from tray
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  - Updated installation instructions
 | 
				
			||||||
 | 
					
 | 
				
			||||||
## 0.7.3 (2022-01-31)
 | 
					## 0.7.3 (2022-01-31)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
### Added
 | 
					### Added
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -25,6 +25,7 @@ but compiling on Windows and macOS should be possible with the right tools.
 | 
				
			|||||||
  - [Common issues](#common-issues)
 | 
					  - [Common issues](#common-issues)
 | 
				
			||||||
    - [cffi version mismatch](#cffi-version-mismatch)
 | 
					    - [cffi version mismatch](#cffi-version-mismatch)
 | 
				
			||||||
    - [Type XYZ unavailable](#type-xyz-unavailable)
 | 
					    - [Type XYZ unavailable](#type-xyz-unavailable)
 | 
				
			||||||
 | 
					    - [libimagequant.so.0: cannot open shared object file: No such file or directory](#libimagequantso0-cannot-open-shared-object-file-no-such-file-or-directory)
 | 
				
			||||||
    
 | 
					    
 | 
				
			||||||
 | 
					
 | 
				
			||||||
## Packages
 | 
					## Packages
 | 
				
			||||||
@@ -379,3 +380,12 @@ sudo chmod 755 /usr/lib/qt5/qml/io/thp/pyotherside/*.so
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
Note that the Qt lib path might be `/usr/lib/qt/` instead of `/usr/lib/qt5/`,
 | 
					Note that the Qt lib path might be `/usr/lib/qt/` instead of `/usr/lib/qt5/`,
 | 
				
			||||||
depending on the distro.
 | 
					depending on the distro.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#### libimagequant.so.0: cannot open shared object file: No such file or directory
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Solution from [here](https://stackoverflow.com/questions/77499381/libimagequant-so-0-cannot-open-shared-object-file-no-such-file-or-directory) works.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					```sh
 | 
				
			||||||
 | 
					sudo ln /lib/libimagequant.so.0.4 /lib/libimagequant.so.0
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -23,6 +23,9 @@
 | 
				
			|||||||
  <provides>
 | 
					  <provides>
 | 
				
			||||||
    <binary>moment</binary>
 | 
					    <binary>moment</binary>
 | 
				
			||||||
  </provides>
 | 
					  </provides>
 | 
				
			||||||
 | 
					  <requires>
 | 
				
			||||||
 | 
					    <display_length compare="ge">360</display_length>
 | 
				
			||||||
 | 
					  </requires>
 | 
				
			||||||
  <project_license>LGPL-3.0-or-later</project_license>
 | 
					  <project_license>LGPL-3.0-or-later</project_license>
 | 
				
			||||||
  <screenshots>
 | 
					  <screenshots>
 | 
				
			||||||
    <screenshot type="default">
 | 
					    <screenshot type="default">
 | 
				
			||||||
@@ -56,6 +59,8 @@
 | 
				
			|||||||
  </screenshots>
 | 
					  </screenshots>
 | 
				
			||||||
  <metadata_license>FSFAP</metadata_license>
 | 
					  <metadata_license>FSFAP</metadata_license>
 | 
				
			||||||
  <releases>
 | 
					  <releases>
 | 
				
			||||||
 | 
					    <release version="0.7.5" date="2024-03-07"/>
 | 
				
			||||||
 | 
					    <release version="0.7.4" date="2024-01-04"/>
 | 
				
			||||||
    <release version="0.7.3" date="2022-01-31"/>
 | 
					    <release version="0.7.3" date="2022-01-31"/>
 | 
				
			||||||
    <release version="0.7.2" date="2021-07-26"/>
 | 
					    <release version="0.7.2" date="2021-07-26"/>
 | 
				
			||||||
    <release version="0.7.1" date="2021-03-04"/>
 | 
					    <release version="0.7.1" date="2021-03-04"/>
 | 
				
			||||||
@@ -73,8 +78,4 @@
 | 
				
			|||||||
    <release version="0.4.1" date="2020-03-23"/>
 | 
					    <release version="0.4.1" date="2020-03-23"/>
 | 
				
			||||||
    <release version="0.4.0" date="2020-03-21"/>
 | 
					    <release version="0.4.0" date="2020-03-21"/>
 | 
				
			||||||
  </releases>
 | 
					  </releases>
 | 
				
			||||||
  <custom>
 | 
					 | 
				
			||||||
    <value key="Purism::form_factor">workstation</value>
 | 
					 | 
				
			||||||
    <value key="Purism::form_factor">mobile</value>
 | 
					 | 
				
			||||||
  </custom>
 | 
					 | 
				
			||||||
</component>
 | 
					</component>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,15 +1,15 @@
 | 
				
			|||||||
remote_pdb >= 2.0.0,  < 3
 | 
					remote_pdb >= 2.0.0,  < 3
 | 
				
			||||||
pdbpp      >= 0.10.2, < 0.11
 | 
					pdbpp      >= 0.10.2, < 0.11
 | 
				
			||||||
devtools   >= 0.4.0,  < 0.5
 | 
					devtools   >= 0.12.0,  < 0.13
 | 
				
			||||||
 | 
					
 | 
				
			||||||
mypy                  >= 0.812,  < 0.900
 | 
					mypy                  >= 1.7.0,   < 1.8
 | 
				
			||||||
flake8                >= 3.8.4,  < 4
 | 
					flake8                >= 6.1.0,   < 7
 | 
				
			||||||
flake8-isort          >= 4.0.0,  < 5
 | 
					flake8-isort          >= 6.1.0,   < 7
 | 
				
			||||||
flake8-bugbear        >= 20.1.4, < 21
 | 
					flake8-bugbear        >= 23.12.0, < 24
 | 
				
			||||||
flake8-commas         >= 2.0.0,   < 3
 | 
					flake8-commas         >= 2.0.0,   < 3
 | 
				
			||||||
flake8-comprehensions >= 3.3.0,   < 4
 | 
					flake8-comprehensions >= 3.3.0,   < 4
 | 
				
			||||||
flake8-executable     >= 2.0.4,   < 3
 | 
					flake8-executable     >= 2.0.4,   < 3
 | 
				
			||||||
flake8-logging-format >= 0.6.0,  < 0.7
 | 
					flake8-logging-format >= 0.9.0,   < 1
 | 
				
			||||||
flake8-pie            >= 0.6.1,  < 0.7
 | 
					flake8-pie            >= 0.16.0,  < 1
 | 
				
			||||||
flake8-quotes         >= 3.2.0,   < 4
 | 
					flake8-quotes         >= 3.2.0,   < 4
 | 
				
			||||||
flake8-colors         >= 0.1.6,   < 0.2
 | 
					flake8-colors         >= 0.1.6,   < 0.2
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -2,11 +2,12 @@ Pillow            >= 7.0.0,  < 9
 | 
				
			|||||||
aiofiles          >= 0.4.0,  < 24.0.0
 | 
					aiofiles          >= 0.4.0,  < 24.0.0
 | 
				
			||||||
appdirs           >= 1.4.4,  < 2
 | 
					appdirs           >= 1.4.4,  < 2
 | 
				
			||||||
cairosvg          >= 2.4.2,  < 3
 | 
					cairosvg          >= 2.4.2,  < 3
 | 
				
			||||||
 | 
					emoji             >= 2.0,    < 3.0
 | 
				
			||||||
filetype          >= 1.0.7,  < 2
 | 
					filetype          >= 1.0.7,  < 2
 | 
				
			||||||
html_sanitizer    >= 1.9.1,  < 2
 | 
					html_sanitizer    >= 1.9.1,  < 3
 | 
				
			||||||
lxml              >= 4.5.1,  < 5
 | 
					lxml              >= 4.5.1,  < 6
 | 
				
			||||||
mistune           >= 2.0.0,  < 3.0
 | 
					mistune           >= 2.0.0,  < 4.0
 | 
				
			||||||
pymediainfo       >= 4.2.1,  < 5
 | 
					pymediainfo       >= 4.2.1,  < 7
 | 
				
			||||||
plyer             >= 1.4.3,  < 2
 | 
					plyer             >= 1.4.3,  < 2
 | 
				
			||||||
sortedcontainers  >= 2.2.2,  < 3
 | 
					sortedcontainers  >= 2.2.2,  < 3
 | 
				
			||||||
watchgod          >= 0.7,    < 0.8
 | 
					watchgod          >= 0.7,    < 0.8
 | 
				
			||||||
@@ -14,7 +15,7 @@ redbaron          >= 0.9.2,  < 1
 | 
				
			|||||||
hsluv             >= 5.0.0,  < 6
 | 
					hsluv             >= 5.0.0,  < 6
 | 
				
			||||||
simpleaudio       >= 1.0.4,  < 2
 | 
					simpleaudio       >= 1.0.4,  < 2
 | 
				
			||||||
dbus-python       >= 1.2.16, < 2; platform_system == "Linux"
 | 
					dbus-python       >= 1.2.16, < 2; platform_system == "Linux"
 | 
				
			||||||
matrix-nio[e2e]   >= 0.20.1, < 1.0.0
 | 
					matrix-nio[e2e]   >= 0.22.0, < 0.24
 | 
				
			||||||
 | 
					
 | 
				
			||||||
async_generator   >= 1.10,  < 2;   python_version < "3.7"
 | 
					async_generator   >= 1.10,  < 2;   python_version < "3.7"
 | 
				
			||||||
dataclasses       >= 0.6,   < 0.7; python_version < "3.7"
 | 
					dataclasses       >= 0.6,   < 0.7; python_version < "3.7"
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -16,4 +16,4 @@ documentation in the following modules first:
 | 
				
			|||||||
__app_name__	 = "moment"
 | 
					__app_name__	 = "moment"
 | 
				
			||||||
__display_name__ = "Moment"
 | 
					__display_name__ = "Moment"
 | 
				
			||||||
__reverse_dns__  = "xyz.mx-moment"
 | 
					__reverse_dns__  = "xyz.mx-moment"
 | 
				
			||||||
__version__      = "0.7.3"
 | 
					__version__      = "0.7.5"
 | 
				
			||||||
@@ -20,7 +20,16 @@ import aiohttp
 | 
				
			|||||||
import nio
 | 
					import nio
 | 
				
			||||||
import plyer
 | 
					import plyer
 | 
				
			||||||
import pyotherside
 | 
					import pyotherside
 | 
				
			||||||
import simpleaudio
 | 
					
 | 
				
			||||||
 | 
					has_simpleaudio = True
 | 
				
			||||||
 | 
					try:
 | 
				
			||||||
 | 
					    import simpleaudio
 | 
				
			||||||
 | 
					except ImportError as e:
 | 
				
			||||||
 | 
					    trace = traceback.format_exc().rstrip()
 | 
				
			||||||
 | 
					    log.error("Importing simpleaudio failed\n%s", trace)
 | 
				
			||||||
 | 
					    has_simpleaudio = False
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from appdirs import AppDirs
 | 
					from appdirs import AppDirs
 | 
				
			||||||
from nio.client.async_client import client_session
 | 
					from nio.client.async_client import client_session
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -533,7 +542,7 @@ class Backend:
 | 
				
			|||||||
			connector		= session.connector,
 | 
								connector		= session.connector,
 | 
				
			||||||
		)
 | 
							)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        api_list = "https://joinmatrix.org/servers.json"
 | 
							api_list = "https://servers.joinmatrix.org/servers.json"
 | 
				
			||||||
		try:
 | 
							try:
 | 
				
			||||||
			response = await session.get(api_list)
 | 
								response = await session.get(api_list)
 | 
				
			||||||
		except:
 | 
							except:
 | 
				
			||||||
@@ -543,17 +552,19 @@ class Backend:
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
		coros	= []
 | 
							coros	= []
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        for server in (await response.json()):
 | 
							for server in (await response.json())["public_servers"]:
 | 
				
			||||||
            homeserver_url = "https://" + server["domain"]
 | 
								homeserver_url = "https://" + server["client_domain"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            if not server["open"]: # ignore closed servers
 | 
								http_s_re  = re.compile("^https?://")
 | 
				
			||||||
                continue
 | 
								# remove https from homepage because it will be re-added later
 | 
				
			||||||
 | 
								site_url = (http_s_re.sub("", server["homepage"])
 | 
				
			||||||
 | 
											if "homepage" in server else server["client_domain"])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
			self.models["homeservers"][homeserver_url] = Homeserver(
 | 
								self.models["homeservers"][homeserver_url] = Homeserver(
 | 
				
			||||||
				id		   = homeserver_url,
 | 
									id		   = homeserver_url,
 | 
				
			||||||
				name		 = server["name"],
 | 
									name		 = server["name"],
 | 
				
			||||||
                site_url     = server["domain"],
 | 
									site_url	 = site_url,
 | 
				
			||||||
                country      = server["jurisdiction"],
 | 
									country	  = server["staff_jur"],
 | 
				
			||||||
				stability	= 0,
 | 
									stability	= 0,
 | 
				
			||||||
				downtimes_ms = 0,
 | 
									downtimes_ms = 0,
 | 
				
			||||||
				# austin's list doesn't have stability/downtime
 | 
									# austin's list doesn't have stability/downtime
 | 
				
			||||||
@@ -588,6 +599,13 @@ class Backend:
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	async def sound_notify(self) -> None:
 | 
						async def sound_notify(self) -> None:
 | 
				
			||||||
 | 
							
 | 
				
			||||||
 | 
							if not has_simpleaudio:
 | 
				
			||||||
 | 
								if self.audio_working:
 | 
				
			||||||
 | 
									log.error("Playing audio not supported as python-simpleaudio is not installed")
 | 
				
			||||||
 | 
									self.audio_working = False
 | 
				
			||||||
 | 
								return
 | 
				
			||||||
 | 
							
 | 
				
			||||||
		path = self.settings.Notifications.default_sound
 | 
							path = self.settings.Notifications.default_sound
 | 
				
			||||||
		path = str(Path(path).expanduser())
 | 
							path = str(Path(path).expanduser())
 | 
				
			||||||
		
 | 
							
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -10,6 +10,7 @@ from urllib.parse import unquote
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
import html_sanitizer.sanitizer as sanitizer
 | 
					import html_sanitizer.sanitizer as sanitizer
 | 
				
			||||||
import lxml.html  # nosec
 | 
					import lxml.html  # nosec
 | 
				
			||||||
 | 
					import emoji
 | 
				
			||||||
import mistune
 | 
					import mistune
 | 
				
			||||||
import nio
 | 
					import nio
 | 
				
			||||||
from html_sanitizer.sanitizer import Sanitizer
 | 
					from html_sanitizer.sanitizer import Sanitizer
 | 
				
			||||||
@@ -40,7 +41,10 @@ def plugin_matrix(md):
 | 
				
			|||||||
	# Mark colour as high priority as otherwise e.g. <red>(hi) matches the
 | 
						# Mark colour as high priority as otherwise e.g. <red>(hi) matches the
 | 
				
			||||||
	# inline_html rule instead of the colour rule.
 | 
						# inline_html rule instead of the colour rule.
 | 
				
			||||||
	md.inline.rules.insert(1, "colour")
 | 
						md.inline.rules.insert(1, "colour")
 | 
				
			||||||
 | 
						if mistune.__version__.startswith("2."):  # v2
 | 
				
			||||||
		md.inline.register_rule("colour", colour, parse_colour)
 | 
							md.inline.register_rule("colour", colour, parse_colour)
 | 
				
			||||||
 | 
						else:
 | 
				
			||||||
 | 
							md.inline.register("colour", colour, parse_colour)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	if md.renderer.NAME == "html":
 | 
						if md.renderer.NAME == "html":
 | 
				
			||||||
		md.renderer.register("colour", render_html_colour)
 | 
							md.renderer.register("colour", render_html_colour)
 | 
				
			||||||
@@ -215,6 +219,14 @@ class HTMLProcessor:
 | 
				
			|||||||
			if not outgoing:
 | 
								if not outgoing:
 | 
				
			||||||
				self._matrix_to_links_add_classes(a_tag)
 | 
									self._matrix_to_links_add_classes(a_tag)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							for node in tree.iterdescendants():
 | 
				
			||||||
 | 
								if node.tag != "code" and node.text:
 | 
				
			||||||
 | 
									node.text = emoji.emojize(
 | 
				
			||||||
 | 
										node.text, language="alias", variant="emoji_type")
 | 
				
			||||||
 | 
								if node.getparent() and node.getparent().tag != "code" and node.tail:
 | 
				
			||||||
 | 
									node.tail = emoji.emojize(
 | 
				
			||||||
 | 
										node.tail, language="alias", variant="emoji_type")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		html = etree.tostring(tree, encoding="utf-8", method="html").decode()
 | 
							html = etree.tostring(tree, encoding="utf-8", method="html").decode()
 | 
				
			||||||
		html = sanit.sanitize(html).rstrip("\n")
 | 
							html = sanit.sanitize(html).rstrip("\n")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -223,6 +235,27 @@ class HTMLProcessor:
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
		# Client-side modifications
 | 
							# Client-side modifications
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							# re-parsing, will sanitize again but allowing style
 | 
				
			||||||
 | 
							tree = etree.fromstring(
 | 
				
			||||||
 | 
								html, parser=etree.HTMLParser(encoding="utf-8"),
 | 
				
			||||||
 | 
							)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							for span_tag in tree.iterfind(".//span[@data-mx-spoiler]"):
 | 
				
			||||||
 | 
								# if there are sub-elements, their styles also need to be set or
 | 
				
			||||||
 | 
								# background-color doesn't seem to apply
 | 
				
			||||||
 | 
								for tag in span_tag.iter():
 | 
				
			||||||
 | 
									tag.set(
 | 
				
			||||||
 | 
										"style",
 | 
				
			||||||
 | 
										"color: black !important; background-color: black !important;"
 | 
				
			||||||
 | 
										+ (tag.get("style") or "")
 | 
				
			||||||
 | 
									)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							html = etree.tostring(tree, encoding="utf-8", method="html").decode()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							html = Sanitizer(self.sanitize_settings(
 | 
				
			||||||
 | 
								inline, outgoing, mentions, extra_attributes={"style"}
 | 
				
			||||||
 | 
							)).sanitize(html).rstrip("\n")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		html = self.quote_regex.sub(r'\1<span class="quote">\2</span>\3', html)
 | 
							html = self.quote_regex.sub(r'\1<span class="quote">\2</span>\3', html)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		if not inline:
 | 
							if not inline:
 | 
				
			||||||
@@ -238,6 +271,7 @@ class HTMLProcessor:
 | 
				
			|||||||
		inline:				bool					 = False,
 | 
							inline:				bool					 = False,
 | 
				
			||||||
		outgoing:			  bool					 = False,
 | 
							outgoing:			  bool					 = False,
 | 
				
			||||||
		display_name_mentions: Optional[Dict[str, str]] = None,
 | 
							display_name_mentions: Optional[Dict[str, str]] = None,
 | 
				
			||||||
 | 
							extra_attributes:	  set					  = set(),
 | 
				
			||||||
	) -> dict:
 | 
						) -> dict:
 | 
				
			||||||
		"""Return an html_sanitizer configuration."""
 | 
							"""Return an html_sanitizer configuration."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -250,16 +284,21 @@ class HTMLProcessor:
 | 
				
			|||||||
			"font": {"color"},
 | 
								"font": {"color"},
 | 
				
			||||||
			"a":	{"href", "class", "data-mention"},
 | 
								"a":	{"href", "class", "data-mention"},
 | 
				
			||||||
			"code": {"class"},
 | 
								"code": {"class"},
 | 
				
			||||||
 | 
								"span": {"data-mx-spoiler", "data-mx-color"},
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
		attributes = {**inlines_attributes, **{
 | 
							attributes = {**inlines_attributes, **{
 | 
				
			||||||
			"ol":   {"start"},
 | 
								"ol":   {"start"},
 | 
				
			||||||
			"hr":   {"width"},
 | 
								"hr":   {"width"},
 | 
				
			||||||
            "span": {"data-mx-color"},
 | 
					 | 
				
			||||||
			"img":  {
 | 
								"img":  {
 | 
				
			||||||
				"data-mx-emote", "src", "alt", "title", "width", "height",
 | 
									"data-mx-emote", "src", "alt", "title", "width", "height",
 | 
				
			||||||
			},
 | 
								},
 | 
				
			||||||
		}}
 | 
							}}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							for key in inlines_attributes:
 | 
				
			||||||
 | 
								inlines_attributes[key] |= extra_attributes
 | 
				
			||||||
 | 
							for key in attributes:
 | 
				
			||||||
 | 
								attributes[key] |= extra_attributes
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		username_link_regexes = [re.compile(r) for r in [
 | 
							username_link_regexes = [re.compile(r) for r in [
 | 
				
			||||||
			rf"(?<!\w)(?P<body>{re.escape(name or user_id)})(?!\w)(?P<host>)"
 | 
								rf"(?<!\w)(?P<body>{re.escape(name or user_id)})(?!\w)(?P<host>)"
 | 
				
			||||||
			for user_id, name in (display_name_mentions or {}).items()
 | 
								for user_id, name in (display_name_mentions or {}).items()
 | 
				
			||||||
@@ -268,11 +307,11 @@ class HTMLProcessor:
 | 
				
			|||||||
		return {
 | 
							return {
 | 
				
			||||||
			"tags": inline_tags if inline else all_tags,
 | 
								"tags": inline_tags if inline else all_tags,
 | 
				
			||||||
			"attributes": inlines_attributes if inline else attributes,
 | 
								"attributes": inlines_attributes if inline else attributes,
 | 
				
			||||||
            "empty": {} if inline else {"hr", "br", "img"},
 | 
								"empty": set() if inline else {"hr", "br", "img"},
 | 
				
			||||||
			"separate": {"a"} if inline else {
 | 
								"separate": {"a"} if inline else {
 | 
				
			||||||
				"a", "p", "li", "table", "tr", "th", "td", "br", "hr", "img",
 | 
									"a", "p", "li", "table", "tr", "th", "td", "br", "hr", "img",
 | 
				
			||||||
			},
 | 
								},
 | 
				
			||||||
            "whitespace": {},
 | 
								"whitespace": set(),
 | 
				
			||||||
			"keep_typographic_whitespace": True,
 | 
								"keep_typographic_whitespace": True,
 | 
				
			||||||
			"add_nofollow": False,
 | 
								"add_nofollow": False,
 | 
				
			||||||
			"autolink": {
 | 
								"autolink": {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -26,6 +26,7 @@ from urllib.parse import urlparse
 | 
				
			|||||||
from uuid import UUID, uuid4
 | 
					from uuid import UUID, uuid4
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import cairosvg
 | 
					import cairosvg
 | 
				
			||||||
 | 
					import emoji
 | 
				
			||||||
import nio
 | 
					import nio
 | 
				
			||||||
from nio.crypto import AsyncDataT as UploadData
 | 
					from nio.crypto import AsyncDataT as UploadData
 | 
				
			||||||
from nio.crypto import async_generator_from_data
 | 
					from nio.crypto import async_generator_from_data
 | 
				
			||||||
@@ -150,7 +151,6 @@ class MatrixClient(nio.AsyncClient):
 | 
				
			|||||||
					"m.call.*",
 | 
										"m.call.*",
 | 
				
			||||||
					"m.room.third_party_invite",
 | 
										"m.room.third_party_invite",
 | 
				
			||||||
					"m.room.tombstone",
 | 
										"m.room.tombstone",
 | 
				
			||||||
                    "m.reaction",
 | 
					 | 
				
			||||||
				],
 | 
									],
 | 
				
			||||||
			},
 | 
								},
 | 
				
			||||||
		},
 | 
							},
 | 
				
			||||||
@@ -178,6 +178,24 @@ class MatrixClient(nio.AsyncClient):
 | 
				
			|||||||
		if host in ("127.0.0.1", "localhost", "::1"):
 | 
							if host in ("127.0.0.1", "localhost", "::1"):
 | 
				
			||||||
			proxy = None
 | 
								proxy = None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							# self is passed in explicitly, so that it will also be passed to
 | 
				
			||||||
 | 
							# handlers in the user config
 | 
				
			||||||
 | 
							self.cmd_handler_map = {
 | 
				
			||||||
 | 
								"me ":		MatrixClient.handle_cmd_emote,
 | 
				
			||||||
 | 
								"react ":	 MatrixClient.handle_cmd_react,
 | 
				
			||||||
 | 
								"spoiler ":   MatrixClient.handle_cmd_spoiler,
 | 
				
			||||||
 | 
								"unspoiler":  MatrixClient.handle_cmd_unspoiler,
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							try:
 | 
				
			||||||
 | 
								self.cmd_handler_map = {
 | 
				
			||||||
 | 
									**self.cmd_handler_map,
 | 
				
			||||||
 | 
									**backend.settings.Commands.get_cmd_handler_map(),
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
							except (AttributeError, KeyError):
 | 
				
			||||||
 | 
								# make sure we don't break older configs
 | 
				
			||||||
 | 
								pass
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		super().__init__(
 | 
							super().__init__(
 | 
				
			||||||
			homeserver = homeserver,
 | 
								homeserver = homeserver,
 | 
				
			||||||
			user	   = user,
 | 
								user	   = user,
 | 
				
			||||||
@@ -225,14 +243,18 @@ class MatrixClient(nio.AsyncClient):
 | 
				
			|||||||
		self.unassigned_event_last_read_by: DefaultDict[str, Dict[str, int]] =\
 | 
							self.unassigned_event_last_read_by: DefaultDict[str, Dict[str, int]] =\
 | 
				
			||||||
			DefaultDict(dict)
 | 
								DefaultDict(dict)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							# {reacted_event_id: {emoji: [user_id]}}
 | 
				
			||||||
 | 
							self.unassigned_reaction_events: Dict[str, Dict[str, List[str]]] = {}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							# {replaced_event_id: [replace_event]}}
 | 
				
			||||||
 | 
							self.unassigned_replace_events: Dict[str, List[Dict[str, str]]] = {}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		self.push_rules:	   nio.PushRulesEvent = nio.PushRulesEvent()
 | 
							self.push_rules:	   nio.PushRulesEvent = nio.PushRulesEvent()
 | 
				
			||||||
		self.ignored_user_ids: Set[str]		   = set()
 | 
							self.ignored_user_ids: Set[str]		   = set()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		# {room_id: event}
 | 
							# {room_id: event}
 | 
				
			||||||
		self.power_level_events: Dict[str, nio.PowerLevelsEvent] = {}
 | 
							self.power_level_events: Dict[str, nio.PowerLevelsEvent] = {}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        self.invalid_disconnecting: bool = False
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		self.nio_callbacks = NioCallbacks(self)
 | 
							self.nio_callbacks = NioCallbacks(self)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -241,7 +263,6 @@ class MatrixClient(nio.AsyncClient):
 | 
				
			|||||||
			type(self).__name__, self.user_id, self.homeserver, self.device_id,
 | 
								type(self).__name__, self.user_id, self.homeserver, self.device_id,
 | 
				
			||||||
		)
 | 
							)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 | 
				
			||||||
	@property
 | 
						@property
 | 
				
			||||||
	def default_device_name(self) -> str:
 | 
						def default_device_name(self) -> str:
 | 
				
			||||||
		"""Device name to set at login if the user hasn't set a custom one."""
 | 
							"""Device name to set at login if the user hasn't set a custom one."""
 | 
				
			||||||
@@ -359,7 +380,7 @@ class MatrixClient(nio.AsyncClient):
 | 
				
			|||||||
					timeout = 10,
 | 
										timeout = 10,
 | 
				
			||||||
				)
 | 
									)
 | 
				
			||||||
			except asyncio.TimeoutError:
 | 
								except asyncio.TimeoutError:
 | 
				
			||||||
                log.warn("%s timed out", self.user_id)
 | 
									log.warning("%s timed out", self.user_id)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		await self.close()
 | 
							await self.close()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -377,7 +398,7 @@ class MatrixClient(nio.AsyncClient):
 | 
				
			|||||||
				account.max_upload_size = future.result() or 0
 | 
									account.max_upload_size = future.result() or 0
 | 
				
			||||||
			except MatrixError:
 | 
								except MatrixError:
 | 
				
			||||||
				trace = traceback.format_exc().rstrip()
 | 
									trace = traceback.format_exc().rstrip()
 | 
				
			||||||
                log.warn(
 | 
									log.warning(
 | 
				
			||||||
					"On %s server config retrieval: %s", self.user_id, trace,
 | 
										"On %s server config retrieval: %s", self.user_id, trace,
 | 
				
			||||||
				)
 | 
									)
 | 
				
			||||||
				self.server_config_task = asyncio.ensure_future(
 | 
									self.server_config_task = asyncio.ensure_future(
 | 
				
			||||||
@@ -603,7 +624,6 @@ class MatrixClient(nio.AsyncClient):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
		return {**self.invited_rooms, **self.rooms}
 | 
							return {**self.invited_rooms, **self.rooms}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 | 
				
			||||||
	async def send_text(
 | 
						async def send_text(
 | 
				
			||||||
		self,
 | 
							self,
 | 
				
			||||||
		room_id:			   str,
 | 
							room_id:			   str,
 | 
				
			||||||
@@ -611,22 +631,46 @@ class MatrixClient(nio.AsyncClient):
 | 
				
			|||||||
		display_name_mentions: Optional[Dict[str, str]] = None,  # {id: name}
 | 
							display_name_mentions: Optional[Dict[str, str]] = None,  # {id: name}
 | 
				
			||||||
		reply_to_event_id:	 Optional[str]			= None,
 | 
							reply_to_event_id:	 Optional[str]			= None,
 | 
				
			||||||
	) -> None:
 | 
						) -> None:
 | 
				
			||||||
        """Send a markdown `m.text` or `m.notice` (with `/me`) message ."""
 | 
							if text.startswith("//") or text.startswith(r"\/"):
 | 
				
			||||||
 | 
								await self._send_text(
 | 
				
			||||||
 | 
									room_id, text[1:], display_name_mentions, reply_to_event_id)
 | 
				
			||||||
 | 
							elif text.startswith("/"):
 | 
				
			||||||
 | 
								for k, v in self.cmd_handler_map.items():
 | 
				
			||||||
 | 
									if text.startswith("/" + k):
 | 
				
			||||||
 | 
										await v(self, room_id, text[len("/" + k):],
 | 
				
			||||||
 | 
												display_name_mentions, reply_to_event_id)
 | 
				
			||||||
 | 
										break
 | 
				
			||||||
 | 
								else:
 | 
				
			||||||
 | 
									await self.send_fake_notice(
 | 
				
			||||||
 | 
										room_id,
 | 
				
			||||||
 | 
										r"That command was not recognised. "
 | 
				
			||||||
 | 
										r"To send a message starting with /, use //",
 | 
				
			||||||
 | 
									)
 | 
				
			||||||
 | 
							else:
 | 
				
			||||||
 | 
								await self._send_text(
 | 
				
			||||||
 | 
									room_id, text, display_name_mentions, reply_to_event_id)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						async def _send_text(
 | 
				
			||||||
 | 
							self,
 | 
				
			||||||
 | 
							room_id:			   str,
 | 
				
			||||||
 | 
							text:				  str,
 | 
				
			||||||
 | 
							display_name_mentions: Optional[Dict[str, str]] = None,  # {id: name}
 | 
				
			||||||
 | 
							reply_to_event_id:	 Optional[str]			= None,
 | 
				
			||||||
 | 
							override_to_html:	  Optional[str]			= None,
 | 
				
			||||||
 | 
							override_echo_body:	Optional[str]			= None,
 | 
				
			||||||
 | 
							emote:				 bool					 = False,
 | 
				
			||||||
 | 
						) -> None:
 | 
				
			||||||
 | 
							"""Send a markdown `m.text` or `m.emote` message ."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		from_md = partial(
 | 
							from_md = partial(
 | 
				
			||||||
			HTML.from_markdown, display_name_mentions=display_name_mentions,
 | 
								HTML.from_markdown, display_name_mentions=display_name_mentions,
 | 
				
			||||||
		)
 | 
							)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        escape = False
 | 
					 | 
				
			||||||
        if text.startswith("//") or text.startswith(r"\/"):
 | 
					 | 
				
			||||||
            escape = True
 | 
					 | 
				
			||||||
            text   = text[1:]
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		content: Dict[str, Any]
 | 
							content: Dict[str, Any]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        if text.startswith("/me ") and not escape:
 | 
							if emote:
 | 
				
			||||||
			event_type = nio.RoomMessageEmote
 | 
								event_type = nio.RoomMessageEmote
 | 
				
			||||||
            text       = text[len("/me "):]
 | 
					 | 
				
			||||||
			content	= {"body": text, "msgtype": "m.emote"}
 | 
								content	= {"body": text, "msgtype": "m.emote"}
 | 
				
			||||||
			to_html	= from_md(text, inline=True, outgoing=True)
 | 
								to_html	= from_md(text, inline=True, outgoing=True)
 | 
				
			||||||
			echo_body  = from_md(text, inline=True)
 | 
								echo_body  = from_md(text, inline=True)
 | 
				
			||||||
@@ -636,6 +680,12 @@ class MatrixClient(nio.AsyncClient):
 | 
				
			|||||||
			to_html	= from_md(text, outgoing=True)
 | 
								to_html	= from_md(text, outgoing=True)
 | 
				
			||||||
			echo_body  = from_md(text)
 | 
								echo_body  = from_md(text)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							# override_echo_body will not be effective if it is a reply.
 | 
				
			||||||
 | 
							# echo_body is only shown before the event is received back from the
 | 
				
			||||||
 | 
							# server, so this is fine if not ideal
 | 
				
			||||||
 | 
							to_html   = override_to_html or to_html
 | 
				
			||||||
 | 
							echo_body = override_echo_body or echo_body
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		if to_html not in (html.escape(text), f"<p>{html.escape(text)}</p>"):
 | 
							if to_html not in (html.escape(text), f"<p>{html.escape(text)}</p>"):
 | 
				
			||||||
			content["format"]		 = "org.matrix.custom.html"
 | 
								content["format"]		 = "org.matrix.custom.html"
 | 
				
			||||||
			content["formatted_body"] = to_html
 | 
								content["formatted_body"] = to_html
 | 
				
			||||||
@@ -691,6 +741,171 @@ class MatrixClient(nio.AsyncClient):
 | 
				
			|||||||
		await self.pause_while_offline()
 | 
							await self.pause_while_offline()
 | 
				
			||||||
		await self._send_message(room_id, content, tx_id)
 | 
							await self._send_message(room_id, content, tx_id)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						async def handle_cmd_emote(
 | 
				
			||||||
 | 
							self,
 | 
				
			||||||
 | 
							room_id:				str,
 | 
				
			||||||
 | 
							text:				   str,
 | 
				
			||||||
 | 
							display_name_mentions:  Optional[Dict[str, str]]  = None,  # {id: name}
 | 
				
			||||||
 | 
							reply_to_event_id:	  Optional[str]			 = None,
 | 
				
			||||||
 | 
						):
 | 
				
			||||||
 | 
							await self._send_text(
 | 
				
			||||||
 | 
								room_id,
 | 
				
			||||||
 | 
								text,
 | 
				
			||||||
 | 
								display_name_mentions,
 | 
				
			||||||
 | 
								reply_to_event_id,
 | 
				
			||||||
 | 
								emote=True,
 | 
				
			||||||
 | 
							)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						async def handle_cmd_react(
 | 
				
			||||||
 | 
							self,
 | 
				
			||||||
 | 
							room_id:				str,
 | 
				
			||||||
 | 
							text:				   str,
 | 
				
			||||||
 | 
							display_name_mentions:  Optional[Dict[str, str]]  = None,  # {id: name}
 | 
				
			||||||
 | 
							reply_to_event_id:	  Optional[str]			 = None,
 | 
				
			||||||
 | 
						) -> None:
 | 
				
			||||||
 | 
							if reply_to_event_id is None or reply_to_event_id == "":
 | 
				
			||||||
 | 
								await self.send_fake_notice(
 | 
				
			||||||
 | 
									room_id, "Please reply to a message to react to it")
 | 
				
			||||||
 | 
							else:
 | 
				
			||||||
 | 
								reaction = emoji.emojize(
 | 
				
			||||||
 | 
									text, language="alias", variant="emoji_type")
 | 
				
			||||||
 | 
								await self.send_reaction(room_id, reaction, reply_to_event_id)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						async def handle_cmd_spoiler(
 | 
				
			||||||
 | 
							self,
 | 
				
			||||||
 | 
							room_id:				str,
 | 
				
			||||||
 | 
							text:				   str,
 | 
				
			||||||
 | 
							display_name_mentions:  Optional[Dict[str, str]]  = None,  # {id: name}
 | 
				
			||||||
 | 
							reply_to_event_id:	  Optional[str]			 = None,
 | 
				
			||||||
 | 
						) -> None:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							from_md = partial(
 | 
				
			||||||
 | 
								HTML.from_markdown, display_name_mentions=display_name_mentions,
 | 
				
			||||||
 | 
							)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							to_html	= from_md(text, outgoing=True)
 | 
				
			||||||
 | 
							echo_body  = from_md(text)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							if to_html.startswith("<p>") and to_html.endswith("</p>"):
 | 
				
			||||||
 | 
								# we want to make sure the <span> is inside the <p>, otherwise the
 | 
				
			||||||
 | 
								# black bar will be too wide
 | 
				
			||||||
 | 
								inner_html = to_html[len('<p>'):-len('</p>')]
 | 
				
			||||||
 | 
								to_html = f"<p><span data-mx-spoiler>{inner_html}</span></p>"
 | 
				
			||||||
 | 
							else:
 | 
				
			||||||
 | 
								to_html = f"<span data-mx-spoiler>{to_html}</span>"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							if echo_body.startswith("<p>") and echo_body.endswith("</p>"):
 | 
				
			||||||
 | 
								inner_html = echo_body[len('<p>'):-len('</p>')]
 | 
				
			||||||
 | 
								echo_body = f"<p><span data-mx-spoiler>{inner_html}</span></p>"
 | 
				
			||||||
 | 
							else:
 | 
				
			||||||
 | 
								echo_body = f"<span data-mx-spoiler>{echo_body}</span>"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							await self._send_text(
 | 
				
			||||||
 | 
								room_id,
 | 
				
			||||||
 | 
								text,
 | 
				
			||||||
 | 
								display_name_mentions,
 | 
				
			||||||
 | 
								reply_to_event_id,
 | 
				
			||||||
 | 
								override_to_html = to_html,
 | 
				
			||||||
 | 
								override_echo_body = echo_body,
 | 
				
			||||||
 | 
							)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						async def handle_cmd_unspoiler(
 | 
				
			||||||
 | 
							self,
 | 
				
			||||||
 | 
							room_id:				str,
 | 
				
			||||||
 | 
							text:				   str,
 | 
				
			||||||
 | 
							display_name_mentions:  Optional[Dict[str, str]]  = None,  # {id: name}
 | 
				
			||||||
 | 
							reply_to_event_id:	  Optional[str]			 = None,
 | 
				
			||||||
 | 
						) -> None:
 | 
				
			||||||
 | 
							if reply_to_event_id is None or reply_to_event_id == "":
 | 
				
			||||||
 | 
								await self.send_fake_notice(
 | 
				
			||||||
 | 
									room_id,
 | 
				
			||||||
 | 
									"Please reply to a message with /unspoiler to unspoiler it",
 | 
				
			||||||
 | 
								)
 | 
				
			||||||
 | 
							else:
 | 
				
			||||||
 | 
								spoiler_event: Event = \
 | 
				
			||||||
 | 
									self.models[self.user_id, room_id, "events"][reply_to_event_id]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								# get formatted_body, fallback to body,
 | 
				
			||||||
 | 
								spoiler = getattr(spoiler_event.source, "formatted_body", None) \
 | 
				
			||||||
 | 
									or getattr(spoiler_event.source, "body", "")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								unspoiler = re.sub(
 | 
				
			||||||
 | 
									r"<span[^>]+data-mx-spoiler[^>]*>(.*?)</?span>", r"\1",
 | 
				
			||||||
 | 
									spoiler,
 | 
				
			||||||
 | 
								)
 | 
				
			||||||
 | 
								await self.send_fake_notice(room_id, unspoiler)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						async def send_reaction(
 | 
				
			||||||
 | 
							self,
 | 
				
			||||||
 | 
							room_id:	str,
 | 
				
			||||||
 | 
							key:		str,
 | 
				
			||||||
 | 
							reacts_to:  str,
 | 
				
			||||||
 | 
						) -> None:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							# local event id in model isn't necessarily the actual event id
 | 
				
			||||||
 | 
							reacts_to_event = self.models[
 | 
				
			||||||
 | 
								self.user_id, room_id, "events"][reacts_to]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							reacts_to_event_id = reacts_to_event.event_id
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							if self.user_id in reacts_to_event.reactions.get(key, {}).get('users', []):
 | 
				
			||||||
 | 
								await self.send_fake_notice(
 | 
				
			||||||
 | 
									room_id,
 | 
				
			||||||
 | 
									"Can't send the same reaction more than once",
 | 
				
			||||||
 | 
								)
 | 
				
			||||||
 | 
								return
 | 
				
			||||||
 | 
							elif reacts_to_event_id.startswith("echo-"):
 | 
				
			||||||
 | 
								await self.send_fake_notice(
 | 
				
			||||||
 | 
									room_id,
 | 
				
			||||||
 | 
									"Can't react to that, it's not a real event",
 | 
				
			||||||
 | 
								)
 | 
				
			||||||
 | 
								return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							item_uuid = uuid4()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							content: Dict[str, Any] = {
 | 
				
			||||||
 | 
								"m.relates_to": {
 | 
				
			||||||
 | 
									"rel_type":  "m.annotation",
 | 
				
			||||||
 | 
									"event_id":  reacts_to_event_id,
 | 
				
			||||||
 | 
									"key":	   key,
 | 
				
			||||||
 | 
								},
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							tx_id = uuid4()
 | 
				
			||||||
 | 
							content[f"{__reverse_dns__}.transaction_id"] = str(tx_id)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							await self.pause_while_offline()
 | 
				
			||||||
 | 
							try:
 | 
				
			||||||
 | 
								await self._send_message(
 | 
				
			||||||
 | 
									room_id, content, item_uuid, message_type = "m.reaction")
 | 
				
			||||||
 | 
							except MatrixError as err:
 | 
				
			||||||
 | 
								if err.m_code == "M_DUPLICATE_ANNOTATION":
 | 
				
			||||||
 | 
									# potentially possible if the new reaction is
 | 
				
			||||||
 | 
									# sent before the existing reaction is loaded
 | 
				
			||||||
 | 
									await self.send_fake_notice(
 | 
				
			||||||
 | 
										room_id,
 | 
				
			||||||
 | 
										"Can't send the same reaction more than once",
 | 
				
			||||||
 | 
									)
 | 
				
			||||||
 | 
									return
 | 
				
			||||||
 | 
								if err.m_code == "M_UNKNOWN":
 | 
				
			||||||
 | 
									await self.send_fake_notice(
 | 
				
			||||||
 | 
										room_id,
 | 
				
			||||||
 | 
										"Failed to send reaction. Has the event you are reacting to fully sent yet?",
 | 
				
			||||||
 | 
									)
 | 
				
			||||||
 | 
								else:
 | 
				
			||||||
 | 
									raise err
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							# only update the UI after the reaction is sent, to not be misleading
 | 
				
			||||||
 | 
							await self._register_reaction(
 | 
				
			||||||
 | 
								self.all_rooms[room_id],
 | 
				
			||||||
 | 
								reacts_to_event_id,
 | 
				
			||||||
 | 
								key,
 | 
				
			||||||
 | 
								self.user_id,
 | 
				
			||||||
 | 
							)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	async def toggle_pause_transfer(
 | 
						async def toggle_pause_transfer(
 | 
				
			||||||
		self, room_id: str, uuid: Union[str, UUID],
 | 
							self, room_id: str, uuid: Union[str, UUID],
 | 
				
			||||||
@@ -742,7 +957,6 @@ class MatrixClient(nio.AsyncClient):
 | 
				
			|||||||
		reply_to_event_id: Optional[str] = None,
 | 
							reply_to_event_id: Optional[str] = None,
 | 
				
			||||||
	) -> None:
 | 
						) -> None:
 | 
				
			||||||
		"""Send a `m.file`, `m.image`, `m.audio` or `m.video` message.
 | 
							"""Send a `m.file`, `m.image`, `m.audio` or `m.video` message.
 | 
				
			||||||
 | 
					 | 
				
			||||||
		The Matrix client-server API states that media messages can't have a
 | 
							The Matrix client-server API states that media messages can't have a
 | 
				
			||||||
		reply attached.
 | 
							reply attached.
 | 
				
			||||||
		Thus, if a `reply_to_event_id` is passed, we send a pseudo-reply as two
 | 
							Thus, if a `reply_to_event_id` is passed, we send a pseudo-reply as two
 | 
				
			||||||
@@ -1016,6 +1230,7 @@ class MatrixClient(nio.AsyncClient):
 | 
				
			|||||||
		room_id:		str,
 | 
							room_id:		str,
 | 
				
			||||||
		transaction_id: UUID,
 | 
							transaction_id: UUID,
 | 
				
			||||||
		event_type:	 Type[nio.Event],
 | 
							event_type:	 Type[nio.Event],
 | 
				
			||||||
 | 
							fake_event:	 bool = False,
 | 
				
			||||||
		**event_fields,
 | 
							**event_fields,
 | 
				
			||||||
	) -> None:
 | 
						) -> None:
 | 
				
			||||||
		"""Register a local model `Event` while waiting for the server.
 | 
							"""Register a local model `Event` while waiting for the server.
 | 
				
			||||||
@@ -1043,13 +1258,13 @@ class MatrixClient(nio.AsyncClient):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
		event = Event(
 | 
							event = Event(
 | 
				
			||||||
			id			= f"echo-{transaction_id}",
 | 
								id			= f"echo-{transaction_id}",
 | 
				
			||||||
            event_id      = "",
 | 
								event_id	  = f"echo-{transaction_id}" if fake_event else "",
 | 
				
			||||||
			event_type	= event_type,
 | 
								event_type	= event_type,
 | 
				
			||||||
			date		  = datetime.now(),
 | 
								date		  = datetime.now(),
 | 
				
			||||||
			sender_id	 = self.user_id,
 | 
								sender_id	 = self.user_id,
 | 
				
			||||||
			sender_name   = our_info.display_name,
 | 
								sender_name   = our_info.display_name,
 | 
				
			||||||
			sender_avatar = our_info.avatar_url,
 | 
								sender_avatar = our_info.avatar_url,
 | 
				
			||||||
            is_local_echo = True,
 | 
								is_local_echo = not fake_event,
 | 
				
			||||||
			links		 = Event.parse_links(content),
 | 
								links		 = Event.parse_links(content),
 | 
				
			||||||
			**event_fields,
 | 
								**event_fields,
 | 
				
			||||||
		)
 | 
							)
 | 
				
			||||||
@@ -1061,9 +1276,18 @@ class MatrixClient(nio.AsyncClient):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
		await self.set_room_last_event(room_id, event)
 | 
							await self.set_room_last_event(room_id, event)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						async def send_fake_notice(self, room_id, msg):
 | 
				
			||||||
 | 
							await self._local_echo(
 | 
				
			||||||
 | 
								room_id,
 | 
				
			||||||
 | 
								uuid4(),
 | 
				
			||||||
 | 
								nio.RoomMessageNotice,
 | 
				
			||||||
 | 
								content = msg,
 | 
				
			||||||
 | 
								fake_event = True,
 | 
				
			||||||
 | 
							)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	async def _send_message(
 | 
						async def _send_message(
 | 
				
			||||||
		self, room_id: str, content: dict, transaction_id: UUID,
 | 
							self, room_id: str, content: dict, transaction_id: UUID,
 | 
				
			||||||
 | 
							message_type: str = "m.room.message",
 | 
				
			||||||
	) -> None:
 | 
						) -> None:
 | 
				
			||||||
		"""Send a message event with `content` dict to a room."""
 | 
							"""Send a message event with `content` dict to a room."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -1073,7 +1297,7 @@ class MatrixClient(nio.AsyncClient):
 | 
				
			|||||||
		async with self.backend.send_locks[room_id]:
 | 
							async with self.backend.send_locks[room_id]:
 | 
				
			||||||
			await self.room_send(
 | 
								await self.room_send(
 | 
				
			||||||
				room_id				   = room_id,
 | 
									room_id				   = room_id,
 | 
				
			||||||
                message_type              = "m.room.message",
 | 
									message_type			  = message_type,
 | 
				
			||||||
				content				   = content,
 | 
									content				   = content,
 | 
				
			||||||
				ignore_unverified_devices = True,
 | 
									ignore_unverified_devices = True,
 | 
				
			||||||
			)
 | 
								)
 | 
				
			||||||
@@ -1107,26 +1331,24 @@ class MatrixClient(nio.AsyncClient):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
		Returns whether there are any messages left to load.
 | 
							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 \
 | 
							if room_id in self.fully_loaded_rooms or \
 | 
				
			||||||
		   room_id in self.invited_rooms or \
 | 
							   room_id in self.invited_rooms or \
 | 
				
			||||||
		   room_id in self.cleared_events_rooms or \
 | 
							   room_id in self.cleared_events_rooms or \
 | 
				
			||||||
		   self.models[self.user_id, "rooms"][room_id].left:
 | 
							   self.models[self.user_id, "rooms"][room_id].left:
 | 
				
			||||||
			return False
 | 
								return False
 | 
				
			||||||
 | 
							log.info("waiting for first sync %s",room_id)
 | 
				
			||||||
		await self.first_sync_done.wait()
 | 
							await self.first_sync_done.wait()
 | 
				
			||||||
 | 
							log.info("waiting for past token %s",room_id)
 | 
				
			||||||
        while not self.past_tokens.get(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
 | 
								# If a new room was added, wait for onSyncResponse to set the token
 | 
				
			||||||
			await asyncio.sleep(0.1)
 | 
								await asyncio.sleep(0.1)
 | 
				
			||||||
 | 
								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.
 | 
				
			||||||
        response = await self.room_messages(
 | 
							log.info("performing actual fetch %s",room_id)
 | 
				
			||||||
            room_id        = 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)}
 | 
				
			||||||
            start          = self.past_tokens[room_id],
 | 
							response = await self.room_messages(**args)
 | 
				
			||||||
            limit          = 100 if room_id in self.loaded_once_rooms else 10,
 | 
							log.info("data returned %s",room_id)
 | 
				
			||||||
            message_filter = self.lazy_load_filter,
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		self.loaded_once_rooms.add(room_id)
 | 
							self.loaded_once_rooms.add(room_id)
 | 
				
			||||||
		more_to_load = True
 | 
							more_to_load = True
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -1575,6 +1797,9 @@ class MatrixClient(nio.AsyncClient):
 | 
				
			|||||||
				resp = await self.get_presence(user_id)
 | 
									resp = await self.get_presence(user_id)
 | 
				
			||||||
		except (MatrixForbidden, MatrixUnrecognized):
 | 
							except (MatrixForbidden, MatrixUnrecognized):
 | 
				
			||||||
			return
 | 
								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(
 | 
							await self.nio_callbacks.onPresenceEvent(nio.PresenceEvent(
 | 
				
			||||||
			user_id		  = resp.user_id,
 | 
								user_id		  = resp.user_id,
 | 
				
			||||||
@@ -1843,7 +2068,7 @@ class MatrixClient(nio.AsyncClient):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
		auth = {
 | 
							auth = {
 | 
				
			||||||
			"type":	 "m.login.password",
 | 
								"type":	 "m.login.password",
 | 
				
			||||||
            "user":     self.user_id,
 | 
								"identifier": {"type":"m.id.user", "user":self.user_id},
 | 
				
			||||||
			"password": password,
 | 
								"password": password,
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -2317,7 +2542,7 @@ class MatrixClient(nio.AsyncClient):
 | 
				
			|||||||
			ev.set_fields(target_name=target_name, target_avatar=target_avatar)
 | 
								ev.set_fields(target_name=target_name, target_avatar=target_avatar)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		if ev.redacter_id and not ev.redacter_name:
 | 
							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.redacter_name	= redacter_name
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		ev.fetch_profile = False
 | 
							ev.fetch_profile = False
 | 
				
			||||||
@@ -2421,6 +2646,122 @@ class MatrixClient(nio.AsyncClient):
 | 
				
			|||||||
		self.backend.notification_avatar_cache[mxc] = path
 | 
							self.backend.notification_avatar_cache[mxc] = path
 | 
				
			||||||
		return path
 | 
							return path
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						async def register_reaction(
 | 
				
			||||||
 | 
							self,
 | 
				
			||||||
 | 
							room:				   nio.MatrixRoom,
 | 
				
			||||||
 | 
							ev:					 nio.ReactionEvent,
 | 
				
			||||||
 | 
							event_id:			   str  = "",
 | 
				
			||||||
 | 
							**fields,
 | 
				
			||||||
 | 
						) -> None:
 | 
				
			||||||
 | 
							await self._register_reaction(room, ev.reacts_to, ev.key, ev.sender)
 | 
				
			||||||
 | 
							await self.register_nio_event(
 | 
				
			||||||
 | 
								room, ev, event_id, type_specifier=TypeSpecifier.Reaction,
 | 
				
			||||||
 | 
								content=ev.key, hidden=True, **fields,
 | 
				
			||||||
 | 
							)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						async def _register_reaction(
 | 
				
			||||||
 | 
							self,
 | 
				
			||||||
 | 
							room:	   nio.MatrixRoom,
 | 
				
			||||||
 | 
							reacts_to:  str,
 | 
				
			||||||
 | 
							key:		str,
 | 
				
			||||||
 | 
							sender:	 str,
 | 
				
			||||||
 | 
						) -> None:
 | 
				
			||||||
 | 
							"""Register/update a reaction."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							model = self.models[self.user_id, room.room_id, "events"]
 | 
				
			||||||
 | 
							reacts_to_event = model.get(reacts_to)
 | 
				
			||||||
 | 
							if not reacts_to_event:  # local echo
 | 
				
			||||||
 | 
								for item in model.values():
 | 
				
			||||||
 | 
									if item.event_id == reacts_to:
 | 
				
			||||||
 | 
										reacts_to_event = item
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							# message is already loaded: update reactions instantly
 | 
				
			||||||
 | 
							if reacts_to_event:
 | 
				
			||||||
 | 
								reactions = reacts_to_event.reactions
 | 
				
			||||||
 | 
								if key not in reactions:
 | 
				
			||||||
 | 
									reactions[key] = {
 | 
				
			||||||
 | 
										"hint": emoji.demojize(key, language="alias"), "users": []}
 | 
				
			||||||
 | 
								if sender not in reactions[key]["users"]:
 | 
				
			||||||
 | 
									reactions[key]["users"].append(sender)
 | 
				
			||||||
 | 
								reacts_to_event.set_fields(reactions=reactions)
 | 
				
			||||||
 | 
								reacts_to_event.notify_change("reactions")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							# message is not loaded yet: register the reaction for later update
 | 
				
			||||||
 | 
							else:
 | 
				
			||||||
 | 
								registry = self.unassigned_reaction_events
 | 
				
			||||||
 | 
								if reacts_to not in registry:
 | 
				
			||||||
 | 
									registry[reacts_to] = {}
 | 
				
			||||||
 | 
								if key not in registry[reacts_to]:
 | 
				
			||||||
 | 
									registry[reacts_to][key] = []
 | 
				
			||||||
 | 
								if sender not in registry[reacts_to][key]:
 | 
				
			||||||
 | 
									registry[reacts_to][key].append(sender)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						async def register_message_replacement(
 | 
				
			||||||
 | 
							self,
 | 
				
			||||||
 | 
							room:				   nio.MatrixRoom,
 | 
				
			||||||
 | 
							ev:					 Union[nio.Event, nio.BadEvent],
 | 
				
			||||||
 | 
							event_id:			   str			= "",
 | 
				
			||||||
 | 
							override_fetch_profile: Optional[bool] = None,
 | 
				
			||||||
 | 
							**fields,
 | 
				
			||||||
 | 
						) -> Event:
 | 
				
			||||||
 | 
							"""Register/update a message replacement."""
 | 
				
			||||||
 | 
							event_id = event_id or ev.event_id
 | 
				
			||||||
 | 
							relates_to = ev.source.get("content", {}).get("m.relates_to", {})
 | 
				
			||||||
 | 
							replaced_event_id = relates_to.get("event_id")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							model = self.models[self.user_id, room.room_id, "events"]
 | 
				
			||||||
 | 
							replaced_event = model.get(replaced_event_id)
 | 
				
			||||||
 | 
							if not replaced_event:  # local echo
 | 
				
			||||||
 | 
								for item in model.values():
 | 
				
			||||||
 | 
									if item.event_id == replaced_event_id:
 | 
				
			||||||
 | 
										replaced_event = item
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							# content
 | 
				
			||||||
 | 
							content = fields.get("content", "").strip()
 | 
				
			||||||
 | 
							inline_content = fields.get("inline_content", "").strip()
 | 
				
			||||||
 | 
							if content and "inline_content" not in fields:
 | 
				
			||||||
 | 
								inline_content = HTML.filter(content, inline=True)
 | 
				
			||||||
 | 
							content_history = {
 | 
				
			||||||
 | 
								"id": event_id,
 | 
				
			||||||
 | 
								"date": datetime.fromtimestamp(ev.server_timestamp / 1000),
 | 
				
			||||||
 | 
								"content": content,
 | 
				
			||||||
 | 
								"content_diff": "",
 | 
				
			||||||
 | 
								"inline_content": inline_content,
 | 
				
			||||||
 | 
								"body": ev.source.get("content", {})
 | 
				
			||||||
 | 
												 .get("m.new_content", {})
 | 
				
			||||||
 | 
												 .get("body") or inline_content,
 | 
				
			||||||
 | 
								"links": Event.parse_links(content),
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							# message is already loaded: update message instantly
 | 
				
			||||||
 | 
							if replaced_event:
 | 
				
			||||||
 | 
								history = replaced_event.content_history or []
 | 
				
			||||||
 | 
								if history:
 | 
				
			||||||
 | 
									content_history["content_diff"] = utils.diff_body(
 | 
				
			||||||
 | 
										history[-1]["content"], content_history["content"])
 | 
				
			||||||
 | 
								history.append(content_history)
 | 
				
			||||||
 | 
								replaced_event.set_fields(
 | 
				
			||||||
 | 
									replaced = True,
 | 
				
			||||||
 | 
									content = content,
 | 
				
			||||||
 | 
									inline_content = inline_content,
 | 
				
			||||||
 | 
									content_history = history,
 | 
				
			||||||
 | 
								)
 | 
				
			||||||
 | 
								replaced_event.source.body = content_history["body"]
 | 
				
			||||||
 | 
								replaced_event.notify_change(
 | 
				
			||||||
 | 
									"replaced", "content", "inline_content", "content_history")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							# message not loaded yet: register the replacement for later update
 | 
				
			||||||
 | 
							else:
 | 
				
			||||||
 | 
								if replaced_event_id not in self.unassigned_replace_events:
 | 
				
			||||||
 | 
									self.unassigned_replace_events[replaced_event_id] = []
 | 
				
			||||||
 | 
								self.unassigned_replace_events[replaced_event_id].append(
 | 
				
			||||||
 | 
									content_history)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							await self.register_nio_event(
 | 
				
			||||||
 | 
								room, ev, event_id, override_fetch_profile,
 | 
				
			||||||
 | 
								type_specifier=TypeSpecifier.MessageReplace,
 | 
				
			||||||
 | 
								hidden=True, **fields,
 | 
				
			||||||
 | 
							)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	async def register_nio_event(
 | 
						async def register_nio_event(
 | 
				
			||||||
		self,
 | 
							self,
 | 
				
			||||||
@@ -2484,6 +2825,15 @@ class MatrixClient(nio.AsyncClient):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
			**fields,
 | 
								**fields,
 | 
				
			||||||
		)
 | 
							)
 | 
				
			||||||
 | 
							item.content_history = [{
 | 
				
			||||||
 | 
								"id": item.id,
 | 
				
			||||||
 | 
								"date": item.date,
 | 
				
			||||||
 | 
								"content": item.content,
 | 
				
			||||||
 | 
								"content_diff": item.content,
 | 
				
			||||||
 | 
								"inline_content": item.inline_content,
 | 
				
			||||||
 | 
								"body": ev.source.get("content", {}).get("body", item.content),
 | 
				
			||||||
 | 
								"links": item.links,
 | 
				
			||||||
 | 
							}]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		# Add the Event to model
 | 
							# Add the Event to model
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -2498,6 +2848,33 @@ class MatrixClient(nio.AsyncClient):
 | 
				
			|||||||
			item.id							 = f"echo-{tx_id}"
 | 
								item.id							 = f"echo-{tx_id}"
 | 
				
			||||||
			self.event_to_echo_ids[ev.event_id] = item.id
 | 
								self.event_to_echo_ids[ev.event_id] = item.id
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							reactions = self.unassigned_reaction_events.get(item.id, {})
 | 
				
			||||||
 | 
							for key, senders in reactions.items():  # update reactions
 | 
				
			||||||
 | 
								if key not in item.reactions:
 | 
				
			||||||
 | 
									item.reactions[key] = {
 | 
				
			||||||
 | 
										"hint": emoji.demojize(key),
 | 
				
			||||||
 | 
										"users": [],
 | 
				
			||||||
 | 
									}
 | 
				
			||||||
 | 
								item.reactions[key]["users"] += senders
 | 
				
			||||||
 | 
							if ev.source.get("type") == "m.reaction" \
 | 
				
			||||||
 | 
									and ev.source.get("unsigned", {}).get("redacted_by"):
 | 
				
			||||||
 | 
								item.type_specifier = TypeSpecifier.ReactionRedaction
 | 
				
			||||||
 | 
								item.hidden = True
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							replace_events = self.unassigned_replace_events.get(item.id)
 | 
				
			||||||
 | 
							if replace_events:
 | 
				
			||||||
 | 
								item.replaced = True
 | 
				
			||||||
 | 
								item.content_history += sorted(
 | 
				
			||||||
 | 
									replace_events, key=lambda r: r["date"])
 | 
				
			||||||
 | 
								for index in range(1, len(item.content_history)):
 | 
				
			||||||
 | 
									item.content_history[index]["content_diff"] = utils.diff_body(
 | 
				
			||||||
 | 
										item.content_history[index - 1]["body"],
 | 
				
			||||||
 | 
										item.content_history[index]["body"])
 | 
				
			||||||
 | 
								item.content = item.content_history[-1]["content"]
 | 
				
			||||||
 | 
								item.inline_content = item.content_history[-1]["inline_content"]
 | 
				
			||||||
 | 
								item.source.body = item.content_history[-1]["body"]
 | 
				
			||||||
 | 
								del self.unassigned_replace_events[item.id]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		model[item.id] = item
 | 
							model[item.id] = item
 | 
				
			||||||
		await self.set_room_last_event(room.room_id, item)
 | 
							await self.set_room_last_event(room.room_id, item)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -2536,8 +2913,8 @@ class MatrixClient(nio.AsyncClient):
 | 
				
			|||||||
			)
 | 
								)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		sender   = item.sender_name or item.sender_id
 | 
							sender   = item.sender_name or item.sender_id
 | 
				
			||||||
        is_linux = platform.system() == "Linux"
 | 
							is_nux = platform.system() == "Linux" or "BSD" in platform.system()
 | 
				
			||||||
        use_html = is_linux and self.backend.settings.Notifications.use_html
 | 
							use_html = is_nux and self.backend.settings.Notifications.use_html
 | 
				
			||||||
		content  = item.inline_content if use_html else item.plain_content
 | 
							content  = item.inline_content if use_html else item.plain_content
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		if isinstance(ev, nio.RoomMessageEmote) and use_html:
 | 
							if isinstance(ev, nio.RoomMessageEmote) and use_html:
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -14,7 +14,7 @@ import lxml  # nosec
 | 
				
			|||||||
import nio
 | 
					import nio
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from ..presence import Presence
 | 
					from ..presence import Presence
 | 
				
			||||||
from ..utils import AutoStrEnum, auto, strip_html_tags
 | 
					from ..utils import AutoStrEnum, auto, strip_html_tags, serialize_value_for_qml
 | 
				
			||||||
from .model_item import ModelItem
 | 
					from .model_item import ModelItem
 | 
				
			||||||
 | 
					
 | 
				
			||||||
OptionalExceptionType = Union[Type[None], Type[Exception]]
 | 
					OptionalExceptionType = Union[Type[None], Type[Exception]]
 | 
				
			||||||
@@ -28,6 +28,9 @@ class TypeSpecifier(AutoStrEnum):
 | 
				
			|||||||
	Unset			= auto()
 | 
						Unset			= auto()
 | 
				
			||||||
	ProfileChange	= auto()
 | 
						ProfileChange	= auto()
 | 
				
			||||||
	MembershipChange = auto()
 | 
						MembershipChange = auto()
 | 
				
			||||||
 | 
						Reaction          = auto()
 | 
				
			||||||
 | 
						ReactionRedaction = auto()
 | 
				
			||||||
 | 
						MessageReplace    = auto()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class PingStatus(AutoStrEnum):
 | 
					class PingStatus(AutoStrEnum):
 | 
				
			||||||
@@ -349,6 +352,7 @@ class Event(ModelItem):
 | 
				
			|||||||
	sender_name:   str				 = field()
 | 
						sender_name:   str				 = field()
 | 
				
			||||||
	sender_avatar: str				 = field()
 | 
						sender_avatar: str				 = field()
 | 
				
			||||||
	fetch_profile: bool				= False
 | 
						fetch_profile: bool				= False
 | 
				
			||||||
 | 
						hidden:		bool				= False
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	content:		   str				   = ""
 | 
						content:		   str				   = ""
 | 
				
			||||||
	inline_content:	str				   = ""
 | 
						inline_content:	str				   = ""
 | 
				
			||||||
@@ -356,6 +360,9 @@ class Event(ModelItem):
 | 
				
			|||||||
	links:			 List[str]			 = field(default_factory=list)
 | 
						links:			 List[str]			 = field(default_factory=list)
 | 
				
			||||||
	mentions:		  List[Tuple[str, str]] = field(default_factory=list)
 | 
						mentions:		  List[Tuple[str, str]] = field(default_factory=list)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						reactions:		 Dict[str, Dict[str, Any]] = field(default_factory=dict)
 | 
				
			||||||
 | 
						replaced:		  bool				  = False
 | 
				
			||||||
 | 
						content_history:   List[Dict[str, Any]]  = field(default_factory=list)
 | 
				
			||||||
	type_specifier: TypeSpecifier = TypeSpecifier.Unset
 | 
						type_specifier: TypeSpecifier = TypeSpecifier.Unset
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	target_id:	 str = ""
 | 
						target_id:	 str = ""
 | 
				
			||||||
@@ -433,5 +440,6 @@ class Event(ModelItem):
 | 
				
			|||||||
		if field == "source":
 | 
							if field == "source":
 | 
				
			||||||
			source_dict = asdict(self.source) if self.source else {}
 | 
								source_dict = asdict(self.source) if self.source else {}
 | 
				
			||||||
			return json.dumps(source_dict)
 | 
								return json.dumps(source_dict)
 | 
				
			||||||
 | 
							if field == "content_history":
 | 
				
			||||||
 | 
								return serialize_value_for_qml(self.content_history)
 | 
				
			||||||
		return super().serialized_field(field)
 | 
							return super().serialized_field(field)
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -82,9 +82,11 @@ class NioCallbacks:
 | 
				
			|||||||
			await self.client.register_nio_room(self.client.all_rooms[room_id])
 | 
								await self.client.register_nio_room(self.client.all_rooms[room_id])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		for room_id, info in resp.rooms.join.items():
 | 
							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])
 | 
								await self.client.register_nio_room(self.client.rooms[room_id])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
			if room_id not in self.client.past_tokens:
 | 
								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
 | 
									self.client.past_tokens[room_id] = info.timeline.prev_batch
 | 
				
			||||||
 | 
					
 | 
				
			||||||
			for ev in info.state:
 | 
								for ev in info.state:
 | 
				
			||||||
@@ -617,7 +619,7 @@ class NioCallbacks:
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
		await self.client.register_nio_event(room, ev, content=co)
 | 
							await self.client.register_nio_event(room, ev, content=co)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						# Room events, invite events and misc events callbacks
 | 
				
			||||||
	async def onRoomNameEvent(
 | 
						async def onRoomNameEvent(
 | 
				
			||||||
		self, room: nio.MatrixRoom, ev: nio.RoomNameEvent,
 | 
							self, room: nio.MatrixRoom, ev: nio.RoomNameEvent,
 | 
				
			||||||
	) -> None:
 | 
						) -> None:
 | 
				
			||||||
@@ -628,6 +630,468 @@ class NioCallbacks:
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
		await self.client.register_nio_event(room, ev, content=co)
 | 
							await self.client.register_nio_event(room, ev, content=co)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						async def onRoomMessageText(
 | 
				
			||||||
 | 
							self, room: nio.MatrixRoom, ev: nio.RoomMessageText,
 | 
				
			||||||
 | 
						) -> None:
 | 
				
			||||||
 | 
							co = HTML_PROCESSOR.filter(
 | 
				
			||||||
 | 
								ev.formatted_body
 | 
				
			||||||
 | 
								if ev.format == "org.matrix.custom.html" else
 | 
				
			||||||
 | 
								plain2html(ev.body),
 | 
				
			||||||
 | 
							)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							mention_list = HTML_PROCESSOR.mentions_in_html(co)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							# message replacement
 | 
				
			||||||
 | 
							relates_to = ev.source.get("content", {}).get("m.relates_to", {})
 | 
				
			||||||
 | 
							if relates_to.get("rel_type") == "m.replace":
 | 
				
			||||||
 | 
								await self.client.register_message_replacement(
 | 
				
			||||||
 | 
									room, ev, content=co, mentions=mention_list,
 | 
				
			||||||
 | 
								)
 | 
				
			||||||
 | 
								return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							await self.client.register_nio_event(
 | 
				
			||||||
 | 
								room, ev, content=co, mentions=mention_list,
 | 
				
			||||||
 | 
							)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						async def onRoomMessageNotice(
 | 
				
			||||||
 | 
							self, room: nio.MatrixRoom, ev: nio.RoomMessageNotice,
 | 
				
			||||||
 | 
						) -> None:
 | 
				
			||||||
 | 
							await self.onRoomMessageText(room, ev)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						async def onRoomMessageEmote(
 | 
				
			||||||
 | 
							self, room: nio.MatrixRoom, ev: nio.RoomMessageEmote,
 | 
				
			||||||
 | 
						) -> None:
 | 
				
			||||||
 | 
							await self.onRoomMessageText(room, ev)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						async def onRoomMessageUnknown(
 | 
				
			||||||
 | 
							self, room: nio.MatrixRoom, ev: nio.RoomMessageUnknown,
 | 
				
			||||||
 | 
						) -> None:
 | 
				
			||||||
 | 
							co = f"%1 sent an unsupported <b>{escape(ev.msgtype)}</b> message"
 | 
				
			||||||
 | 
							await self.client.register_nio_event(room, ev, content=co)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						async def onRoomMessageMedia(
 | 
				
			||||||
 | 
							self, room: nio.MatrixRoom, ev: nio.RoomMessageMedia,
 | 
				
			||||||
 | 
						) -> None:
 | 
				
			||||||
 | 
							info			 = ev.source["content"].get("info", {})
 | 
				
			||||||
 | 
							media_crypt_dict = ev.source["content"].get("file", {})
 | 
				
			||||||
 | 
							thumb_info	   = info.get("thumbnail_info", {})
 | 
				
			||||||
 | 
							thumb_crypt_dict = info.get("thumbnail_file", {})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							try:
 | 
				
			||||||
 | 
								media_local_path: Union[Path, str] = await Media(
 | 
				
			||||||
 | 
									cache		  = self.client.backend.media_cache,
 | 
				
			||||||
 | 
									client_user_id = self.user_id,
 | 
				
			||||||
 | 
									mxc			= ev.url,
 | 
				
			||||||
 | 
									title		  = ev.body,
 | 
				
			||||||
 | 
									room_id		= room.room_id,
 | 
				
			||||||
 | 
									filesize	   = info.get("size") or 0,
 | 
				
			||||||
 | 
									crypt_dict	 = media_crypt_dict,
 | 
				
			||||||
 | 
								).get_local()
 | 
				
			||||||
 | 
							except FileNotFoundError:
 | 
				
			||||||
 | 
								media_local_path = ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							item = await self.client.register_nio_event(
 | 
				
			||||||
 | 
								room,
 | 
				
			||||||
 | 
								ev,
 | 
				
			||||||
 | 
								content		= "",
 | 
				
			||||||
 | 
								inline_content = ev.body,
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								media_url		= ev.url,
 | 
				
			||||||
 | 
								media_http_url   = await self.client.mxc_to_http(ev.url),
 | 
				
			||||||
 | 
								media_title	  = ev.body,
 | 
				
			||||||
 | 
								media_width	  = info.get("w") or 0,
 | 
				
			||||||
 | 
								media_height	 = info.get("h") or 0,
 | 
				
			||||||
 | 
								media_duration   = info.get("duration") or 0,
 | 
				
			||||||
 | 
								media_size	   = info.get("size") or 0,
 | 
				
			||||||
 | 
								media_mime	   = info.get("mimetype") or "",
 | 
				
			||||||
 | 
								media_crypt_dict = media_crypt_dict,
 | 
				
			||||||
 | 
								media_local_path = media_local_path,
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								thumbnail_url =
 | 
				
			||||||
 | 
									info.get("thumbnail_url") or thumb_crypt_dict.get("url") or "",
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								thumbnail_width	  = thumb_info.get("w") or 0,
 | 
				
			||||||
 | 
								thumbnail_height	 = thumb_info.get("h") or 0,
 | 
				
			||||||
 | 
								thumbnail_mime	   = thumb_info.get("mimetype") or "",
 | 
				
			||||||
 | 
								thumbnail_crypt_dict = thumb_crypt_dict,
 | 
				
			||||||
 | 
							)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							self.client.backend.mxc_events[ev.url].append(item)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						async def onRoomEncryptedMedia(
 | 
				
			||||||
 | 
							self, room: nio.MatrixRoom, ev: nio.RoomEncryptedMedia,
 | 
				
			||||||
 | 
						) -> None:
 | 
				
			||||||
 | 
							await self.onRoomMessageMedia(room, ev)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						async def onReactionEvent(
 | 
				
			||||||
 | 
							self, room: nio.MatrixRoom, ev: nio.ReactionEvent,
 | 
				
			||||||
 | 
						) -> None:
 | 
				
			||||||
 | 
							await self.client.register_reaction(room, ev, ev.event_id)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						async def onRedactionEvent(
 | 
				
			||||||
 | 
							self, room: nio.MatrixRoom, ev: nio.RedactionEvent,
 | 
				
			||||||
 | 
						) -> None:
 | 
				
			||||||
 | 
							model = self.models[self.user_id, room.room_id, "events"]
 | 
				
			||||||
 | 
							event = None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							for existing in model._sorted_data:
 | 
				
			||||||
 | 
								if existing.event_id == ev.redacts:
 | 
				
			||||||
 | 
									event = existing
 | 
				
			||||||
 | 
									break
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							if not (
 | 
				
			||||||
 | 
								event and
 | 
				
			||||||
 | 
								(event.event_type is not nio.RedactedEvent or event.is_local_echo)
 | 
				
			||||||
 | 
							):
 | 
				
			||||||
 | 
								await self.client.register_nio_room(room)
 | 
				
			||||||
 | 
								return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							event_type = event.source.source.get("type")
 | 
				
			||||||
 | 
							if not event_type == "m.reaction":
 | 
				
			||||||
 | 
								event.source.source["content"]  = {}
 | 
				
			||||||
 | 
							event.source.source["unsigned"] = {
 | 
				
			||||||
 | 
								"redacted_by":	  ev.event_id,
 | 
				
			||||||
 | 
								"redacted_because": ev.source,
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							# Remove reactions
 | 
				
			||||||
 | 
							if event_type == "m.reaction":
 | 
				
			||||||
 | 
								relates_to = event.source.source.get(
 | 
				
			||||||
 | 
									"content", {}).get("m.relates_to", {})
 | 
				
			||||||
 | 
								reacted_event_id = relates_to.get("event_id")
 | 
				
			||||||
 | 
								reacted_event = model.get(reacted_event_id)
 | 
				
			||||||
 | 
								key = relates_to.get("key")
 | 
				
			||||||
 | 
								sender = ev.source.get("sender")
 | 
				
			||||||
 | 
								# Remove reactions from registry
 | 
				
			||||||
 | 
								reg = self.client.unassigned_reaction_events.get(
 | 
				
			||||||
 | 
									reacted_event_id)
 | 
				
			||||||
 | 
								if reg and key in reg and sender in reg[key]:
 | 
				
			||||||
 | 
									reg[key].remove(sender)
 | 
				
			||||||
 | 
								# Remove reactions from loaded messages
 | 
				
			||||||
 | 
								if reacted_event and key in reacted_event.reactions:
 | 
				
			||||||
 | 
									if sender in reacted_event.reactions[key]:
 | 
				
			||||||
 | 
										reacted_event.reactions[key].remove(sender)
 | 
				
			||||||
 | 
									if not reacted_event.reactions[key]:
 | 
				
			||||||
 | 
										del reacted_event.reactions[key]
 | 
				
			||||||
 | 
									reacted_event.notify_change('reactions')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							await self.onRedactedEvent(
 | 
				
			||||||
 | 
								room,
 | 
				
			||||||
 | 
								nio.RedactedEvent.from_dict(event.source.source),
 | 
				
			||||||
 | 
								event_id = event.id,
 | 
				
			||||||
 | 
							)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						async def onRedactedEvent(
 | 
				
			||||||
 | 
							self, room: nio.MatrixRoom, ev: nio.RedactedEvent, event_id: str = "",
 | 
				
			||||||
 | 
						) -> None:
 | 
				
			||||||
 | 
							redacter_name, _, must_fetch_redacter = \
 | 
				
			||||||
 | 
								await self.client.get_member_profile(room.room_id, ev.redacter) \
 | 
				
			||||||
 | 
								if ev.redacter else ("", "", False)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							await self.client.register_nio_event(
 | 
				
			||||||
 | 
								room,
 | 
				
			||||||
 | 
								ev,
 | 
				
			||||||
 | 
								event_id = event_id,
 | 
				
			||||||
 | 
								reason   = ev.reason or "",
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								content = await self.client.get_redacted_event_content(
 | 
				
			||||||
 | 
									type(ev), ev.redacter, ev.sender, ev.reason,
 | 
				
			||||||
 | 
								),
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								mentions			   = [],
 | 
				
			||||||
 | 
								type_specifier		 = TypeSpecifier.Unset,
 | 
				
			||||||
 | 
								media_url			  = "",
 | 
				
			||||||
 | 
								media_http_url		 = "",
 | 
				
			||||||
 | 
								media_title			= "",
 | 
				
			||||||
 | 
								media_local_path	   = "",
 | 
				
			||||||
 | 
								thumbnail_url		  = "",
 | 
				
			||||||
 | 
								redacter_id			= ev.redacter or "",
 | 
				
			||||||
 | 
								redacter_name		  = redacter_name,
 | 
				
			||||||
 | 
								override_fetch_profile = True,
 | 
				
			||||||
 | 
							)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						async def onRoomCreateEvent(
 | 
				
			||||||
 | 
							self, room: nio.MatrixRoom, ev: nio.RoomCreateEvent,
 | 
				
			||||||
 | 
						) -> None:
 | 
				
			||||||
 | 
							co = "%1 allowed users on other matrix servers to join this room" \
 | 
				
			||||||
 | 
								 if ev.federate else \
 | 
				
			||||||
 | 
								 "%1 blocked users on other matrix servers from joining this room"
 | 
				
			||||||
 | 
							await self.client.register_nio_event(room, ev, content=co)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						async def onRoomGuestAccessEvent(
 | 
				
			||||||
 | 
							self, room: nio.MatrixRoom, ev: nio.RoomGuestAccessEvent,
 | 
				
			||||||
 | 
						) -> None:
 | 
				
			||||||
 | 
							allowed = "allowed" if ev.guest_access == "can_join" else "forbad"
 | 
				
			||||||
 | 
							co	  = f"%1 {allowed} guests to join the room"
 | 
				
			||||||
 | 
							await self.client.register_nio_event(room, ev, content=co)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						async def onRoomJoinRulesEvent(
 | 
				
			||||||
 | 
							self, room: nio.MatrixRoom, ev: nio.RoomJoinRulesEvent,
 | 
				
			||||||
 | 
						) -> None:
 | 
				
			||||||
 | 
							access = "public" if ev.join_rule == "public" else "invite-only"
 | 
				
			||||||
 | 
							co	 = f"%1 made the room {access}"
 | 
				
			||||||
 | 
							await self.client.register_nio_event(room, ev, content=co)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						async def onRoomHistoryVisibilityEvent(
 | 
				
			||||||
 | 
							self, room: nio.MatrixRoom, ev: nio.RoomHistoryVisibilityEvent,
 | 
				
			||||||
 | 
						) -> None:
 | 
				
			||||||
 | 
							if ev.history_visibility == "shared":
 | 
				
			||||||
 | 
								to = "all room members"
 | 
				
			||||||
 | 
							elif ev.history_visibility == "world_readable":
 | 
				
			||||||
 | 
								to = "any member or outsider"
 | 
				
			||||||
 | 
							elif ev.history_visibility == "joined":
 | 
				
			||||||
 | 
								to = "all room members, since the time they joined"
 | 
				
			||||||
 | 
							elif ev.history_visibility == "invited":
 | 
				
			||||||
 | 
								to = "all room members, since the time they were invited"
 | 
				
			||||||
 | 
							else:
 | 
				
			||||||
 | 
								to = "???"
 | 
				
			||||||
 | 
								log.warning("Invalid visibility - %s",
 | 
				
			||||||
 | 
											json.dumps(vars(ev), indent=4))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							co = f"%1 made future room history visible to {to}"
 | 
				
			||||||
 | 
							await self.client.register_nio_event(room, ev, content=co)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						async def onPowerLevelsEvent(
 | 
				
			||||||
 | 
							self, room: nio.MatrixRoom, ev: nio.PowerLevelsEvent,
 | 
				
			||||||
 | 
						) -> None:
 | 
				
			||||||
 | 
							levels = ev.power_levels
 | 
				
			||||||
 | 
							stored = self.client.power_level_events.get(room.room_id)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							if not stored or ev.server_timestamp > stored.server_timestamp:
 | 
				
			||||||
 | 
								self.client.power_level_events[room.room_id] = ev
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							try:
 | 
				
			||||||
 | 
								previous = ev.source["unsigned"]["prev_content"]
 | 
				
			||||||
 | 
							except KeyError:
 | 
				
			||||||
 | 
								previous = {}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							users_previous  = previous.get("users", {})
 | 
				
			||||||
 | 
							events_previous = previous.get("events", {})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							changes:	   List[Tuple[str, int, int]] = []
 | 
				
			||||||
 | 
							event_changes: List[Tuple[str, int, int]] = []
 | 
				
			||||||
 | 
							user_changes:  List[Tuple[str, int, int]] = []
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							def lvl(level: int) -> str:
 | 
				
			||||||
 | 
								return (
 | 
				
			||||||
 | 
									f"Admin ({level})" if level == 100 else
 | 
				
			||||||
 | 
									f"Moderator ({level})" if level >= 50 else
 | 
				
			||||||
 | 
									f"User ({level})" if level >= 0 else
 | 
				
			||||||
 | 
									f"Muted ({level})"
 | 
				
			||||||
 | 
								)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							def format_defaults_dict(
 | 
				
			||||||
 | 
								levels:   Dict[str, Union[int, dict]],
 | 
				
			||||||
 | 
								previous: Dict[str, Union[int, dict]],
 | 
				
			||||||
 | 
								prefix:   str			 = "",
 | 
				
			||||||
 | 
							) -> None:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								default_0 = ("users_default", "events_default", "invite")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								for name in set({**levels, **previous}):
 | 
				
			||||||
 | 
									if not prefix and name in ("users", "events"):
 | 
				
			||||||
 | 
										continue
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
									old_level = previous.get(
 | 
				
			||||||
 | 
										name, 0 if not prefix and name in default_0 else 50,
 | 
				
			||||||
 | 
									)
 | 
				
			||||||
 | 
									level = levels.get(
 | 
				
			||||||
 | 
										name, 0 if not prefix and name in default_0 else 50,
 | 
				
			||||||
 | 
									)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
									if isinstance(level, dict):
 | 
				
			||||||
 | 
										if not isinstance(old_level, dict):
 | 
				
			||||||
 | 
											old_level = {}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
										format_defaults_dict(level, old_level, f"{prefix}{name}.")
 | 
				
			||||||
 | 
										continue
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
									if not isinstance(old_level, int):
 | 
				
			||||||
 | 
										old_level = 50
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
									if old_level != level or not previous:
 | 
				
			||||||
 | 
										changes.append((f"{prefix}{name}", old_level, level))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							format_defaults_dict(ev.source["content"], previous)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							# Minimum level to send event changes
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							for ev_type in set({**levels.events, **events_previous}):
 | 
				
			||||||
 | 
								old_level = events_previous.get(
 | 
				
			||||||
 | 
									ev_type,
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
									levels.defaults.state_default
 | 
				
			||||||
 | 
									if ev_type.startswith("m.room.") else
 | 
				
			||||||
 | 
									levels.defaults.events_default,
 | 
				
			||||||
 | 
								)
 | 
				
			||||||
 | 
								level = levels.events.get(
 | 
				
			||||||
 | 
									ev_type,
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
									levels.defaults.state_default
 | 
				
			||||||
 | 
									if ev_type.startswith("m.room.") else
 | 
				
			||||||
 | 
									levels.defaults.events_default,
 | 
				
			||||||
 | 
								)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								if old_level != level or not previous:
 | 
				
			||||||
 | 
									event_changes.append((ev_type, old_level, level))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							# User level changes
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							for user_id in set({**levels.users, **users_previous}):
 | 
				
			||||||
 | 
								old_level = \
 | 
				
			||||||
 | 
									users_previous.get(user_id, levels.defaults.users_default)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								level = levels.users.get(user_id, levels.defaults.users_default)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								if old_level != level or not previous:
 | 
				
			||||||
 | 
									user_changes.append((user_id, old_level, level))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
									if user_id in room.users:
 | 
				
			||||||
 | 
										await self.client.add_member(room, user_id)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							# Gather and format changes
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							if changes or event_changes or user_changes:
 | 
				
			||||||
 | 
								changes.sort(key=lambda c: (c[2], c[0]))
 | 
				
			||||||
 | 
								event_changes.sort(key=lambda c: (c[2], c[0]))
 | 
				
			||||||
 | 
								user_changes.sort(key=lambda c: (c[2], c[0]))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								all_changes = changes + event_changes + user_changes
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								if len(all_changes) == 1:
 | 
				
			||||||
 | 
									co = HTML_PROCESSOR.from_markdown(
 | 
				
			||||||
 | 
										"%%1 changed the level for **%s**: %s → %s " % (
 | 
				
			||||||
 | 
											all_changes[0][0],
 | 
				
			||||||
 | 
											lvl(all_changes[0][1]).lower(),
 | 
				
			||||||
 | 
											lvl(all_changes[0][2]).lower(),
 | 
				
			||||||
 | 
										),
 | 
				
			||||||
 | 
										inline = True,
 | 
				
			||||||
 | 
									)
 | 
				
			||||||
 | 
								else:
 | 
				
			||||||
 | 
									co = HTML_PROCESSOR.from_markdown("\n".join([
 | 
				
			||||||
 | 
										"%1 changed the room's permissions",
 | 
				
			||||||
 | 
										"",
 | 
				
			||||||
 | 
										"Change | Previous | Current ",
 | 
				
			||||||
 | 
										"--- | --- | ---",
 | 
				
			||||||
 | 
										*[
 | 
				
			||||||
 | 
											f"{name} | {lvl(old)} | {lvl(now)}"
 | 
				
			||||||
 | 
											for name, old, now in all_changes
 | 
				
			||||||
 | 
										],
 | 
				
			||||||
 | 
									]))
 | 
				
			||||||
 | 
							else:
 | 
				
			||||||
 | 
								co = "%1 didn't change the room's permissions"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							await self.client.register_nio_event(room, ev, content=co)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						async def process_room_member_event(
 | 
				
			||||||
 | 
							self, room: nio.MatrixRoom, ev: nio.RoomMemberEvent,
 | 
				
			||||||
 | 
						) -> Optional[Tuple[TypeSpecifier, str]]:
 | 
				
			||||||
 | 
							"""Return a `TypeSpecifier` and string describing a member event.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							Matrix member events can represent many actions:
 | 
				
			||||||
 | 
							a user joined the room, a user banned another, a user changed their
 | 
				
			||||||
 | 
							display name, etc.
 | 
				
			||||||
 | 
							"""
 | 
				
			||||||
 | 
							if ev.prev_content == ev.content:
 | 
				
			||||||
 | 
								return None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							prev			= ev.prev_content
 | 
				
			||||||
 | 
							now			 = ev.content
 | 
				
			||||||
 | 
							membership	  = ev.membership
 | 
				
			||||||
 | 
							prev_membership = ev.prev_membership
 | 
				
			||||||
 | 
							ev_date		 = datetime.fromtimestamp(ev.server_timestamp / 1000)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							member_change = TypeSpecifier.MembershipChange
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							# Membership changes
 | 
				
			||||||
 | 
							if not prev or membership != prev_membership:
 | 
				
			||||||
 | 
								if not self.client.backend.settings.Chat.show_membership_events:
 | 
				
			||||||
 | 
									return None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								reason = escape(
 | 
				
			||||||
 | 
									f", reason: {now['reason']}" if now.get("reason") else "",
 | 
				
			||||||
 | 
								)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								if membership == "join":
 | 
				
			||||||
 | 
									return (
 | 
				
			||||||
 | 
										member_change,
 | 
				
			||||||
 | 
										"%1 accepted their invitation"
 | 
				
			||||||
 | 
										if prev and prev_membership == "invite" else
 | 
				
			||||||
 | 
										"%1 joined the room",
 | 
				
			||||||
 | 
									)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								if membership == "invite":
 | 
				
			||||||
 | 
									return (member_change, "%1 invited %2 to the room")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								if membership == "leave":
 | 
				
			||||||
 | 
									if ev.state_key == ev.sender:
 | 
				
			||||||
 | 
										return (
 | 
				
			||||||
 | 
											member_change,
 | 
				
			||||||
 | 
											f"%1 declined their invitation{reason}"
 | 
				
			||||||
 | 
											if prev and prev_membership == "invite" else
 | 
				
			||||||
 | 
											f"%1 left the room{reason}",
 | 
				
			||||||
 | 
										)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
									return (
 | 
				
			||||||
 | 
										member_change,
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
										f"%1 withdrew %2's invitation{reason}"
 | 
				
			||||||
 | 
										if prev and prev_membership == "invite" else
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
										f"%1 unbanned %2 from the room{reason}"
 | 
				
			||||||
 | 
										if prev and prev_membership == "ban" else
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
										f"%1 kicked %2 out from the room{reason}",
 | 
				
			||||||
 | 
									)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								if membership == "ban":
 | 
				
			||||||
 | 
									return (member_change, f"%1 banned %2 from the room{reason}")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							# Profile changes
 | 
				
			||||||
 | 
							changed = []
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							if prev and now.get("avatar_url") != prev.get("avatar_url"):
 | 
				
			||||||
 | 
								changed.append("profile picture")  # TODO: <img>s
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							if prev and now.get("displayname") != prev.get("displayname"):
 | 
				
			||||||
 | 
								changed.append('display name from "{}" to "{}"'.format(
 | 
				
			||||||
 | 
									escape(prev.get("displayname") or ev.state_key),
 | 
				
			||||||
 | 
									escape(now.get("displayname") or ev.state_key),
 | 
				
			||||||
 | 
								))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							if changed:
 | 
				
			||||||
 | 
								# Update our account profile if the event is newer than last update
 | 
				
			||||||
 | 
								if ev.state_key == self.user_id:
 | 
				
			||||||
 | 
									account = self.models["accounts"][self.user_id]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
									if account.profile_updated < ev_date:
 | 
				
			||||||
 | 
										account.set_fields(
 | 
				
			||||||
 | 
											profile_updated = ev_date,
 | 
				
			||||||
 | 
											display_name	= now.get("displayname") or "",
 | 
				
			||||||
 | 
											avatar_url	  = now.get("avatar_url") or "",
 | 
				
			||||||
 | 
										)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								if not self.client.backend.settings.Chat.show_profile_changes:
 | 
				
			||||||
 | 
									return None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								return (
 | 
				
			||||||
 | 
									TypeSpecifier.ProfileChange,
 | 
				
			||||||
 | 
									"%1 changed their {}".format(" and ".join(changed)),
 | 
				
			||||||
 | 
								)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	async def onRoomAvatarEvent(
 | 
						async def onRoomAvatarEvent(
 | 
				
			||||||
		self, room: nio.MatrixRoom, ev: nio.RoomAvatarEvent,
 | 
							self, room: nio.MatrixRoom, ev: nio.RoomAvatarEvent,
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -14,6 +14,7 @@ import xml.etree.cElementTree as xml_etree
 | 
				
			|||||||
from concurrent.futures import ProcessPoolExecutor
 | 
					from concurrent.futures import ProcessPoolExecutor
 | 
				
			||||||
from contextlib import suppress
 | 
					from contextlib import suppress
 | 
				
			||||||
from datetime import date, datetime, time, timedelta
 | 
					from datetime import date, datetime, time, timedelta
 | 
				
			||||||
 | 
					from difflib import SequenceMatcher
 | 
				
			||||||
from enum import Enum
 | 
					from enum import Enum
 | 
				
			||||||
from enum import auto as autostr
 | 
					from enum import auto as autostr
 | 
				
			||||||
from pathlib import Path
 | 
					from pathlib import Path
 | 
				
			||||||
@@ -206,6 +207,28 @@ def strip_html_tags(text: str) -> str:
 | 
				
			|||||||
	return re.sub(r"<\/?[^>]+(>|$)", "", text)
 | 
						return re.sub(r"<\/?[^>]+(>|$)", "", text)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def remove_reply(text: str):
 | 
				
			||||||
 | 
					    return re.sub(r"<mx-reply.*?>.*?<\/mx-reply>", "", text)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def diff_body(a: str, b: str):
 | 
				
			||||||
 | 
					    sm = SequenceMatcher(None, remove_reply(a), remove_reply(b))
 | 
				
			||||||
 | 
					    output = []
 | 
				
			||||||
 | 
					    for opcode, a0, a1, b0, b1 in sm.get_opcodes():
 | 
				
			||||||
 | 
					        if opcode == "equal":
 | 
				
			||||||
 | 
					            output.append(sm.a[a0:a1])
 | 
				
			||||||
 | 
					        elif opcode == "insert":
 | 
				
			||||||
 | 
					            output.append(f"<ins>{sm.b[b0:b1]}</ins>")
 | 
				
			||||||
 | 
					        elif opcode == "delete":
 | 
				
			||||||
 | 
					            output.append(f"<del>{sm.a[a0:a1]}</del>")
 | 
				
			||||||
 | 
					        elif opcode == "replace":
 | 
				
			||||||
 | 
					            output.append(f"<del>{sm.a[a0:a1]}</del>")
 | 
				
			||||||
 | 
					            output.append(f"<ins>{sm.b[b0:b1]}</ins>")
 | 
				
			||||||
 | 
					        else:
 | 
				
			||||||
 | 
					            raise RuntimeError(f"unexpected opcode: {opcode}")
 | 
				
			||||||
 | 
					    return "".join(output)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def serialize_value_for_qml(
 | 
					def serialize_value_for_qml(
 | 
				
			||||||
	value: Any, json_list_dicts: bool = False, reject_unknown: bool = False,
 | 
						value: Any, json_list_dicts: bool = False, reject_unknown: bool = False,
 | 
				
			||||||
) -> Any:
 | 
					) -> Any:
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -541,3 +541,36 @@ class Keys:
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
		# Sign out checked sessions if any, else sign out all sessions.
 | 
							# Sign out checked sessions if any, else sign out all sessions.
 | 
				
			||||||
		sign_out = ["Alt+S", "Delete"]
 | 
							sign_out = ["Alt+S", "Delete"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class Commands:
 | 
				
			||||||
 | 
						# If you are an advanced user, here you can define new /commands
 | 
				
			||||||
 | 
						#
 | 
				
			||||||
 | 
						# get_cmd_handler_map should return a dictionary of the form
 | 
				
			||||||
 | 
						# {
 | 
				
			||||||
 | 
						#   "command": command_handling_function,
 | 
				
			||||||
 | 
						#   "another_command": ...
 | 
				
			||||||
 | 
						# }
 | 
				
			||||||
 | 
						#
 | 
				
			||||||
 | 
						# each command_handling_function should have the signature:
 | 
				
			||||||
 | 
						#
 | 
				
			||||||
 | 
						# async def command_handling_function(
 | 
				
			||||||
 | 
						#	 matrix_client:		  moment.backend.MatrixClient,
 | 
				
			||||||
 | 
						#	 room_id:				str,
 | 
				
			||||||
 | 
						#	 text:				   str,   # text after the end of /command
 | 
				
			||||||
 | 
						#	 display_name_mentions:  Optional[Dict[str, str]]  = None,  # {id: name}
 | 
				
			||||||
 | 
						#	 reply_to_event_id:	  Optional[str]			 = None,
 | 
				
			||||||
 | 
						# ) -> None:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						# Example:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						def get_cmd_handler_map(self):
 | 
				
			||||||
 | 
							return {
 | 
				
			||||||
 | 
							  # "rot13 ": self.handle_cmd_rot13,
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						# @staticmethod
 | 
				
			||||||
 | 
						# def handle_cmd_rot13(matrix_client, room_id, text, display_name_mentions, reply_to_event_id):
 | 
				
			||||||
 | 
						#	 import codecs
 | 
				
			||||||
 | 
						#	 matrix_client._send_text(
 | 
				
			||||||
 | 
						#		 room_id, codecs.encode(text, 'rot_13'), display_name_mentions, reply_to_event_id
 | 
				
			||||||
 | 
						#	 )
 | 
				
			||||||
@@ -35,7 +35,9 @@ TextEdit {
 | 
				
			|||||||
    focus: false
 | 
					    focus: false
 | 
				
			||||||
    selectByMouse: true
 | 
					    selectByMouse: true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    onLinkActivated: if (enableLinkActivation && link !== '#state-text')
 | 
					    onLinkActivated: if (enableLinkActivation
 | 
				
			||||||
 | 
					            && link !== '#state-text'
 | 
				
			||||||
 | 
					            && link !== '#replaced-text')
 | 
				
			||||||
        Qt.openUrlExternally(link)
 | 
					        Qt.openUrlExternally(link)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    MouseArea {
 | 
					    MouseArea {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -28,8 +28,17 @@ HPage {
 | 
				
			|||||||
            HTabButton { text: qsTr("Security") }
 | 
					            HTabButton { text: qsTr("Security") }
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        General { userId: page.userId }
 | 
					        General {
 | 
				
			||||||
        Notifications { userId: page.userId }
 | 
					            userId: page.userId
 | 
				
			||||||
        Security { userId: page.userId }
 | 
					            implicitWidth: 0
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        Notifications {
 | 
				
			||||||
 | 
					            userId: page.userId
 | 
				
			||||||
 | 
					            implicitWidth: 0
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        Security {
 | 
				
			||||||
 | 
					            userId: page.userId
 | 
				
			||||||
 | 
					            implicitWidth: 0
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -132,6 +132,8 @@ HFlickableColumnPage {
 | 
				
			|||||||
        // Layout.preferredWidth: 256 * theme.uiScale
 | 
					        // Layout.preferredWidth: 256 * theme.uiScale
 | 
				
			||||||
        Layout.preferredHeight: width
 | 
					        Layout.preferredHeight: width
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        HoverHandler { id: overlayHover }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        Rectangle {
 | 
					        Rectangle {
 | 
				
			||||||
            anchors.fill: parent
 | 
					            anchors.fill: parent
 | 
				
			||||||
            z: 10
 | 
					            z: 10
 | 
				
			||||||
@@ -154,8 +156,6 @@ HFlickableColumnPage {
 | 
				
			|||||||
            Behavior on opacity { HNumberAnimation {} }
 | 
					            Behavior on opacity { HNumberAnimation {} }
 | 
				
			||||||
            Behavior on color { HColorAnimation {} }
 | 
					            Behavior on color { HColorAnimation {} }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            HoverHandler { id: overlayHover }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            MouseArea {
 | 
					            MouseArea {
 | 
				
			||||||
                anchors.fill: parent
 | 
					                anchors.fill: parent
 | 
				
			||||||
                enabled: ready && account.presence !== "offline"
 | 
					                enabled: ready && account.presence !== "offline"
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -26,8 +26,17 @@ HPage {
 | 
				
			|||||||
            HTabButton { text: qsTr("Create group") }
 | 
					            HTabButton { text: qsTr("Create group") }
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        DirectChat { userId: page.userId }
 | 
					        DirectChat {
 | 
				
			||||||
        JoinRoom { userId: page.userId }
 | 
					            userId: page.userId
 | 
				
			||||||
        CreateRoom { userId: page.userId }
 | 
					            implicitWidth: 0
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        JoinRoom {
 | 
				
			||||||
 | 
					            userId: page.userId
 | 
				
			||||||
 | 
					            implicitWidth: 0
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        CreateRoom {
 | 
				
			||||||
 | 
					            userId: page.userId
 | 
				
			||||||
 | 
					            implicitWidth: 0
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -49,6 +49,18 @@ HRowLayout {
 | 
				
			|||||||
            ">"
 | 
					            ">"
 | 
				
			||||||
        ) + "</font></font></a>"
 | 
					        ) + "</font></font></a>"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    readonly property var reactions: model.reactions
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    readonly property var contentHistory: model.content_history
 | 
				
			||||||
 | 
					    readonly property string replacedText:
 | 
				
			||||||
 | 
					    `<a href="#replaced-text" style="text-decoration: none">` +
 | 
				
			||||||
 | 
					    `<font size=${theme.fontSize.small}px><font ` + (
 | 
				
			||||||
 | 
					            model.replaced ?
 | 
				
			||||||
 | 
					            `color="${theme.chat.message.readCounter}"> 🖉` :  // U+1F589
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            ">"
 | 
				
			||||||
 | 
					        ) + "</font></font></a>"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    readonly property bool pureMedia: ! contentText && linksRepeater.count
 | 
					    readonly property bool pureMedia: ! contentText && linksRepeater.count
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    readonly property bool hoveredSelectable: contentHover.hovered
 | 
					    readonly property bool hoveredSelectable: contentHover.hovered
 | 
				
			||||||
@@ -123,6 +135,13 @@ HRowLayout {
 | 
				
			|||||||
            id: contentLabel
 | 
					            id: contentLabel
 | 
				
			||||||
            visible: ! pureMedia
 | 
					            visible: ! pureMedia
 | 
				
			||||||
            enableLinkActivation: ! eventList.selectedCount
 | 
					            enableLinkActivation: ! eventList.selectedCount
 | 
				
			||||||
 | 
					            onLinkActivated:
 | 
				
			||||||
 | 
					                if(link === "#replaced-text") window.makePopup(
 | 
				
			||||||
 | 
					                    "Popups/MessageReplaceHistoryPopup.qml",
 | 
				
			||||||
 | 
					                    {
 | 
				
			||||||
 | 
					                        contentHistory: contentHistory
 | 
				
			||||||
 | 
					                    },
 | 
				
			||||||
 | 
					                )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            selectByMouse:
 | 
					            selectByMouse:
 | 
				
			||||||
                eventList.selectedCount <= 1 &&
 | 
					                eventList.selectedCount <= 1 &&
 | 
				
			||||||
@@ -163,6 +182,7 @@ HRowLayout {
 | 
				
			|||||||
                timeText +
 | 
					                timeText +
 | 
				
			||||||
                "</font>" +
 | 
					                "</font>" +
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                replacedText +
 | 
				
			||||||
                stateText
 | 
					                stateText
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            transform: Translate { x: xOffset }
 | 
					            transform: Translate { x: xOffset }
 | 
				
			||||||
@@ -298,6 +318,8 @@ HRowLayout {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
                    linksRepeater.summedWidth +
 | 
					                    linksRepeater.summedWidth +
 | 
				
			||||||
                    (pureMedia ? 0 : parent.leftPadding + parent.rightPadding),
 | 
					                    (pureMedia ? 0 : parent.leftPadding + parent.rightPadding),
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    reactionsRow.width
 | 
				
			||||||
                )
 | 
					                )
 | 
				
			||||||
                height: contentColumn.height
 | 
					                height: contentColumn.height
 | 
				
			||||||
                radius: theme.chat.message.radius
 | 
					                radius: theme.chat.message.radius
 | 
				
			||||||
@@ -361,6 +383,94 @@ HRowLayout {
 | 
				
			|||||||
                Layout.preferredHeight: item ? item.height : -1
 | 
					                Layout.preferredHeight: item ? item.height : -1
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        Row {
 | 
				
			||||||
 | 
					            id: reactionsRow
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            spacing: 10
 | 
				
			||||||
 | 
					            bottomPadding: 7
 | 
				
			||||||
 | 
					            leftPadding: 10
 | 
				
			||||||
 | 
					            rightPadding: 10
 | 
				
			||||||
 | 
					            Layout.alignment: onRight ? Qt.AlignRight : Qt.AlignLeft
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            Repeater {
 | 
				
			||||||
 | 
					                id: reactionsRepeater
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                model: {
 | 
				
			||||||
 | 
					                    const reactions = Object.entries(
 | 
				
			||||||
 | 
					                        JSON.parse(eventDelegate.currentModel.reactions));
 | 
				
			||||||
 | 
					                    return reactions;
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                Rectangle {
 | 
				
			||||||
 | 
					                    id: reactionItem
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    required property var modelData
 | 
				
			||||||
 | 
					                    readonly property var icon: modelData[0]
 | 
				
			||||||
 | 
					                    readonly property var hint: modelData[1]["hint"]
 | 
				
			||||||
 | 
					                    readonly property var users: modelData[1]["users"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    width: reactionContent.width
 | 
				
			||||||
 | 
					                    height: theme.fontSize.normal + 10
 | 
				
			||||||
 | 
					                    radius: width / 2
 | 
				
			||||||
 | 
					                    color: theme.colors.strongBackground
 | 
				
			||||||
 | 
					                    border.color: theme.colors.accentBackground
 | 
				
			||||||
 | 
					                    border.width: 1
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    Row {
 | 
				
			||||||
 | 
					                        id: reactionContent
 | 
				
			||||||
 | 
					                        spacing: 5
 | 
				
			||||||
 | 
					                        topPadding: 3
 | 
				
			||||||
 | 
					                        leftPadding: 10
 | 
				
			||||||
 | 
					                        rightPadding: 10
 | 
				
			||||||
 | 
					                        Text {
 | 
				
			||||||
 | 
					                            id: reactionIcon
 | 
				
			||||||
 | 
					                            color: theme.colors.brightText
 | 
				
			||||||
 | 
					                            font.pixelSize: theme.fontSize.normal
 | 
				
			||||||
 | 
					                            font.family: theme.fontFamily.sans
 | 
				
			||||||
 | 
					                            text: parent.parent.icon
 | 
				
			||||||
 | 
					                        }
 | 
				
			||||||
 | 
					                        Text {
 | 
				
			||||||
 | 
					                            id: reactionCounter
 | 
				
			||||||
 | 
					                            color: theme.colors.brightText
 | 
				
			||||||
 | 
					                            font.pixelSize: theme.fontSize.normal
 | 
				
			||||||
 | 
					                            font.family: theme.fontFamily.sans
 | 
				
			||||||
 | 
					                            text: parent.parent.users.length
 | 
				
			||||||
 | 
					                        }
 | 
				
			||||||
 | 
					                    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    MouseArea {
 | 
				
			||||||
 | 
					                        id: reactionItemMouseArea
 | 
				
			||||||
 | 
					                        anchors.fill: parent
 | 
				
			||||||
 | 
					                        onEntered: { reactionTooltip.visible = true }
 | 
				
			||||||
 | 
					                        onExited: { reactionTooltip.visible = false }
 | 
				
			||||||
 | 
					                        hoverEnabled: true
 | 
				
			||||||
 | 
					                    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    HToolTip {
 | 
				
			||||||
 | 
					                        id: reactionTooltip
 | 
				
			||||||
 | 
					                        visible: false
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                        label.textFormat: HLabel.StyledText
 | 
				
			||||||
 | 
					                        text: {
 | 
				
			||||||
 | 
					                            const members =
 | 
				
			||||||
 | 
					                                ModelStore.get(chat.userId, chat.roomId, "members")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                            const lines = [parent.hint]
 | 
				
			||||||
 | 
					                            for (const userId of parent.users) {
 | 
				
			||||||
 | 
					                                const member = members.find(userId)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                                const by  = utils.coloredNameHtml(
 | 
				
			||||||
 | 
					                                    member ? member.display_name: userId, userId,
 | 
				
			||||||
 | 
					                                )
 | 
				
			||||||
 | 
					                                lines.push(qsTr("%1").arg(by))
 | 
				
			||||||
 | 
					                            }
 | 
				
			||||||
 | 
					                            return lines.join("<br>")
 | 
				
			||||||
 | 
					                        }
 | 
				
			||||||
 | 
					                    }
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    HSpacer {}
 | 
					    HSpacer {}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -72,13 +72,15 @@ HColumnLayout {
 | 
				
			|||||||
        eventList.toggleCheck(model.index)
 | 
					        eventList.toggleCheck(model.index)
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    visible: !model.hidden
 | 
				
			||||||
    width: eventList.width - eventList.leftMargin - eventList.rightMargin
 | 
					    width: eventList.width - eventList.leftMargin - eventList.rightMargin
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // Needed because of eventList's MouseArea which steals the
 | 
					    // Needed because of eventList's MouseArea which steals the
 | 
				
			||||||
    // HSelectableLabel's MouseArea hover events
 | 
					    // HSelectableLabel's MouseArea hover events
 | 
				
			||||||
    onCursorShapeChanged: eventList.cursorShape = cursorShape
 | 
					    onCursorShapeChanged: eventList.cursorShape = cursorShape
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    Component.onCompleted: if (model.fetch_profile)
 | 
					    Component.onCompleted: {
 | 
				
			||||||
 | 
					        if (model.fetch_profile)
 | 
				
			||||||
            fetchProfilesFutureId = py.callClientCoro(
 | 
					            fetchProfilesFutureId = py.callClientCoro(
 | 
				
			||||||
                chat.userId,
 | 
					                chat.userId,
 | 
				
			||||||
                "get_event_profiles",
 | 
					                "get_event_profiles",
 | 
				
			||||||
@@ -86,6 +88,10 @@ HColumnLayout {
 | 
				
			|||||||
                // The if avoids segfault if eventDelegate is already destroyed
 | 
					                // The if avoids segfault if eventDelegate is already destroyed
 | 
				
			||||||
                () => { if (eventDelegate) fetchProfilesFutureId = "" }
 | 
					                () => { if (eventDelegate) fetchProfilesFutureId = "" }
 | 
				
			||||||
            )
 | 
					            )
 | 
				
			||||||
 | 
					        // Workaround for hiding messages of certain types
 | 
				
			||||||
 | 
					        if (!eventDelegate.visible)
 | 
				
			||||||
 | 
					            eventDelegate.height = 0
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    Component.onDestruction:
 | 
					    Component.onDestruction:
 | 
				
			||||||
        if (fetchProfilesFutureId) py.cancelCoro(fetchProfilesFutureId)
 | 
					        if (fetchProfilesFutureId) py.cancelCoro(fetchProfilesFutureId)
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -266,10 +266,28 @@ Rectangle {
 | 
				
			|||||||
            highlightRangeMode = previous
 | 
					            highlightRangeMode = previous
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        function focusPreviousVisibleMessage() {
 | 
				
			||||||
 | 
					            incrementCurrentIndex()
 | 
				
			||||||
 | 
					            let lastIndex = -1
 | 
				
			||||||
 | 
					            while ( currentIndex != lastIndex && model.get(currentIndex).hidden ) {
 | 
				
			||||||
 | 
					                lastIndex = currentIndex
 | 
				
			||||||
 | 
					                incrementCurrentIndex()
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        function focusPreviousMessage() {
 | 
					        function focusPreviousMessage() {
 | 
				
			||||||
            currentIndex === -1 && visibleEnd.y < contentHeight - height / 4 ?
 | 
					            currentIndex === -1 && visibleEnd.y < contentHeight - height / 4 ?
 | 
				
			||||||
            focusCenterMessage() :
 | 
					            focusCenterMessage() :
 | 
				
			||||||
            incrementCurrentIndex()
 | 
					            focusPreviousVisibleMessage()
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        function focusNextVisibleMessage() {
 | 
				
			||||||
 | 
					            decrementCurrentIndex()
 | 
				
			||||||
 | 
					            while ( currentIndex > -1 && model.get(currentIndex).hidden ) {
 | 
				
			||||||
 | 
					                if ( currentIndex === 0 )
 | 
				
			||||||
 | 
					                    currentIndex = -1;
 | 
				
			||||||
 | 
					                decrementCurrentIndex()
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        function focusNextMessage() {
 | 
					        function focusNextMessage() {
 | 
				
			||||||
@@ -279,7 +297,7 @@ Rectangle {
 | 
				
			|||||||
            eventList.currentIndex === 0 ?
 | 
					            eventList.currentIndex === 0 ?
 | 
				
			||||||
            eventList.currentIndex = -1 :
 | 
					            eventList.currentIndex = -1 :
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            decrementCurrentIndex()
 | 
					            focusNextVisibleMessage()
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        function copySelectedDelegates() {
 | 
					        function copySelectedDelegates() {
 | 
				
			||||||
@@ -332,7 +350,7 @@ Rectangle {
 | 
				
			|||||||
        }
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        function canCombine(item, itemAfter) {
 | 
					        function canCombine(item, itemAfter) {
 | 
				
			||||||
            if (! item || ! itemAfter) return false
 | 
					            if (! item || ! itemAfter || item.hidden) return false
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            return Boolean(
 | 
					            return Boolean(
 | 
				
			||||||
                ! canTalkBreak(item, itemAfter) &&
 | 
					                ! canTalkBreak(item, itemAfter) &&
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										260
									
								
								src/gui/Pages/Chat/Timeline/HistoryContent.qml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										260
									
								
								src/gui/Pages/Chat/Timeline/HistoryContent.qml
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,260 @@
 | 
				
			|||||||
 | 
					// Copyright Mirage authors & contributors <https://github.com/mirukana/mirage>
 | 
				
			||||||
 | 
					// SPDX-License-Identifier: LGPL-3.0-or-later
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import QtQuick 2.12
 | 
				
			||||||
 | 
					import QtQuick.Layouts 1.12
 | 
				
			||||||
 | 
					import "../../../Base"
 | 
				
			||||||
 | 
					import "../../.."
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					HRowLayout {
 | 
				
			||||||
 | 
					    id: historyContent
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    readonly property var mentions: []
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    readonly property string mentionsCSS: {
 | 
				
			||||||
 | 
					        const lines = []
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        for (const [name, link] of mentions) {
 | 
				
			||||||
 | 
					            if (! link.match(/^https?:\/\/matrix.to\/#\/@.+/)) continue
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            lines.push(
 | 
				
			||||||
 | 
					                `.mention[data-mention='${utils.escapeHtml(name)}'] ` +
 | 
				
			||||||
 | 
					                `{ color: ${utils.nameColor(name)} }`
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return "<style type='text/css'>" + lines.join("\n") + "</style>"
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    readonly property string diffCSS: {
 | 
				
			||||||
 | 
					        const lines = [
 | 
				
			||||||
 | 
					            "del { background-color: #f8d7da; color: #721c24; text-decoration: line-through; }",
 | 
				
			||||||
 | 
					            "ins { background-color: #d4edda; color: #155724; text-decoration: underline; }",
 | 
				
			||||||
 | 
					        ]
 | 
				
			||||||
 | 
					        return "<style type='text/css'>" + lines.join("\n") + "</style>"
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    readonly property string senderText: ""
 | 
				
			||||||
 | 
					    property string contentText: model.content_diff
 | 
				
			||||||
 | 
					    readonly property string timeText: utils.formatTime(model.date, false)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    readonly property bool pureMedia: false
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    readonly property bool hoveredSelectable: contentHover.hovered
 | 
				
			||||||
 | 
					    readonly property string hoveredLink:
 | 
				
			||||||
 | 
					        linksRepeater.lastHovered && linksRepeater.lastHovered.hovered ?
 | 
				
			||||||
 | 
					        linksRepeater.lastHovered.mediaUrl :
 | 
				
			||||||
 | 
					        contentLabel.hoveredLink
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    readonly property alias contentLabel: contentLabel
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    readonly property int xOffset: 0
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    readonly property int maxMessageWidth:
 | 
				
			||||||
 | 
					        contentText.includes("<pre>") || contentText.includes("<table>") ?
 | 
				
			||||||
 | 
					        -1 :
 | 
				
			||||||
 | 
					        window.settings.Chat.max_messages_line_length < 0 ?
 | 
				
			||||||
 | 
					        -1 :
 | 
				
			||||||
 | 
					        Math.ceil(
 | 
				
			||||||
 | 
					            mainUI.fontMetrics.averageCharacterWidth *
 | 
				
			||||||
 | 
					            window.settings.Chat.max_messages_line_length
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    readonly property alias selectedText: contentLabel.selectedPlainText
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    spacing: theme.chat.message.horizontalSpacing
 | 
				
			||||||
 | 
					    layoutDirection: Qt.LeftToRight
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    HColumnLayout {
 | 
				
			||||||
 | 
					        id: contentColumn
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        Layout.fillWidth: true
 | 
				
			||||||
 | 
					        Layout.alignment: Qt.AlignVCenter
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        HSelectableLabel {
 | 
				
			||||||
 | 
					            id: contentLabel
 | 
				
			||||||
 | 
					            visible: ! pureMedia
 | 
				
			||||||
 | 
					            enableLinkActivation: ! historyList.selectedCount
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            selectByMouse:
 | 
				
			||||||
 | 
					                historyList.selectedCount <= 1 &&
 | 
				
			||||||
 | 
					                historyDelegate.checked &&
 | 
				
			||||||
 | 
					                textSelectionBlocker.point.scenePosition === Qt.point(0, 0)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            topPadding: theme.chat.message.verticalSpacing
 | 
				
			||||||
 | 
					            bottomPadding: topPadding
 | 
				
			||||||
 | 
					            leftPadding: historyContent.spacing
 | 
				
			||||||
 | 
					            rightPadding: leftPadding
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            color: theme.chat.message.body
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            font.italic: false
 | 
				
			||||||
 | 
					            wrapMode: TextEdit.Wrap
 | 
				
			||||||
 | 
					            textFormat: Text.RichText
 | 
				
			||||||
 | 
					            text:
 | 
				
			||||||
 | 
					                // CSS
 | 
				
			||||||
 | 
					                theme.chat.message.styleInclude + mentionsCSS + diffCSS +
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                // Sender name & message body
 | 
				
			||||||
 | 
					                (
 | 
				
			||||||
 | 
					                    compact && contentText.match(/^\s*<(p|h[1-6])>/) ?
 | 
				
			||||||
 | 
					                    contentText.replace(
 | 
				
			||||||
 | 
					                        /(^\s*<(p|h[1-6])>)/, "$1" + senderText,
 | 
				
			||||||
 | 
					                    ) :
 | 
				
			||||||
 | 
					                    senderText + contentText
 | 
				
			||||||
 | 
					                ) +
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                // Time
 | 
				
			||||||
 | 
					                // For some reason, if there's only one space,
 | 
				
			||||||
 | 
					                // times will be on their own lines most of the time.
 | 
				
			||||||
 | 
					                "  " +
 | 
				
			||||||
 | 
					                `<font size=${theme.fontSize.small}px ` +
 | 
				
			||||||
 | 
					                      `color=${theme.chat.message.date}>` +
 | 
				
			||||||
 | 
					                timeText +
 | 
				
			||||||
 | 
					                "</font>"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            transform: Translate { x: xOffset }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            Layout.maximumWidth: historyContent.maxMessageWidth
 | 
				
			||||||
 | 
					            Layout.fillWidth: true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            onSelectedTextChanged: if (selectedPlainText) {
 | 
				
			||||||
 | 
					                historyList.delegateWithSelectedText = model.id
 | 
				
			||||||
 | 
					                historyList.selectedText             = selectedPlainText
 | 
				
			||||||
 | 
					            } else if (historyList.delegateWithSelectedText === model.id) {
 | 
				
			||||||
 | 
					                historyList.delegateWithSelectedText = ""
 | 
				
			||||||
 | 
					                historyList.selectedText             = ""
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            Connections {
 | 
				
			||||||
 | 
					                target: historyList
 | 
				
			||||||
 | 
					                onCheckedChanged: contentLabel.deselect()
 | 
				
			||||||
 | 
					                onDelegateWithSelectedTextChanged: {
 | 
				
			||||||
 | 
					                    if (historyList.delegateWithSelectedText !== model.id)
 | 
				
			||||||
 | 
					                        contentLabel.deselect()
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            HoverHandler { id: contentHover }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            PointHandler {
 | 
				
			||||||
 | 
					                id: mousePointHandler
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                property bool checkedNow: false
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                acceptedButtons: Qt.LeftButton
 | 
				
			||||||
 | 
					                acceptedModifiers: Qt.NoModifier
 | 
				
			||||||
 | 
					                acceptedPointerTypes:
 | 
				
			||||||
 | 
					                    PointerDevice.GenericPointer | PointerDevice.Eraser
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                onActiveChanged: {
 | 
				
			||||||
 | 
					                    if (active &&
 | 
				
			||||||
 | 
					                            ! historyDelegate.checked &&
 | 
				
			||||||
 | 
					                            (! parent.hoveredLink ||
 | 
				
			||||||
 | 
					                            ! parent.enableLinkActivation)) {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                        historyList.check(model.index)
 | 
				
			||||||
 | 
					                        checkedNow = true
 | 
				
			||||||
 | 
					                    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    if (! active && historyDelegate.checked) {
 | 
				
			||||||
 | 
					                        checkedNow ?
 | 
				
			||||||
 | 
					                        checkedNow = false :
 | 
				
			||||||
 | 
					                        historyList.uncheck(model.index)
 | 
				
			||||||
 | 
					                    }
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            PointHandler {
 | 
				
			||||||
 | 
					                id: mouseShiftPointHandler
 | 
				
			||||||
 | 
					                acceptedButtons: Qt.LeftButton
 | 
				
			||||||
 | 
					                acceptedModifiers: Qt.ShiftModifier
 | 
				
			||||||
 | 
					                acceptedPointerTypes:
 | 
				
			||||||
 | 
					                    PointerDevice.GenericPointer | PointerDevice.Eraser
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                onActiveChanged: {
 | 
				
			||||||
 | 
					                    if (active &&
 | 
				
			||||||
 | 
					                            ! historyDelegate.checked &&
 | 
				
			||||||
 | 
					                            (! parent.hoveredLink ||
 | 
				
			||||||
 | 
					                            ! parent.enableLinkActivation)) {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                        historyList.checkFromLastToHere(model.index)
 | 
				
			||||||
 | 
					                    }
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            TapHandler {
 | 
				
			||||||
 | 
					                id: touchTapHandler
 | 
				
			||||||
 | 
					                acceptedButtons: Qt.LeftButton
 | 
				
			||||||
 | 
					                acceptedPointerTypes: PointerDevice.Finger | PointerDevice.Pen
 | 
				
			||||||
 | 
					                onTapped:
 | 
				
			||||||
 | 
					                    if (! parent.hoveredLink || ! parent.enableLinkActivation)
 | 
				
			||||||
 | 
					                        historyDelegate.toggleChecked()
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            TapHandler {
 | 
				
			||||||
 | 
					                id: textSelectionBlocker
 | 
				
			||||||
 | 
					                acceptedPointerTypes: PointerDevice.Finger | PointerDevice.Pen
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            Rectangle {
 | 
				
			||||||
 | 
					                id: contentBackground
 | 
				
			||||||
 | 
					                width: Math.max(
 | 
				
			||||||
 | 
					                    parent.paintedWidth +
 | 
				
			||||||
 | 
					                    parent.leftPadding + parent.rightPadding,
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    linksRepeater.summedWidth +
 | 
				
			||||||
 | 
					                    (pureMedia ? 0 : parent.leftPadding + parent.rightPadding),
 | 
				
			||||||
 | 
					                )
 | 
				
			||||||
 | 
					                height: contentColumn.height
 | 
				
			||||||
 | 
					                radius: theme.chat.message.radius
 | 
				
			||||||
 | 
					                z: -100
 | 
				
			||||||
 | 
					                color: historyDelegate.checked &&
 | 
				
			||||||
 | 
					                       ! contentLabel.selectedPlainText &&
 | 
				
			||||||
 | 
					                       ! mousePointHandler.active &&
 | 
				
			||||||
 | 
					                       ! mouseShiftPointHandler.active ?
 | 
				
			||||||
 | 
					                       theme.chat.message.checkedBackground :
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                       theme.chat.message.background
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        HRepeater {
 | 
				
			||||||
 | 
					            id: linksRepeater
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            property EventMediaLoader lastHovered: null
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            model: {
 | 
				
			||||||
 | 
					                const links = historyDelegate.currentModel.links
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                if (historyDelegate.currentModel.media_url)
 | 
				
			||||||
 | 
					                    links.push(historyDelegate.currentModel.media_url)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                return links
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            EventMediaLoader {
 | 
				
			||||||
 | 
					                singleMediaInfo: historyDelegate.currentModel
 | 
				
			||||||
 | 
					                mediaUrl: modelData
 | 
				
			||||||
 | 
					                showSender: pureMedia ? senderText : ""
 | 
				
			||||||
 | 
					                showDate: pureMedia ? timeText : ""
 | 
				
			||||||
 | 
					                showLocalEcho: pureMedia && (
 | 
				
			||||||
 | 
					                    singleMediaInfo.is_local_echo ||
 | 
				
			||||||
 | 
					                    singleMediaInfo.read_by_count
 | 
				
			||||||
 | 
					                ) ? stateText : ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                transform: Translate { x: xOffset }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                onHoveredChanged: if (hovered) linksRepeater.lastHovered = this
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                Layout.bottomMargin: pureMedia ? 0 : contentLabel.bottomPadding
 | 
				
			||||||
 | 
					                Layout.leftMargin: pureMedia ? 0 : historyContent.spacing
 | 
				
			||||||
 | 
					                Layout.rightMargin: pureMedia ? 0 : historyContent.spacing
 | 
				
			||||||
 | 
					                Layout.preferredWidth: item ? item.width : -1
 | 
				
			||||||
 | 
					                Layout.preferredHeight: item ? item.height : -1
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    HSpacer {}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										93
									
								
								src/gui/Pages/Chat/Timeline/HistoryDelegate.qml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										93
									
								
								src/gui/Pages/Chat/Timeline/HistoryDelegate.qml
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,93 @@
 | 
				
			|||||||
 | 
					// Copyright Mirage authors & contributors <https://github.com/mirukana/mirage>
 | 
				
			||||||
 | 
					// SPDX-License-Identifier: LGPL-3.0-or-later
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import QtQuick 2.12
 | 
				
			||||||
 | 
					import QtQuick.Layouts 1.12
 | 
				
			||||||
 | 
					import Clipboard 0.1
 | 
				
			||||||
 | 
					import "../../.."
 | 
				
			||||||
 | 
					import "../../../Base"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					HColumnLayout {
 | 
				
			||||||
 | 
					    id: historyDelegate
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Remember timeline goes from newest message at index 0 to oldest
 | 
				
			||||||
 | 
					    readonly property var previousModel: historyList.model.get(model.index + 1)
 | 
				
			||||||
 | 
					    readonly property var nextModel: historyList.model.get(model.index - 1)
 | 
				
			||||||
 | 
					    readonly property QtObject currentModel: model
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    readonly property bool isFocused: model.index === historyList.currentIndex
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    readonly property bool compact: window.settings.General.compact
 | 
				
			||||||
 | 
					    readonly property bool checked: model.id in historyList.checked
 | 
				
			||||||
 | 
					    readonly property bool isOwn: true
 | 
				
			||||||
 | 
					    readonly property bool isRedacted: false
 | 
				
			||||||
 | 
					    readonly property bool onRight: ! historyList.ownEventsOnLeft && isOwn
 | 
				
			||||||
 | 
					    readonly property bool combine: false
 | 
				
			||||||
 | 
					    readonly property bool talkBreak: false
 | 
				
			||||||
 | 
					    readonly property bool dayBreak:
 | 
				
			||||||
 | 
					        model.index === 0 ? true : historyList.canDayBreak(previousModel, model)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    readonly property bool hideNameLine: true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    readonly property int cursorShape:
 | 
				
			||||||
 | 
					        historyContent.hoveredLink ? Qt.PointingHandCursor :
 | 
				
			||||||
 | 
					        historyContent.hoveredSelectable ? Qt.IBeamCursor :
 | 
				
			||||||
 | 
					        Qt.ArrowCursor
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    readonly property int separationSpacing: theme.spacing * (
 | 
				
			||||||
 | 
					        dayBreak  ? 4 :
 | 
				
			||||||
 | 
					        talkBreak ? 6 :
 | 
				
			||||||
 | 
					        combine && compact ? 0.25 :
 | 
				
			||||||
 | 
					        combine ? 0.5 :
 | 
				
			||||||
 | 
					        compact ? 1 :
 | 
				
			||||||
 | 
					        2
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    readonly property alias historyContent: historyContent
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    function toggleChecked() {
 | 
				
			||||||
 | 
					        historyList.toggleCheck(model.index)
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    width: historyList.width - historyList.leftMargin - historyList.rightMargin
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Needed because of historyList's MouseArea which steals the
 | 
				
			||||||
 | 
					    // HSelectableLabel's MouseArea hover events
 | 
				
			||||||
 | 
					    onCursorShapeChanged: historyList.cursorShape = cursorShape
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    ListView.onRemove: historyList.uncheck(model.id)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    DelegateTransitionFixer {}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Item {
 | 
				
			||||||
 | 
					        Layout.fillWidth: true
 | 
				
			||||||
 | 
					        visible: model.index !== 0
 | 
				
			||||||
 | 
					        Layout.preferredHeight: separationSpacing
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    DayBreak {
 | 
				
			||||||
 | 
					        visible: dayBreak
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        Layout.fillWidth: true
 | 
				
			||||||
 | 
					        Layout.minimumWidth: parent.width
 | 
				
			||||||
 | 
					        Layout.bottomMargin: separationSpacing
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    HistoryContent {
 | 
				
			||||||
 | 
					        id: historyContent
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        Layout.fillWidth: true
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    TapHandler {
 | 
				
			||||||
 | 
					        acceptedButtons: Qt.LeftButton
 | 
				
			||||||
 | 
					        acceptedModifiers: Qt.NoModifier
 | 
				
			||||||
 | 
					        onTapped: toggleChecked()
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    TapHandler {
 | 
				
			||||||
 | 
					        acceptedButtons: Qt.LeftButton
 | 
				
			||||||
 | 
					        acceptedModifiers: Qt.ShiftModifier
 | 
				
			||||||
 | 
					        onTapped: historyList.checkFromLastToHere(model.index)
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										206
									
								
								src/gui/Pages/Chat/Timeline/HistoryList.qml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										206
									
								
								src/gui/Pages/Chat/Timeline/HistoryList.qml
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,206 @@
 | 
				
			|||||||
 | 
					// Copyright Mirage authors & contributors <https://github.com/mirukana/mirage>
 | 
				
			||||||
 | 
					// SPDX-License-Identifier: LGPL-3.0-or-later
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import QtQuick 2.12
 | 
				
			||||||
 | 
					import QtQuick.Layouts 1.12
 | 
				
			||||||
 | 
					import QtQuick.Window 2.12
 | 
				
			||||||
 | 
					import Clipboard 0.1
 | 
				
			||||||
 | 
					import "../../.."
 | 
				
			||||||
 | 
					import "../../../Base"
 | 
				
			||||||
 | 
					import "../../../PythonBridge"
 | 
				
			||||||
 | 
					import "../../../ShortcutBundles"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Rectangle {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    readonly property alias historyList: historyList
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    color: theme.chat.eventList.background
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    HShortcut {
 | 
				
			||||||
 | 
					        sequences: window.settings.Keys.Messages.unfocus_or_deselect
 | 
				
			||||||
 | 
					        onActivated: {
 | 
				
			||||||
 | 
					            historyList.selectedCount ?
 | 
				
			||||||
 | 
					            historyList.checked = {} :
 | 
				
			||||||
 | 
					            historyList.currentIndex = -1
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    HShortcut {
 | 
				
			||||||
 | 
					        sequences: window.settings.Keys.Messages.previous
 | 
				
			||||||
 | 
					        onActivated: historyList.focusPreviousMessage()
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    HShortcut {
 | 
				
			||||||
 | 
					        sequences: window.settings.Keys.Messages.next
 | 
				
			||||||
 | 
					        onActivated: historyList.focusNextMessage()
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    HShortcut {
 | 
				
			||||||
 | 
					        active: historyList.currentItem
 | 
				
			||||||
 | 
					        sequences: window.settings.Keys.Messages.select
 | 
				
			||||||
 | 
					        onActivated: historyList.toggleCheck(historyList.currentIndex)
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    HShortcut {
 | 
				
			||||||
 | 
					        active: historyList.currentItem
 | 
				
			||||||
 | 
					        sequences: window.settings.Keys.Messages.select_until_here
 | 
				
			||||||
 | 
					        onActivated:
 | 
				
			||||||
 | 
					            historyList.checkFromLastToHere(historyList.currentIndex)
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    HShortcut {
 | 
				
			||||||
 | 
					        sequences: window.settings.Keys.Messages.open_links_files
 | 
				
			||||||
 | 
					        onActivated: {
 | 
				
			||||||
 | 
					            const indice =
 | 
				
			||||||
 | 
					                historyList.getFocusedOrSelectedOrLastMediaEvents(true)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            for (const i of Array.from(indice).sort().reverse()) {
 | 
				
			||||||
 | 
					                const event = historyList.model.get(i)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                for (const url of JSON.parse(event.links)) {
 | 
				
			||||||
 | 
					                    utils.getLinkType(url) === Utils.Media.Image ?
 | 
				
			||||||
 | 
					                    historyList.openImageViewer(event, url) :
 | 
				
			||||||
 | 
					                    Qt.openUrlExternally(url)
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    HShortcut {
 | 
				
			||||||
 | 
					        sequences: window.settings.Keys.Messages.open_links_files_externally
 | 
				
			||||||
 | 
					        onActivated: {
 | 
				
			||||||
 | 
					            const indice =
 | 
				
			||||||
 | 
					                historyList.getFocusedOrSelectedOrLastMediaEvents(true)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            for (const i of Array.from(indice).sort().reverse()) {
 | 
				
			||||||
 | 
					                const event = historyList.model.get(i)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                for (const url of JSON.parse(event.links))
 | 
				
			||||||
 | 
					                    Qt.openUrlExternally(url)
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    HListView {
 | 
				
			||||||
 | 
					        id: historyList
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        property bool ownEventsOnLeft: false
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        property string delegateWithSelectedText: ""
 | 
				
			||||||
 | 
					        property string selectedText: ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        property bool showFocusedSeenTooltips: false
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        property alias cursorShape: cursorShapeArea.cursorShape
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        function focusCenterMessage() {
 | 
				
			||||||
 | 
					            const previous     = highlightRangeMode
 | 
				
			||||||
 | 
					            highlightRangeMode = HListView.NoHighlightRange
 | 
				
			||||||
 | 
					            currentIndex       = indexAt(0, contentY + height / 2)
 | 
				
			||||||
 | 
					            highlightRangeMode = previous
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        function focusPreviousMessage() {
 | 
				
			||||||
 | 
					            currentIndex === -1 && visibleEnd.y < contentHeight - height / 4 ?
 | 
				
			||||||
 | 
					            focusCenterMessage() :
 | 
				
			||||||
 | 
					            incrementCurrentIndex()
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        function focusNextMessage() {
 | 
				
			||||||
 | 
					            currentIndex === -1 && visibleEnd.y < contentHeight - height / 4 ?
 | 
				
			||||||
 | 
					            focusCenterMessage() :
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            historyList.currentIndex === 0 ?
 | 
				
			||||||
 | 
					            historyList.currentIndex = -1 :
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            decrementCurrentIndex()
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        function copySelectedDelegates() {
 | 
				
			||||||
 | 
					            if (historyList.selectedText) {
 | 
				
			||||||
 | 
					                Clipboard.text = historyList.selectedText
 | 
				
			||||||
 | 
					                return
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            if (! historyList.selectedCount && historyList.currentIndex !== -1) {
 | 
				
			||||||
 | 
					                const model  = historyList.model.get(historyList.currentIndex)
 | 
				
			||||||
 | 
					                const source = JSON.parse(model.source)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                Clipboard.text =
 | 
				
			||||||
 | 
					                    model.media_http_url &&
 | 
				
			||||||
 | 
					                    utils.isEmptyObject(JSON.parse(model.media_crypt_dict)) ?
 | 
				
			||||||
 | 
					                    model.media_http_url :
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    "body" in source ?
 | 
				
			||||||
 | 
					                    source.body :
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    utils.stripHtmlTags(utils.processedEventText(model))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                return
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            const contents = []
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            for (const model of historyList.getSortedChecked()) {
 | 
				
			||||||
 | 
					                const source = JSON.parse(model.source)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                contents.push(
 | 
				
			||||||
 | 
					                    model.media_http_url &&
 | 
				
			||||||
 | 
					                    utils.isEmptyObject(JSON.parse(model.media_crypt_dict)) ?
 | 
				
			||||||
 | 
					                    model.media_http_url :
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    "body" in source ?
 | 
				
			||||||
 | 
					                    source.body :
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    utils.stripHtmlTags(utils.processedEventText(model))
 | 
				
			||||||
 | 
					                )
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            Clipboard.text = contents.join("\n\n")
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        function canDayBreak(item, itemAfter) {
 | 
				
			||||||
 | 
					            if (! item || ! itemAfter || ! item.date || ! itemAfter.date)
 | 
				
			||||||
 | 
					                return false
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            return item.date.getDate() !== itemAfter.date.getDate()
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        function getFocusedOrSelectedOrLastMediaEvents(acceptLinks=false) {
 | 
				
			||||||
 | 
					            if (historyList.selectedCount) return historyList.checkedIndice
 | 
				
			||||||
 | 
					            if (historyList.currentIndex !== -1) return [historyList.currentIndex]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Find most recent event that's a media or contains links
 | 
				
			||||||
 | 
					            for (let i = 0; i < historyList.model.count && i <= 1000; i++) {
 | 
				
			||||||
 | 
					                const ev    = historyList.model.get(i)
 | 
				
			||||||
 | 
					                const links = JSON.parse(ev.links)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                if (ev.media_url || (acceptLinks && links.length)) return [i]
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        anchors.fill: parent
 | 
				
			||||||
 | 
					        clip: true
 | 
				
			||||||
 | 
					        keyNavigationWraps: false
 | 
				
			||||||
 | 
					        leftMargin: theme.spacing
 | 
				
			||||||
 | 
					        rightMargin: theme.spacing
 | 
				
			||||||
 | 
					        topMargin: theme.spacing
 | 
				
			||||||
 | 
					        bottomMargin: theme.spacing
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // model: ModelStore.get(chat.userRoomId[0], chat.userRoomId[1], "events")
 | 
				
			||||||
 | 
					        model: []
 | 
				
			||||||
 | 
					        delegate: HistoryDelegate {}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        highlight: Rectangle {
 | 
				
			||||||
 | 
					            color: theme.chat.message.focusedHighlight
 | 
				
			||||||
 | 
					            opacity: theme.chat.message.focusedHighlightOpacity
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        MouseArea {
 | 
				
			||||||
 | 
					            id: cursorShapeArea
 | 
				
			||||||
 | 
					            anchors.fill: parent
 | 
				
			||||||
 | 
					            acceptedButtons: Qt.NoButton
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										44
									
								
								src/gui/Popups/MessageReplaceHistoryPopup.qml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										44
									
								
								src/gui/Popups/MessageReplaceHistoryPopup.qml
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,44 @@
 | 
				
			|||||||
 | 
					// Copyright Mirage authors & contributors <https://github.com/mirukana/mirage>
 | 
				
			||||||
 | 
					// SPDX-License-Identifier: LGPL-3.0-or-later
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import QtQuick 2.12
 | 
				
			||||||
 | 
					import QtQuick.Controls 2.12
 | 
				
			||||||
 | 
					import QtQuick.Layouts 1.12
 | 
				
			||||||
 | 
					import "../Base"
 | 
				
			||||||
 | 
					import "../Base/Buttons"
 | 
				
			||||||
 | 
					import "../Pages/Chat/Timeline"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					HColumnPopup {
 | 
				
			||||||
 | 
					    id: popup
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    contentWidthLimit:
 | 
				
			||||||
 | 
					        window.settings.Chat.max_messages_line_length < 0 ?
 | 
				
			||||||
 | 
					        theme.controls.popup.defaultWidth * 2 :
 | 
				
			||||||
 | 
					        Math.ceil(
 | 
				
			||||||
 | 
					            mainUI.fontMetrics.averageCharacterWidth *
 | 
				
			||||||
 | 
					            window.settings.Chat.max_messages_line_length
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					    property var contentHistory
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    page.footer: AutoDirectionLayout {
 | 
				
			||||||
 | 
					        CancelButton {
 | 
				
			||||||
 | 
					            id: cancelButton
 | 
				
			||||||
 | 
					            onClicked: popup.close()
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    onOpened: cancelButton.forceActiveFocus()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    SummaryLabel {
 | 
				
			||||||
 | 
					        text: qsTr("Message History")
 | 
				
			||||||
 | 
					        textFormat: Text.StyledText
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    HistoryList {
 | 
				
			||||||
 | 
					        id: historyList
 | 
				
			||||||
 | 
					        historyList.model: contentHistory
 | 
				
			||||||
 | 
					        height: 400
 | 
				
			||||||
 | 
					        Layout.fillWidth: true
 | 
				
			||||||
 | 
					        Layout.fillHeight: true
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -214,6 +214,31 @@ QtObject {
 | 
				
			|||||||
        const unknownMsg = type === "RoomMessageUnknown"
 | 
					        const unknownMsg = type === "RoomMessageUnknown"
 | 
				
			||||||
        const sender     = coloredNameHtml(ev.sender_name, ev.sender_id)
 | 
					        const sender     = coloredNameHtml(ev.sender_name, ev.sender_id)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (ev.type_specifier === "Reaction") {
 | 
				
			||||||
 | 
					            let name = coloredNameHtml(
 | 
				
			||||||
 | 
					                ev.sender_name, ev.sender_id, "", true,
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					            let reaction = ev.content
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            return qsTr(
 | 
				
			||||||
 | 
					                `<font color="${theme.chat.message.noticeBody}">` +
 | 
				
			||||||
 | 
					                name + ": " + reaction +
 | 
				
			||||||
 | 
					                "</font>"
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        if (ev.type_specifier === "ReactionRedaction") {
 | 
				
			||||||
 | 
					            let name = coloredNameHtml(
 | 
				
			||||||
 | 
					                ev.sender_name, ev.sender_id, "", true,
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					            let reaction = ev.content
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            return qsTr(
 | 
				
			||||||
 | 
					                `<font color="${theme.chat.message.noticeBody}">` +
 | 
				
			||||||
 | 
					                name + " removed a reaction" +
 | 
				
			||||||
 | 
					                "</font>"
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        if (type === "RoomMessageEmote")
 | 
					        if (type === "RoomMessageEmote")
 | 
				
			||||||
            return ev.content.match(/^\s*<(p|h[1-6])>/) ?
 | 
					            return ev.content.match(/^\s*<(p|h[1-6])>/) ?
 | 
				
			||||||
                   ev.content.replace(/(^\s*<(p|h[1-6])>)/, `$1${sender} `) :
 | 
					                   ev.content.replace(/(^\s*<(p|h[1-6])>)/, `$1${sender} `) :
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -367,7 +367,7 @@ int main(int argc, char *argv[]) {
 | 
				
			|||||||
    QApplication::setOrganizationName("moment");
 | 
					    QApplication::setOrganizationName("moment");
 | 
				
			||||||
    QApplication::setApplicationName("moment");
 | 
					    QApplication::setApplicationName("moment");
 | 
				
			||||||
    QApplication::setApplicationDisplayName("Moment");
 | 
					    QApplication::setApplicationDisplayName("Moment");
 | 
				
			||||||
    QApplication::setApplicationVersion("0.7.3");
 | 
					    QApplication::setApplicationVersion("0.7.5");
 | 
				
			||||||
    QApplication::setAttribute(Qt::AA_EnableHighDpiScaling);
 | 
					    QApplication::setAttribute(Qt::AA_EnableHighDpiScaling);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // app needs to be constructed before attempting to migrate
 | 
					    // app needs to be constructed before attempting to migrate
 | 
				
			||||||
 
 | 
				
			|||||||
 Submodule submodules/SortFilterProxyModel updated: 36befddf5d...5a930885b7
									
								
							 Submodule submodules/hsluv-c updated: 9e9be32d60...c0cb66d62f
									
								
							
		Reference in New Issue
	
	Block a user