Compare commits
93 Commits
v0.0.9
...
v0.0.11-al
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9cdb3371a | ||
|
|
0ff8828a1c | ||
|
|
331a6bca89 | ||
|
|
963ce3c7c2 | ||
|
|
ec88f4441e | ||
|
|
34d3b844af | ||
|
|
52010d44d7 | ||
|
|
0ef5c39f7f | ||
|
|
fed19d7a4b | ||
|
|
ed9ee26854 | ||
|
|
2e6c711644 | ||
|
|
d1b03880f3 | ||
|
|
d961fe3f7b | ||
|
|
9e40b49e5e | ||
|
|
33d3407694 | ||
|
|
f880358a83 | ||
|
|
f0de97a049 | ||
|
|
a9cb5608f0 | ||
|
|
c420c9dd65 | ||
|
|
ba7d0392d8 | ||
|
|
9ed9400b67 | ||
|
|
84eaadc09a | ||
|
|
998e50f4a5 | ||
|
|
f39261ff84 | ||
|
|
98aa2f871d | ||
|
|
952374aab0 | ||
|
|
e99674b245 | ||
|
|
82ed796a91 | ||
|
|
3296f58859 | ||
|
|
26802bab55 | ||
|
|
fd3fef5c9e | ||
|
|
af96bfbb41 | ||
|
|
5f927ce9c3 | ||
|
|
6e923f3878 | ||
|
|
ebd89423e9 | ||
|
|
9fce71f896 | ||
|
|
93502f9993 | ||
|
|
6529e61963 | ||
|
|
a9c1e69a89 | ||
|
|
3e45ca3d2c | ||
|
|
7dd09e32a8 | ||
|
|
1dcd658928 | ||
|
|
382a72a468 | ||
|
|
591fc0af83 | ||
|
|
2b6363f529 | ||
|
|
6470e845e0 | ||
|
|
b023e38f77 | ||
|
|
e66a8c6716 | ||
|
|
9a9bdb4862 | ||
|
|
e40a8a8d2e | ||
|
|
f4492c9f77 | ||
|
|
a32915b7e9 | ||
|
|
3355eb2d26 | ||
|
|
7b6c5df268 | ||
|
|
2e6376ff86 | ||
|
|
480888a1fc | ||
|
|
4fc05c7b40 | ||
|
|
3003f0a528 | ||
|
|
df3896df9c | ||
|
|
2a66496913 | ||
|
|
b4fc574163 | ||
|
|
e63341fe32 | ||
|
|
657e61fe2e | ||
|
|
94999dc4c0 | ||
|
|
54cb7991be | ||
|
|
c94d7d0ad7 | ||
|
|
d44961c461 | ||
|
|
6d80b516f8 | ||
|
|
04480eda1b | ||
|
|
653287478e | ||
|
|
4571788678 | ||
|
|
9a1adfb287 | ||
|
|
cb4455655f | ||
|
|
4fc71c9291 | ||
|
|
d8d8e91295 | ||
|
|
497be7f099 | ||
|
|
64e4f67e43 | ||
|
|
a18d0f54eb | ||
|
|
59e1862e9c | ||
|
|
14415a30fc | ||
|
|
6c0d126f4b | ||
|
|
c6982c9737 | ||
|
|
46f6d37f76 | ||
|
|
3971801aa3 | ||
|
|
7bc34c8145 | ||
|
|
91ca50aecb | ||
|
|
949100bdc7 | ||
|
|
b995906c79 | ||
|
|
e5b284ed19 | ||
|
|
0f17bbfa17 | ||
|
|
aba72aa64d | ||
|
|
72d35431de | ||
|
|
a98bbd97be |
94
.github/workflows/binaries.yml
vendored
Normal file
94
.github/workflows/binaries.yml
vendored
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
name: Binaries
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
package:
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
platform: [ubuntu-latest, windows-latest, macos-latest]
|
||||||
|
arch: [x86_64, aarch64]
|
||||||
|
exclude:
|
||||||
|
- platform: windows-latest
|
||||||
|
arch: aarch64
|
||||||
|
include:
|
||||||
|
- platform: ubuntu-latest
|
||||||
|
arch: x86_64
|
||||||
|
triple: unknown-linux-musl
|
||||||
|
- platform: ubuntu-latest
|
||||||
|
arch: aarch64
|
||||||
|
triple: unknown-linux-gnu
|
||||||
|
- platform: macos-latest
|
||||||
|
triple: apple-darwin
|
||||||
|
- platform: windows-latest
|
||||||
|
triple: pc-windows-msvc
|
||||||
|
runs-on: ${{ matrix.platform }}
|
||||||
|
env:
|
||||||
|
TARGET: ${{ matrix.arch }}-${{ matrix.triple }}
|
||||||
|
SCCACHE_GHA_ENABLED: "true"
|
||||||
|
RUSTC_WRAPPER: "sccache"
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
submodules: true
|
||||||
|
- name: Install Rust (stable)
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
targets: ${{ env.TARGET }}
|
||||||
|
- name: Install C cross-compilation toolchain
|
||||||
|
if: matrix.platform == 'ubuntu-latest'
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt install -f -y build-essential crossbuild-essential-arm64 musl-dev
|
||||||
|
# Cross-compilation env vars for x86_64-unknown-linux-musl
|
||||||
|
echo CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER=x86_64-linux-musl-gcc >> $GITHUB_ENV
|
||||||
|
echo AR_x86_64_unknown_linux_musl=x86_64-linux-gnu-ar >> $GITHUB_ENV
|
||||||
|
echo CC_x86_64_unknown_linux_musl=x86_64-linux-musl-gcc >> $GITHUB_ENV
|
||||||
|
echo CXX_x86_64_unknown_linux_musl=x86_64-linux-gnu-g++ >> $GITHUB_ENV
|
||||||
|
# Cross-compilation env vars for aarch64-unknown-linux-gnu
|
||||||
|
echo CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc >> $GITHUB_ENV
|
||||||
|
echo AR_aarch64_unknown_linux_gnu=aarch64-linux-gnu-ar >> $GITHUB_ENV
|
||||||
|
echo CC_aarch64_unknown_linux_gnu=aarch64-linux-gnu-gcc >> $GITHUB_ENV
|
||||||
|
echo CXX_aarch64_unknown_linux_gnu=aarch64-linux-gnu-g++ >> $GITHUB_ENV
|
||||||
|
- name: Cache cargo registry
|
||||||
|
uses: actions/cache@v3
|
||||||
|
with:
|
||||||
|
path: ~/.cargo/registry
|
||||||
|
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
- name: Run sccache-cache
|
||||||
|
uses: mozilla-actions/sccache-action@v0.0.9
|
||||||
|
- name: 'Build: binary'
|
||||||
|
run: cargo +stable build --release --locked --target ${{ env.TARGET }}
|
||||||
|
- name: 'Upload: binary'
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: iamb-${{ env.TARGET }}-binary
|
||||||
|
path: |
|
||||||
|
./target/${{ env.TARGET }}/release/iamb
|
||||||
|
./target/${{ env.TARGET }}/release/iamb.exe
|
||||||
|
- name: 'Package: deb'
|
||||||
|
if: matrix.platform == 'ubuntu-latest'
|
||||||
|
run: |
|
||||||
|
cargo +stable install --locked cargo-deb
|
||||||
|
cargo +stable deb --no-strip --target ${{ env.TARGET }}
|
||||||
|
- name: 'Upload: deb'
|
||||||
|
if: matrix.platform == 'ubuntu-latest'
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: iamb-${{ env.TARGET }}-deb
|
||||||
|
path: ./target/${{ env.TARGET }}/debian/iamb*.deb
|
||||||
|
- name: 'Package: rpm'
|
||||||
|
if: matrix.platform == 'ubuntu-latest'
|
||||||
|
run: |
|
||||||
|
cargo +stable install --locked cargo-generate-rpm
|
||||||
|
cargo +stable generate-rpm --target ${{ env.TARGET }}
|
||||||
|
- name: 'Upload: rpm'
|
||||||
|
if: matrix.platform == 'ubuntu-latest'
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: iamb-${{ env.TARGET }}-rpm
|
||||||
|
path: ./target/${{ env.TARGET }}/generate-rpm/iamb*.rpm
|
||||||
15
.github/workflows/ci.yml
vendored
15
.github/workflows/ci.yml
vendored
@@ -22,8 +22,8 @@ jobs:
|
|||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
with:
|
with:
|
||||||
submodules: true
|
submodules: true
|
||||||
- name: Install Rust (1.70 w/ clippy)
|
- name: Install Rust (1.83 w/ clippy)
|
||||||
uses: dtolnay/rust-toolchain@1.70
|
uses: dtolnay/rust-toolchain@1.83
|
||||||
with:
|
with:
|
||||||
components: clippy
|
components: clippy
|
||||||
- name: Install Rust (nightly w/ rustfmt)
|
- name: Install Rust (nightly w/ rustfmt)
|
||||||
@@ -34,7 +34,7 @@ jobs:
|
|||||||
path: ~/.cargo/registry
|
path: ~/.cargo/registry
|
||||||
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
|
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
|
||||||
- name: Run sccache-cache
|
- name: Run sccache-cache
|
||||||
uses: mozilla-actions/sccache-action@v0.0.3
|
uses: mozilla-actions/sccache-action@v0.0.9
|
||||||
- name: Check formatting
|
- name: Check formatting
|
||||||
run: cargo +nightly fmt --all -- --check
|
run: cargo +nightly fmt --all -- --check
|
||||||
- name: Check Clippy
|
- name: Check Clippy
|
||||||
@@ -45,12 +45,3 @@ jobs:
|
|||||||
reporter: 'github-check'
|
reporter: 'github-check'
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: cargo test --locked
|
run: cargo test --locked
|
||||||
- name: Build artifacts
|
|
||||||
run: cargo build --release --locked
|
|
||||||
- name: Upload artifacts
|
|
||||||
uses: actions/upload-artifact@master
|
|
||||||
with:
|
|
||||||
name: iamb-${{ matrix.platform }}
|
|
||||||
path: |
|
|
||||||
./target/release/iamb
|
|
||||||
./target/release/iamb.exe
|
|
||||||
|
|||||||
4065
Cargo.lock
generated
4065
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
75
Cargo.toml
75
Cargo.toml
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "iamb"
|
name = "iamb"
|
||||||
version = "0.0.9"
|
version = "0.0.11-alpha.1"
|
||||||
edition = "2018"
|
edition = "2018"
|
||||||
authors = ["Ulyssa <git@ulyssa.dev>"]
|
authors = ["Ulyssa <git@ulyssa.dev>"]
|
||||||
repository = "https://github.com/ulyssa/iamb"
|
repository = "https://github.com/ulyssa/iamb"
|
||||||
@@ -11,12 +11,13 @@ license = "Apache-2.0"
|
|||||||
exclude = [".github", "CONTRIBUTING.md"]
|
exclude = [".github", "CONTRIBUTING.md"]
|
||||||
keywords = ["matrix", "chat", "tui", "vim"]
|
keywords = ["matrix", "chat", "tui", "vim"]
|
||||||
categories = ["command-line-utilities"]
|
categories = ["command-line-utilities"]
|
||||||
rust-version = "1.70"
|
rust-version = "1.83"
|
||||||
build = "build.rs"
|
build = "build.rs"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["bundled"]
|
default = ["bundled", "desktop"]
|
||||||
bundled = ["matrix-sdk/bundled-sqlite", "rustls-tls"]
|
bundled = ["matrix-sdk/bundled-sqlite", "rustls-tls"]
|
||||||
|
desktop = ["dep:notify-rust", "modalkit/clipboard"]
|
||||||
native-tls = ["matrix-sdk/native-tls"]
|
native-tls = ["matrix-sdk/native-tls"]
|
||||||
rustls-tls = ["matrix-sdk/rustls-tls"]
|
rustls-tls = ["matrix-sdk/rustls-tls"]
|
||||||
|
|
||||||
@@ -26,27 +27,27 @@ default-features = false
|
|||||||
features = ["build", "git", "gitcl",]
|
features = ["build", "git", "gitcl",]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
arboard = "3.3.0"
|
anyhow = "1.0"
|
||||||
bitflags = "^2.3"
|
bitflags = "^2.3"
|
||||||
chrono = "0.4"
|
chrono = "0.4"
|
||||||
clap = {version = "~4.3", features = ["derive"]}
|
clap = {version = "~4.3", features = ["derive"]}
|
||||||
comrak = {version = "0.18.0", features = ["shortcodes"]}
|
|
||||||
css-color-parser = "0.1.2"
|
css-color-parser = "0.1.2"
|
||||||
dirs = "4.0.0"
|
dirs = "4.0.0"
|
||||||
emojis = "~0.5.2"
|
emojis = "0.5"
|
||||||
|
feruca = "0.10.1"
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
gethostname = "0.4.1"
|
gethostname = "0.4.1"
|
||||||
html5ever = "0.26.0"
|
html5ever = "0.26.0"
|
||||||
image = "0.24.5"
|
image = "^0.25.6"
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
markup5ever_rcdom = "0.2.0"
|
markup5ever_rcdom = "0.2.0"
|
||||||
mime = "^0.3.16"
|
mime = "^0.3.16"
|
||||||
mime_guess = "^2.0.4"
|
mime_guess = "^2.0.4"
|
||||||
notify-rust = { version = "4.10.0", default-features = false, features = ["zbus", "serde"] }
|
nom = "7.0.0"
|
||||||
open = "3.2.0"
|
open = "3.2.0"
|
||||||
rand = "0.8.5"
|
rand = "0.8.5"
|
||||||
ratatui = "0.23"
|
ratatui = "0.29.0"
|
||||||
ratatui-image = { version = "0.8.1", features = ["serde"] }
|
ratatui-image = { version = "~8.0.1", features = ["serde"] }
|
||||||
regex = "^1.5"
|
regex = "^1.5"
|
||||||
rpassword = "^7.2"
|
rpassword = "^7.2"
|
||||||
serde = "^1.0"
|
serde = "^1.0"
|
||||||
@@ -62,19 +63,33 @@ unicode-segmentation = "^1.7"
|
|||||||
unicode-width = "0.1.10"
|
unicode-width = "0.1.10"
|
||||||
url = {version = "^2.2.2", features = ["serde"]}
|
url = {version = "^2.2.2", features = ["serde"]}
|
||||||
edit = "0.1.4"
|
edit = "0.1.4"
|
||||||
|
humansize = "2.0.0"
|
||||||
|
linkify = "0.10.0"
|
||||||
|
|
||||||
|
[dependencies.comrak]
|
||||||
|
version = "0.22.0"
|
||||||
|
default-features = false
|
||||||
|
features = ["shortcodes"]
|
||||||
|
|
||||||
|
[dependencies.notify-rust]
|
||||||
|
version = "~4.10.0"
|
||||||
|
default-features = false
|
||||||
|
features = ["zbus", "serde"]
|
||||||
|
optional = true
|
||||||
|
|
||||||
[dependencies.modalkit]
|
[dependencies.modalkit]
|
||||||
version = "0.0.18"
|
version = "0.0.23"
|
||||||
|
default-features = false
|
||||||
#git = "https://github.com/ulyssa/modalkit"
|
#git = "https://github.com/ulyssa/modalkit"
|
||||||
#rev = "cb8c8aeb9a499b9b16615ce144f9014d78036e01"
|
#rev = "e40dbb0bfeabe4cfd08facd2acb446080a330d75"
|
||||||
|
|
||||||
[dependencies.modalkit-ratatui]
|
[dependencies.modalkit-ratatui]
|
||||||
version = "0.0.18"
|
version = "0.0.23"
|
||||||
#git = "https://github.com/ulyssa/modalkit"
|
#git = "https://github.com/ulyssa/modalkit"
|
||||||
#rev = "cb8c8aeb9a499b9b16615ce144f9014d78036e01"
|
#rev = "e40dbb0bfeabe4cfd08facd2acb446080a330d75"
|
||||||
|
|
||||||
[dependencies.matrix-sdk]
|
[dependencies.matrix-sdk]
|
||||||
version = "0.7.1"
|
version = "0.10.0"
|
||||||
default-features = false
|
default-features = false
|
||||||
features = ["e2e-encryption", "sqlite", "sso-login"]
|
features = ["e2e-encryption", "sqlite", "sso-login"]
|
||||||
|
|
||||||
@@ -90,3 +105,33 @@ pretty_assertions = "1.4.0"
|
|||||||
inherits = "release"
|
inherits = "release"
|
||||||
incremental = false
|
incremental = false
|
||||||
lto = true
|
lto = true
|
||||||
|
|
||||||
|
[package.metadata.deb]
|
||||||
|
section = "net"
|
||||||
|
license-file = ["LICENSE", "0"]
|
||||||
|
assets = [
|
||||||
|
# Binary:
|
||||||
|
["target/release/iamb", "usr/bin/iamb", "755"],
|
||||||
|
# Manual pages:
|
||||||
|
["docs/iamb.1", "usr/share/man/man1/iamb.1", "644"],
|
||||||
|
["docs/iamb.5", "usr/share/man/man5/iamb.5", "644"],
|
||||||
|
# Other assets:
|
||||||
|
["iamb.desktop", "usr/share/applications/iamb.desktop", "644"],
|
||||||
|
["config.example.toml", "usr/share/iamb/config.example.toml", "644"],
|
||||||
|
["docs/iamb.svg", "usr/share/icons/hicolor/scalable/apps/iamb.svg", "644"],
|
||||||
|
["docs/iamb.metainfo.xml", "usr/share/metainfo/iamb.metainfo.xml", "644"],
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata.generate-rpm]
|
||||||
|
assets = [
|
||||||
|
# Binary:
|
||||||
|
{ source = "target/release/iamb", dest = "/usr/bin/iamb", mode = "755" },
|
||||||
|
# Manual pages:
|
||||||
|
{ source = "docs/iamb.1", dest = "/usr/share/man/man1/iamb.1", mode = "644" },
|
||||||
|
{ source = "docs/iamb.5", dest = "/usr/share/man/man5/iamb.5", mode = "644" },
|
||||||
|
# Other assets:
|
||||||
|
{ source = "iamb.desktop", dest = "/usr/share/applications/iamb.desktop", mode = "644" },
|
||||||
|
{ source = "config.example.toml", dest = "/usr/share/iamb/config.example.toml", mode = "644"},
|
||||||
|
{ source = "docs/iamb.svg", dest = "/usr/share/icons/hicolor/scalable/apps/iamb.svg", mode = "644"},
|
||||||
|
{ source = "docs/iamb.metainfo.xml", dest = "/usr/share/metainfo/iamb.metainfo.xml", mode = "644"},
|
||||||
|
]
|
||||||
|
|||||||
2
LICENSE
2
LICENSE
@@ -186,7 +186,7 @@
|
|||||||
same "printed page" as the copyright notice for easier
|
same "printed page" as the copyright notice for easier
|
||||||
identification within third-party archives.
|
identification within third-party archives.
|
||||||
|
|
||||||
Copyright [yyyy] [name of copyright owner]
|
Copyright 2024 Ulyssa Mello
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
you may not use this file except in compliance with the License.
|
you may not use this file except in compliance with the License.
|
||||||
|
|||||||
@@ -28,8 +28,9 @@ and GCC are present.
|
|||||||
In addition to the compiled binary, there are other files in the repo that
|
In addition to the compiled binary, there are other files in the repo that
|
||||||
you'll want to install as part of a package:
|
you'll want to install as part of a package:
|
||||||
|
|
||||||
|
<!-- Please keep in sync w/ the `deb`/`generate-rpm` sections of `Cargo.toml` -->
|
||||||
| Repository Path | Installed Path (may vary per OS) |
|
| Repository Path | Installed Path (may vary per OS) |
|
||||||
| -------------------- | ----------------------------------------------- |
|
| ----------------------- | ----------------------------------------------- |
|
||||||
| /iamb.desktop | /usr/share/applications/iamb.desktop |
|
| /iamb.desktop | /usr/share/applications/iamb.desktop |
|
||||||
| /config.example.toml | /usr/share/iamb/config.example.toml |
|
| /config.example.toml | /usr/share/iamb/config.example.toml |
|
||||||
| /docs/iamb-256x256.png | /usr/share/icons/hicolor/256x256/apps/iamb.png |
|
| /docs/iamb-256x256.png | /usr/share/icons/hicolor/256x256/apps/iamb.png |
|
||||||
@@ -37,6 +38,7 @@ you'll want to install as part of a package:
|
|||||||
| /docs/iamb.svg | /usr/share/icons/hicolor/scalable/apps/iamb.svg |
|
| /docs/iamb.svg | /usr/share/icons/hicolor/scalable/apps/iamb.svg |
|
||||||
| /docs/iamb.1 | /usr/share/man/man1/iamb.1 |
|
| /docs/iamb.1 | /usr/share/man/man1/iamb.1 |
|
||||||
| /docs/iamb.5 | /usr/share/man/man5/iamb.5 |
|
| /docs/iamb.5 | /usr/share/man/man5/iamb.5 |
|
||||||
|
| /docs/iamb.metainfo.xml | /usr/share/metainfo/iamb.metainfo.xml |
|
||||||
|
|
||||||
[ring-lto]: https://github.com/briansmith/ring/issues/1444
|
[ring-lto]: https://github.com/briansmith/ring/issues/1444
|
||||||
[rustls]: https://crates.io/crates/rustls
|
[rustls]: https://crates.io/crates/rustls
|
||||||
|
|||||||
139
README.md
139
README.md
@@ -11,7 +11,6 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
## About
|
## About
|
||||||
|
|
||||||
`iamb` is a Matrix client for the terminal that uses Vim keybindings. It includes support for:
|
`iamb` is a Matrix client for the terminal that uses Vim keybindings. It includes support for:
|
||||||
@@ -19,6 +18,7 @@
|
|||||||
- Threads, spaces, E2EE, and read receipts
|
- Threads, spaces, E2EE, and read receipts
|
||||||
- Image previews in terminals that support it (sixels, Kitty, and iTerm2), or using pixelated blocks for those that don't
|
- Image previews in terminals that support it (sixels, Kitty, and iTerm2), or using pixelated blocks for those that don't
|
||||||
- Notifications via terminal bell or desktop environment
|
- Notifications via terminal bell or desktop environment
|
||||||
|
- Send Markdown, HTML or plaintext messages
|
||||||
- Creating, joining, and leaving rooms
|
- Creating, joining, and leaving rooms
|
||||||
- Sending and accepting room invitations
|
- Sending and accepting room invitations
|
||||||
- Editing, redacting, and reacting to messages
|
- Editing, redacting, and reacting to messages
|
||||||
@@ -32,54 +32,6 @@ _You may want to [see this page as it was when the latest version was published]
|
|||||||
You can find documentation for installing, configuring, and using iamb on its
|
You can find documentation for installing, configuring, and using iamb on its
|
||||||
website, [iamb.chat].
|
website, [iamb.chat].
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
Install Rust (1.70.0 or above) and Cargo, and then run:
|
|
||||||
|
|
||||||
```
|
|
||||||
cargo install --locked iamb
|
|
||||||
```
|
|
||||||
|
|
||||||
See [Configuration](#configuration) for getting a profile set up.
|
|
||||||
|
|
||||||
### NetBSD
|
|
||||||
|
|
||||||
On NetBSD a package is available from the official repositories. To install it simply run:
|
|
||||||
|
|
||||||
```
|
|
||||||
pkgin install iamb
|
|
||||||
```
|
|
||||||
|
|
||||||
### Arch Linux
|
|
||||||
|
|
||||||
On Arch Linux a [package](https://aur.archlinux.org/packages/iamb-git) is available in the
|
|
||||||
Arch User Repositories (AUR). To install it simply run with your favorite AUR helper:
|
|
||||||
|
|
||||||
```
|
|
||||||
paru iamb-git
|
|
||||||
```
|
|
||||||
### openSUSE Tumbleweed
|
|
||||||
|
|
||||||
On openSUSE Tumbleweed a [package](https://build.opensuse.org/package/show/home%3Asmolsheep/iamb) is available from openSUSE Build Service (OBS). To install just use OBS Package Installer:
|
|
||||||
|
|
||||||
```
|
|
||||||
opi iamb
|
|
||||||
```
|
|
||||||
|
|
||||||
### Nix / NixOS (flake)
|
|
||||||
|
|
||||||
```
|
|
||||||
nix profile install "github:ulyssa/iamb"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Snap
|
|
||||||
|
|
||||||
A snap for Linux distributions which [support](https://snapcraft.io/docs/installing-snapd) the packaging system.
|
|
||||||
|
|
||||||
```
|
|
||||||
snap install iamb
|
|
||||||
```
|
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
You can create a basic configuration in `$CONFIG_DIR/iamb/config.toml` that looks like:
|
You can create a basic configuration in `$CONFIG_DIR/iamb/config.toml` that looks like:
|
||||||
@@ -99,14 +51,97 @@ url = "https://example.com"
|
|||||||
user_id = "@user:example.com"
|
user_id = "@user:example.com"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Installation (via `crates.io`)
|
||||||
|
|
||||||
|
Install Rust (1.83.0 or above) and Cargo, and then run:
|
||||||
|
|
||||||
|
```
|
||||||
|
cargo install --locked iamb
|
||||||
|
```
|
||||||
|
|
||||||
|
See [Configuration](#configuration) for getting a profile set up.
|
||||||
|
|
||||||
|
## Installation (via package managers)
|
||||||
|
|
||||||
|
### Arch Linux
|
||||||
|
|
||||||
|
On Arch Linux a [package](https://aur.archlinux.org/packages/iamb-git) is available in the
|
||||||
|
Arch User Repositories (AUR). To install it simply run with your favorite AUR helper:
|
||||||
|
|
||||||
|
```
|
||||||
|
paru iamb-git
|
||||||
|
```
|
||||||
|
|
||||||
|
### FreeBSD
|
||||||
|
|
||||||
|
On FreeBSD a package is available from the official repositories. To install it simply run:
|
||||||
|
|
||||||
|
```
|
||||||
|
pkg install iamb
|
||||||
|
```
|
||||||
|
|
||||||
|
### Gentoo
|
||||||
|
|
||||||
|
On Gentoo, an ebuild is available from the community-managed
|
||||||
|
[GURU overlay](https://wiki.gentoo.org/wiki/Project:GURU).
|
||||||
|
|
||||||
|
You can enable the GURU overlay with:
|
||||||
|
|
||||||
|
```
|
||||||
|
eselect repository enable guru
|
||||||
|
emerge --sync guru
|
||||||
|
```
|
||||||
|
|
||||||
|
And then install `iamb` with:
|
||||||
|
|
||||||
|
```
|
||||||
|
emerge --ask iamb
|
||||||
|
```
|
||||||
|
|
||||||
|
### macOS
|
||||||
|
|
||||||
|
On macOS a [package](https://formulae.brew.sh/formula/iamb#default) is available in Homebrew's
|
||||||
|
repository. To install it simply run:
|
||||||
|
|
||||||
|
```
|
||||||
|
brew install iamb
|
||||||
|
```
|
||||||
|
|
||||||
|
### NetBSD
|
||||||
|
|
||||||
|
On NetBSD a package is available from the official repositories. To install it simply run:
|
||||||
|
|
||||||
|
```
|
||||||
|
pkgin install iamb
|
||||||
|
```
|
||||||
|
|
||||||
|
### Nix / NixOS (flake)
|
||||||
|
|
||||||
|
```
|
||||||
|
nix profile install "github:ulyssa/iamb"
|
||||||
|
```
|
||||||
|
|
||||||
|
### openSUSE Tumbleweed
|
||||||
|
|
||||||
|
On openSUSE Tumbleweed a [package](https://build.opensuse.org/package/show/openSUSE:Factory/iamb) is available from the official repositories. To install it simply run:
|
||||||
|
|
||||||
|
```
|
||||||
|
zypper install iamb
|
||||||
|
```
|
||||||
|
|
||||||
|
### Snap
|
||||||
|
|
||||||
|
A snap for Linux distributions which [support](https://snapcraft.io/docs/installing-snapd) the packaging system.
|
||||||
|
|
||||||
|
```
|
||||||
|
snap install iamb
|
||||||
|
```
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
iamb is released under the [Apache License, Version 2.0].
|
iamb is released under the [Apache License, Version 2.0].
|
||||||
|
|
||||||
[Apache License, Version 2.0]: https://github.com/ulyssa/iamb/blob/master/LICENSE
|
[Apache License, Version 2.0]: https://github.com/ulyssa/iamb/blob/master/LICENSE
|
||||||
[client-comparison-matrix]: https://matrix.org/clients-matrix/
|
|
||||||
[crates-io-iamb]: https://crates.io/crates/iamb
|
[crates-io-iamb]: https://crates.io/crates/iamb
|
||||||
[iamb.chat]: https://iamb.chat
|
[iamb.chat]: https://iamb.chat
|
||||||
[gomuks]: https://github.com/tulir/gomuks
|
|
||||||
[weechat-matrix]: https://github.com/poljar/weechat-matrix
|
|
||||||
[well_known_entry]: https://spec.matrix.org/latest/client-server-api/#getwell-knownmatrixclient
|
[well_known_entry]: https://spec.matrix.org/latest/client-server-api/#getwell-knownmatrixclient
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ url = "https://matrix.org"
|
|||||||
|
|
||||||
[settings]
|
[settings]
|
||||||
default_room = "#iamb-users:0x.badd.cafe"
|
default_room = "#iamb-users:0x.badd.cafe"
|
||||||
|
external_edit_file_suffix = ".md"
|
||||||
log_level = "warn"
|
log_level = "warn"
|
||||||
message_shortcode_display = false
|
message_shortcode_display = false
|
||||||
open_command = ["my-open", "--file"]
|
open_command = ["my-open", "--file"]
|
||||||
|
|||||||
141
docs/iamb.1
141
docs/iamb.1
@@ -54,13 +54,17 @@ version and quit.
|
|||||||
View a list of joined rooms and direct messages.
|
View a list of joined rooms and direct messages.
|
||||||
.It Sy ":dms"
|
.It Sy ":dms"
|
||||||
View a list of direct messages.
|
View a list of direct messages.
|
||||||
.It Sy ":logout"
|
.It Sy ":logout [user id]"
|
||||||
Log out of
|
Log out of
|
||||||
.Nm .
|
.Nm .
|
||||||
.It Sy ":rooms"
|
.It Sy ":rooms"
|
||||||
View a list of joined rooms.
|
View a list of joined rooms.
|
||||||
.It Sy ":spaces"
|
.It Sy ":spaces"
|
||||||
View a list of joined spaces.
|
View a list of joined spaces.
|
||||||
|
.It Sy ":unreads"
|
||||||
|
View a list of unread rooms.
|
||||||
|
.It Sy ":unreads clear"
|
||||||
|
Mark all rooms as read.
|
||||||
.It Sy ":welcome"
|
.It Sy ":welcome"
|
||||||
View the startup Welcome window.
|
View the startup Welcome window.
|
||||||
.El
|
.El
|
||||||
@@ -75,37 +79,54 @@ Import and decrypt keys from
|
|||||||
.Pa path .
|
.Pa path .
|
||||||
.It Sy ":verify"
|
.It Sy ":verify"
|
||||||
View a list of ongoing E2EE verifications.
|
View a list of ongoing E2EE verifications.
|
||||||
|
.It Sy ":verify accept [key]"
|
||||||
|
Accept a verification request.
|
||||||
|
.It Sy ":verify cancel [key]"
|
||||||
|
Cancel an in-progress verification.
|
||||||
|
.It Sy ":verify confirm [key]"
|
||||||
|
Confirm an in-progress verification.
|
||||||
|
.It Sy ":verify mismatch [key]"
|
||||||
|
Reject an in-progress verification due to mismatched Emoji.
|
||||||
|
.It Sy ":verify request [user id]"
|
||||||
|
Request a new verification with the specified user.
|
||||||
.El
|
.El
|
||||||
|
|
||||||
.Sh "MESSAGE COMMANDS"
|
.Sh "MESSAGE COMMANDS"
|
||||||
.Bl -tag -width Ds
|
.Bl -tag -width Ds
|
||||||
.It Sy ":download"
|
.It Sy ":download [path]"
|
||||||
Download an attachment from the selected message.
|
Download an attachment from the selected message and save it to the optional path.
|
||||||
|
.It Sy ":open [path]"
|
||||||
|
Download and then open an attachment, or open a link in a message.
|
||||||
.It Sy ":edit"
|
.It Sy ":edit"
|
||||||
Edit the selected message.
|
Edit the selected message.
|
||||||
.It Sy ":editor"
|
.It Sy ":editor"
|
||||||
Open an external
|
Open an external
|
||||||
.Ev $EDITOR
|
.Ev $EDITOR
|
||||||
to compose a message.
|
to compose a message.
|
||||||
.It Sy ":open"
|
|
||||||
Download and then open an attachment, or open a link in a message.
|
|
||||||
.It Sy ":react [shortcode]"
|
.It Sy ":react [shortcode]"
|
||||||
React to the selected message with an Emoji.
|
React to the selected message with an Emoji.
|
||||||
.It Sy ":redact [reason]"
|
|
||||||
Redact the selected message.
|
|
||||||
.It Sy ":reply"
|
|
||||||
Reply to the selected message.
|
|
||||||
.It Sy ":unreact [shortcode]"
|
.It Sy ":unreact [shortcode]"
|
||||||
Remove your reaction from the selected message.
|
Remove your reaction from the selected message.
|
||||||
When no arguments are given, remove all of your reactions from the message.
|
When no arguments are given, remove all of your reactions from the message.
|
||||||
.It Sy ":upload"
|
.It Sy ":redact [reason]"
|
||||||
|
Redact the selected message with the optional reason.
|
||||||
|
.It Sy ":reply"
|
||||||
|
Reply to the selected message.
|
||||||
|
.It Sy ":cancel"
|
||||||
|
Cancel the currently drafted message including replies.
|
||||||
|
.It Sy ":upload [path]"
|
||||||
Upload an attachment and send it to the currently selected room.
|
Upload an attachment and send it to the currently selected room.
|
||||||
.El
|
.El
|
||||||
|
|
||||||
.Sh "ROOM COMMANDS"
|
.Sh "ROOM COMMANDS"
|
||||||
.Bl -tag -width Ds
|
.Bl -tag -width Ds
|
||||||
.It Sy ":create"
|
.It Sy ":create [arguments]"
|
||||||
Create a new room.
|
Create a new room. Arguments can be
|
||||||
|
.Dq ++alias=[alias] ,
|
||||||
|
.Dq ++public ,
|
||||||
|
.Dq ++space ,
|
||||||
|
and
|
||||||
|
.Dq ++encrypted .
|
||||||
.It Sy ":invite accept"
|
.It Sy ":invite accept"
|
||||||
Accept an invitation to the currently focused room.
|
Accept an invitation to the currently focused room.
|
||||||
.It Sy ":invite reject"
|
.It Sy ":invite reject"
|
||||||
@@ -113,7 +134,7 @@ Reject an invitation to the currently focused room.
|
|||||||
.It Sy ":invite send [user]"
|
.It Sy ":invite send [user]"
|
||||||
Send an invitation to a user to join the currently focused room.
|
Send an invitation to a user to join the currently focused room.
|
||||||
.It Sy ":join [room]"
|
.It Sy ":join [room]"
|
||||||
Join a room.
|
Join a room or open it if you are already joined.
|
||||||
.It Sy ":leave"
|
.It Sy ":leave"
|
||||||
Leave the currently focused room.
|
Leave the currently focused room.
|
||||||
.It Sy ":members"
|
.It Sy ":members"
|
||||||
@@ -122,6 +143,29 @@ View a list of members of the currently focused room.
|
|||||||
Set the name of the currently focused room.
|
Set the name of the currently focused room.
|
||||||
.It Sy ":room name unset"
|
.It Sy ":room name unset"
|
||||||
Unset the name of the currently focused room.
|
Unset the name of the currently focused room.
|
||||||
|
.It Sy ":room dm set"
|
||||||
|
Mark the currently focused room as a direct message.
|
||||||
|
.It Sy ":room dm unset"
|
||||||
|
Mark the currently focused room as a normal room.
|
||||||
|
.It Sy ":room notify set [level]"
|
||||||
|
Set a notification level for the currently focused room.
|
||||||
|
Valid levels are
|
||||||
|
.Dq mute ,
|
||||||
|
.Dq mentions ,
|
||||||
|
.Dq keywords ,
|
||||||
|
and
|
||||||
|
.Dq all .
|
||||||
|
Note that
|
||||||
|
.Dq mentions
|
||||||
|
and
|
||||||
|
.Dq keywords
|
||||||
|
are aliases for the same behaviour.
|
||||||
|
.It Sy ":room notify unset"
|
||||||
|
Unset any room-level notification configuration.
|
||||||
|
.It Sy ":room notify show"
|
||||||
|
Show the current room-level notification configuration.
|
||||||
|
If the room is using the account-level default, then this will print
|
||||||
|
.Dq default .
|
||||||
.It Sy ":room tag set [tag]"
|
.It Sy ":room tag set [tag]"
|
||||||
Add a tag to the currently focused room.
|
Add a tag to the currently focused room.
|
||||||
.It Sy ":room tag unset [tag]"
|
.It Sy ":room tag unset [tag]"
|
||||||
@@ -130,6 +174,40 @@ Remove a tag from the currently focused room.
|
|||||||
Set the topic of the currently focused room.
|
Set the topic of the currently focused room.
|
||||||
.It Sy ":room topic unset"
|
.It Sy ":room topic unset"
|
||||||
Unset the topic of the currently focused room.
|
Unset the topic of the currently focused room.
|
||||||
|
.It Sy ":room topic show"
|
||||||
|
Show the topic of the currently focused room.
|
||||||
|
.It Sy ":room alias set [alias]"
|
||||||
|
Create and point the given alias to the room.
|
||||||
|
.It Sy ":room alias unset [alias]"
|
||||||
|
Delete the provided alias from the room's alternative alias list.
|
||||||
|
.It Sy ":room alias show"
|
||||||
|
Show alternative aliases to the room, if any are set.
|
||||||
|
.It Sy ":room id show"
|
||||||
|
Show the Matrix identifier for the room.
|
||||||
|
.It Sy ":room canon set [alias]"
|
||||||
|
Set the room's canonical alias to the one provided, and make the previous one an alternative alias.
|
||||||
|
.It Sy ":room canon unset [alias]"
|
||||||
|
Delete the room's canonical alias.
|
||||||
|
.It Sy ":room canon show"
|
||||||
|
Show the room's canonical alias, if any is set.
|
||||||
|
.It Sy ":room ban [user] [reason]"
|
||||||
|
Ban a user from this room with an optional reason.
|
||||||
|
.It Sy ":room unban [user] [reason]"
|
||||||
|
Unban a user from this room with an optional reason.
|
||||||
|
.It Sy ":room kick [user] [reason]"
|
||||||
|
Kick a user from this room with an optional reason.
|
||||||
|
.El
|
||||||
|
|
||||||
|
.Sh "SPACE COMMANDS"
|
||||||
|
.Bl -tag -width Ds
|
||||||
|
.It Sy ":space child set [room_id] [arguments]"
|
||||||
|
Add a room to the currently focused space.
|
||||||
|
.Dq ++suggested
|
||||||
|
marks the room as a suggested child.
|
||||||
|
.Dq ++order=[string]
|
||||||
|
specifies a string by which children are lexicographically ordered.
|
||||||
|
.It Sy ":space child remove"
|
||||||
|
Remove the selected room from the currently focused space.
|
||||||
.El
|
.El
|
||||||
|
|
||||||
.Sh "WINDOW COMMANDS"
|
.Sh "WINDOW COMMANDS"
|
||||||
@@ -176,6 +254,43 @@ Close all but one tab.
|
|||||||
Go to the preview tab.
|
Go to the preview tab.
|
||||||
.El
|
.El
|
||||||
|
|
||||||
|
.Sh "SLASH COMMANDS"
|
||||||
|
.Bl -tag -width Ds
|
||||||
|
.It Sy "/markdown" , Sy "/md"
|
||||||
|
Interpret the message body as Markdown markup.
|
||||||
|
This is the default behaviour.
|
||||||
|
.It Sy "/html" , Sy "/h"
|
||||||
|
Send the message body as literal HTML.
|
||||||
|
.It Sy "/plaintext" , Sy "/plain" , Sy "/p"
|
||||||
|
Do not interpret any markup in the message body and send it as it is.
|
||||||
|
.It Sy "/me"
|
||||||
|
Send an emote message.
|
||||||
|
.It Sy "/confetti"
|
||||||
|
Produces no effect in
|
||||||
|
.Nm ,
|
||||||
|
but will display confetti in Matrix clients that support doing so.
|
||||||
|
.It Sy "/fireworks"
|
||||||
|
Produces no effect in
|
||||||
|
.Nm ,
|
||||||
|
but will display fireworks in Matrix clients that support doing so.
|
||||||
|
.It Sy "/hearts"
|
||||||
|
Produces no effect in
|
||||||
|
.Nm ,
|
||||||
|
but will display floating hearts in Matrix clients that support doing so.
|
||||||
|
.It Sy "/rainfall"
|
||||||
|
Produces no effect in
|
||||||
|
.Nm ,
|
||||||
|
but will display rainfall in Matrix clients that support doing so.
|
||||||
|
.It Sy "/snowfall"
|
||||||
|
Produces no effect in
|
||||||
|
.Nm ,
|
||||||
|
but will display snowfall in Matrix clients that support doing so.
|
||||||
|
.It Sy "/spaceinvaders"
|
||||||
|
Produces no effect in
|
||||||
|
.Nm ,
|
||||||
|
but will display aliens from Space Invaders in Matrix clients that support doing so.
|
||||||
|
.El
|
||||||
|
|
||||||
.Sh EXAMPLES
|
.Sh EXAMPLES
|
||||||
.Ss Example 1: Starting with a specific profile
|
.Ss Example 1: Starting with a specific profile
|
||||||
To start with a profile named
|
To start with a profile named
|
||||||
|
|||||||
37
docs/iamb.5
37
docs/iamb.5
@@ -125,6 +125,9 @@ key and can be overridden as described in
|
|||||||
.Sx PROFILES .
|
.Sx PROFILES .
|
||||||
.Bl -tag -width Ds
|
.Bl -tag -width Ds
|
||||||
|
|
||||||
|
.It Sy external_edit_file_suffix
|
||||||
|
Suffix to append to temporary file names when using the :editor command. Defaults to .md.
|
||||||
|
|
||||||
.It Sy default_room
|
.It Sy default_room
|
||||||
The room to show by default instead of the
|
The room to show by default instead of the
|
||||||
.Sy :welcome
|
.Sy :welcome
|
||||||
@@ -170,6 +173,9 @@ respective shortcodes.
|
|||||||
.It Sy message_user_color
|
.It Sy message_user_color
|
||||||
Defines whether or not the message body is colored like the username.
|
Defines whether or not the message body is colored like the username.
|
||||||
|
|
||||||
|
.It Sy normal_after_send
|
||||||
|
Defines whether to reset input to Normal mode after sending a message.
|
||||||
|
|
||||||
.It Sy notifications
|
.It Sy notifications
|
||||||
When this subsection is present, you can enable and configure push notifications.
|
When this subsection is present, you can enable and configure push notifications.
|
||||||
See
|
See
|
||||||
@@ -205,6 +211,9 @@ See
|
|||||||
.Sx "SORTING LISTS"
|
.Sx "SORTING LISTS"
|
||||||
for more details.
|
for more details.
|
||||||
|
|
||||||
|
.It Sy state_event_display
|
||||||
|
Defines whether the state events like joined or left are shown.
|
||||||
|
|
||||||
.It Sy typing_notice_send
|
.It Sy typing_notice_send
|
||||||
Defines whether or not the typing state is sent.
|
Defines whether or not the typing state is sent.
|
||||||
|
|
||||||
@@ -228,6 +237,10 @@ Possible values are
|
|||||||
Specify the width of the column where usernames are displayed in a room.
|
Specify the width of the column where usernames are displayed in a room.
|
||||||
Usernames that are too long are truncated.
|
Usernames that are too long are truncated.
|
||||||
Defaults to 30.
|
Defaults to 30.
|
||||||
|
|
||||||
|
.It Sy tabstop
|
||||||
|
Number of spaces that a <Tab> counts for.
|
||||||
|
Defaults to 4.
|
||||||
.El
|
.El
|
||||||
|
|
||||||
.Ss Example 1: Avoid showing Emojis (useful for terminals w/o support)
|
.Ss Example 1: Avoid showing Emojis (useful for terminals w/o support)
|
||||||
@@ -266,6 +279,8 @@ to use the desktop mechanism (default).
|
|||||||
Setting this field to
|
Setting this field to
|
||||||
.Dq Sy bell
|
.Dq Sy bell
|
||||||
will use the terminal bell instead.
|
will use the terminal bell instead.
|
||||||
|
Both can be used via
|
||||||
|
.Dq Sy desktop|bell .
|
||||||
|
|
||||||
.It Sy show_message
|
.It Sy show_message
|
||||||
controls whether to show the message in the desktop notification, and defaults to
|
controls whether to show the message in the desktop notification, and defaults to
|
||||||
@@ -329,9 +344,29 @@ window.
|
|||||||
Defaults to
|
Defaults to
|
||||||
.Sy ["power",\ "id"] .
|
.Sy ["power",\ "id"] .
|
||||||
.El
|
.El
|
||||||
|
|
||||||
|
The available values are:
|
||||||
|
.Bl -tag -width Ds
|
||||||
|
.It Sy favorite
|
||||||
|
Put favorite rooms before other rooms.
|
||||||
|
.It Sy lowpriority
|
||||||
|
Put lowpriority rooms after other rooms.
|
||||||
|
.It Sy name
|
||||||
|
Sort rooms by alphabetically ascending room name.
|
||||||
|
.It Sy alias
|
||||||
|
Sort rooms by alphabetically ascending canonical room alias.
|
||||||
|
.It Sy id
|
||||||
|
Sort rooms by alphabetically ascending Matrix room identifier.
|
||||||
|
.It Sy unread
|
||||||
|
Put unread rooms before other rooms.
|
||||||
|
.It Sy recent
|
||||||
|
Sort rooms by most recent message timestamp.
|
||||||
|
.It Sy invite
|
||||||
|
Put invites before other rooms.
|
||||||
|
.El
|
||||||
.El
|
.El
|
||||||
|
|
||||||
.Ss Example 1: Group room members by ther server first
|
.Ss Example 1: Group room members by their server first
|
||||||
.Bd -literal -offset indent
|
.Bd -literal -offset indent
|
||||||
[settings.sort]
|
[settings.sort]
|
||||||
members = ["server", "localpart"]
|
members = ["server", "localpart"]
|
||||||
|
|||||||
52
docs/iamb.metainfo.xml
Normal file
52
docs/iamb.metainfo.xml
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<component type="console-application">
|
||||||
|
<id>chat.iamb.iamb</id>
|
||||||
|
|
||||||
|
<name>iamb</name>
|
||||||
|
<summary>A terminal Matrix client for Vim addicts</summary>
|
||||||
|
<url type="homepage">https://iamb.chat</url>
|
||||||
|
|
||||||
|
<releases>
|
||||||
|
<release version="0.0.10" date="2024-08-20"/>
|
||||||
|
<release version="0.0.9" date="2024-03-28"/>
|
||||||
|
</releases>
|
||||||
|
|
||||||
|
<developer id="dev.ulyssa">
|
||||||
|
<name>Ulyssa</name>
|
||||||
|
</developer>
|
||||||
|
|
||||||
|
<developer_name>Ulyssa</developer_name>
|
||||||
|
<metadata_license>CC-BY-SA-4.0</metadata_license>
|
||||||
|
<project_license>Apache-2.0</project_license>
|
||||||
|
|
||||||
|
<content_rating type="oars-1.1">
|
||||||
|
<content_attribute id="social-chat">intense</content_attribute>
|
||||||
|
</content_rating>
|
||||||
|
|
||||||
|
<screenshots>
|
||||||
|
<screenshot type="default">
|
||||||
|
<image>https://iamb.chat/static/images/metainfo-screenshot.png</image>
|
||||||
|
<caption>Example screenshot of room and lists of rooms, spaces and members within iamb</caption>
|
||||||
|
</screenshot>
|
||||||
|
</screenshots>
|
||||||
|
|
||||||
|
<description>
|
||||||
|
<p>
|
||||||
|
iamb is a client for the Matrix communication protocol. It provides a
|
||||||
|
terminal user interface with familiar Vim keybindings, and includes
|
||||||
|
support for multiple profiles, threads, spaces, notifications,
|
||||||
|
reactions, custom keybindings, and more.
|
||||||
|
</p>
|
||||||
|
</description>
|
||||||
|
|
||||||
|
<launchable type="desktop-id">iamb.desktop</launchable>
|
||||||
|
|
||||||
|
<categories>
|
||||||
|
<category>Network</category>
|
||||||
|
<category>Chat</category>
|
||||||
|
</categories>
|
||||||
|
|
||||||
|
<provides>
|
||||||
|
<binary>iamb</binary>
|
||||||
|
</provides>
|
||||||
|
</component>
|
||||||
58
flake.lock
generated
58
flake.lock
generated
@@ -5,29 +5,11 @@
|
|||||||
"systems": "systems"
|
"systems": "systems"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1709126324,
|
"lastModified": 1731533236,
|
||||||
"narHash": "sha256-q6EQdSeUZOG26WelxqkmR7kArjgWCdw5sfJVHPH/7j8=",
|
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||||
"owner": "numtide",
|
"owner": "numtide",
|
||||||
"repo": "flake-utils",
|
"repo": "flake-utils",
|
||||||
"rev": "d465f4819400de7c8d874d50b982301f28a84605",
|
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "numtide",
|
|
||||||
"repo": "flake-utils",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"flake-utils_2": {
|
|
||||||
"inputs": {
|
|
||||||
"systems": "systems_2"
|
|
||||||
},
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1705309234,
|
|
||||||
"narHash": "sha256-uNRRNRKmJyCRC/8y1RqBkqWBLM034y4qN7EprSdmgyA=",
|
|
||||||
"owner": "numtide",
|
|
||||||
"repo": "flake-utils",
|
|
||||||
"rev": "1ef2e671c3b0c19053962c07dbda38332dcebf26",
|
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -38,11 +20,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1709703039,
|
"lastModified": 1736883708,
|
||||||
"narHash": "sha256-6hqgQ8OK6gsMu1VtcGKBxKQInRLHtzulDo9Z5jxHEFY=",
|
"narHash": "sha256-uQ+NQ0/xYU0N1CnXsa2zghgNaOPxWpMJXSUJJ9W7140=",
|
||||||
"owner": "nixos",
|
"owner": "nixos",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "9df3e30ce24fd28c7b3e2de0d986769db5d6225d",
|
"rev": "eb62e6aa39ea67e0b8018ba8ea077efe65807dc8",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -54,11 +36,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs_2": {
|
"nixpkgs_2": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1706487304,
|
"lastModified": 1736320768,
|
||||||
"narHash": "sha256-LE8lVX28MV2jWJsidW13D2qrHU/RUUONendL2Q/WlJg=",
|
"narHash": "sha256-nIYdTAiKIGnFNugbomgBJR+Xv5F1ZQU+HfaBqJKroC0=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "90f456026d284c22b3e3497be980b2e47d0b28ac",
|
"rev": "4bc9c909d9ac828a039f288cf872d16d38185db8",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -77,15 +59,14 @@
|
|||||||
},
|
},
|
||||||
"rust-overlay": {
|
"rust-overlay": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"flake-utils": "flake-utils_2",
|
|
||||||
"nixpkgs": "nixpkgs_2"
|
"nixpkgs": "nixpkgs_2"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1709863839,
|
"lastModified": 1736994333,
|
||||||
"narHash": "sha256-QpEL5FmZNi2By3sKZY55wGniFXc4wEn9PQczlE8TG0o=",
|
"narHash": "sha256-v4Jrok5yXsZ6dwj2+2uo5cSyUi9fBTurHqHvNHLT1XA=",
|
||||||
"owner": "oxalica",
|
"owner": "oxalica",
|
||||||
"repo": "rust-overlay",
|
"repo": "rust-overlay",
|
||||||
"rev": "e5ab9ee98f479081ad971473d2bc13c59e9fbc0a",
|
"rev": "848db855cb9e88785996e961951659570fc58814",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -108,21 +89,6 @@
|
|||||||
"repo": "default",
|
"repo": "default",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"systems_2": {
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1681028828,
|
|
||||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
|
||||||
"owner": "nix-systems",
|
|
||||||
"repo": "default",
|
|
||||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "nix-systems",
|
|
||||||
"repo": "default",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"root": "root",
|
"root": "root",
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
# We only need the nightly overlay in the devShell because .rs files are formatted with nightly.
|
# We only need the nightly overlay in the devShell because .rs files are formatted with nightly.
|
||||||
overlays = [ (import rust-overlay) ];
|
overlays = [ (import rust-overlay) ];
|
||||||
pkgs = import nixpkgs { inherit system overlays; };
|
pkgs = import nixpkgs { inherit system overlays; };
|
||||||
rustNightly = pkgs.rust-bin.nightly."2024-03-08".default;
|
rustNightly = pkgs.rust-bin.nightly."2024-12-12".default;
|
||||||
in
|
in
|
||||||
with pkgs;
|
with pkgs;
|
||||||
{
|
{
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
};
|
};
|
||||||
nativeBuildInputs = [ pkg-config ];
|
nativeBuildInputs = [ pkg-config ];
|
||||||
buildInputs = [ openssl ] ++ lib.optionals stdenv.isDarwin
|
buildInputs = [ openssl ] ++ lib.optionals stdenv.isDarwin
|
||||||
(with darwin.apple_sdk.frameworks; [ AppKit Security ]);
|
(with darwin.apple_sdk.frameworks; [ AppKit Security Cocoa ]);
|
||||||
};
|
};
|
||||||
|
|
||||||
devShell = mkShell {
|
devShell = mkShell {
|
||||||
@@ -38,6 +38,7 @@
|
|||||||
pkg-config
|
pkg-config
|
||||||
cargo-tarpaulin
|
cargo-tarpaulin
|
||||||
cargo-watch
|
cargo-watch
|
||||||
|
sqlite
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
3
rust-toolchain.toml
Normal file
3
rust-toolchain.toml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[toolchain]
|
||||||
|
channel = "1.83"
|
||||||
|
components = [ "clippy" ]
|
||||||
499
src/base.rs
499
src/base.rs
File diff suppressed because it is too large
Load Diff
451
src/commands.rs
451
src/commands.rs
@@ -2,9 +2,9 @@
|
|||||||
//!
|
//!
|
||||||
//! The command-bar commands are set up here, and iamb-specific commands are defined here. See
|
//! The command-bar commands are set up here, and iamb-specific commands are defined here. See
|
||||||
//! [modalkit::env::vim::command] for additional Vim commands we pull in.
|
//! [modalkit::env::vim::command] for additional Vim commands we pull in.
|
||||||
use std::convert::TryFrom;
|
use std::{convert::TryFrom, str::FromStr as _};
|
||||||
|
|
||||||
use matrix_sdk::ruma::{events::tag::TagName, OwnedUserId};
|
use matrix_sdk::ruma::{events::tag::TagName, OwnedRoomId, OwnedUserId};
|
||||||
|
|
||||||
use modalkit::{
|
use modalkit::{
|
||||||
commands::{CommandError, CommandResult, CommandStep},
|
commands::{CommandError, CommandResult, CommandStep},
|
||||||
@@ -20,12 +20,14 @@ use crate::base::{
|
|||||||
IambAction,
|
IambAction,
|
||||||
IambId,
|
IambId,
|
||||||
KeysAction,
|
KeysAction,
|
||||||
|
MemberUpdateAction,
|
||||||
MessageAction,
|
MessageAction,
|
||||||
ProgramCommand,
|
ProgramCommand,
|
||||||
ProgramCommands,
|
ProgramCommands,
|
||||||
RoomAction,
|
RoomAction,
|
||||||
RoomField,
|
RoomField,
|
||||||
SendAction,
|
SendAction,
|
||||||
|
SpaceAction,
|
||||||
VerifyAction,
|
VerifyAction,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -34,7 +36,7 @@ type ProgResult = CommandResult<ProgramCommand>;
|
|||||||
|
|
||||||
/// Convert strings the user types into a tag name.
|
/// Convert strings the user types into a tag name.
|
||||||
fn tag_name(name: String) -> Result<TagName, CommandError> {
|
fn tag_name(name: String) -> Result<TagName, CommandError> {
|
||||||
let tag = match name.as_str() {
|
let tag = match name.to_lowercase().as_str() {
|
||||||
"fav" | "favorite" | "favourite" | "m.favourite" => TagName::Favorite,
|
"fav" | "favorite" | "favourite" | "m.favourite" => TagName::Favorite,
|
||||||
"low" | "lowpriority" | "low_priority" | "low-priority" | "m.lowpriority" => {
|
"low" | "lowpriority" | "low_priority" | "low-priority" | "m.lowpriority" => {
|
||||||
TagName::LowPriority
|
TagName::LowPriority
|
||||||
@@ -221,24 +223,17 @@ fn iamb_edit(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn iamb_react(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
fn iamb_react(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
||||||
let args = desc.arg.strings()?;
|
let mut args = desc.arg.strings()?;
|
||||||
|
|
||||||
if args.len() != 1 {
|
if args.len() != 1 {
|
||||||
return Result::Err(CommandError::InvalidArgument);
|
return Result::Err(CommandError::InvalidArgument);
|
||||||
}
|
}
|
||||||
|
|
||||||
let k = args[0].as_str();
|
let react = args.remove(0);
|
||||||
|
let mact = IambAction::from(MessageAction::React(react, desc.bang));
|
||||||
if let Some(emoji) = emojis::get(k).or_else(|| emojis::get_by_shortcode(k)) {
|
|
||||||
let mact = IambAction::from(MessageAction::React(emoji.to_string()));
|
|
||||||
let step = CommandStep::Continue(mact.into(), ctx.context.clone());
|
let step = CommandStep::Continue(mact.into(), ctx.context.clone());
|
||||||
|
|
||||||
return Ok(step);
|
return Ok(step);
|
||||||
} else {
|
|
||||||
let msg = format!("Invalid Emoji or shortcode: {k}");
|
|
||||||
|
|
||||||
return Result::Err(CommandError::Error(msg));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn iamb_unreact(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
fn iamb_unreact(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
||||||
@@ -248,20 +243,8 @@ fn iamb_unreact(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
|||||||
return Result::Err(CommandError::InvalidArgument);
|
return Result::Err(CommandError::InvalidArgument);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mact = if let Some(k) = args.pop() {
|
let reaction = args.pop();
|
||||||
let k = k.as_str();
|
let mact = IambAction::from(MessageAction::Unreact(reaction, desc.bang));
|
||||||
|
|
||||||
if let Some(emoji) = emojis::get(k).or_else(|| emojis::get_by_shortcode(k)) {
|
|
||||||
IambAction::from(MessageAction::Unreact(Some(emoji.to_string())))
|
|
||||||
} else {
|
|
||||||
let msg = format!("Invalid Emoji or shortcode: {k}");
|
|
||||||
|
|
||||||
return Result::Err(CommandError::Error(msg));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
IambAction::from(MessageAction::Unreact(None))
|
|
||||||
};
|
|
||||||
|
|
||||||
let step = CommandStep::Continue(mact.into(), ctx.context.clone());
|
let step = CommandStep::Continue(mact.into(), ctx.context.clone());
|
||||||
|
|
||||||
return Ok(step);
|
return Ok(step);
|
||||||
@@ -325,6 +308,30 @@ fn iamb_chats(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
|||||||
return Ok(step);
|
return Ok(step);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn iamb_unreads(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
||||||
|
let mut args = desc.arg.strings()?;
|
||||||
|
|
||||||
|
if args.len() > 1 {
|
||||||
|
return Result::Err(CommandError::InvalidArgument);
|
||||||
|
}
|
||||||
|
|
||||||
|
match args.pop().as_deref() {
|
||||||
|
Some("clear") => {
|
||||||
|
let clear = IambAction::ClearUnreads;
|
||||||
|
let step = CommandStep::Continue(clear.into(), ctx.context.clone());
|
||||||
|
|
||||||
|
return Ok(step);
|
||||||
|
},
|
||||||
|
Some(_) => return Result::Err(CommandError::InvalidArgument),
|
||||||
|
None => {
|
||||||
|
let open = ctx.switch(OpenTarget::Application(IambId::UnreadList));
|
||||||
|
let step = CommandStep::Continue(open, ctx.context.clone());
|
||||||
|
|
||||||
|
return Ok(step);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn iamb_spaces(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
fn iamb_spaces(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
||||||
if !desc.arg.text.is_empty() {
|
if !desc.arg.text.is_empty() {
|
||||||
return Result::Err(CommandError::InvalidArgument);
|
return Result::Err(CommandError::InvalidArgument);
|
||||||
@@ -422,6 +429,37 @@ fn iamb_room(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let act: IambAction = match (field.as_str(), action.as_str(), args.pop()) {
|
let act: IambAction = match (field.as_str(), action.as_str(), args.pop()) {
|
||||||
|
// :room dm set
|
||||||
|
("dm", "set", None) => RoomAction::SetDirect(true).into(),
|
||||||
|
("dm", "set", Some(_)) => return Result::Err(CommandError::InvalidArgument),
|
||||||
|
|
||||||
|
// :room dm set
|
||||||
|
("dm", "unset", None) => RoomAction::SetDirect(false).into(),
|
||||||
|
("dm", "unset", Some(_)) => return Result::Err(CommandError::InvalidArgument),
|
||||||
|
|
||||||
|
// :room [kick|ban|unban] <user>
|
||||||
|
("kick", u, r) => {
|
||||||
|
RoomAction::MemberUpdate(MemberUpdateAction::Kick, u.into(), r, desc.bang).into()
|
||||||
|
},
|
||||||
|
("ban", u, r) => {
|
||||||
|
RoomAction::MemberUpdate(MemberUpdateAction::Ban, u.into(), r, desc.bang).into()
|
||||||
|
},
|
||||||
|
("unban", u, r) => {
|
||||||
|
RoomAction::MemberUpdate(MemberUpdateAction::Unban, u.into(), r, desc.bang).into()
|
||||||
|
},
|
||||||
|
|
||||||
|
// :room history set <visibility>
|
||||||
|
("history", "set", Some(s)) => RoomAction::Set(RoomField::History, s).into(),
|
||||||
|
("history", "set", None) => return Result::Err(CommandError::InvalidArgument),
|
||||||
|
|
||||||
|
// :room history unset
|
||||||
|
("history", "unset", None) => RoomAction::Unset(RoomField::History).into(),
|
||||||
|
("history", "unset", Some(_)) => return Result::Err(CommandError::InvalidArgument),
|
||||||
|
|
||||||
|
// :room history show
|
||||||
|
("history", "show", None) => RoomAction::Show(RoomField::History).into(),
|
||||||
|
("history", "show", Some(_)) => return Result::Err(CommandError::InvalidArgument),
|
||||||
|
|
||||||
// :room name set <room-name>
|
// :room name set <room-name>
|
||||||
("name", "set", Some(s)) => RoomAction::Set(RoomField::Name, s).into(),
|
("name", "set", Some(s)) => RoomAction::Set(RoomField::Name, s).into(),
|
||||||
("name", "set", None) => return Result::Err(CommandError::InvalidArgument),
|
("name", "set", None) => return Result::Err(CommandError::InvalidArgument),
|
||||||
@@ -438,6 +476,10 @@ fn iamb_room(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
|||||||
("topic", "unset", None) => RoomAction::Unset(RoomField::Topic).into(),
|
("topic", "unset", None) => RoomAction::Unset(RoomField::Topic).into(),
|
||||||
("topic", "unset", Some(_)) => return Result::Err(CommandError::InvalidArgument),
|
("topic", "unset", Some(_)) => return Result::Err(CommandError::InvalidArgument),
|
||||||
|
|
||||||
|
// :room topic show
|
||||||
|
("topic", "show", None) => RoomAction::Show(RoomField::Topic).into(),
|
||||||
|
("topic", "show", Some(_)) => return Result::Err(CommandError::InvalidArgument),
|
||||||
|
|
||||||
// :room tag set <tag-name>
|
// :room tag set <tag-name>
|
||||||
("tag", "set", Some(s)) => RoomAction::Set(RoomField::Tag(tag_name(s)?), "".into()).into(),
|
("tag", "set", Some(s)) => RoomAction::Set(RoomField::Tag(tag_name(s)?), "".into()).into(),
|
||||||
("tag", "set", None) => return Result::Err(CommandError::InvalidArgument),
|
("tag", "set", None) => return Result::Err(CommandError::InvalidArgument),
|
||||||
@@ -446,6 +488,139 @@ fn iamb_room(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
|||||||
("tag", "unset", Some(s)) => RoomAction::Unset(RoomField::Tag(tag_name(s)?)).into(),
|
("tag", "unset", Some(s)) => RoomAction::Unset(RoomField::Tag(tag_name(s)?)).into(),
|
||||||
("tag", "unset", None) => return Result::Err(CommandError::InvalidArgument),
|
("tag", "unset", None) => return Result::Err(CommandError::InvalidArgument),
|
||||||
|
|
||||||
|
// :room notify set <notification-level>
|
||||||
|
("notify", "set", Some(s)) => RoomAction::Set(RoomField::NotificationMode, s).into(),
|
||||||
|
("notify", "set", None) => return Result::Err(CommandError::InvalidArgument),
|
||||||
|
|
||||||
|
// :room notify unset <notification-level>
|
||||||
|
("notify", "unset", None) => RoomAction::Unset(RoomField::NotificationMode).into(),
|
||||||
|
("notify", "unset", Some(_)) => return Result::Err(CommandError::InvalidArgument),
|
||||||
|
|
||||||
|
// :room notify show
|
||||||
|
("notify", "show", None) => RoomAction::Show(RoomField::NotificationMode).into(),
|
||||||
|
("notify", "show", Some(_)) => return Result::Err(CommandError::InvalidArgument),
|
||||||
|
|
||||||
|
// :room aliases show
|
||||||
|
("alias", "show", None) => RoomAction::Show(RoomField::Aliases).into(),
|
||||||
|
("alias", "show", Some(_)) => return Result::Err(CommandError::InvalidArgument),
|
||||||
|
|
||||||
|
// :room aliases unset <alias>
|
||||||
|
("alias", "unset", Some(s)) => RoomAction::Unset(RoomField::Alias(s)).into(),
|
||||||
|
("alias", "unset", None) => return Result::Err(CommandError::InvalidArgument),
|
||||||
|
|
||||||
|
// :room aliases set <alias>
|
||||||
|
("alias", "set", Some(s)) => RoomAction::Set(RoomField::Alias(s), "".into()).into(),
|
||||||
|
("alias", "set", None) => return Result::Err(CommandError::InvalidArgument),
|
||||||
|
|
||||||
|
// :room canonicalalias show
|
||||||
|
("canonicalalias" | "canon", "show", None) => {
|
||||||
|
RoomAction::Show(RoomField::CanonicalAlias).into()
|
||||||
|
},
|
||||||
|
("canonicalalias" | "canon", "show", Some(_)) => {
|
||||||
|
return Result::Err(CommandError::InvalidArgument)
|
||||||
|
},
|
||||||
|
|
||||||
|
// :room canonicalalias set
|
||||||
|
("canonicalalias" | "canon", "set", Some(s)) => {
|
||||||
|
RoomAction::Set(RoomField::CanonicalAlias, s).into()
|
||||||
|
},
|
||||||
|
("canonicalalias" | "canon", "set", None) => {
|
||||||
|
return Result::Err(CommandError::InvalidArgument)
|
||||||
|
},
|
||||||
|
|
||||||
|
// :room canonicalalias unset
|
||||||
|
("canonicalalias" | "canon", "unset", None) => {
|
||||||
|
RoomAction::Unset(RoomField::CanonicalAlias).into()
|
||||||
|
},
|
||||||
|
("canonicalalias" | "canon", "unset", Some(_)) => {
|
||||||
|
return Result::Err(CommandError::InvalidArgument)
|
||||||
|
},
|
||||||
|
|
||||||
|
// :room id show
|
||||||
|
("id", "show", None) => RoomAction::Show(RoomField::Id).into(),
|
||||||
|
("id", "show", Some(_)) => return Result::Err(CommandError::InvalidArgument),
|
||||||
|
|
||||||
|
_ => return Result::Err(CommandError::InvalidArgument),
|
||||||
|
};
|
||||||
|
|
||||||
|
let step = CommandStep::Continue(act.into(), ctx.context.clone());
|
||||||
|
|
||||||
|
return Ok(step);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn iamb_space(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
||||||
|
let mut args = desc.arg.options()?;
|
||||||
|
|
||||||
|
if args.len() < 2 {
|
||||||
|
return Err(CommandError::InvalidArgument);
|
||||||
|
}
|
||||||
|
|
||||||
|
let OptionType::Positional(field) = args.remove(0) else {
|
||||||
|
return Err(CommandError::InvalidArgument);
|
||||||
|
};
|
||||||
|
let OptionType::Positional(action) = args.remove(0) else {
|
||||||
|
return Err(CommandError::InvalidArgument);
|
||||||
|
};
|
||||||
|
|
||||||
|
let act: IambAction = match (field.as_str(), action.as_str()) {
|
||||||
|
// :space child remove
|
||||||
|
("child", "remove") => {
|
||||||
|
if !(args.is_empty()) {
|
||||||
|
return Err(CommandError::InvalidArgument);
|
||||||
|
}
|
||||||
|
SpaceAction::RemoveChild.into()
|
||||||
|
},
|
||||||
|
// :space child set <child>
|
||||||
|
("child", "set") => {
|
||||||
|
let mut order = None;
|
||||||
|
let mut suggested = false;
|
||||||
|
let mut raw_child = None;
|
||||||
|
|
||||||
|
for arg in args {
|
||||||
|
match arg {
|
||||||
|
OptionType::Flag(name, Some(arg)) => {
|
||||||
|
match name.as_str() {
|
||||||
|
"order" => {
|
||||||
|
if order.is_some() {
|
||||||
|
let msg = "Multiple ++order arguments are not allowed";
|
||||||
|
let err = CommandError::Error(msg.into());
|
||||||
|
|
||||||
|
return Err(err);
|
||||||
|
} else {
|
||||||
|
order = Some(arg);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_ => return Err(CommandError::InvalidArgument),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
OptionType::Flag(name, None) => {
|
||||||
|
match name.as_str() {
|
||||||
|
"suggested" => suggested = true,
|
||||||
|
_ => return Err(CommandError::InvalidArgument),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
OptionType::Positional(arg) => {
|
||||||
|
if raw_child.is_some() {
|
||||||
|
let msg = "Multiple room arguments are not allowed";
|
||||||
|
let err = CommandError::Error(msg.into());
|
||||||
|
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
raw_child = Some(arg);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let child = if let Some(child) = raw_child {
|
||||||
|
OwnedRoomId::from_str(&child)
|
||||||
|
.map_err(|_| CommandError::Error("Invalid room id specified".into()))?
|
||||||
|
} else {
|
||||||
|
let msg = "Must specify a room to add";
|
||||||
|
return Err(CommandError::Error(msg.into()));
|
||||||
|
};
|
||||||
|
|
||||||
|
SpaceAction::SetChild(child, order, suggested).into()
|
||||||
|
},
|
||||||
_ => return Result::Err(CommandError::InvalidArgument),
|
_ => return Result::Err(CommandError::InvalidArgument),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -507,6 +682,9 @@ fn iamb_open(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
|||||||
fn iamb_logout(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
fn iamb_logout(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
||||||
let args = desc.arg.strings()?;
|
let args = desc.arg.strings()?;
|
||||||
|
|
||||||
|
if args.is_empty() {
|
||||||
|
return Result::Err(CommandError::Error("Missing username".to_string()));
|
||||||
|
}
|
||||||
if args.len() != 1 {
|
if args.len() != 1 {
|
||||||
return Result::Err(CommandError::InvalidArgument);
|
return Result::Err(CommandError::InvalidArgument);
|
||||||
}
|
}
|
||||||
@@ -579,11 +757,21 @@ fn add_iamb_commands(cmds: &mut ProgramCommands) {
|
|||||||
f: iamb_rooms,
|
f: iamb_rooms,
|
||||||
});
|
});
|
||||||
cmds.add_command(ProgramCommand { name: "room".into(), aliases: vec![], f: iamb_room });
|
cmds.add_command(ProgramCommand { name: "room".into(), aliases: vec![], f: iamb_room });
|
||||||
|
cmds.add_command(ProgramCommand {
|
||||||
|
name: "space".into(),
|
||||||
|
aliases: vec![],
|
||||||
|
f: iamb_space,
|
||||||
|
});
|
||||||
cmds.add_command(ProgramCommand {
|
cmds.add_command(ProgramCommand {
|
||||||
name: "spaces".into(),
|
name: "spaces".into(),
|
||||||
aliases: vec![],
|
aliases: vec![],
|
||||||
f: iamb_spaces,
|
f: iamb_spaces,
|
||||||
});
|
});
|
||||||
|
cmds.add_command(ProgramCommand {
|
||||||
|
name: "unreads".into(),
|
||||||
|
aliases: vec![],
|
||||||
|
f: iamb_unreads,
|
||||||
|
});
|
||||||
cmds.add_command(ProgramCommand {
|
cmds.add_command(ProgramCommand {
|
||||||
name: "unreact".into(),
|
name: "unreact".into(),
|
||||||
aliases: vec![],
|
aliases: vec![],
|
||||||
@@ -628,7 +816,7 @@ pub fn setup_commands() -> ProgramCommands {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use matrix_sdk::ruma::user_id;
|
use matrix_sdk::ruma::{room_id, user_id};
|
||||||
use modalkit::actions::WindowAction;
|
use modalkit::actions::WindowAction;
|
||||||
use modalkit::editing::context::EditContext;
|
use modalkit::editing::context::EditContext;
|
||||||
|
|
||||||
@@ -789,6 +977,32 @@ mod tests {
|
|||||||
assert_eq!(res, Err(CommandError::InvalidArgument));
|
assert_eq!(res, Err(CommandError::InvalidArgument));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cmd_room_dm_set() {
|
||||||
|
let mut cmds = setup_commands();
|
||||||
|
let ctx = EditContext::default();
|
||||||
|
|
||||||
|
let res = cmds.input_cmd("room dm set", ctx.clone()).unwrap();
|
||||||
|
let act = RoomAction::SetDirect(true);
|
||||||
|
assert_eq!(res, vec![(act.into(), ctx.clone())]);
|
||||||
|
|
||||||
|
let res = cmds.input_cmd("room dm set true", ctx.clone());
|
||||||
|
assert_eq!(res, Err(CommandError::InvalidArgument));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cmd_room_dm_unset() {
|
||||||
|
let mut cmds = setup_commands();
|
||||||
|
let ctx = EditContext::default();
|
||||||
|
|
||||||
|
let res = cmds.input_cmd("room dm unset", ctx.clone()).unwrap();
|
||||||
|
let act = RoomAction::SetDirect(false);
|
||||||
|
assert_eq!(res, vec![(act.into(), ctx.clone())]);
|
||||||
|
|
||||||
|
let res = cmds.input_cmd("room dm unset true", ctx.clone());
|
||||||
|
assert_eq!(res, Err(CommandError::InvalidArgument));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_cmd_room_tag_set() {
|
fn test_cmd_room_tag_set() {
|
||||||
let mut cmds = setup_commands();
|
let mut cmds = setup_commands();
|
||||||
@@ -923,6 +1137,124 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cmd_room_notification_mode_set() {
|
||||||
|
let mut cmds = setup_commands();
|
||||||
|
let ctx = EditContext::default();
|
||||||
|
|
||||||
|
let cmd = "room notify set mute";
|
||||||
|
let res = cmds.input_cmd(cmd, ctx.clone()).unwrap();
|
||||||
|
let act = RoomAction::Set(RoomField::NotificationMode, "mute".into());
|
||||||
|
assert_eq!(res, vec![(act.into(), ctx.clone())]);
|
||||||
|
|
||||||
|
let cmd = "room notify unset";
|
||||||
|
let res = cmds.input_cmd(cmd, ctx.clone()).unwrap();
|
||||||
|
let act = RoomAction::Unset(RoomField::NotificationMode);
|
||||||
|
assert_eq!(res, vec![(act.into(), ctx.clone())]);
|
||||||
|
|
||||||
|
let cmd = "room notify show";
|
||||||
|
let res = cmds.input_cmd(cmd, ctx.clone()).unwrap();
|
||||||
|
let act = RoomAction::Show(RoomField::NotificationMode);
|
||||||
|
assert_eq!(res, vec![(act.into(), ctx.clone())]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cmd_room_id_show() {
|
||||||
|
let mut cmds = setup_commands();
|
||||||
|
let ctx = EditContext::default();
|
||||||
|
|
||||||
|
let res = cmds.input_cmd("room id show", ctx.clone()).unwrap();
|
||||||
|
let act = RoomAction::Show(RoomField::Id);
|
||||||
|
assert_eq!(res, vec![(act.into(), ctx.clone())]);
|
||||||
|
|
||||||
|
let res = cmds.input_cmd("room id show foo", ctx.clone());
|
||||||
|
assert_eq!(res, Err(CommandError::InvalidArgument));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cmd_space_child() {
|
||||||
|
let mut cmds = setup_commands();
|
||||||
|
let ctx = EditContext::default();
|
||||||
|
|
||||||
|
let cmd = "space";
|
||||||
|
let res = cmds.input_cmd(cmd, ctx.clone());
|
||||||
|
assert_eq!(res, Err(CommandError::InvalidArgument));
|
||||||
|
|
||||||
|
let cmd = "space ++foo bar baz";
|
||||||
|
let res = cmds.input_cmd(cmd, ctx.clone());
|
||||||
|
assert_eq!(res, Err(CommandError::InvalidArgument));
|
||||||
|
|
||||||
|
let cmd = "space child foo";
|
||||||
|
let res = cmds.input_cmd(cmd, ctx.clone());
|
||||||
|
assert_eq!(res, Err(CommandError::InvalidArgument));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cmd_space_child_set() {
|
||||||
|
let mut cmds = setup_commands();
|
||||||
|
let ctx = EditContext::default();
|
||||||
|
|
||||||
|
let cmd = "space child set !roomid:example.org";
|
||||||
|
let res = cmds.input_cmd(cmd, ctx.clone()).unwrap();
|
||||||
|
let act = SpaceAction::SetChild(room_id!("!roomid:example.org").to_owned(), None, false);
|
||||||
|
assert_eq!(res, vec![(act.into(), ctx.clone())]);
|
||||||
|
|
||||||
|
let cmd = "space child set ++order=abcd ++suggested !roomid:example.org";
|
||||||
|
let res = cmds.input_cmd(cmd, ctx.clone()).unwrap();
|
||||||
|
let act = SpaceAction::SetChild(
|
||||||
|
room_id!("!roomid:example.org").to_owned(),
|
||||||
|
Some("abcd".into()),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert_eq!(res, vec![(act.into(), ctx.clone())]);
|
||||||
|
|
||||||
|
let cmd = "space child set ++order=abcd ++order=1234 !roomid:example.org";
|
||||||
|
let res = cmds.input_cmd(cmd, ctx.clone());
|
||||||
|
assert_eq!(
|
||||||
|
res,
|
||||||
|
Err(CommandError::Error("Multiple ++order arguments are not allowed".into()))
|
||||||
|
);
|
||||||
|
|
||||||
|
let cmd = "space child set !roomid:example.org !otherroom:example.org";
|
||||||
|
let res = cmds.input_cmd(cmd, ctx.clone());
|
||||||
|
assert_eq!(res, Err(CommandError::Error("Multiple room arguments are not allowed".into())));
|
||||||
|
|
||||||
|
let cmd = "space child set ++foo=abcd !roomid:example.org";
|
||||||
|
let res = cmds.input_cmd(cmd, ctx.clone());
|
||||||
|
assert_eq!(res, Err(CommandError::InvalidArgument));
|
||||||
|
|
||||||
|
let cmd = "space child set ++foo !roomid:example.org";
|
||||||
|
let res = cmds.input_cmd(cmd, ctx.clone());
|
||||||
|
assert_eq!(res, Err(CommandError::InvalidArgument));
|
||||||
|
|
||||||
|
let cmd = "space child ++order=abcd ++suggested set !roomid:example.org";
|
||||||
|
let res = cmds.input_cmd(cmd, ctx.clone());
|
||||||
|
assert_eq!(res, Err(CommandError::InvalidArgument));
|
||||||
|
|
||||||
|
let cmd = "space child set foo";
|
||||||
|
let res = cmds.input_cmd(cmd, ctx.clone());
|
||||||
|
assert_eq!(res, Err(CommandError::Error("Invalid room id specified".into())));
|
||||||
|
|
||||||
|
let cmd = "space child set";
|
||||||
|
let res = cmds.input_cmd(cmd, ctx.clone());
|
||||||
|
assert_eq!(res, Err(CommandError::Error("Must specify a room to add".into())));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cmd_space_child_remove() {
|
||||||
|
let mut cmds = setup_commands();
|
||||||
|
let ctx = EditContext::default();
|
||||||
|
|
||||||
|
let cmd = "space child remove";
|
||||||
|
let res = cmds.input_cmd(cmd, ctx.clone()).unwrap();
|
||||||
|
let act = SpaceAction::RemoveChild;
|
||||||
|
assert_eq!(res, vec![(act.into(), ctx.clone())]);
|
||||||
|
|
||||||
|
let cmd = "space child remove foo";
|
||||||
|
let res = cmds.input_cmd(cmd, ctx.clone());
|
||||||
|
assert_eq!(res, Err(CommandError::InvalidArgument));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_cmd_invite() {
|
fn test_cmd_invite() {
|
||||||
let mut cmds = setup_commands();
|
let mut cmds = setup_commands();
|
||||||
@@ -960,6 +1292,69 @@ mod tests {
|
|||||||
assert_eq!(res, Err(CommandError::InvalidArgument));
|
assert_eq!(res, Err(CommandError::InvalidArgument));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cmd_room_kick() {
|
||||||
|
let mut cmds = setup_commands();
|
||||||
|
let ctx = EditContext::default();
|
||||||
|
|
||||||
|
let res = cmds.input_cmd("room kick @user:example.com", ctx.clone()).unwrap();
|
||||||
|
let act = IambAction::Room(RoomAction::MemberUpdate(
|
||||||
|
MemberUpdateAction::Kick,
|
||||||
|
"@user:example.com".into(),
|
||||||
|
None,
|
||||||
|
false,
|
||||||
|
));
|
||||||
|
assert_eq!(res, vec![(act.into(), ctx.clone())]);
|
||||||
|
|
||||||
|
let res = cmds.input_cmd("room! kick @user:example.com", ctx.clone()).unwrap();
|
||||||
|
let act = IambAction::Room(RoomAction::MemberUpdate(
|
||||||
|
MemberUpdateAction::Kick,
|
||||||
|
"@user:example.com".into(),
|
||||||
|
None,
|
||||||
|
true,
|
||||||
|
));
|
||||||
|
assert_eq!(res, vec![(act.into(), ctx.clone())]);
|
||||||
|
|
||||||
|
let res = cmds
|
||||||
|
.input_cmd("room! kick @user:example.com \"reason here\"", ctx.clone())
|
||||||
|
.unwrap();
|
||||||
|
let act = IambAction::Room(RoomAction::MemberUpdate(
|
||||||
|
MemberUpdateAction::Kick,
|
||||||
|
"@user:example.com".into(),
|
||||||
|
Some("reason here".into()),
|
||||||
|
true,
|
||||||
|
));
|
||||||
|
assert_eq!(res, vec![(act.into(), ctx.clone())]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cmd_room_ban_unban() {
|
||||||
|
let mut cmds = setup_commands();
|
||||||
|
let ctx = EditContext::default();
|
||||||
|
|
||||||
|
let res = cmds
|
||||||
|
.input_cmd("room! ban @user:example.com \"spam\"", ctx.clone())
|
||||||
|
.unwrap();
|
||||||
|
let act = IambAction::Room(RoomAction::MemberUpdate(
|
||||||
|
MemberUpdateAction::Ban,
|
||||||
|
"@user:example.com".into(),
|
||||||
|
Some("spam".into()),
|
||||||
|
true,
|
||||||
|
));
|
||||||
|
assert_eq!(res, vec![(act.into(), ctx.clone())]);
|
||||||
|
|
||||||
|
let res = cmds
|
||||||
|
.input_cmd("room unban @user:example.com \"reconciled\"", ctx.clone())
|
||||||
|
.unwrap();
|
||||||
|
let act = IambAction::Room(RoomAction::MemberUpdate(
|
||||||
|
MemberUpdateAction::Unban,
|
||||||
|
"@user:example.com".into(),
|
||||||
|
Some("reconciled".into()),
|
||||||
|
false,
|
||||||
|
));
|
||||||
|
assert_eq!(res, vec![(act.into(), ctx.clone())]);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_cmd_redact() {
|
fn test_cmd_redact() {
|
||||||
let mut cmds = setup_commands();
|
let mut cmds = setup_commands();
|
||||||
|
|||||||
231
src/config.rs
231
src/config.rs
@@ -1,16 +1,17 @@
|
|||||||
//! # Logic for loading and validating application configuration
|
//! # Logic for loading and validating application configuration
|
||||||
use std::borrow::Cow;
|
use std::borrow::Cow;
|
||||||
use std::collections::hash_map::DefaultHasher;
|
use std::collections::hash_map::DefaultHasher;
|
||||||
use std::collections::HashMap;
|
use std::collections::{BTreeMap, HashMap};
|
||||||
|
use std::env;
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use std::fs::File;
|
use std::fs::File;
|
||||||
use std::hash::{Hash, Hasher};
|
use std::hash::{Hash, Hasher};
|
||||||
use std::io::{BufReader, BufWriter};
|
use std::io::{BufReader, BufWriter, Write};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process;
|
use std::process;
|
||||||
|
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use matrix_sdk::matrix_auth::MatrixSession;
|
use matrix_sdk::authentication::matrix::MatrixSession;
|
||||||
use matrix_sdk::ruma::{OwnedDeviceId, OwnedRoomAliasId, OwnedRoomId, OwnedUserId, UserId};
|
use matrix_sdk::ruma::{OwnedDeviceId, OwnedRoomAliasId, OwnedRoomId, OwnedUserId, UserId};
|
||||||
use ratatui::style::{Color, Modifier as StyleModifier, Style};
|
use ratatui::style::{Color, Modifier as StyleModifier, Style};
|
||||||
use ratatui::text::Span;
|
use ratatui::text::Span;
|
||||||
@@ -45,8 +46,9 @@ const DEFAULT_MEMBERS_SORT: [SortColumn<SortFieldUser>; 2] = [
|
|||||||
SortColumn(SortFieldUser::UserId, SortOrder::Ascending),
|
SortColumn(SortFieldUser::UserId, SortOrder::Ascending),
|
||||||
];
|
];
|
||||||
|
|
||||||
const DEFAULT_ROOM_SORT: [SortColumn<SortFieldRoom>; 4] = [
|
const DEFAULT_ROOM_SORT: [SortColumn<SortFieldRoom>; 5] = [
|
||||||
SortColumn(SortFieldRoom::Favorite, SortOrder::Ascending),
|
SortColumn(SortFieldRoom::Favorite, SortOrder::Ascending),
|
||||||
|
SortColumn(SortFieldRoom::Invite, SortOrder::Ascending),
|
||||||
SortColumn(SortFieldRoom::LowPriority, SortOrder::Ascending),
|
SortColumn(SortFieldRoom::LowPriority, SortOrder::Ascending),
|
||||||
SortColumn(SortFieldRoom::Unread, SortOrder::Ascending),
|
SortColumn(SortFieldRoom::Unread, SortOrder::Ascending),
|
||||||
SortColumn(SortFieldRoom::Name, SortOrder::Ascending),
|
SortColumn(SortFieldRoom::Name, SortOrder::Ascending),
|
||||||
@@ -97,14 +99,14 @@ fn validate_profile_name(name: &str) -> bool {
|
|||||||
|
|
||||||
let mut chars = name.chars();
|
let mut chars = name.chars();
|
||||||
|
|
||||||
if !chars.next().map_or(false, |c| c.is_ascii_alphanumeric()) {
|
if !chars.next().is_some_and(|c| c.is_ascii_alphanumeric()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
name.chars().all(is_profile_char)
|
name.chars().all(is_profile_char)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate_profile_names(names: &HashMap<String, ProfileConfig>) {
|
fn validate_profile_names(names: &BTreeMap<String, ProfileConfig>) {
|
||||||
for name in names.keys() {
|
for name in names.keys() {
|
||||||
if validate_profile_name(name.as_str()) {
|
if validate_profile_name(name.as_str()) {
|
||||||
continue;
|
continue;
|
||||||
@@ -151,7 +153,7 @@ pub enum ConfigError {
|
|||||||
pub struct Keys(pub Vec<TerminalKey>, pub String);
|
pub struct Keys(pub Vec<TerminalKey>, pub String);
|
||||||
pub struct KeysVisitor;
|
pub struct KeysVisitor;
|
||||||
|
|
||||||
impl<'de> Visitor<'de> for KeysVisitor {
|
impl Visitor<'_> for KeysVisitor {
|
||||||
type Value = Keys;
|
type Value = Keys;
|
||||||
|
|
||||||
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
|
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
|
||||||
@@ -182,7 +184,7 @@ impl<'de> Deserialize<'de> for Keys {
|
|||||||
pub struct VimModes(pub Vec<VimMode>);
|
pub struct VimModes(pub Vec<VimMode>);
|
||||||
pub struct VimModesVisitor;
|
pub struct VimModesVisitor;
|
||||||
|
|
||||||
impl<'de> Visitor<'de> for VimModesVisitor {
|
impl Visitor<'_> for VimModesVisitor {
|
||||||
type Value = VimModes;
|
type Value = VimModes;
|
||||||
|
|
||||||
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
|
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
|
||||||
@@ -232,7 +234,7 @@ impl From<LogLevel> for Level {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'de> Visitor<'de> for LogLevelVisitor {
|
impl Visitor<'_> for LogLevelVisitor {
|
||||||
type Value = LogLevel;
|
type Value = LogLevel;
|
||||||
|
|
||||||
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
|
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
|
||||||
@@ -267,7 +269,7 @@ impl<'de> Deserialize<'de> for LogLevel {
|
|||||||
pub struct UserColor(pub Color);
|
pub struct UserColor(pub Color);
|
||||||
pub struct UserColorVisitor;
|
pub struct UserColorVisitor;
|
||||||
|
|
||||||
impl<'de> Visitor<'de> for UserColorVisitor {
|
impl Visitor<'_> for UserColorVisitor {
|
||||||
type Value = UserColor;
|
type Value = UserColor;
|
||||||
|
|
||||||
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
|
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
|
||||||
@@ -321,7 +323,7 @@ pub struct Session {
|
|||||||
impl From<Session> for MatrixSession {
|
impl From<Session> for MatrixSession {
|
||||||
fn from(session: Session) -> Self {
|
fn from(session: Session) -> Self {
|
||||||
MatrixSession {
|
MatrixSession {
|
||||||
tokens: matrix_sdk::matrix_auth::MatrixSessionTokens {
|
tokens: matrix_sdk::authentication::matrix::MatrixSessionTokens {
|
||||||
access_token: session.access_token,
|
access_token: session.access_token,
|
||||||
refresh_token: session.refresh_token,
|
refresh_token: session.refresh_token,
|
||||||
},
|
},
|
||||||
@@ -352,29 +354,31 @@ pub struct UserDisplayTunables {
|
|||||||
|
|
||||||
pub type UserOverrides = HashMap<OwnedUserId, UserDisplayTunables>;
|
pub type UserOverrides = HashMap<OwnedUserId, UserDisplayTunables>;
|
||||||
|
|
||||||
fn merge_sorts(a: SortOverrides, b: SortOverrides) -> SortOverrides {
|
fn merge_sorts(profile: SortOverrides, global: SortOverrides) -> SortOverrides {
|
||||||
SortOverrides {
|
SortOverrides {
|
||||||
chats: b.chats.or(a.chats),
|
chats: profile.chats.or(global.chats),
|
||||||
dms: b.dms.or(a.dms),
|
dms: profile.dms.or(global.dms),
|
||||||
rooms: b.rooms.or(a.rooms),
|
rooms: profile.rooms.or(global.rooms),
|
||||||
spaces: b.spaces.or(a.spaces),
|
spaces: profile.spaces.or(global.spaces),
|
||||||
members: b.members.or(a.members),
|
members: profile.members.or(global.members),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn merge_maps<K, V>(a: Option<HashMap<K, V>>, b: Option<HashMap<K, V>>) -> Option<HashMap<K, V>>
|
fn merge_maps<K, V>(
|
||||||
|
profile: Option<HashMap<K, V>>,
|
||||||
|
global: Option<HashMap<K, V>>,
|
||||||
|
) -> Option<HashMap<K, V>>
|
||||||
where
|
where
|
||||||
K: Eq + Hash,
|
K: Eq + Hash,
|
||||||
{
|
{
|
||||||
match (a, b) {
|
match (global, profile) {
|
||||||
(Some(a), None) => Some(a),
|
(Some(m), None) | (None, Some(m)) => Some(m),
|
||||||
(None, Some(b)) => Some(b),
|
(Some(mut global), Some(profile)) => {
|
||||||
(Some(mut a), Some(b)) => {
|
for (k, v) in profile {
|
||||||
for (k, v) in b {
|
global.insert(k, v);
|
||||||
a.insert(k, v);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Some(a)
|
Some(global)
|
||||||
},
|
},
|
||||||
(None, None) => None,
|
(None, None) => None,
|
||||||
}
|
}
|
||||||
@@ -398,14 +402,77 @@ pub enum UserDisplayStyle {
|
|||||||
DisplayName,
|
DisplayName,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
#[serde(rename_all = "lowercase")]
|
pub struct NotifyVia {
|
||||||
pub enum NotifyVia {
|
|
||||||
/// Deliver notifications via terminal bell.
|
/// Deliver notifications via terminal bell.
|
||||||
Bell,
|
pub bell: bool,
|
||||||
/// Deliver notifications via desktop mechanism.
|
/// Deliver notifications via desktop mechanism.
|
||||||
#[default]
|
#[cfg(feature = "desktop")]
|
||||||
Desktop,
|
pub desktop: bool,
|
||||||
|
}
|
||||||
|
pub struct NotifyViaVisitor;
|
||||||
|
|
||||||
|
impl Default for NotifyVia {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
bell: cfg!(not(feature = "desktop")),
|
||||||
|
#[cfg(feature = "desktop")]
|
||||||
|
desktop: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Visitor<'_> for NotifyViaVisitor {
|
||||||
|
type Value = NotifyVia;
|
||||||
|
|
||||||
|
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
|
||||||
|
formatter.write_str("a valid notify destination (e.g. \"bell\" or \"desktop\")")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
|
||||||
|
where
|
||||||
|
E: SerdeError,
|
||||||
|
{
|
||||||
|
let mut via = NotifyVia {
|
||||||
|
bell: false,
|
||||||
|
#[cfg(feature = "desktop")]
|
||||||
|
desktop: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
for value in value.split('|') {
|
||||||
|
match value.to_ascii_lowercase().as_str() {
|
||||||
|
"bell" => {
|
||||||
|
via.bell = true;
|
||||||
|
},
|
||||||
|
#[cfg(feature = "desktop")]
|
||||||
|
"desktop" => {
|
||||||
|
via.desktop = true;
|
||||||
|
},
|
||||||
|
#[cfg(not(feature = "desktop"))]
|
||||||
|
"desktop" => {
|
||||||
|
return Err(E::custom("desktop notification support was compiled out"))
|
||||||
|
},
|
||||||
|
_ => return Err(E::custom("could not parse into a notify destination")),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(via)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for NotifyVia {
|
||||||
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
deserializer.deserialize_str(NotifyViaVisitor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)]
|
||||||
|
pub struct Mouse {
|
||||||
|
#[serde(default)]
|
||||||
|
pub enabled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)]
|
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)]
|
||||||
@@ -491,12 +558,14 @@ impl SortOverrides {
|
|||||||
pub struct TunableValues {
|
pub struct TunableValues {
|
||||||
pub log_level: Level,
|
pub log_level: Level,
|
||||||
pub message_shortcode_display: bool,
|
pub message_shortcode_display: bool,
|
||||||
|
pub normal_after_send: bool,
|
||||||
pub reaction_display: bool,
|
pub reaction_display: bool,
|
||||||
pub reaction_shortcode_display: bool,
|
pub reaction_shortcode_display: bool,
|
||||||
pub read_receipt_send: bool,
|
pub read_receipt_send: bool,
|
||||||
pub read_receipt_display: bool,
|
pub read_receipt_display: bool,
|
||||||
pub request_timeout: u64,
|
pub request_timeout: u64,
|
||||||
pub sort: SortValues,
|
pub sort: SortValues,
|
||||||
|
pub state_event_display: bool,
|
||||||
pub typing_notice_send: bool,
|
pub typing_notice_send: bool,
|
||||||
pub typing_notice_display: bool,
|
pub typing_notice_display: bool,
|
||||||
pub users: UserOverrides,
|
pub users: UserOverrides,
|
||||||
@@ -504,15 +573,19 @@ pub struct TunableValues {
|
|||||||
pub message_user_color: bool,
|
pub message_user_color: bool,
|
||||||
pub default_room: Option<String>,
|
pub default_room: Option<String>,
|
||||||
pub open_command: Option<Vec<String>>,
|
pub open_command: Option<Vec<String>>,
|
||||||
|
pub mouse: Mouse,
|
||||||
pub notifications: Notifications,
|
pub notifications: Notifications,
|
||||||
pub image_preview: Option<ImagePreviewValues>,
|
pub image_preview: Option<ImagePreviewValues>,
|
||||||
pub user_gutter_width: usize,
|
pub user_gutter_width: usize,
|
||||||
|
pub external_edit_file_suffix: String,
|
||||||
|
pub tabstop: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Default, Deserialize)]
|
#[derive(Clone, Default, Deserialize)]
|
||||||
pub struct Tunables {
|
pub struct Tunables {
|
||||||
pub log_level: Option<LogLevel>,
|
pub log_level: Option<LogLevel>,
|
||||||
pub message_shortcode_display: Option<bool>,
|
pub message_shortcode_display: Option<bool>,
|
||||||
|
pub normal_after_send: Option<bool>,
|
||||||
pub reaction_display: Option<bool>,
|
pub reaction_display: Option<bool>,
|
||||||
pub reaction_shortcode_display: Option<bool>,
|
pub reaction_shortcode_display: Option<bool>,
|
||||||
pub read_receipt_send: Option<bool>,
|
pub read_receipt_send: Option<bool>,
|
||||||
@@ -520,6 +593,7 @@ pub struct Tunables {
|
|||||||
pub request_timeout: Option<u64>,
|
pub request_timeout: Option<u64>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub sort: SortOverrides,
|
pub sort: SortOverrides,
|
||||||
|
pub state_event_display: Option<bool>,
|
||||||
pub typing_notice_send: Option<bool>,
|
pub typing_notice_send: Option<bool>,
|
||||||
pub typing_notice_display: Option<bool>,
|
pub typing_notice_display: Option<bool>,
|
||||||
pub users: Option<UserOverrides>,
|
pub users: Option<UserOverrides>,
|
||||||
@@ -527,9 +601,12 @@ pub struct Tunables {
|
|||||||
pub message_user_color: Option<bool>,
|
pub message_user_color: Option<bool>,
|
||||||
pub default_room: Option<String>,
|
pub default_room: Option<String>,
|
||||||
pub open_command: Option<Vec<String>>,
|
pub open_command: Option<Vec<String>>,
|
||||||
|
pub mouse: Option<Mouse>,
|
||||||
pub notifications: Option<Notifications>,
|
pub notifications: Option<Notifications>,
|
||||||
pub image_preview: Option<ImagePreview>,
|
pub image_preview: Option<ImagePreview>,
|
||||||
pub user_gutter_width: Option<usize>,
|
pub user_gutter_width: Option<usize>,
|
||||||
|
pub external_edit_file_suffix: Option<String>,
|
||||||
|
pub tabstop: Option<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Tunables {
|
impl Tunables {
|
||||||
@@ -539,6 +616,7 @@ impl Tunables {
|
|||||||
message_shortcode_display: self
|
message_shortcode_display: self
|
||||||
.message_shortcode_display
|
.message_shortcode_display
|
||||||
.or(other.message_shortcode_display),
|
.or(other.message_shortcode_display),
|
||||||
|
normal_after_send: self.normal_after_send.or(other.normal_after_send),
|
||||||
reaction_display: self.reaction_display.or(other.reaction_display),
|
reaction_display: self.reaction_display.or(other.reaction_display),
|
||||||
reaction_shortcode_display: self
|
reaction_shortcode_display: self
|
||||||
.reaction_shortcode_display
|
.reaction_shortcode_display
|
||||||
@@ -547,6 +625,7 @@ impl Tunables {
|
|||||||
read_receipt_display: self.read_receipt_display.or(other.read_receipt_display),
|
read_receipt_display: self.read_receipt_display.or(other.read_receipt_display),
|
||||||
request_timeout: self.request_timeout.or(other.request_timeout),
|
request_timeout: self.request_timeout.or(other.request_timeout),
|
||||||
sort: merge_sorts(self.sort, other.sort),
|
sort: merge_sorts(self.sort, other.sort),
|
||||||
|
state_event_display: self.state_event_display.or(other.state_event_display),
|
||||||
typing_notice_send: self.typing_notice_send.or(other.typing_notice_send),
|
typing_notice_send: self.typing_notice_send.or(other.typing_notice_send),
|
||||||
typing_notice_display: self.typing_notice_display.or(other.typing_notice_display),
|
typing_notice_display: self.typing_notice_display.or(other.typing_notice_display),
|
||||||
users: merge_maps(self.users, other.users),
|
users: merge_maps(self.users, other.users),
|
||||||
@@ -554,9 +633,14 @@ impl Tunables {
|
|||||||
message_user_color: self.message_user_color.or(other.message_user_color),
|
message_user_color: self.message_user_color.or(other.message_user_color),
|
||||||
default_room: self.default_room.or(other.default_room),
|
default_room: self.default_room.or(other.default_room),
|
||||||
open_command: self.open_command.or(other.open_command),
|
open_command: self.open_command.or(other.open_command),
|
||||||
|
mouse: self.mouse.or(other.mouse),
|
||||||
notifications: self.notifications.or(other.notifications),
|
notifications: self.notifications.or(other.notifications),
|
||||||
image_preview: self.image_preview.or(other.image_preview),
|
image_preview: self.image_preview.or(other.image_preview),
|
||||||
user_gutter_width: self.user_gutter_width.or(other.user_gutter_width),
|
user_gutter_width: self.user_gutter_width.or(other.user_gutter_width),
|
||||||
|
external_edit_file_suffix: self
|
||||||
|
.external_edit_file_suffix
|
||||||
|
.or(other.external_edit_file_suffix),
|
||||||
|
tabstop: self.tabstop.or(other.tabstop),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -564,12 +648,14 @@ impl Tunables {
|
|||||||
TunableValues {
|
TunableValues {
|
||||||
log_level: self.log_level.map(Level::from).unwrap_or(Level::INFO),
|
log_level: self.log_level.map(Level::from).unwrap_or(Level::INFO),
|
||||||
message_shortcode_display: self.message_shortcode_display.unwrap_or(false),
|
message_shortcode_display: self.message_shortcode_display.unwrap_or(false),
|
||||||
|
normal_after_send: self.normal_after_send.unwrap_or(false),
|
||||||
reaction_display: self.reaction_display.unwrap_or(true),
|
reaction_display: self.reaction_display.unwrap_or(true),
|
||||||
reaction_shortcode_display: self.reaction_shortcode_display.unwrap_or(false),
|
reaction_shortcode_display: self.reaction_shortcode_display.unwrap_or(false),
|
||||||
read_receipt_send: self.read_receipt_send.unwrap_or(true),
|
read_receipt_send: self.read_receipt_send.unwrap_or(true),
|
||||||
read_receipt_display: self.read_receipt_display.unwrap_or(true),
|
read_receipt_display: self.read_receipt_display.unwrap_or(true),
|
||||||
request_timeout: self.request_timeout.unwrap_or(DEFAULT_REQ_TIMEOUT),
|
request_timeout: self.request_timeout.unwrap_or(DEFAULT_REQ_TIMEOUT),
|
||||||
sort: self.sort.values(),
|
sort: self.sort.values(),
|
||||||
|
state_event_display: self.state_event_display.unwrap_or(true),
|
||||||
typing_notice_send: self.typing_notice_send.unwrap_or(true),
|
typing_notice_send: self.typing_notice_send.unwrap_or(true),
|
||||||
typing_notice_display: self.typing_notice_display.unwrap_or(true),
|
typing_notice_display: self.typing_notice_display.unwrap_or(true),
|
||||||
users: self.users.unwrap_or_default(),
|
users: self.users.unwrap_or_default(),
|
||||||
@@ -577,9 +663,14 @@ impl Tunables {
|
|||||||
message_user_color: self.message_user_color.unwrap_or(false),
|
message_user_color: self.message_user_color.unwrap_or(false),
|
||||||
default_room: self.default_room,
|
default_room: self.default_room,
|
||||||
open_command: self.open_command,
|
open_command: self.open_command,
|
||||||
|
mouse: self.mouse.unwrap_or_default(),
|
||||||
notifications: self.notifications.unwrap_or_default(),
|
notifications: self.notifications.unwrap_or_default(),
|
||||||
image_preview: self.image_preview.map(ImagePreview::values),
|
image_preview: self.image_preview.map(ImagePreview::values),
|
||||||
user_gutter_width: self.user_gutter_width.unwrap_or(30),
|
user_gutter_width: self.user_gutter_width.unwrap_or(30),
|
||||||
|
external_edit_file_suffix: self
|
||||||
|
.external_edit_file_suffix
|
||||||
|
.unwrap_or_else(|| ".md".to_string()),
|
||||||
|
tabstop: self.tabstop.unwrap_or(4),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -711,7 +802,7 @@ pub struct ProfileConfig {
|
|||||||
|
|
||||||
#[derive(Clone, Deserialize)]
|
#[derive(Clone, Deserialize)]
|
||||||
pub struct IambConfig {
|
pub struct IambConfig {
|
||||||
pub profiles: HashMap<String, ProfileConfig>,
|
pub profiles: BTreeMap<String, ProfileConfig>,
|
||||||
pub default_profile: Option<String>,
|
pub default_profile: Option<String>,
|
||||||
pub settings: Option<Tunables>,
|
pub settings: Option<Tunables>,
|
||||||
pub dirs: Option<Directories>,
|
pub dirs: Option<Directories>,
|
||||||
@@ -751,8 +842,16 @@ pub struct ApplicationSettings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ApplicationSettings {
|
impl ApplicationSettings {
|
||||||
|
fn get_xdg_config_home() -> Option<PathBuf> {
|
||||||
|
env::var("XDG_CONFIG_HOME").ok().map(PathBuf::from)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn load(cli: Iamb) -> Result<Self, Box<dyn std::error::Error>> {
|
pub fn load(cli: Iamb) -> Result<Self, Box<dyn std::error::Error>> {
|
||||||
let mut config_dir = cli.config_directory.or_else(dirs::config_dir).unwrap_or_else(|| {
|
let mut config_dir = cli
|
||||||
|
.config_directory
|
||||||
|
.or_else(Self::get_xdg_config_home)
|
||||||
|
.or_else(dirs::config_dir)
|
||||||
|
.unwrap_or_else(|| {
|
||||||
usage!(
|
usage!(
|
||||||
"No user configuration directory found;\
|
"No user configuration directory found;\
|
||||||
please specify one via -C.\n\n
|
please specify one via -C.\n\n
|
||||||
@@ -798,14 +897,36 @@ impl ApplicationSettings {
|
|||||||
} else if profiles.len() == 1 {
|
} else if profiles.len() == 1 {
|
||||||
profiles.into_iter().next().unwrap()
|
profiles.into_iter().next().unwrap()
|
||||||
} else {
|
} else {
|
||||||
|
loop {
|
||||||
|
println!("\nNo profile specified. Available profiles:");
|
||||||
|
profiles
|
||||||
|
.keys()
|
||||||
|
.enumerate()
|
||||||
|
.for_each(|(i, name)| println!("{}: {}", i, name));
|
||||||
|
|
||||||
|
print!("Select a number or 'q' to quit: ");
|
||||||
|
let _ = std::io::stdout().flush();
|
||||||
|
|
||||||
|
let mut input = String::new();
|
||||||
|
let _ = std::io::stdin().read_line(&mut input);
|
||||||
|
|
||||||
|
if input.trim() == "q" {
|
||||||
usage!(
|
usage!(
|
||||||
"No profile specified. \
|
"No profile specified. \
|
||||||
Please use -P or add \"default_profile\" to your configuration.\n\n\
|
Please use -P or add \"default_profile\" to your configuration.\n\n\
|
||||||
For more information try '--help'",
|
For more information try '--help'",
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
if let Ok(i) = input.trim().parse::<usize>() {
|
||||||
|
if i < profiles.len() {
|
||||||
|
break profiles.into_iter().nth(i).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
println!("\nInvalid index.");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let macros = merge_maps(macros, profile.macros.take()).unwrap_or_default();
|
let macros = merge_maps(profile.macros.take(), macros).unwrap_or_default();
|
||||||
let layout = profile.layout.take().or(layout).unwrap_or_default();
|
let layout = profile.layout.take().or(layout).unwrap_or_default();
|
||||||
|
|
||||||
let tunables = global.unwrap_or_default();
|
let tunables = global.unwrap_or_default();
|
||||||
@@ -880,7 +1001,7 @@ impl ApplicationSettings {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_user_char_span<'a>(&self, user_id: &'a UserId) -> Span<'a> {
|
pub fn get_user_char_span(&self, user_id: &UserId) -> Span {
|
||||||
let (color, c) = self
|
let (color, c) = self
|
||||||
.tunables
|
.tunables
|
||||||
.users
|
.users
|
||||||
@@ -912,15 +1033,16 @@ impl ApplicationSettings {
|
|||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_user_style(&self, user_id: &UserId) -> Style {
|
pub fn get_user_color(&self, user_id: &UserId) -> Color {
|
||||||
let color = self
|
self.tunables
|
||||||
.tunables
|
|
||||||
.users
|
.users
|
||||||
.get(user_id)
|
.get(user_id)
|
||||||
.and_then(|user| user.color.as_ref().map(|c| c.0))
|
.and_then(|user| user.color.as_ref().map(|c| c.0))
|
||||||
.unwrap_or_else(|| user_color(user_id.as_str()));
|
.unwrap_or_else(|| user_color(user_id.as_str()))
|
||||||
|
}
|
||||||
|
|
||||||
user_style_from_color(color)
|
pub fn get_user_style(&self, user_id: &UserId) -> Style {
|
||||||
|
user_style_from_color(self.get_user_color(user_id))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_user_span<'a>(&self, user_id: &'a UserId, info: &'a RoomInfo) -> Span<'a> {
|
pub fn get_user_span<'a>(&self, user_id: &'a UserId, info: &'a RoomInfo) -> Span<'a> {
|
||||||
@@ -1003,10 +1125,10 @@ mod tests {
|
|||||||
assert_eq!(res, Some(b.clone()));
|
assert_eq!(res, Some(b.clone()));
|
||||||
|
|
||||||
let res = merge_maps(Some(b.clone()), Some(c.clone()));
|
let res = merge_maps(Some(b.clone()), Some(c.clone()));
|
||||||
assert_eq!(res, Some(c.clone()));
|
assert_eq!(res, Some(b.clone()));
|
||||||
|
|
||||||
let res = merge_maps(Some(c.clone()), Some(b.clone()));
|
let res = merge_maps(Some(c.clone()), Some(b.clone()));
|
||||||
assert_eq!(res, Some(b.clone()));
|
assert_eq!(res, Some(c.clone()));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1164,12 +1286,35 @@ mod tests {
|
|||||||
let j = "j".parse::<TerminalKey>().unwrap();
|
let j = "j".parse::<TerminalKey>().unwrap();
|
||||||
let esc = "<Esc>".parse::<TerminalKey>().unwrap();
|
let esc = "<Esc>".parse::<TerminalKey>().unwrap();
|
||||||
|
|
||||||
let jj = Keys(vec![j.clone(), j], "jj".into());
|
let jj = Keys(vec![j, j], "jj".into());
|
||||||
let run = mapped.get(&jj).unwrap();
|
let run = mapped.get(&jj).unwrap();
|
||||||
let exp = Keys(vec![esc], "<Esc>".into());
|
let exp = Keys(vec![esc], "<Esc>".into());
|
||||||
assert_eq!(run, &exp);
|
assert_eq!(run, &exp);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_notify_via() {
|
||||||
|
assert_eq!(NotifyVia { bell: false, desktop: true }, NotifyVia::default());
|
||||||
|
assert_eq!(
|
||||||
|
NotifyVia { bell: false, desktop: true },
|
||||||
|
serde_json::from_str(r#""desktop""#).unwrap()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
NotifyVia { bell: true, desktop: false },
|
||||||
|
serde_json::from_str(r#""bell""#).unwrap()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
NotifyVia { bell: true, desktop: true },
|
||||||
|
serde_json::from_str(r#""bell|desktop""#).unwrap()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
NotifyVia { bell: true, desktop: true },
|
||||||
|
serde_json::from_str(r#""desktop|bell""#).unwrap()
|
||||||
|
);
|
||||||
|
assert!(serde_json::from_str::<NotifyVia>(r#""other""#).is_err());
|
||||||
|
assert!(serde_json::from_str::<NotifyVia>(r#""""#).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_load_example_config_toml() {
|
fn test_load_example_config_toml() {
|
||||||
let path = PathBuf::from("config.example.toml");
|
let path = PathBuf::from("config.example.toml");
|
||||||
|
|||||||
@@ -3,13 +3,13 @@
|
|||||||
//! The keybindings are set up here. We define some iamb-specific keybindings, but the default Vim
|
//! The keybindings are set up here. We define some iamb-specific keybindings, but the default Vim
|
||||||
//! keys come from [modalkit::env::vim::keybindings].
|
//! keys come from [modalkit::env::vim::keybindings].
|
||||||
use modalkit::{
|
use modalkit::{
|
||||||
actions::{MacroAction, WindowAction},
|
actions::{InsertTextAction, MacroAction, WindowAction},
|
||||||
env::vim::keybindings::{InputStep, VimBindings},
|
env::vim::keybindings::{InputStep, VimBindings},
|
||||||
env::vim::VimMode,
|
env::vim::VimMode,
|
||||||
env::CommonKeyClass,
|
env::CommonKeyClass,
|
||||||
key::TerminalKey,
|
key::TerminalKey,
|
||||||
keybindings::{EdgeEvent, EdgeRepeat, InputBindings},
|
keybindings::{EdgeEvent, EdgeRepeat, InputBindings},
|
||||||
prelude::Count,
|
prelude::*,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::base::{IambAction, IambInfo, Keybindings, MATRIX_ID_WORD};
|
use crate::base::{IambAction, IambInfo, Keybindings, MATRIX_ID_WORD};
|
||||||
@@ -36,6 +36,7 @@ pub fn setup_keybindings() -> Keybindings {
|
|||||||
let ctrl_z = "<C-Z>".parse::<TerminalKey>().unwrap();
|
let ctrl_z = "<C-Z>".parse::<TerminalKey>().unwrap();
|
||||||
let key_m_lc = "m".parse::<TerminalKey>().unwrap();
|
let key_m_lc = "m".parse::<TerminalKey>().unwrap();
|
||||||
let key_z_lc = "z".parse::<TerminalKey>().unwrap();
|
let key_z_lc = "z".parse::<TerminalKey>().unwrap();
|
||||||
|
let shift_enter = "<S-Enter>".parse::<TerminalKey>().unwrap();
|
||||||
|
|
||||||
let cwz = vec![once(&ctrl_w), once(&key_z_lc)];
|
let cwz = vec![once(&ctrl_w), once(&key_z_lc)];
|
||||||
let cwcz = vec![once(&ctrl_w), once(&ctrl_z)];
|
let cwcz = vec![once(&ctrl_w), once(&ctrl_z)];
|
||||||
@@ -57,6 +58,17 @@ pub fn setup_keybindings() -> Keybindings {
|
|||||||
ism.add_mapping(VimMode::Visual, &cwm, &stoggle);
|
ism.add_mapping(VimMode::Visual, &cwm, &stoggle);
|
||||||
ism.add_mapping(VimMode::Normal, &cwcm, &stoggle);
|
ism.add_mapping(VimMode::Normal, &cwcm, &stoggle);
|
||||||
ism.add_mapping(VimMode::Visual, &cwcm, &stoggle);
|
ism.add_mapping(VimMode::Visual, &cwcm, &stoggle);
|
||||||
|
|
||||||
|
let shift_enter = vec![once(&shift_enter)];
|
||||||
|
let newline = IambStep::new().actions(vec![InsertTextAction::Type(
|
||||||
|
Char::Single('\n').into(),
|
||||||
|
MoveDir1D::Previous,
|
||||||
|
1.into(),
|
||||||
|
)
|
||||||
|
.into()]);
|
||||||
|
ism.add_mapping(VimMode::Insert, &cwm, &newline);
|
||||||
|
ism.add_mapping(VimMode::Insert, &shift_enter, &newline);
|
||||||
|
|
||||||
ism
|
ism
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
189
src/main.rs
189
src/main.rs
@@ -44,10 +44,16 @@ use modalkit::crossterm::{
|
|||||||
read,
|
read,
|
||||||
DisableBracketedPaste,
|
DisableBracketedPaste,
|
||||||
DisableFocusChange,
|
DisableFocusChange,
|
||||||
|
DisableMouseCapture,
|
||||||
EnableBracketedPaste,
|
EnableBracketedPaste,
|
||||||
EnableFocusChange,
|
EnableFocusChange,
|
||||||
|
EnableMouseCapture,
|
||||||
Event,
|
Event,
|
||||||
KeyEventKind,
|
KeyEventKind,
|
||||||
|
KeyboardEnhancementFlags,
|
||||||
|
MouseEventKind,
|
||||||
|
PopKeyboardEnhancementFlags,
|
||||||
|
PushKeyboardEnhancementFlags,
|
||||||
},
|
},
|
||||||
execute,
|
execute,
|
||||||
terminal::{EnterAlternateScreen, LeaveAlternateScreen, SetTitle},
|
terminal::{EnterAlternateScreen, LeaveAlternateScreen, SetTitle},
|
||||||
@@ -56,7 +62,7 @@ use modalkit::crossterm::{
|
|||||||
use ratatui::{
|
use ratatui::{
|
||||||
backend::CrosstermBackend,
|
backend::CrosstermBackend,
|
||||||
layout::Rect,
|
layout::Rect,
|
||||||
style::{Color, Style},
|
style::{Color, Modifier, Style},
|
||||||
text::Span,
|
text::Span,
|
||||||
widgets::Paragraph,
|
widgets::Paragraph,
|
||||||
Terminal,
|
Terminal,
|
||||||
@@ -83,6 +89,7 @@ use crate::{
|
|||||||
ChatStore,
|
ChatStore,
|
||||||
HomeserverAction,
|
HomeserverAction,
|
||||||
IambAction,
|
IambAction,
|
||||||
|
IambCompleter,
|
||||||
IambError,
|
IambError,
|
||||||
IambId,
|
IambId,
|
||||||
IambInfo,
|
IambInfo,
|
||||||
@@ -126,8 +133,8 @@ use modalkit::{
|
|||||||
|
|
||||||
use modalkit_ratatui::{
|
use modalkit_ratatui::{
|
||||||
cmdbar::CommandBarState,
|
cmdbar::CommandBarState,
|
||||||
screen::{Screen, ScreenState, TabLayoutDescription},
|
screen::{Screen, ScreenState, TabbedLayoutDescription},
|
||||||
windows::WindowLayoutDescription,
|
windows::{WindowLayoutDescription, WindowLayoutState},
|
||||||
TerminalCursor,
|
TerminalCursor,
|
||||||
TerminalExtOps,
|
TerminalExtOps,
|
||||||
Window,
|
Window,
|
||||||
@@ -173,6 +180,17 @@ fn config_tab_to_desc(
|
|||||||
Ok(desc)
|
Ok(desc)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn restore_layout(
|
||||||
|
area: Rect,
|
||||||
|
settings: &ApplicationSettings,
|
||||||
|
store: &mut ProgramStore,
|
||||||
|
) -> IambResult<FocusList<WindowLayoutState<IambWindow, IambInfo>>> {
|
||||||
|
let layout = std::fs::read(&settings.layout_json)?;
|
||||||
|
let tabs: TabbedLayoutDescription<IambInfo> =
|
||||||
|
serde_json::from_slice(&layout).map_err(IambError::from)?;
|
||||||
|
tabs.to_layout(area.into(), store)
|
||||||
|
}
|
||||||
|
|
||||||
fn setup_screen(
|
fn setup_screen(
|
||||||
settings: ApplicationSettings,
|
settings: ApplicationSettings,
|
||||||
store: &mut ProgramStore,
|
store: &mut ProgramStore,
|
||||||
@@ -183,12 +201,14 @@ fn setup_screen(
|
|||||||
|
|
||||||
match settings.layout {
|
match settings.layout {
|
||||||
config::Layout::Restore => {
|
config::Layout::Restore => {
|
||||||
if let Ok(layout) = std::fs::read(&settings.layout_json) {
|
match restore_layout(area, &settings, store) {
|
||||||
let tabs: TabLayoutDescription<IambInfo> =
|
Ok(tabs) => {
|
||||||
serde_json::from_slice(&layout).map_err(IambError::from)?;
|
|
||||||
let tabs = tabs.to_layout(area.into(), store)?;
|
|
||||||
|
|
||||||
return Ok(ScreenState::from_list(tabs, cmd));
|
return Ok(ScreenState::from_list(tabs, cmd));
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
// Log the issue with restoring and then continue.
|
||||||
|
tracing::warn!(err = %e, "Failed to restore layout from disk");
|
||||||
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
config::Layout::New => {},
|
config::Layout::New => {},
|
||||||
@@ -239,7 +259,7 @@ struct Application {
|
|||||||
focused: bool,
|
focused: bool,
|
||||||
|
|
||||||
/// The tab layout before the last executed [TabAction].
|
/// The tab layout before the last executed [TabAction].
|
||||||
last_layout: Option<TabLayoutDescription<IambInfo>>,
|
last_layout: Option<TabbedLayoutDescription<IambInfo>>,
|
||||||
|
|
||||||
/// Whether we need to do a full redraw (e.g., after running a subprocess).
|
/// Whether we need to do a full redraw (e.g., after running a subprocess).
|
||||||
dirty: bool,
|
dirty: bool,
|
||||||
@@ -250,16 +270,7 @@ impl Application {
|
|||||||
settings: ApplicationSettings,
|
settings: ApplicationSettings,
|
||||||
store: AsyncProgramStore,
|
store: AsyncProgramStore,
|
||||||
) -> IambResult<Application> {
|
) -> IambResult<Application> {
|
||||||
let mut stdout = stdout();
|
let backend = CrosstermBackend::new(stdout());
|
||||||
crossterm::terminal::enable_raw_mode()?;
|
|
||||||
crossterm::execute!(stdout, EnterAlternateScreen)?;
|
|
||||||
crossterm::execute!(stdout, EnableBracketedPaste)?;
|
|
||||||
crossterm::execute!(stdout, EnableFocusChange)?;
|
|
||||||
|
|
||||||
let title = format!("iamb ({})", settings.profile.user_id);
|
|
||||||
crossterm::execute!(stdout, SetTitle(title))?;
|
|
||||||
|
|
||||||
let backend = CrosstermBackend::new(stdout);
|
|
||||||
let terminal = Terminal::new(backend)?;
|
let terminal = Terminal::new(backend)?;
|
||||||
|
|
||||||
let mut bindings = crate::keybindings::setup_keybindings();
|
let mut bindings = crate::keybindings::setup_keybindings();
|
||||||
@@ -303,7 +314,7 @@ impl Application {
|
|||||||
}
|
}
|
||||||
|
|
||||||
term.draw(|f| {
|
term.draw(|f| {
|
||||||
let area = f.size();
|
let area = f.area();
|
||||||
|
|
||||||
let modestr = bindings.show_mode();
|
let modestr = bindings.show_mode();
|
||||||
let cursor = bindings.get_cursor_indicator();
|
let cursor = bindings.get_cursor_indicator();
|
||||||
@@ -317,6 +328,9 @@ impl Application {
|
|||||||
.show_dialog(dialogstr)
|
.show_dialog(dialogstr)
|
||||||
.show_mode(modestr)
|
.show_mode(modestr)
|
||||||
.borders(true)
|
.borders(true)
|
||||||
|
.border_style(Style::default().add_modifier(Modifier::DIM))
|
||||||
|
.tab_style(Style::default().add_modifier(Modifier::DIM))
|
||||||
|
.tab_style_focused(Style::default().remove_modifier(Modifier::DIM))
|
||||||
.focus(focused);
|
.focus(focused);
|
||||||
f.render_stateful_widget(screen, area, sstate);
|
f.render_stateful_widget(screen, area, sstate);
|
||||||
|
|
||||||
@@ -332,7 +346,7 @@ impl Application {
|
|||||||
let inner = Rect::new(cx, cy, 1, 1);
|
let inner = Rect::new(cx, cy, 1, 1);
|
||||||
f.render_widget(para, inner)
|
f.render_widget(para, inner)
|
||||||
}
|
}
|
||||||
f.set_cursor(cx, cy);
|
f.set_cursor_position((cx, cy));
|
||||||
}
|
}
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
@@ -357,13 +371,39 @@ impl Application {
|
|||||||
|
|
||||||
return Ok(ke.into());
|
return Ok(ke.into());
|
||||||
},
|
},
|
||||||
Event::Mouse(_) => {
|
Event::Mouse(me) => {
|
||||||
// Do nothing for now.
|
let dir = match me.kind {
|
||||||
|
MouseEventKind::ScrollUp => MoveDir2D::Up,
|
||||||
|
MouseEventKind::ScrollDown => MoveDir2D::Down,
|
||||||
|
MouseEventKind::ScrollLeft => MoveDir2D::Left,
|
||||||
|
MouseEventKind::ScrollRight => MoveDir2D::Right,
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
let size = ScrollSize::Cell;
|
||||||
|
let style = ScrollStyle::Direction2D(dir, size, 1.into());
|
||||||
|
let ctx = ProgramContext::default();
|
||||||
|
let mut store = self.store.lock().await;
|
||||||
|
|
||||||
|
match self.screen.scroll(&style, &ctx, store.deref_mut()) {
|
||||||
|
Ok(None) => {},
|
||||||
|
Ok(Some(info)) => {
|
||||||
|
drop(store);
|
||||||
|
self.handle_info(info);
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
self.screen.push_error(e);
|
||||||
|
},
|
||||||
|
}
|
||||||
},
|
},
|
||||||
Event::FocusGained => {
|
Event::FocusGained => {
|
||||||
|
let mut store = self.store.lock().await;
|
||||||
|
store.application.focused = true;
|
||||||
self.focused = true;
|
self.focused = true;
|
||||||
},
|
},
|
||||||
Event::FocusLost => {
|
Event::FocusLost => {
|
||||||
|
let mut store = self.store.lock().await;
|
||||||
|
store.application.focused = false;
|
||||||
self.focused = false;
|
self.focused = false;
|
||||||
},
|
},
|
||||||
Event::Resize(_, _) => {
|
Event::Resize(_, _) => {
|
||||||
@@ -481,7 +521,7 @@ impl Application {
|
|||||||
None
|
None
|
||||||
},
|
},
|
||||||
Action::Command(act) => {
|
Action::Command(act) => {
|
||||||
let acts = store.application.cmds.command(&act, &ctx)?;
|
let acts = store.application.cmds.command(&act, &ctx, &mut store.registers)?;
|
||||||
self.action_prepend(acts);
|
self.action_prepend(acts);
|
||||||
|
|
||||||
None
|
None
|
||||||
@@ -493,7 +533,7 @@ impl Application {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// Unimplemented.
|
// Unimplemented.
|
||||||
Action::KeywordLookup => {
|
Action::KeywordLookup(_) => {
|
||||||
// XXX: implement
|
// XXX: implement
|
||||||
None
|
None
|
||||||
},
|
},
|
||||||
@@ -518,6 +558,21 @@ impl Application {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let info = match action {
|
let info = match action {
|
||||||
|
IambAction::ClearUnreads => {
|
||||||
|
let user_id = &store.application.settings.profile.user_id;
|
||||||
|
|
||||||
|
// Clear any notifications we displayed:
|
||||||
|
store.application.open_notifications.clear();
|
||||||
|
|
||||||
|
for room_id in store.application.sync_info.chats() {
|
||||||
|
if let Some(room) = store.application.rooms.get_mut(room_id) {
|
||||||
|
room.fully_read(user_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
},
|
||||||
|
|
||||||
IambAction::ToggleScrollbackFocus => {
|
IambAction::ToggleScrollbackFocus => {
|
||||||
self.screen.current_window_mut()?.focus_toggle();
|
self.screen.current_window_mut()?.focus_toggle();
|
||||||
|
|
||||||
@@ -534,6 +589,9 @@ impl Application {
|
|||||||
IambAction::Message(act) => {
|
IambAction::Message(act) => {
|
||||||
self.screen.current_window_mut()?.message_command(act, ctx, store).await?
|
self.screen.current_window_mut()?.message_command(act, ctx, store).await?
|
||||||
},
|
},
|
||||||
|
IambAction::Space(act) => {
|
||||||
|
self.screen.current_window_mut()?.space_command(act, ctx, store).await?
|
||||||
|
},
|
||||||
IambAction::Room(act) => {
|
IambAction::Room(act) => {
|
||||||
let acts = self.screen.current_window_mut()?.room_command(act, ctx, store).await?;
|
let acts = self.screen.current_window_mut()?.room_command(act, ctx, store).await?;
|
||||||
self.action_prepend(acts);
|
self.action_prepend(acts);
|
||||||
@@ -541,6 +599,9 @@ impl Application {
|
|||||||
None
|
None
|
||||||
},
|
},
|
||||||
IambAction::Send(act) => {
|
IambAction::Send(act) => {
|
||||||
|
if store.application.settings.tunables.normal_after_send {
|
||||||
|
self.bindings.reset_mode();
|
||||||
|
}
|
||||||
self.screen.current_window_mut()?.send_command(act, ctx, store).await?
|
self.screen.current_window_mut()?.send_command(act, ctx, store).await?
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -824,7 +885,7 @@ async fn check_import_keys(
|
|||||||
let encrypted = match encrypt_room_key_export(&keys, &passphrase, 500000) {
|
let encrypted = match encrypt_room_key_export(&keys, &passphrase, 500000) {
|
||||||
Ok(encrypted) => encrypted,
|
Ok(encrypted) => encrypted,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
format!("* Failed to encrypt room keys during export: {e}");
|
println!("* Failed to encrypt room keys during export: {e}");
|
||||||
process::exit(2);
|
process::exit(2);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -905,6 +966,50 @@ async fn login_normal(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set up the terminal for drawing the TUI, and getting additional info.
|
||||||
|
fn setup_tty(settings: &ApplicationSettings, enable_enhanced_keys: bool) -> std::io::Result<()> {
|
||||||
|
let title = format!("iamb ({})", settings.profile.user_id.as_str());
|
||||||
|
|
||||||
|
// Enable raw mode and enter the alternate screen.
|
||||||
|
crossterm::terminal::enable_raw_mode()?;
|
||||||
|
crossterm::execute!(stdout(), EnterAlternateScreen)?;
|
||||||
|
|
||||||
|
if enable_enhanced_keys {
|
||||||
|
// Enable the Kitty keyboard enhancement protocol for improved keypresses.
|
||||||
|
crossterm::queue!(
|
||||||
|
stdout(),
|
||||||
|
PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES)
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if settings.tunables.mouse.enabled {
|
||||||
|
crossterm::execute!(stdout(), EnableMouseCapture)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
crossterm::execute!(stdout(), EnableBracketedPaste, EnableFocusChange, SetTitle(title))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Do our best to reverse what we did in setup_tty() when we exit or crash.
|
||||||
|
fn restore_tty(enable_enhanced_keys: bool, enable_mouse: bool) {
|
||||||
|
if enable_enhanced_keys {
|
||||||
|
let _ = crossterm::queue!(stdout(), PopKeyboardEnhancementFlags);
|
||||||
|
}
|
||||||
|
|
||||||
|
if enable_mouse {
|
||||||
|
let _ = crossterm::queue!(stdout(), DisableMouseCapture);
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = crossterm::execute!(
|
||||||
|
stdout(),
|
||||||
|
DisableBracketedPaste,
|
||||||
|
DisableFocusChange,
|
||||||
|
LeaveAlternateScreen,
|
||||||
|
CursorShow,
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = crossterm::terminal::disable_raw_mode();
|
||||||
|
}
|
||||||
|
|
||||||
async fn run(settings: ApplicationSettings) -> IambResult<()> {
|
async fn run(settings: ApplicationSettings) -> IambResult<()> {
|
||||||
// Get old keys the first time we run w/ the upgraded SDK.
|
// Get old keys the first time we run w/ the upgraded SDK.
|
||||||
let import_keys = check_import_keys(&settings).await?;
|
let import_keys = check_import_keys(&settings).await?;
|
||||||
@@ -916,7 +1021,9 @@ async fn run(settings: ApplicationSettings) -> IambResult<()> {
|
|||||||
// Set up the async worker thread and global store.
|
// Set up the async worker thread and global store.
|
||||||
let worker = ClientWorker::spawn(client.clone(), settings.clone()).await;
|
let worker = ClientWorker::spawn(client.clone(), settings.clone()).await;
|
||||||
let store = ChatStore::new(worker.clone(), settings.clone());
|
let store = ChatStore::new(worker.clone(), settings.clone());
|
||||||
let store = Store::new(store);
|
let mut store = Store::new(store);
|
||||||
|
store.completer = Box::new(IambCompleter);
|
||||||
|
|
||||||
let store = Arc::new(AsyncMutex::new(store));
|
let store = Arc::new(AsyncMutex::new(store));
|
||||||
worker.init(store.clone());
|
worker.init(store.clone());
|
||||||
|
|
||||||
@@ -938,27 +1045,31 @@ async fn run(settings: ApplicationSettings) -> IambResult<()> {
|
|||||||
Ok(()) => (),
|
Ok(()) => (),
|
||||||
}
|
}
|
||||||
|
|
||||||
fn restore_tty() {
|
// Set up the terminal for drawing, and cleanup properly on panics.
|
||||||
let _ = crossterm::terminal::disable_raw_mode();
|
let enable_enhanced_keys = match crossterm::terminal::supports_keyboard_enhancement() {
|
||||||
let _ = crossterm::execute!(stdout(), DisableBracketedPaste);
|
Ok(supported) => supported,
|
||||||
let _ = crossterm::execute!(stdout(), DisableFocusChange);
|
Err(e) => {
|
||||||
let _ = crossterm::execute!(stdout(), LeaveAlternateScreen);
|
tracing::warn!(err = %e,
|
||||||
let _ = crossterm::execute!(stdout(), CursorShow);
|
"Failed to determine whether the terminal supports keyboard enhancements");
|
||||||
}
|
false
|
||||||
|
},
|
||||||
|
};
|
||||||
|
setup_tty(&settings, enable_enhanced_keys)?;
|
||||||
|
|
||||||
// Make sure panics clean up the terminal properly.
|
|
||||||
let orig_hook = std::panic::take_hook();
|
let orig_hook = std::panic::take_hook();
|
||||||
|
let enable_mouse = settings.tunables.mouse.enabled;
|
||||||
std::panic::set_hook(Box::new(move |panic_info| {
|
std::panic::set_hook(Box::new(move |panic_info| {
|
||||||
restore_tty();
|
restore_tty(enable_enhanced_keys, enable_mouse);
|
||||||
orig_hook(panic_info);
|
orig_hook(panic_info);
|
||||||
process::exit(1);
|
process::exit(1);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// And finally, start running the terminal UI.
|
||||||
let mut application = Application::new(settings, store).await?;
|
let mut application = Application::new(settings, store).await?;
|
||||||
|
|
||||||
// We can now run the application.
|
|
||||||
application.run().await?;
|
application.run().await?;
|
||||||
restore_tty();
|
|
||||||
|
// Clean up the terminal on exit.
|
||||||
|
restore_tty(enable_enhanced_keys, enable_mouse);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
376
src/message/compose.rs
Normal file
376
src/message/compose.rs
Normal file
@@ -0,0 +1,376 @@
|
|||||||
|
//! Code for converting composed messages into content to send to the homeserver.
|
||||||
|
use comrak::{markdown_to_html, ComrakOptions};
|
||||||
|
use nom::{
|
||||||
|
branch::alt,
|
||||||
|
bytes::complete::tag,
|
||||||
|
character::complete::space0,
|
||||||
|
combinator::value,
|
||||||
|
IResult,
|
||||||
|
};
|
||||||
|
|
||||||
|
use matrix_sdk::ruma::events::room::message::{
|
||||||
|
EmoteMessageEventContent,
|
||||||
|
MessageType,
|
||||||
|
RoomMessageEventContent,
|
||||||
|
TextMessageEventContent,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default)]
|
||||||
|
enum SlashCommand {
|
||||||
|
/// Send an emote message.
|
||||||
|
Emote,
|
||||||
|
|
||||||
|
/// Send a message as literal HTML.
|
||||||
|
Html,
|
||||||
|
|
||||||
|
/// Send a message without parsing any markup.
|
||||||
|
Plaintext,
|
||||||
|
|
||||||
|
/// Send a Markdown message (the default message markup).
|
||||||
|
#[default]
|
||||||
|
Markdown,
|
||||||
|
|
||||||
|
/// Send a message with confetti effects in clients that show them.
|
||||||
|
Confetti,
|
||||||
|
|
||||||
|
/// Send a message with fireworks effects in clients that show them.
|
||||||
|
Fireworks,
|
||||||
|
|
||||||
|
/// Send a message with heart effects in clients that show them.
|
||||||
|
Hearts,
|
||||||
|
|
||||||
|
/// Send a message with rainfall effects in clients that show them.
|
||||||
|
Rainfall,
|
||||||
|
|
||||||
|
/// Send a message with snowfall effects in clients that show them.
|
||||||
|
Snowfall,
|
||||||
|
|
||||||
|
/// Send a message with heart effects in clients that show them.
|
||||||
|
SpaceInvaders,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SlashCommand {
|
||||||
|
fn to_message(&self, input: &str) -> anyhow::Result<MessageType> {
|
||||||
|
let msgtype = match self {
|
||||||
|
SlashCommand::Emote => {
|
||||||
|
let msg = if let Some(html) = text_to_html(input) {
|
||||||
|
EmoteMessageEventContent::html(input, html)
|
||||||
|
} else {
|
||||||
|
EmoteMessageEventContent::plain(input)
|
||||||
|
};
|
||||||
|
|
||||||
|
MessageType::Emote(msg)
|
||||||
|
},
|
||||||
|
SlashCommand::Html => {
|
||||||
|
let msg = TextMessageEventContent::html(input, input);
|
||||||
|
MessageType::Text(msg)
|
||||||
|
},
|
||||||
|
SlashCommand::Plaintext => {
|
||||||
|
let msg = TextMessageEventContent::plain(input);
|
||||||
|
MessageType::Text(msg)
|
||||||
|
},
|
||||||
|
SlashCommand::Markdown => {
|
||||||
|
let msg = text_to_message_content(input.to_string());
|
||||||
|
MessageType::Text(msg)
|
||||||
|
},
|
||||||
|
SlashCommand::Confetti => {
|
||||||
|
MessageType::new("nic.custom.confetti", input.into(), Default::default())?
|
||||||
|
},
|
||||||
|
SlashCommand::Fireworks => {
|
||||||
|
MessageType::new("nic.custom.fireworks", input.into(), Default::default())?
|
||||||
|
},
|
||||||
|
SlashCommand::Hearts => {
|
||||||
|
MessageType::new("io.element.effect.hearts", input.into(), Default::default())?
|
||||||
|
},
|
||||||
|
SlashCommand::Rainfall => {
|
||||||
|
MessageType::new("io.element.effect.rainfall", input.into(), Default::default())?
|
||||||
|
},
|
||||||
|
SlashCommand::Snowfall => {
|
||||||
|
MessageType::new("io.element.effect.snowfall", input.into(), Default::default())?
|
||||||
|
},
|
||||||
|
SlashCommand::SpaceInvaders => {
|
||||||
|
MessageType::new(
|
||||||
|
"io.element.effects.space_invaders",
|
||||||
|
input.into(),
|
||||||
|
Default::default(),
|
||||||
|
)?
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(msgtype)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_slash_command_inner(input: &str) -> IResult<&str, SlashCommand> {
|
||||||
|
let (input, _) = space0(input)?;
|
||||||
|
let (input, slash) = alt((
|
||||||
|
value(SlashCommand::Emote, tag("/me ")),
|
||||||
|
value(SlashCommand::Html, tag("/h ")),
|
||||||
|
value(SlashCommand::Html, tag("/html ")),
|
||||||
|
value(SlashCommand::Plaintext, tag("/p ")),
|
||||||
|
value(SlashCommand::Plaintext, tag("/plain ")),
|
||||||
|
value(SlashCommand::Plaintext, tag("/plaintext ")),
|
||||||
|
value(SlashCommand::Markdown, tag("/md ")),
|
||||||
|
value(SlashCommand::Markdown, tag("/markdown ")),
|
||||||
|
value(SlashCommand::Confetti, tag("/confetti ")),
|
||||||
|
value(SlashCommand::Fireworks, tag("/fireworks ")),
|
||||||
|
value(SlashCommand::Hearts, tag("/hearts ")),
|
||||||
|
value(SlashCommand::Rainfall, tag("/rainfall ")),
|
||||||
|
value(SlashCommand::Snowfall, tag("/snowfall ")),
|
||||||
|
value(SlashCommand::SpaceInvaders, tag("/spaceinvaders ")),
|
||||||
|
))(input)?;
|
||||||
|
let (input, _) = space0(input)?;
|
||||||
|
|
||||||
|
Ok((input, slash))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_slash_command(input: &str) -> anyhow::Result<(&str, SlashCommand)> {
|
||||||
|
match parse_slash_command_inner(input) {
|
||||||
|
Ok(input) => Ok(input),
|
||||||
|
Err(e) => Err(anyhow::anyhow!("Failed to parse slash command: {e}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check whether this character is not used for markup in Markdown.
|
||||||
|
///
|
||||||
|
/// Markdown uses just about every ASCII punctuation symbol in some way, especially
|
||||||
|
/// once autolinking is involved, so we really just check whether it's non-punctuation or
|
||||||
|
/// single/double quotations.
|
||||||
|
fn not_markdown_char(c: char) -> bool {
|
||||||
|
if !c.is_ascii_punctuation() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
matches!(c, '"' | '\'')
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check whether the input actually needs to be processed as Markdown.
|
||||||
|
fn not_markdown(input: &str) -> bool {
|
||||||
|
input.chars().all(not_markdown_char)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn text_to_html(input: &str) -> Option<String> {
|
||||||
|
if not_markdown(input) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut options = ComrakOptions::default();
|
||||||
|
options.extension.autolink = true;
|
||||||
|
options.extension.shortcodes = true;
|
||||||
|
options.extension.strikethrough = true;
|
||||||
|
options.render.hardbreaks = true;
|
||||||
|
markdown_to_html(input, &options).into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn text_to_message_content(input: String) -> TextMessageEventContent {
|
||||||
|
if let Some(html) = text_to_html(input.as_str()) {
|
||||||
|
TextMessageEventContent::html(input, html)
|
||||||
|
} else {
|
||||||
|
TextMessageEventContent::plain(input)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn text_to_message(input: String) -> RoomMessageEventContent {
|
||||||
|
let msg = parse_slash_command(input.as_str())
|
||||||
|
.and_then(|(input, slash)| slash.to_message(input))
|
||||||
|
.unwrap_or_else(|_| MessageType::Text(text_to_message_content(input)));
|
||||||
|
|
||||||
|
RoomMessageEventContent::new(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_markdown_autolink() {
|
||||||
|
let input = "http://example.com\n";
|
||||||
|
let content = text_to_message_content(input.into());
|
||||||
|
assert_eq!(content.body, input);
|
||||||
|
assert_eq!(
|
||||||
|
content.formatted.unwrap().body,
|
||||||
|
"<p><a href=\"http://example.com\">http://example.com</a></p>\n"
|
||||||
|
);
|
||||||
|
|
||||||
|
let input = "www.example.com\n";
|
||||||
|
let content = text_to_message_content(input.into());
|
||||||
|
assert_eq!(content.body, input);
|
||||||
|
assert_eq!(
|
||||||
|
content.formatted.unwrap().body,
|
||||||
|
"<p><a href=\"http://www.example.com\">www.example.com</a></p>\n"
|
||||||
|
);
|
||||||
|
|
||||||
|
let input = "See docs (they're at https://iamb.chat)\n";
|
||||||
|
let content = text_to_message_content(input.into());
|
||||||
|
assert_eq!(content.body, input);
|
||||||
|
assert_eq!(
|
||||||
|
content.formatted.unwrap().body,
|
||||||
|
"<p>See docs (they're at <a href=\"https://iamb.chat\">https://iamb.chat</a>)</p>\n"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_markdown_message() {
|
||||||
|
let input = "**bold**\n";
|
||||||
|
let content = text_to_message_content(input.into());
|
||||||
|
assert_eq!(content.body, input);
|
||||||
|
assert_eq!(content.formatted.unwrap().body, "<p><strong>bold</strong></p>\n");
|
||||||
|
|
||||||
|
let input = "*emphasis*\n";
|
||||||
|
let content = text_to_message_content(input.into());
|
||||||
|
assert_eq!(content.body, input);
|
||||||
|
assert_eq!(content.formatted.unwrap().body, "<p><em>emphasis</em></p>\n");
|
||||||
|
|
||||||
|
let input = "`code`\n";
|
||||||
|
let content = text_to_message_content(input.into());
|
||||||
|
assert_eq!(content.body, input);
|
||||||
|
assert_eq!(content.formatted.unwrap().body, "<p><code>code</code></p>\n");
|
||||||
|
|
||||||
|
let input = "```rust\nconst A: usize = 1;\n```\n";
|
||||||
|
let content = text_to_message_content(input.into());
|
||||||
|
assert_eq!(content.body, input);
|
||||||
|
assert_eq!(
|
||||||
|
content.formatted.unwrap().body,
|
||||||
|
"<pre><code class=\"language-rust\">const A: usize = 1;\n</code></pre>\n"
|
||||||
|
);
|
||||||
|
|
||||||
|
let input = ":heart:\n";
|
||||||
|
let content = text_to_message_content(input.into());
|
||||||
|
assert_eq!(content.body, input);
|
||||||
|
assert_eq!(content.formatted.unwrap().body, "<p>\u{2764}\u{FE0F}</p>\n");
|
||||||
|
|
||||||
|
let input = "para *1*\n\npara _2_\n";
|
||||||
|
let content = text_to_message_content(input.into());
|
||||||
|
assert_eq!(content.body, input);
|
||||||
|
assert_eq!(
|
||||||
|
content.formatted.unwrap().body,
|
||||||
|
"<p>para <em>1</em></p>\n<p>para <em>2</em></p>\n"
|
||||||
|
);
|
||||||
|
|
||||||
|
let input = "line 1\nline ~~2~~\n";
|
||||||
|
let content = text_to_message_content(input.into());
|
||||||
|
assert_eq!(content.body, input);
|
||||||
|
assert_eq!(content.formatted.unwrap().body, "<p>line 1<br />\nline <del>2</del></p>\n");
|
||||||
|
|
||||||
|
let input = "# Heading\n## Subheading\n\ntext\n";
|
||||||
|
let content = text_to_message_content(input.into());
|
||||||
|
assert_eq!(content.body, input);
|
||||||
|
assert_eq!(
|
||||||
|
content.formatted.unwrap().body,
|
||||||
|
"<h1>Heading</h1>\n<h2>Subheading</h2>\n<p>text</p>\n"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_markdown_headers() {
|
||||||
|
let input = "hello\n=====\n";
|
||||||
|
let content = text_to_message_content(input.into());
|
||||||
|
assert_eq!(content.body, input);
|
||||||
|
assert_eq!(content.formatted.unwrap().body, "<h1>hello</h1>\n");
|
||||||
|
|
||||||
|
let input = "hello\n-----\n";
|
||||||
|
let content = text_to_message_content(input.into());
|
||||||
|
assert_eq!(content.body, input);
|
||||||
|
assert_eq!(content.formatted.unwrap().body, "<h2>hello</h2>\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_markdown_lists() {
|
||||||
|
let input = "- A\n- B\n- C\n";
|
||||||
|
let content = text_to_message_content(input.into());
|
||||||
|
assert_eq!(content.body, input);
|
||||||
|
assert_eq!(
|
||||||
|
content.formatted.unwrap().body,
|
||||||
|
"<ul>\n<li>A</li>\n<li>B</li>\n<li>C</li>\n</ul>\n"
|
||||||
|
);
|
||||||
|
|
||||||
|
let input = "1) A\n2) B\n3) C\n";
|
||||||
|
let content = text_to_message_content(input.into());
|
||||||
|
assert_eq!(content.body, input);
|
||||||
|
assert_eq!(
|
||||||
|
content.formatted.unwrap().body,
|
||||||
|
"<ol>\n<li>A</li>\n<li>B</li>\n<li>C</li>\n</ol>\n"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_no_markdown_conversion_on_simple_text() {
|
||||||
|
let input = "para 1\n\npara 2\n";
|
||||||
|
let content = text_to_message_content(input.into());
|
||||||
|
assert_eq!(content.body, input);
|
||||||
|
assert!(content.formatted.is_none());
|
||||||
|
|
||||||
|
let input = "line 1\nline 2\n";
|
||||||
|
let content = text_to_message_content(input.into());
|
||||||
|
assert_eq!(content.body, input);
|
||||||
|
assert!(content.formatted.is_none());
|
||||||
|
|
||||||
|
let input = "isn't markdown\n";
|
||||||
|
let content = text_to_message_content(input.into());
|
||||||
|
assert_eq!(content.body, input);
|
||||||
|
assert!(content.formatted.is_none());
|
||||||
|
|
||||||
|
let input = "\"scare quotes\"\n";
|
||||||
|
let content = text_to_message_content(input.into());
|
||||||
|
assert_eq!(content.body, input);
|
||||||
|
assert!(content.formatted.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn text_to_message_slash_commands() {
|
||||||
|
let MessageType::Text(content) = text_to_message("/html <b>bold</b>".into()).msgtype else {
|
||||||
|
panic!("Expected MessageType::Text");
|
||||||
|
};
|
||||||
|
assert_eq!(content.body, "<b>bold</b>");
|
||||||
|
assert_eq!(content.formatted.unwrap().body, "<b>bold</b>");
|
||||||
|
|
||||||
|
let MessageType::Text(content) = text_to_message("/h <b>bold</b>".into()).msgtype else {
|
||||||
|
panic!("Expected MessageType::Text");
|
||||||
|
};
|
||||||
|
assert_eq!(content.body, "<b>bold</b>");
|
||||||
|
assert_eq!(content.formatted.unwrap().body, "<b>bold</b>");
|
||||||
|
|
||||||
|
let MessageType::Text(content) = text_to_message("/plain <b>bold</b>".into()).msgtype
|
||||||
|
else {
|
||||||
|
panic!("Expected MessageType::Text");
|
||||||
|
};
|
||||||
|
assert_eq!(content.body, "<b>bold</b>");
|
||||||
|
assert!(content.formatted.is_none(), "{:?}", content.formatted);
|
||||||
|
|
||||||
|
let MessageType::Text(content) = text_to_message("/p <b>bold</b>".into()).msgtype else {
|
||||||
|
panic!("Expected MessageType::Text");
|
||||||
|
};
|
||||||
|
assert_eq!(content.body, "<b>bold</b>");
|
||||||
|
assert!(content.formatted.is_none(), "{:?}", content.formatted);
|
||||||
|
|
||||||
|
let MessageType::Emote(content) = text_to_message("/me *bold*".into()).msgtype else {
|
||||||
|
panic!("Expected MessageType::Emote");
|
||||||
|
};
|
||||||
|
assert_eq!(content.body, "*bold*");
|
||||||
|
assert_eq!(content.formatted.unwrap().body, "<p><em>bold</em></p>\n");
|
||||||
|
|
||||||
|
let content = text_to_message("/confetti hello".into()).msgtype;
|
||||||
|
assert_eq!(content.msgtype(), "nic.custom.confetti");
|
||||||
|
assert_eq!(content.body(), "hello");
|
||||||
|
|
||||||
|
let content = text_to_message("/fireworks hello".into()).msgtype;
|
||||||
|
assert_eq!(content.msgtype(), "nic.custom.fireworks");
|
||||||
|
assert_eq!(content.body(), "hello");
|
||||||
|
|
||||||
|
let content = text_to_message("/hearts hello".into()).msgtype;
|
||||||
|
assert_eq!(content.msgtype(), "io.element.effect.hearts");
|
||||||
|
assert_eq!(content.body(), "hello");
|
||||||
|
|
||||||
|
let content = text_to_message("/rainfall hello".into()).msgtype;
|
||||||
|
assert_eq!(content.msgtype(), "io.element.effect.rainfall");
|
||||||
|
assert_eq!(content.body(), "hello");
|
||||||
|
|
||||||
|
let content = text_to_message("/snowfall hello".into()).msgtype;
|
||||||
|
assert_eq!(content.msgtype(), "io.element.effect.snowfall");
|
||||||
|
assert_eq!(content.body(), "hello");
|
||||||
|
|
||||||
|
let content = text_to_message("/spaceinvaders hello".into()).msgtype;
|
||||||
|
assert_eq!(content.msgtype(), "io.element.effects.space_invaders");
|
||||||
|
assert_eq!(content.body(), "hello");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,10 +10,12 @@
|
|||||||
//!
|
//!
|
||||||
//! This isn't as important for iamb, since it isn't a browser environment, but we do still map
|
//! This isn't as important for iamb, since it isn't a browser environment, but we do still map
|
||||||
//! input onto an enum of the safe list of tags to keep it easy to understand and process.
|
//! input onto an enum of the safe list of tags to keep it easy to understand and process.
|
||||||
|
use std::borrow::Cow;
|
||||||
use std::ops::Deref;
|
use std::ops::Deref;
|
||||||
|
|
||||||
use css_color_parser::Color as CssColor;
|
use css_color_parser::Color as CssColor;
|
||||||
use markup5ever_rcdom::{Handle, NodeData, RcDom};
|
use markup5ever_rcdom::{Handle, NodeData, RcDom};
|
||||||
|
use matrix_sdk::ruma::{OwnedRoomAliasId, OwnedRoomId, OwnedUserId};
|
||||||
use unicode_segmentation::UnicodeSegmentation;
|
use unicode_segmentation::UnicodeSegmentation;
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
@@ -34,10 +36,13 @@ use ratatui::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
config::ApplicationSettings,
|
||||||
message::printer::TextPrinter,
|
message::printer::TextPrinter,
|
||||||
util::{join_cell_text, space_text},
|
util::{join_cell_text, space_text},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const QUOTE_COLOR: Color = Color::Indexed(236);
|
||||||
|
|
||||||
/// Generate bullet points from a [ListStyle].
|
/// Generate bullet points from a [ListStyle].
|
||||||
pub struct BulletIterator {
|
pub struct BulletIterator {
|
||||||
style: ListStyle,
|
style: ListStyle,
|
||||||
@@ -148,7 +153,12 @@ impl Table {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn to_text(&self, width: usize, style: Style, emoji_shortcodes: bool) -> Text {
|
fn to_text<'a>(
|
||||||
|
&'a self,
|
||||||
|
width: usize,
|
||||||
|
style: Style,
|
||||||
|
settings: &'a ApplicationSettings,
|
||||||
|
) -> Text<'a> {
|
||||||
let mut text = Text::default();
|
let mut text = Text::default();
|
||||||
let columns = self.columns();
|
let columns = self.columns();
|
||||||
let cell_total = width.saturating_sub(columns).saturating_sub(1);
|
let cell_total = width.saturating_sub(columns).saturating_sub(1);
|
||||||
@@ -167,7 +177,7 @@ impl Table {
|
|||||||
if let Some(caption) = &self.caption {
|
if let Some(caption) = &self.caption {
|
||||||
let subw = width.saturating_sub(6);
|
let subw = width.saturating_sub(6);
|
||||||
let mut printer =
|
let mut printer =
|
||||||
TextPrinter::new(subw, style, true, emoji_shortcodes).align(Alignment::Center);
|
TextPrinter::new(subw, style, true, settings).align(Alignment::Center);
|
||||||
caption.print(&mut printer, style);
|
caption.print(&mut printer, style);
|
||||||
|
|
||||||
for mut line in printer.finish().lines {
|
for mut line in printer.finish().lines {
|
||||||
@@ -214,7 +224,7 @@ impl Table {
|
|||||||
CellType::Data => style,
|
CellType::Data => style,
|
||||||
};
|
};
|
||||||
|
|
||||||
cell.to_text(*w, style, emoji_shortcodes)
|
cell.to_text(*w, style, settings)
|
||||||
} else {
|
} else {
|
||||||
space_text(*w, style)
|
space_text(*w, style)
|
||||||
};
|
};
|
||||||
@@ -260,6 +270,7 @@ pub enum StyleTreeNode {
|
|||||||
Anchor(Box<StyleTreeNode>, char, Url),
|
Anchor(Box<StyleTreeNode>, char, Url),
|
||||||
Blockquote(Box<StyleTreeNode>),
|
Blockquote(Box<StyleTreeNode>),
|
||||||
Break,
|
Break,
|
||||||
|
#[allow(dead_code)]
|
||||||
Code(Box<StyleTreeNode>, Option<String>),
|
Code(Box<StyleTreeNode>, Option<String>),
|
||||||
Header(Box<StyleTreeNode>, usize),
|
Header(Box<StyleTreeNode>, usize),
|
||||||
Image(Option<String>),
|
Image(Option<String>),
|
||||||
@@ -270,13 +281,22 @@ pub enum StyleTreeNode {
|
|||||||
Ruler,
|
Ruler,
|
||||||
Style(Box<StyleTreeNode>, Style),
|
Style(Box<StyleTreeNode>, Style),
|
||||||
Table(Table),
|
Table(Table),
|
||||||
Text(String),
|
Text(Cow<'static, str>),
|
||||||
Sequence(StyleTreeChildren),
|
Sequence(StyleTreeChildren),
|
||||||
|
RoomAlias(OwnedRoomAliasId),
|
||||||
|
RoomId(OwnedRoomId),
|
||||||
|
UserId(OwnedUserId),
|
||||||
|
DisplayName(String, OwnedUserId),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl StyleTreeNode {
|
impl StyleTreeNode {
|
||||||
pub fn to_text(&self, width: usize, style: Style, emoji_shortcodes: bool) -> Text {
|
pub fn to_text<'a>(
|
||||||
let mut printer = TextPrinter::new(width, style, true, emoji_shortcodes);
|
&'a self,
|
||||||
|
width: usize,
|
||||||
|
style: Style,
|
||||||
|
settings: &'a ApplicationSettings,
|
||||||
|
) -> Text<'a> {
|
||||||
|
let mut printer = TextPrinter::new(width, style, true, settings);
|
||||||
self.print(&mut printer, style);
|
self.print(&mut printer, style);
|
||||||
printer.finish()
|
printer.finish()
|
||||||
}
|
}
|
||||||
@@ -311,6 +331,12 @@ impl StyleTreeNode {
|
|||||||
StyleTreeNode::Ruler => {},
|
StyleTreeNode::Ruler => {},
|
||||||
StyleTreeNode::Text(_) => {},
|
StyleTreeNode::Text(_) => {},
|
||||||
StyleTreeNode::Break => {},
|
StyleTreeNode::Break => {},
|
||||||
|
|
||||||
|
// TODO: eventually these should turn into internal links:
|
||||||
|
StyleTreeNode::UserId(_) => {},
|
||||||
|
StyleTreeNode::RoomId(_) => {},
|
||||||
|
StyleTreeNode::RoomAlias(_) => {},
|
||||||
|
StyleTreeNode::DisplayName(_, _) => {},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -327,11 +353,14 @@ impl StyleTreeNode {
|
|||||||
printer.push_span_nobreak(span);
|
printer.push_span_nobreak(span);
|
||||||
},
|
},
|
||||||
StyleTreeNode::Blockquote(child) => {
|
StyleTreeNode::Blockquote(child) => {
|
||||||
let mut subp = printer.sub(4);
|
let mut subp = printer.sub(3);
|
||||||
child.print(&mut subp, style);
|
child.print(&mut subp, style);
|
||||||
|
|
||||||
for mut line in subp.finish() {
|
for mut line in subp.finish() {
|
||||||
line.spans.insert(0, Span::styled(" ", style));
|
line.spans.insert(0, Span::styled(" ", style));
|
||||||
|
line.spans
|
||||||
|
.insert(0, Span::styled(line::THICK_VERTICAL, style.fg(QUOTE_COLOR)));
|
||||||
|
line.spans.insert(0, Span::styled(" ", style));
|
||||||
printer.push_line(line);
|
printer.push_line(line);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -429,14 +458,14 @@ impl StyleTreeNode {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
StyleTreeNode::Table(table) => {
|
StyleTreeNode::Table(table) => {
|
||||||
let text = table.to_text(width, style, printer.emoji_shortcodes());
|
let text = table.to_text(width, style, printer.settings);
|
||||||
printer.push_text(text);
|
printer.push_text(text);
|
||||||
},
|
},
|
||||||
StyleTreeNode::Break => {
|
StyleTreeNode::Break => {
|
||||||
printer.push_break();
|
printer.push_break();
|
||||||
},
|
},
|
||||||
StyleTreeNode::Text(s) => {
|
StyleTreeNode::Text(s) => {
|
||||||
printer.push_str(s.as_str(), style);
|
printer.push_str(s.as_ref(), style);
|
||||||
},
|
},
|
||||||
|
|
||||||
StyleTreeNode::Style(child, patch) => child.print(printer, style.patch(*patch)),
|
StyleTreeNode::Style(child, patch) => child.print(printer, style.patch(*patch)),
|
||||||
@@ -445,13 +474,30 @@ impl StyleTreeNode {
|
|||||||
child.print(printer, style);
|
child.print(printer, style);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
StyleTreeNode::UserId(user_id) => {
|
||||||
|
let style = printer.settings().get_user_style(user_id);
|
||||||
|
printer.push_str(user_id.as_str(), style);
|
||||||
|
},
|
||||||
|
StyleTreeNode::DisplayName(display_name, user_id) => {
|
||||||
|
let style = printer.settings().get_user_style(user_id);
|
||||||
|
printer.push_str(display_name.as_str(), style);
|
||||||
|
},
|
||||||
|
StyleTreeNode::RoomId(room_id) => {
|
||||||
|
let bold = style.add_modifier(StyleModifier::BOLD);
|
||||||
|
printer.push_str(room_id.as_str(), bold);
|
||||||
|
},
|
||||||
|
StyleTreeNode::RoomAlias(alias) => {
|
||||||
|
let bold = style.add_modifier(StyleModifier::BOLD);
|
||||||
|
printer.push_str(alias.as_str(), bold);
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A processed HTML document.
|
/// A processed HTML document.
|
||||||
pub struct StyleTree {
|
pub struct StyleTree {
|
||||||
children: StyleTreeChildren,
|
pub(super) children: StyleTreeChildren,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl StyleTree {
|
impl StyleTree {
|
||||||
@@ -465,14 +511,14 @@ impl StyleTree {
|
|||||||
return links;
|
return links;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn to_text(
|
pub fn to_text<'a>(
|
||||||
&self,
|
&'a self,
|
||||||
width: usize,
|
width: usize,
|
||||||
style: Style,
|
style: Style,
|
||||||
hide_reply: bool,
|
hide_reply: bool,
|
||||||
emoji_shortcodes: bool,
|
settings: &'a ApplicationSettings,
|
||||||
) -> Text<'_> {
|
) -> Text<'a> {
|
||||||
let mut printer = TextPrinter::new(width, style, hide_reply, emoji_shortcodes);
|
let mut printer = TextPrinter::new(width, style, hide_reply, settings);
|
||||||
|
|
||||||
for child in self.children.iter() {
|
for child in self.children.iter() {
|
||||||
child.print(&mut printer, style);
|
child.print(&mut printer, style);
|
||||||
@@ -483,11 +529,11 @@ impl StyleTree {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub struct TreeGenState {
|
pub struct TreeGenState {
|
||||||
link_num: u8,
|
pub link_num: u8,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TreeGenState {
|
impl TreeGenState {
|
||||||
fn next_link_char(&mut self) -> Option<char> {
|
pub fn next_link_char(&mut self) -> Option<char> {
|
||||||
let num = self.link_num;
|
let num = self.link_num;
|
||||||
|
|
||||||
if num < 62 {
|
if num < 62 {
|
||||||
@@ -660,7 +706,7 @@ fn h2t(hdl: &Handle, state: &mut TreeGenState) -> StyleTreeChildren {
|
|||||||
|
|
||||||
let tree = match &node.data {
|
let tree = match &node.data {
|
||||||
NodeData::Document => *c2t(node.children.borrow().as_slice(), state),
|
NodeData::Document => *c2t(node.children.borrow().as_slice(), state),
|
||||||
NodeData::Text { contents } => StyleTreeNode::Text(contents.borrow().to_string()),
|
NodeData::Text { contents } => StyleTreeNode::Text(contents.borrow().to_string().into()),
|
||||||
NodeData::Element { name, attrs, .. } => {
|
NodeData::Element { name, attrs, .. } => {
|
||||||
match name.local.as_ref() {
|
match name.local.as_ref() {
|
||||||
// Message that this one replies to.
|
// Message that this one replies to.
|
||||||
@@ -707,7 +753,7 @@ fn h2t(hdl: &Handle, state: &mut TreeGenState) -> StyleTreeChildren {
|
|||||||
|
|
||||||
StyleTreeNode::Style(c, s)
|
StyleTreeNode::Style(c, s)
|
||||||
},
|
},
|
||||||
"del" | "strike" => {
|
"del" | "s" | "strike" => {
|
||||||
let c = c2t(&node.children.borrow(), state);
|
let c = c2t(&node.children.borrow(), state);
|
||||||
let s = Style::default().add_modifier(StyleModifier::CROSSED_OUT);
|
let s = Style::default().add_modifier(StyleModifier::CROSSED_OUT);
|
||||||
|
|
||||||
@@ -810,17 +856,19 @@ pub fn parse_matrix_html(s: &str) -> StyleTree {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub mod tests {
|
pub mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::tests::mock_settings;
|
||||||
use crate::util::space_span;
|
use crate::util::space_span;
|
||||||
use pretty_assertions::assert_eq;
|
use pretty_assertions::assert_eq;
|
||||||
use unicode_width::UnicodeWidthStr;
|
use unicode_width::UnicodeWidthStr;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_header() {
|
fn test_header() {
|
||||||
|
let settings = mock_settings();
|
||||||
let bold = Style::default().add_modifier(StyleModifier::BOLD);
|
let bold = Style::default().add_modifier(StyleModifier::BOLD);
|
||||||
|
|
||||||
let s = "<h1>Header 1</h1>";
|
let s = "<h1>Header 1</h1>";
|
||||||
let tree = parse_matrix_html(s);
|
let tree = parse_matrix_html(s);
|
||||||
let text = tree.to_text(20, Style::default(), false, false);
|
let text = tree.to_text(20, Style::default(), false, &settings);
|
||||||
assert_eq!(text.lines, vec![Line::from(vec![
|
assert_eq!(text.lines, vec![Line::from(vec![
|
||||||
Span::styled("#", bold),
|
Span::styled("#", bold),
|
||||||
Span::styled(" ", bold),
|
Span::styled(" ", bold),
|
||||||
@@ -832,7 +880,7 @@ pub mod tests {
|
|||||||
|
|
||||||
let s = "<h2>Header 2</h2>";
|
let s = "<h2>Header 2</h2>";
|
||||||
let tree = parse_matrix_html(s);
|
let tree = parse_matrix_html(s);
|
||||||
let text = tree.to_text(20, Style::default(), false, false);
|
let text = tree.to_text(20, Style::default(), false, &settings);
|
||||||
assert_eq!(text.lines, vec![Line::from(vec![
|
assert_eq!(text.lines, vec![Line::from(vec![
|
||||||
Span::styled("#", bold),
|
Span::styled("#", bold),
|
||||||
Span::styled("#", bold),
|
Span::styled("#", bold),
|
||||||
@@ -845,7 +893,7 @@ pub mod tests {
|
|||||||
|
|
||||||
let s = "<h3>Header 3</h3>";
|
let s = "<h3>Header 3</h3>";
|
||||||
let tree = parse_matrix_html(s);
|
let tree = parse_matrix_html(s);
|
||||||
let text = tree.to_text(20, Style::default(), false, false);
|
let text = tree.to_text(20, Style::default(), false, &settings);
|
||||||
assert_eq!(text.lines, vec![Line::from(vec![
|
assert_eq!(text.lines, vec![Line::from(vec![
|
||||||
Span::styled("#", bold),
|
Span::styled("#", bold),
|
||||||
Span::styled("#", bold),
|
Span::styled("#", bold),
|
||||||
@@ -859,7 +907,7 @@ pub mod tests {
|
|||||||
|
|
||||||
let s = "<h4>Header 4</h4>";
|
let s = "<h4>Header 4</h4>";
|
||||||
let tree = parse_matrix_html(s);
|
let tree = parse_matrix_html(s);
|
||||||
let text = tree.to_text(20, Style::default(), false, false);
|
let text = tree.to_text(20, Style::default(), false, &settings);
|
||||||
assert_eq!(text.lines, vec![Line::from(vec![
|
assert_eq!(text.lines, vec![Line::from(vec![
|
||||||
Span::styled("#", bold),
|
Span::styled("#", bold),
|
||||||
Span::styled("#", bold),
|
Span::styled("#", bold),
|
||||||
@@ -874,7 +922,7 @@ pub mod tests {
|
|||||||
|
|
||||||
let s = "<h5>Header 5</h5>";
|
let s = "<h5>Header 5</h5>";
|
||||||
let tree = parse_matrix_html(s);
|
let tree = parse_matrix_html(s);
|
||||||
let text = tree.to_text(20, Style::default(), false, false);
|
let text = tree.to_text(20, Style::default(), false, &settings);
|
||||||
assert_eq!(text.lines, vec![Line::from(vec![
|
assert_eq!(text.lines, vec![Line::from(vec![
|
||||||
Span::styled("#", bold),
|
Span::styled("#", bold),
|
||||||
Span::styled("#", bold),
|
Span::styled("#", bold),
|
||||||
@@ -890,7 +938,7 @@ pub mod tests {
|
|||||||
|
|
||||||
let s = "<h6>Header 6</h6>";
|
let s = "<h6>Header 6</h6>";
|
||||||
let tree = parse_matrix_html(s);
|
let tree = parse_matrix_html(s);
|
||||||
let text = tree.to_text(20, Style::default(), false, false);
|
let text = tree.to_text(20, Style::default(), false, &settings);
|
||||||
assert_eq!(text.lines, vec![Line::from(vec![
|
assert_eq!(text.lines, vec![Line::from(vec![
|
||||||
Span::styled("#", bold),
|
Span::styled("#", bold),
|
||||||
Span::styled("#", bold),
|
Span::styled("#", bold),
|
||||||
@@ -908,6 +956,7 @@ pub mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_style() {
|
fn test_style() {
|
||||||
|
let settings = mock_settings();
|
||||||
let def = Style::default();
|
let def = Style::default();
|
||||||
let bold = def.add_modifier(StyleModifier::BOLD);
|
let bold = def.add_modifier(StyleModifier::BOLD);
|
||||||
let italic = def.add_modifier(StyleModifier::ITALIC);
|
let italic = def.add_modifier(StyleModifier::ITALIC);
|
||||||
@@ -917,7 +966,7 @@ pub mod tests {
|
|||||||
|
|
||||||
let s = "<b>Bold!</b>";
|
let s = "<b>Bold!</b>";
|
||||||
let tree = parse_matrix_html(s);
|
let tree = parse_matrix_html(s);
|
||||||
let text = tree.to_text(20, Style::default(), false, false);
|
let text = tree.to_text(20, Style::default(), false, &settings);
|
||||||
assert_eq!(text.lines, vec![Line::from(vec![
|
assert_eq!(text.lines, vec![Line::from(vec![
|
||||||
Span::styled("Bold", bold),
|
Span::styled("Bold", bold),
|
||||||
Span::styled("!", bold),
|
Span::styled("!", bold),
|
||||||
@@ -926,7 +975,7 @@ pub mod tests {
|
|||||||
|
|
||||||
let s = "<strong>Bold!</strong>";
|
let s = "<strong>Bold!</strong>";
|
||||||
let tree = parse_matrix_html(s);
|
let tree = parse_matrix_html(s);
|
||||||
let text = tree.to_text(20, Style::default(), false, false);
|
let text = tree.to_text(20, Style::default(), false, &settings);
|
||||||
assert_eq!(text.lines, vec![Line::from(vec![
|
assert_eq!(text.lines, vec![Line::from(vec![
|
||||||
Span::styled("Bold", bold),
|
Span::styled("Bold", bold),
|
||||||
Span::styled("!", bold),
|
Span::styled("!", bold),
|
||||||
@@ -935,7 +984,7 @@ pub mod tests {
|
|||||||
|
|
||||||
let s = "<i>Italic!</i>";
|
let s = "<i>Italic!</i>";
|
||||||
let tree = parse_matrix_html(s);
|
let tree = parse_matrix_html(s);
|
||||||
let text = tree.to_text(20, Style::default(), false, false);
|
let text = tree.to_text(20, Style::default(), false, &settings);
|
||||||
assert_eq!(text.lines, vec![Line::from(vec![
|
assert_eq!(text.lines, vec![Line::from(vec![
|
||||||
Span::styled("Italic", italic),
|
Span::styled("Italic", italic),
|
||||||
Span::styled("!", italic),
|
Span::styled("!", italic),
|
||||||
@@ -944,7 +993,7 @@ pub mod tests {
|
|||||||
|
|
||||||
let s = "<em>Italic!</em>";
|
let s = "<em>Italic!</em>";
|
||||||
let tree = parse_matrix_html(s);
|
let tree = parse_matrix_html(s);
|
||||||
let text = tree.to_text(20, Style::default(), false, false);
|
let text = tree.to_text(20, Style::default(), false, &settings);
|
||||||
assert_eq!(text.lines, vec![Line::from(vec![
|
assert_eq!(text.lines, vec![Line::from(vec![
|
||||||
Span::styled("Italic", italic),
|
Span::styled("Italic", italic),
|
||||||
Span::styled("!", italic),
|
Span::styled("!", italic),
|
||||||
@@ -953,7 +1002,7 @@ pub mod tests {
|
|||||||
|
|
||||||
let s = "<del>Strikethrough!</del>";
|
let s = "<del>Strikethrough!</del>";
|
||||||
let tree = parse_matrix_html(s);
|
let tree = parse_matrix_html(s);
|
||||||
let text = tree.to_text(20, Style::default(), false, false);
|
let text = tree.to_text(20, Style::default(), false, &settings);
|
||||||
assert_eq!(text.lines, vec![Line::from(vec![
|
assert_eq!(text.lines, vec![Line::from(vec![
|
||||||
Span::styled("Strikethrough", strike),
|
Span::styled("Strikethrough", strike),
|
||||||
Span::styled("!", strike),
|
Span::styled("!", strike),
|
||||||
@@ -962,7 +1011,7 @@ pub mod tests {
|
|||||||
|
|
||||||
let s = "<strike>Strikethrough!</strike>";
|
let s = "<strike>Strikethrough!</strike>";
|
||||||
let tree = parse_matrix_html(s);
|
let tree = parse_matrix_html(s);
|
||||||
let text = tree.to_text(20, Style::default(), false, false);
|
let text = tree.to_text(20, Style::default(), false, &settings);
|
||||||
assert_eq!(text.lines, vec![Line::from(vec![
|
assert_eq!(text.lines, vec![Line::from(vec![
|
||||||
Span::styled("Strikethrough", strike),
|
Span::styled("Strikethrough", strike),
|
||||||
Span::styled("!", strike),
|
Span::styled("!", strike),
|
||||||
@@ -971,7 +1020,7 @@ pub mod tests {
|
|||||||
|
|
||||||
let s = "<u>Underline!</u>";
|
let s = "<u>Underline!</u>";
|
||||||
let tree = parse_matrix_html(s);
|
let tree = parse_matrix_html(s);
|
||||||
let text = tree.to_text(20, Style::default(), false, false);
|
let text = tree.to_text(20, Style::default(), false, &settings);
|
||||||
assert_eq!(text.lines, vec![Line::from(vec![
|
assert_eq!(text.lines, vec![Line::from(vec![
|
||||||
Span::styled("Underline", underl),
|
Span::styled("Underline", underl),
|
||||||
Span::styled("!", underl),
|
Span::styled("!", underl),
|
||||||
@@ -980,7 +1029,7 @@ pub mod tests {
|
|||||||
|
|
||||||
let s = "<font color=\"#ff0000\">Red!</u>";
|
let s = "<font color=\"#ff0000\">Red!</u>";
|
||||||
let tree = parse_matrix_html(s);
|
let tree = parse_matrix_html(s);
|
||||||
let text = tree.to_text(20, Style::default(), false, false);
|
let text = tree.to_text(20, Style::default(), false, &settings);
|
||||||
assert_eq!(text.lines, vec![Line::from(vec![
|
assert_eq!(text.lines, vec![Line::from(vec![
|
||||||
Span::styled("Red", red),
|
Span::styled("Red", red),
|
||||||
Span::styled("!", red),
|
Span::styled("!", red),
|
||||||
@@ -989,7 +1038,7 @@ pub mod tests {
|
|||||||
|
|
||||||
let s = "<font color=\"red\">Red!</u>";
|
let s = "<font color=\"red\">Red!</u>";
|
||||||
let tree = parse_matrix_html(s);
|
let tree = parse_matrix_html(s);
|
||||||
let text = tree.to_text(20, Style::default(), false, false);
|
let text = tree.to_text(20, Style::default(), false, &settings);
|
||||||
assert_eq!(text.lines, vec![Line::from(vec![
|
assert_eq!(text.lines, vec![Line::from(vec![
|
||||||
Span::styled("Red", red),
|
Span::styled("Red", red),
|
||||||
Span::styled("!", red),
|
Span::styled("!", red),
|
||||||
@@ -999,9 +1048,10 @@ pub mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_paragraph() {
|
fn test_paragraph() {
|
||||||
|
let settings = mock_settings();
|
||||||
let s = "<p>Hello world!</p><p>Content</p><p>Goodbye world!</p>";
|
let s = "<p>Hello world!</p><p>Content</p><p>Goodbye world!</p>";
|
||||||
let tree = parse_matrix_html(s);
|
let tree = parse_matrix_html(s);
|
||||||
let text = tree.to_text(10, Style::default(), false, false);
|
let text = tree.to_text(10, Style::default(), false, &settings);
|
||||||
assert_eq!(text.lines.len(), 7);
|
assert_eq!(text.lines.len(), 7);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
text.lines[0],
|
text.lines[0],
|
||||||
@@ -1026,25 +1076,42 @@ pub mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_blockquote() {
|
fn test_blockquote() {
|
||||||
|
let settings = mock_settings();
|
||||||
let s = "<blockquote>Hello world!</blockquote>";
|
let s = "<blockquote>Hello world!</blockquote>";
|
||||||
let tree = parse_matrix_html(s);
|
let tree = parse_matrix_html(s);
|
||||||
let text = tree.to_text(10, Style::default(), false, false);
|
let text = tree.to_text(10, Style::default(), false, &settings);
|
||||||
|
let style = Style::new().fg(QUOTE_COLOR);
|
||||||
assert_eq!(text.lines.len(), 2);
|
assert_eq!(text.lines.len(), 2);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
text.lines[0],
|
text.lines[0],
|
||||||
Line::from(vec![Span::raw(" "), Span::raw("Hello"), Span::raw(" ")])
|
Line::from(vec![
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::styled(line::THICK_VERTICAL, style),
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::raw("Hello"),
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::raw(" "),
|
||||||
|
])
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
text.lines[1],
|
text.lines[1],
|
||||||
Line::from(vec![Span::raw(" "), Span::raw("world"), Span::raw("!")])
|
Line::from(vec![
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::styled(line::THICK_VERTICAL, style),
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::raw("world"),
|
||||||
|
Span::raw("!"),
|
||||||
|
Span::raw(" "),
|
||||||
|
])
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_list_unordered() {
|
fn test_list_unordered() {
|
||||||
|
let settings = mock_settings();
|
||||||
let s = "<ul><li>List Item 1</li><li>List Item 2</li><li>List Item 3</li></ul>";
|
let s = "<ul><li>List Item 1</li><li>List Item 2</li><li>List Item 3</li></ul>";
|
||||||
let tree = parse_matrix_html(s);
|
let tree = parse_matrix_html(s);
|
||||||
let text = tree.to_text(8, Style::default(), false, false);
|
let text = tree.to_text(8, Style::default(), false, &settings);
|
||||||
assert_eq!(text.lines.len(), 6);
|
assert_eq!(text.lines.len(), 6);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
text.lines[0],
|
text.lines[0],
|
||||||
@@ -1104,9 +1171,10 @@ pub mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_list_ordered() {
|
fn test_list_ordered() {
|
||||||
|
let settings = mock_settings();
|
||||||
let s = "<ol><li>List Item 1</li><li>List Item 2</li><li>List Item 3</li></ol>";
|
let s = "<ol><li>List Item 1</li><li>List Item 2</li><li>List Item 3</li></ol>";
|
||||||
let tree = parse_matrix_html(s);
|
let tree = parse_matrix_html(s);
|
||||||
let text = tree.to_text(9, Style::default(), false, false);
|
let text = tree.to_text(9, Style::default(), false, &settings);
|
||||||
assert_eq!(text.lines.len(), 6);
|
assert_eq!(text.lines.len(), 6);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
text.lines[0],
|
text.lines[0],
|
||||||
@@ -1166,6 +1234,7 @@ pub mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_table() {
|
fn test_table() {
|
||||||
|
let settings = mock_settings();
|
||||||
let s = "<table>\
|
let s = "<table>\
|
||||||
<thead>\
|
<thead>\
|
||||||
<tr><th>Column 1</th><th>Column 2</th><th>Column 3</th></tr>
|
<tr><th>Column 1</th><th>Column 2</th><th>Column 3</th></tr>
|
||||||
@@ -1176,7 +1245,7 @@ pub mod tests {
|
|||||||
<tr><td>a</td><td>b</td><td>c</td></tr>\
|
<tr><td>a</td><td>b</td><td>c</td></tr>\
|
||||||
</tbody></table>";
|
</tbody></table>";
|
||||||
let tree = parse_matrix_html(s);
|
let tree = parse_matrix_html(s);
|
||||||
let text = tree.to_text(15, Style::default(), false, false);
|
let text = tree.to_text(15, Style::default(), false, &settings);
|
||||||
let bold = Style::default().add_modifier(StyleModifier::BOLD);
|
let bold = Style::default().add_modifier(StyleModifier::BOLD);
|
||||||
assert_eq!(text.lines.len(), 11);
|
assert_eq!(text.lines.len(), 11);
|
||||||
|
|
||||||
@@ -1266,10 +1335,11 @@ pub mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_matrix_reply() {
|
fn test_matrix_reply() {
|
||||||
|
let settings = mock_settings();
|
||||||
let s = "<mx-reply>This was replied to</mx-reply>This is the reply";
|
let s = "<mx-reply>This was replied to</mx-reply>This is the reply";
|
||||||
|
|
||||||
let tree = parse_matrix_html(s);
|
let tree = parse_matrix_html(s);
|
||||||
let text = tree.to_text(10, Style::default(), false, false);
|
let text = tree.to_text(10, Style::default(), false, &settings);
|
||||||
assert_eq!(text.lines.len(), 4);
|
assert_eq!(text.lines.len(), 4);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
text.lines[0],
|
text.lines[0],
|
||||||
@@ -1306,7 +1376,7 @@ pub mod tests {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let tree = parse_matrix_html(s);
|
let tree = parse_matrix_html(s);
|
||||||
let text = tree.to_text(10, Style::default(), true, false);
|
let text = tree.to_text(10, Style::default(), true, &settings);
|
||||||
assert_eq!(text.lines.len(), 2);
|
assert_eq!(text.lines.len(), 2);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
text.lines[0],
|
text.lines[0],
|
||||||
@@ -1331,9 +1401,10 @@ pub mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_self_closing() {
|
fn test_self_closing() {
|
||||||
|
let settings = mock_settings();
|
||||||
let s = "Hello<br>World<br>Goodbye";
|
let s = "Hello<br>World<br>Goodbye";
|
||||||
let tree = parse_matrix_html(s);
|
let tree = parse_matrix_html(s);
|
||||||
let text = tree.to_text(7, Style::default(), true, false);
|
let text = tree.to_text(7, Style::default(), true, &settings);
|
||||||
assert_eq!(text.lines.len(), 3);
|
assert_eq!(text.lines.len(), 3);
|
||||||
assert_eq!(text.lines[0], Line::from(vec![Span::raw("Hello"), Span::raw(" "),]));
|
assert_eq!(text.lines[0], Line::from(vec![Span::raw("Hello"), Span::raw(" "),]));
|
||||||
assert_eq!(text.lines[1], Line::from(vec![Span::raw("World"), Span::raw(" "),]));
|
assert_eq!(text.lines[1], Line::from(vec![Span::raw("World"), Span::raw(" "),]));
|
||||||
@@ -1342,9 +1413,10 @@ pub mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_embedded_newline() {
|
fn test_embedded_newline() {
|
||||||
|
let settings = mock_settings();
|
||||||
let s = "<p>Hello\nWorld</p>";
|
let s = "<p>Hello\nWorld</p>";
|
||||||
let tree = parse_matrix_html(s);
|
let tree = parse_matrix_html(s);
|
||||||
let text = tree.to_text(15, Style::default(), true, false);
|
let text = tree.to_text(15, Style::default(), true, &settings);
|
||||||
assert_eq!(text.lines.len(), 1);
|
assert_eq!(text.lines.len(), 1);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
text.lines[0],
|
text.lines[0],
|
||||||
@@ -1359,16 +1431,18 @@ pub mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_pre_tag() {
|
fn test_pre_tag() {
|
||||||
|
let settings = mock_settings();
|
||||||
let s = concat!(
|
let s = concat!(
|
||||||
"<pre><code class=\"language-rust\">",
|
"<pre><code class=\"language-rust\">",
|
||||||
"fn hello() -> usize {\n",
|
"fn hello() -> usize {\n",
|
||||||
|
" \t// weired\n",
|
||||||
" return 5;\n",
|
" return 5;\n",
|
||||||
"}\n",
|
"}\n",
|
||||||
"</code></pre>\n"
|
"</code></pre>\n"
|
||||||
);
|
);
|
||||||
let tree = parse_matrix_html(s);
|
let tree = parse_matrix_html(s);
|
||||||
let text = tree.to_text(25, Style::default(), true, false);
|
let text = tree.to_text(25, Style::default(), true, &settings);
|
||||||
assert_eq!(text.lines.len(), 5);
|
assert_eq!(text.lines.len(), 6);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
text.lines[0],
|
text.lines[0],
|
||||||
Line::from(vec![
|
Line::from(vec![
|
||||||
@@ -1399,6 +1473,20 @@ pub mod tests {
|
|||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
text.lines[2],
|
text.lines[2],
|
||||||
|
Line::from(vec![
|
||||||
|
Span::raw(line::VERTICAL),
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::raw("/"),
|
||||||
|
Span::raw("/"),
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::raw("weired"),
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::raw(line::VERTICAL)
|
||||||
|
])
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
text.lines[3],
|
||||||
Line::from(vec![
|
Line::from(vec![
|
||||||
Span::raw(line::VERTICAL),
|
Span::raw(line::VERTICAL),
|
||||||
Span::raw(" "),
|
Span::raw(" "),
|
||||||
@@ -1411,7 +1499,7 @@ pub mod tests {
|
|||||||
])
|
])
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
text.lines[3],
|
text.lines[4],
|
||||||
Line::from(vec![
|
Line::from(vec![
|
||||||
Span::raw(line::VERTICAL),
|
Span::raw(line::VERTICAL),
|
||||||
Span::raw("}"),
|
Span::raw("}"),
|
||||||
@@ -1420,7 +1508,7 @@ pub mod tests {
|
|||||||
])
|
])
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
text.lines[4],
|
text.lines[5],
|
||||||
Line::from(vec![
|
Line::from(vec![
|
||||||
Span::raw(line::BOTTOM_LEFT),
|
Span::raw(line::BOTTOM_LEFT),
|
||||||
Span::raw(line::HORIZONTAL.repeat(23)),
|
Span::raw(line::HORIZONTAL.repeat(23)),
|
||||||
@@ -1431,6 +1519,11 @@ pub mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_emoji_shortcodes() {
|
fn test_emoji_shortcodes() {
|
||||||
|
let mut enabled = mock_settings();
|
||||||
|
enabled.tunables.message_shortcode_display = true;
|
||||||
|
let mut disabled = mock_settings();
|
||||||
|
disabled.tunables.message_shortcode_display = false;
|
||||||
|
|
||||||
for shortcode in ["exploding_head", "polar_bear", "canada"] {
|
for shortcode in ["exploding_head", "polar_bear", "canada"] {
|
||||||
let emoji = emojis::get_by_shortcode(shortcode).unwrap().as_str();
|
let emoji = emojis::get_by_shortcode(shortcode).unwrap().as_str();
|
||||||
let emoji_width = UnicodeWidthStr::width(emoji);
|
let emoji_width = UnicodeWidthStr::width(emoji);
|
||||||
@@ -1439,13 +1532,13 @@ pub mod tests {
|
|||||||
let s = format!("<p>{emoji}</p>");
|
let s = format!("<p>{emoji}</p>");
|
||||||
let tree = parse_matrix_html(s.as_str());
|
let tree = parse_matrix_html(s.as_str());
|
||||||
// Test with emojis_shortcodes set to false
|
// Test with emojis_shortcodes set to false
|
||||||
let text = tree.to_text(20, Style::default(), false, false);
|
let text = tree.to_text(20, Style::default(), false, &disabled);
|
||||||
assert_eq!(text.lines, vec![Line::from(vec![
|
assert_eq!(text.lines, vec![Line::from(vec![
|
||||||
Span::raw(emoji),
|
Span::raw(emoji),
|
||||||
space_span(20 - emoji_width, Style::default()),
|
space_span(20 - emoji_width, Style::default()),
|
||||||
]),]);
|
]),]);
|
||||||
// Test with emojis_shortcodes set to true
|
// Test with emojis_shortcodes set to true
|
||||||
let text = tree.to_text(20, Style::default(), false, true);
|
let text = tree.to_text(20, Style::default(), false, &enabled);
|
||||||
assert_eq!(text.lines, vec![Line::from(vec![
|
assert_eq!(text.lines, vec![Line::from(vec![
|
||||||
Span::raw(replacement.as_str()),
|
Span::raw(replacement.as_str()),
|
||||||
space_span(20 - replacement_width, Style::default()),
|
space_span(20 - replacement_width, Style::default()),
|
||||||
|
|||||||
@@ -2,14 +2,15 @@
|
|||||||
use std::borrow::Cow;
|
use std::borrow::Cow;
|
||||||
use std::cmp::{Ord, Ordering, PartialOrd};
|
use std::cmp::{Ord, Ordering, PartialOrd};
|
||||||
use std::collections::hash_map::DefaultHasher;
|
use std::collections::hash_map::DefaultHasher;
|
||||||
use std::collections::hash_set;
|
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::convert::TryFrom;
|
use std::convert::{TryFrom, TryInto};
|
||||||
|
use std::fmt::{self, Display};
|
||||||
use std::hash::{Hash, Hasher};
|
use std::hash::{Hash, Hasher};
|
||||||
use std::ops::{Deref, DerefMut};
|
use std::ops::{Deref, DerefMut};
|
||||||
|
|
||||||
use chrono::{DateTime, Local as LocalTz, NaiveDateTime, TimeZone};
|
use chrono::{DateTime, Local as LocalTz};
|
||||||
use comrak::{markdown_to_html, ComrakOptions};
|
use humansize::{format_size, DECIMAL};
|
||||||
|
use matrix_sdk::ruma::events::receipt::ReceiptThread;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use unicode_width::UnicodeWidthStr;
|
use unicode_width::UnicodeWidthStr;
|
||||||
|
|
||||||
@@ -31,10 +32,10 @@ use matrix_sdk::ruma::{
|
|||||||
Relation,
|
Relation,
|
||||||
RoomMessageEvent,
|
RoomMessageEvent,
|
||||||
RoomMessageEventContent,
|
RoomMessageEventContent,
|
||||||
TextMessageEventContent,
|
|
||||||
},
|
},
|
||||||
redaction::SyncRoomRedactionEvent,
|
redaction::SyncRoomRedactionEvent,
|
||||||
},
|
},
|
||||||
|
AnySyncStateEvent,
|
||||||
RedactContent,
|
RedactContent,
|
||||||
RedactedUnsigned,
|
RedactedUnsigned,
|
||||||
},
|
},
|
||||||
@@ -64,13 +65,20 @@ use crate::{
|
|||||||
util::{replace_emojis_in_str, space, space_span, take_width, wrapped_text},
|
util::{replace_emojis_in_str, space, space_span, take_width, wrapped_text},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
mod compose;
|
||||||
mod html;
|
mod html;
|
||||||
mod printer;
|
mod printer;
|
||||||
|
mod state;
|
||||||
|
|
||||||
|
pub use self::compose::text_to_message;
|
||||||
|
use self::state::{body_cow_state, html_state};
|
||||||
|
pub use html::TreeGenState;
|
||||||
|
|
||||||
|
type ProtocolPreview<'a> = (&'a Protocol, u16, u16);
|
||||||
|
|
||||||
pub type MessageKey = (MessageTimeStamp, OwnedEventId);
|
pub type MessageKey = (MessageTimeStamp, OwnedEventId);
|
||||||
|
|
||||||
#[derive(Default)]
|
pub struct Messages(BTreeMap<MessageKey, Message>, pub ReceiptThread);
|
||||||
pub struct Messages(BTreeMap<MessageKey, Message>);
|
|
||||||
|
|
||||||
impl Deref for Messages {
|
impl Deref for Messages {
|
||||||
type Target = BTreeMap<MessageKey, Message>;
|
type Target = BTreeMap<MessageKey, Message>;
|
||||||
@@ -87,6 +95,18 @@ impl DerefMut for Messages {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Messages {
|
impl Messages {
|
||||||
|
pub fn new(thread: ReceiptThread) -> Self {
|
||||||
|
Self(Default::default(), thread)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn main() -> Self {
|
||||||
|
Self::new(ReceiptThread::Main)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn thread(root: OwnedEventId) -> Self {
|
||||||
|
Self::new(ReceiptThread::Thread(root))
|
||||||
|
}
|
||||||
|
|
||||||
pub fn insert_message(&mut self, key: MessageKey, msg: impl Into<Message>) {
|
pub fn insert_message(&mut self, key: MessageKey, msg: impl Into<Message>) {
|
||||||
let event_id = key.1.clone();
|
let event_id = key.1.clone();
|
||||||
let msg = msg.into();
|
let msg = msg.into();
|
||||||
@@ -127,20 +147,22 @@ const MIN_MSG_LEN: usize = 30;
|
|||||||
const TIME_GUTTER_EMPTY: &str = " ";
|
const TIME_GUTTER_EMPTY: &str = " ";
|
||||||
const TIME_GUTTER_EMPTY_SPAN: Span<'static> = span_static(TIME_GUTTER_EMPTY);
|
const TIME_GUTTER_EMPTY_SPAN: Span<'static> = span_static(TIME_GUTTER_EMPTY);
|
||||||
|
|
||||||
fn text_to_message_content(input: String) -> TextMessageEventContent {
|
const USIZE_TOO_SMALL: bool = usize::BITS < u64::BITS;
|
||||||
let mut options = ComrakOptions::default();
|
|
||||||
options.extension.autolink = true;
|
|
||||||
options.extension.shortcodes = true;
|
|
||||||
options.extension.strikethrough = true;
|
|
||||||
options.render.hardbreaks = true;
|
|
||||||
let html = markdown_to_html(input.as_str(), &options);
|
|
||||||
|
|
||||||
TextMessageEventContent::html(input, html)
|
/// Convert the [u64] hash to [usize] as needed.
|
||||||
|
fn hash_finish_usize(hasher: DefaultHasher) -> Option<usize> {
|
||||||
|
if USIZE_TOO_SMALL {
|
||||||
|
(hasher.finish() % usize::MAX as u64).try_into().ok()
|
||||||
|
} else {
|
||||||
|
hasher.finish().try_into().ok()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn text_to_message(input: String) -> RoomMessageEventContent {
|
/// Hash an [EventId] into a [usize].
|
||||||
let msg = MessageType::Text(text_to_message_content(input));
|
fn hash_event_id(event_id: &EventId) -> Option<usize> {
|
||||||
RoomMessageEventContent::new(msg)
|
let mut hasher = DefaultHasher::new();
|
||||||
|
event_id.hash(&mut hasher);
|
||||||
|
hash_finish_usize(hasher)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Before the image is loaded, already display a placeholder frame of the image size.
|
/// Before the image is loaded, already display a placeholder frame of the image size.
|
||||||
@@ -155,7 +177,9 @@ fn placeholder_frame(
|
|||||||
}
|
}
|
||||||
let mut placeholder = "\u{230c}".to_string();
|
let mut placeholder = "\u{230c}".to_string();
|
||||||
placeholder.push_str(&" ".repeat(width - 2));
|
placeholder.push_str(&" ".repeat(width - 2));
|
||||||
placeholder.push_str("\u{230d}\n");
|
placeholder.push('\u{230d}');
|
||||||
|
placeholder.push_str(&"\n".repeat((height - 1) / 2));
|
||||||
|
|
||||||
if *height > 2 {
|
if *height > 2 {
|
||||||
if let Some(text) = text {
|
if let Some(text) = text {
|
||||||
if text.width() <= width - 2 {
|
if text.width() <= width - 2 {
|
||||||
@@ -165,7 +189,7 @@ fn placeholder_frame(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
placeholder.push_str(&"\n".repeat(height - 2));
|
placeholder.push_str(&"\n".repeat(height / 2));
|
||||||
placeholder.push('\u{230e}');
|
placeholder.push('\u{230e}');
|
||||||
placeholder.push_str(&" ".repeat(width - 2));
|
placeholder.push_str(&" ".repeat(width - 2));
|
||||||
placeholder.push_str("\u{230f}\n");
|
placeholder.push_str("\u{230f}\n");
|
||||||
@@ -175,9 +199,8 @@ fn placeholder_frame(
|
|||||||
#[inline]
|
#[inline]
|
||||||
fn millis_to_datetime(ms: UInt) -> DateTime<LocalTz> {
|
fn millis_to_datetime(ms: UInt) -> DateTime<LocalTz> {
|
||||||
let time = i64::from(ms) / 1000;
|
let time = i64::from(ms) / 1000;
|
||||||
let time = NaiveDateTime::from_timestamp_opt(time, 0).unwrap_or_default();
|
let time = DateTime::from_timestamp(time, 0).unwrap_or_default();
|
||||||
|
time.into()
|
||||||
LocalTz.from_utc_datetime(&time)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(thiserror::Error, Debug)]
|
#[derive(thiserror::Error, Debug)]
|
||||||
@@ -326,17 +349,14 @@ impl MessageCursor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn from_cursor(cursor: &Cursor, thread: &Messages) -> Option<Self> {
|
pub fn from_cursor(cursor: &Cursor, thread: &Messages) -> Option<Self> {
|
||||||
let ev_hash = u64::try_from(cursor.get_x()).ok()?;
|
let ev_hash = cursor.get_x();
|
||||||
let ev_term = OwnedEventId::try_from("$").ok()?;
|
let ev_term = OwnedEventId::try_from("$").ok()?;
|
||||||
|
|
||||||
let ts_start = MessageTimeStamp::try_from(cursor.get_y()).ok()?;
|
let ts_start = MessageTimeStamp::try_from(cursor.get_y()).ok()?;
|
||||||
let start = (ts_start, ev_term);
|
let start = (ts_start, ev_term);
|
||||||
|
|
||||||
for ((ts, event_id), _) in thread.range(&start..) {
|
for ((ts, event_id), _) in thread.range(&start..) {
|
||||||
let mut hasher = DefaultHasher::new();
|
if hash_event_id(event_id)? == ev_hash {
|
||||||
event_id.hash(&mut hasher);
|
|
||||||
|
|
||||||
if hasher.finish() == ev_hash {
|
|
||||||
return Self::from((*ts, event_id.clone())).into();
|
return Self::from((*ts, event_id.clone())).into();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -355,11 +375,8 @@ impl MessageCursor {
|
|||||||
pub fn to_cursor(&self, thread: &Messages) -> Option<Cursor> {
|
pub fn to_cursor(&self, thread: &Messages) -> Option<Cursor> {
|
||||||
let (ts, event_id) = self.to_key(thread)?;
|
let (ts, event_id) = self.to_key(thread)?;
|
||||||
|
|
||||||
let y: usize = usize::try_from(ts).ok()?;
|
let y = usize::try_from(ts).ok()?;
|
||||||
|
let x = hash_event_id(event_id)?;
|
||||||
let mut hasher = DefaultHasher::new();
|
|
||||||
event_id.hash(&mut hasher);
|
|
||||||
let x = usize::try_from(hasher.finish()).ok()?;
|
|
||||||
|
|
||||||
Cursor::new(y, x).into()
|
Cursor::new(y, x).into()
|
||||||
}
|
}
|
||||||
@@ -427,6 +444,7 @@ pub enum MessageEvent {
|
|||||||
EncryptedRedacted(Box<RedactedRoomEncryptedEvent>),
|
EncryptedRedacted(Box<RedactedRoomEncryptedEvent>),
|
||||||
Original(Box<OriginalRoomMessageEvent>),
|
Original(Box<OriginalRoomMessageEvent>),
|
||||||
Redacted(Box<RedactedRoomMessageEvent>),
|
Redacted(Box<RedactedRoomMessageEvent>),
|
||||||
|
State(Box<AnySyncStateEvent>),
|
||||||
Local(OwnedEventId, Box<RoomMessageEventContent>),
|
Local(OwnedEventId, Box<RoomMessageEventContent>),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -437,6 +455,7 @@ impl MessageEvent {
|
|||||||
MessageEvent::EncryptedRedacted(ev) => ev.event_id.as_ref(),
|
MessageEvent::EncryptedRedacted(ev) => ev.event_id.as_ref(),
|
||||||
MessageEvent::Original(ev) => ev.event_id.as_ref(),
|
MessageEvent::Original(ev) => ev.event_id.as_ref(),
|
||||||
MessageEvent::Redacted(ev) => ev.event_id.as_ref(),
|
MessageEvent::Redacted(ev) => ev.event_id.as_ref(),
|
||||||
|
MessageEvent::State(ev) => ev.event_id(),
|
||||||
MessageEvent::Local(event_id, _) => event_id.as_ref(),
|
MessageEvent::Local(event_id, _) => event_id.as_ref(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -447,6 +466,7 @@ impl MessageEvent {
|
|||||||
MessageEvent::Original(ev) => Some(&ev.content),
|
MessageEvent::Original(ev) => Some(&ev.content),
|
||||||
MessageEvent::EncryptedRedacted(_) => None,
|
MessageEvent::EncryptedRedacted(_) => None,
|
||||||
MessageEvent::Redacted(_) => None,
|
MessageEvent::Redacted(_) => None,
|
||||||
|
MessageEvent::State(_) => None,
|
||||||
MessageEvent::Local(_, content) => Some(content),
|
MessageEvent::Local(_, content) => Some(content),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -464,6 +484,7 @@ impl MessageEvent {
|
|||||||
MessageEvent::Original(ev) => body_cow_content(&ev.content),
|
MessageEvent::Original(ev) => body_cow_content(&ev.content),
|
||||||
MessageEvent::EncryptedRedacted(ev) => body_cow_reason(&ev.unsigned),
|
MessageEvent::EncryptedRedacted(ev) => body_cow_reason(&ev.unsigned),
|
||||||
MessageEvent::Redacted(ev) => body_cow_reason(&ev.unsigned),
|
MessageEvent::Redacted(ev) => body_cow_reason(&ev.unsigned),
|
||||||
|
MessageEvent::State(ev) => body_cow_state(ev),
|
||||||
MessageEvent::Local(_, content) => body_cow_content(content),
|
MessageEvent::Local(_, content) => body_cow_content(content),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -474,6 +495,7 @@ impl MessageEvent {
|
|||||||
MessageEvent::EncryptedRedacted(_) => return None,
|
MessageEvent::EncryptedRedacted(_) => return None,
|
||||||
MessageEvent::Original(ev) => &ev.content,
|
MessageEvent::Original(ev) => &ev.content,
|
||||||
MessageEvent::Redacted(_) => return None,
|
MessageEvent::Redacted(_) => return None,
|
||||||
|
MessageEvent::State(ev) => return Some(html_state(ev)),
|
||||||
MessageEvent::Local(_, content) => content,
|
MessageEvent::Local(_, content) => content,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -493,6 +515,7 @@ impl MessageEvent {
|
|||||||
MessageEvent::EncryptedOriginal(_) => return,
|
MessageEvent::EncryptedOriginal(_) => return,
|
||||||
MessageEvent::EncryptedRedacted(_) => return,
|
MessageEvent::EncryptedRedacted(_) => return,
|
||||||
MessageEvent::Redacted(_) => return,
|
MessageEvent::Redacted(_) => return,
|
||||||
|
MessageEvent::State(_) => return,
|
||||||
MessageEvent::Local(_, _) => return,
|
MessageEvent::Local(_, _) => return,
|
||||||
MessageEvent::Original(ev) => {
|
MessageEvent::Original(ev) => {
|
||||||
let redacted = RedactedRoomMessageEvent {
|
let redacted = RedactedRoomMessageEvent {
|
||||||
@@ -509,6 +532,27 @@ impl MessageEvent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Macro rule converting a File / Image / Audio / Video to its text content with the shape:
|
||||||
|
/// `[Attached <type>: <content>[ (<human readable file size>)]]`
|
||||||
|
macro_rules! display_file_to_text {
|
||||||
|
( $msgtype:ident, $content:expr ) => {
|
||||||
|
return Cow::Owned(format!(
|
||||||
|
"[Attached {}: {}{}]",
|
||||||
|
stringify!($msgtype),
|
||||||
|
$content.body,
|
||||||
|
$content
|
||||||
|
.info
|
||||||
|
.as_ref()
|
||||||
|
.map(|info| {
|
||||||
|
info.size
|
||||||
|
.map(|s| format!(" ({})", format_size(u64::from(s), DECIMAL)))
|
||||||
|
.unwrap_or_else(String::new)
|
||||||
|
})
|
||||||
|
.unwrap_or_else(String::new)
|
||||||
|
))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
fn body_cow_content(content: &RoomMessageEventContent) -> Cow<'_, str> {
|
fn body_cow_content(content: &RoomMessageEventContent) -> Cow<'_, str> {
|
||||||
let s = match &content.msgtype {
|
let s = match &content.msgtype {
|
||||||
MessageType::Text(content) => content.body.as_str(),
|
MessageType::Text(content) => content.body.as_str(),
|
||||||
@@ -518,19 +562,30 @@ fn body_cow_content(content: &RoomMessageEventContent) -> Cow<'_, str> {
|
|||||||
MessageType::ServerNotice(content) => content.body.as_str(),
|
MessageType::ServerNotice(content) => content.body.as_str(),
|
||||||
|
|
||||||
MessageType::Audio(content) => {
|
MessageType::Audio(content) => {
|
||||||
return Cow::Owned(format!("[Attached Audio: {}]", content.body));
|
display_file_to_text!(Audio, content);
|
||||||
},
|
},
|
||||||
MessageType::File(content) => {
|
MessageType::File(content) => {
|
||||||
return Cow::Owned(format!("[Attached File: {}]", content.body));
|
display_file_to_text!(File, content);
|
||||||
},
|
},
|
||||||
MessageType::Image(content) => {
|
MessageType::Image(content) => {
|
||||||
return Cow::Owned(format!("[Attached Image: {}]", content.body));
|
display_file_to_text!(Image, content);
|
||||||
},
|
},
|
||||||
MessageType::Video(content) => {
|
MessageType::Video(content) => {
|
||||||
return Cow::Owned(format!("[Attached Video: {}]", content.body));
|
display_file_to_text!(Video, content);
|
||||||
},
|
},
|
||||||
_ => {
|
_ => {
|
||||||
return Cow::Owned(format!("[Unknown message type: {:?}]", content.msgtype()));
|
match content.msgtype() {
|
||||||
|
// Just show the body text for the special Element messages.
|
||||||
|
"nic.custom.confetti" |
|
||||||
|
"nic.custom.fireworks" |
|
||||||
|
"io.element.effect.hearts" |
|
||||||
|
"io.element.effect.rainfall" |
|
||||||
|
"io.element.effect.snowfall" |
|
||||||
|
"io.element.effects.space_invaders" => content.body(),
|
||||||
|
other => {
|
||||||
|
return Cow::Owned(format!("[Unknown message type: {other:?}]"));
|
||||||
|
},
|
||||||
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -592,8 +647,8 @@ struct MessageFormatter<'a> {
|
|||||||
/// The date the message was sent.
|
/// The date the message was sent.
|
||||||
date: Option<Span<'a>>,
|
date: Option<Span<'a>>,
|
||||||
|
|
||||||
/// Iterator over the users who have read up to this message.
|
/// The users who have read up to this message.
|
||||||
read: Option<hash_set::Iter<'a, OwnedUserId>>,
|
read: Vec<OwnedUserId>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> MessageFormatter<'a> {
|
impl<'a> MessageFormatter<'a> {
|
||||||
@@ -626,13 +681,11 @@ impl<'a> MessageFormatter<'a> {
|
|||||||
line.push(time);
|
line.push(time);
|
||||||
|
|
||||||
// Show read receipts.
|
// Show read receipts.
|
||||||
let user_char =
|
let user_char = |user: OwnedUserId| -> Span { settings.get_user_char_span(&user) };
|
||||||
|user: &'a OwnedUserId| -> Span<'a> { settings.get_user_char_span(user) };
|
|
||||||
let mut read = self.read.iter_mut().flatten();
|
|
||||||
|
|
||||||
let a = read.next().map(user_char).unwrap_or_else(|| Span::raw(" "));
|
let a = self.read.pop().map(user_char).unwrap_or_else(|| Span::raw(" "));
|
||||||
let b = read.next().map(user_char).unwrap_or_else(|| Span::raw(" "));
|
let b = self.read.pop().map(user_char).unwrap_or_else(|| Span::raw(" "));
|
||||||
let c = read.next().map(user_char).unwrap_or_else(|| Span::raw(" "));
|
let c = self.read.pop().map(user_char).unwrap_or_else(|| Span::raw(" "));
|
||||||
|
|
||||||
line.push(Span::raw(" "));
|
line.push(Span::raw(" "));
|
||||||
line.push(c);
|
line.push(c);
|
||||||
@@ -685,11 +738,11 @@ impl<'a> MessageFormatter<'a> {
|
|||||||
style: Style,
|
style: Style,
|
||||||
text: &mut Text<'a>,
|
text: &mut Text<'a>,
|
||||||
info: &'a RoomInfo,
|
info: &'a RoomInfo,
|
||||||
) {
|
settings: &'a ApplicationSettings,
|
||||||
|
) -> Option<ProtocolPreview<'a>> {
|
||||||
let width = self.width();
|
let width = self.width();
|
||||||
let w = width.saturating_sub(2);
|
let w = width.saturating_sub(2);
|
||||||
let shortcodes = self.settings.tunables.message_shortcode_display;
|
let (mut replied, proto) = msg.show_msg(w, style, true, settings);
|
||||||
let (mut replied, _) = msg.show_msg(w, style, true, shortcodes);
|
|
||||||
let mut sender = msg.sender_span(info, self.settings);
|
let mut sender = msg.sender_span(info, self.settings);
|
||||||
let sender_width = UnicodeWidthStr::width(sender.content.as_ref());
|
let sender_width = UnicodeWidthStr::width(sender.content.as_ref());
|
||||||
let trailing = w.saturating_sub(sender_width + 1);
|
let trailing = w.saturating_sub(sender_width + 1);
|
||||||
@@ -708,16 +761,26 @@ impl<'a> MessageFormatter<'a> {
|
|||||||
text,
|
text,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Determine the image offset of the reply header, taking into account the formatting
|
||||||
|
let proto = proto.map(|p| {
|
||||||
|
let y_off = text.lines.len() as u16;
|
||||||
|
// Adjust x_off by 2 to account for the vertical line and indent
|
||||||
|
let x_off = self.cols.user_gutter_width(settings) + 2;
|
||||||
|
(p, x_off, y_off)
|
||||||
|
});
|
||||||
|
|
||||||
for line in replied.lines.iter_mut() {
|
for line in replied.lines.iter_mut() {
|
||||||
line.spans.insert(0, Span::styled(THICK_VERTICAL, style));
|
line.spans.insert(0, Span::styled(THICK_VERTICAL, style));
|
||||||
line.spans.insert(0, Span::styled(" ", style));
|
line.spans.insert(0, Span::styled(" ", style));
|
||||||
}
|
}
|
||||||
|
|
||||||
self.push_text(replied, style, text);
|
self.push_text(replied, style, text);
|
||||||
|
|
||||||
|
proto
|
||||||
}
|
}
|
||||||
|
|
||||||
fn push_reactions(&mut self, counts: Vec<(&'a str, usize)>, style: Style, text: &mut Text<'a>) {
|
fn push_reactions(&mut self, counts: Vec<(&'a str, usize)>, style: Style, text: &mut Text<'a>) {
|
||||||
let mut emojis = printer::TextPrinter::new(self.width(), style, false, false);
|
let mut emojis = printer::TextPrinter::new(self.width(), style, false, self.settings);
|
||||||
let mut reactions = 0;
|
let mut reactions = 0;
|
||||||
|
|
||||||
for (key, count) in counts {
|
for (key, count) in counts {
|
||||||
@@ -766,7 +829,7 @@ impl<'a> MessageFormatter<'a> {
|
|||||||
let plural = len != 1;
|
let plural = len != 1;
|
||||||
let style = Style::default();
|
let style = Style::default();
|
||||||
let mut threaded =
|
let mut threaded =
|
||||||
printer::TextPrinter::new(self.width(), style, false, false).literal(true);
|
printer::TextPrinter::new(self.width(), style, false, self.settings).literal(true);
|
||||||
let len = Span::styled(len.to_string(), style.add_modifier(StyleModifier::BOLD));
|
let len = Span::styled(len.to_string(), style.add_modifier(StyleModifier::BOLD));
|
||||||
threaded.push_str(" \u{2937} ", style);
|
threaded.push_str(" \u{2937} ", style);
|
||||||
threaded.push_span_nobreak(len);
|
threaded.push_span_nobreak(len);
|
||||||
@@ -783,7 +846,7 @@ impl<'a> MessageFormatter<'a> {
|
|||||||
pub enum ImageStatus {
|
pub enum ImageStatus {
|
||||||
None,
|
None,
|
||||||
Downloading(ImagePreviewSize),
|
Downloading(ImagePreviewSize),
|
||||||
Loaded(Box<dyn Protocol>),
|
Loaded(Protocol),
|
||||||
Error(String),
|
Error(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -818,6 +881,7 @@ impl Message {
|
|||||||
MessageEvent::Local(_, content) => content,
|
MessageEvent::Local(_, content) => content,
|
||||||
MessageEvent::Original(ev) => &ev.content,
|
MessageEvent::Original(ev) => &ev.content,
|
||||||
MessageEvent::Redacted(_) => return None,
|
MessageEvent::Redacted(_) => return None,
|
||||||
|
MessageEvent::State(_) => return None,
|
||||||
};
|
};
|
||||||
|
|
||||||
match &content.relates_to {
|
match &content.relates_to {
|
||||||
@@ -838,6 +902,7 @@ impl Message {
|
|||||||
MessageEvent::Local(_, content) => content,
|
MessageEvent::Local(_, content) => content,
|
||||||
MessageEvent::Original(ev) => &ev.content,
|
MessageEvent::Original(ev) => &ev.content,
|
||||||
MessageEvent::Redacted(_) => return None,
|
MessageEvent::Redacted(_) => return None,
|
||||||
|
MessageEvent::State(_) => return None,
|
||||||
};
|
};
|
||||||
|
|
||||||
match &content.relates_to {
|
match &content.relates_to {
|
||||||
@@ -863,7 +928,7 @@ impl Message {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if settings.tunables.message_user_color {
|
if settings.tunables.message_user_color {
|
||||||
let color = crate::config::user_color(self.sender.as_str());
|
let color = settings.get_user_color(&self.sender);
|
||||||
style = style.fg(color);
|
style = style.fg(color);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -891,7 +956,13 @@ impl Message {
|
|||||||
let fill = width - user_gutter - TIME_GUTTER - READ_GUTTER;
|
let fill = width - user_gutter - TIME_GUTTER - READ_GUTTER;
|
||||||
let user = self.show_sender(prev, true, info, settings);
|
let user = self.show_sender(prev, true, info, settings);
|
||||||
let time = self.timestamp.show_time();
|
let time = self.timestamp.show_time();
|
||||||
let read = info.event_receipts.get(self.event.event_id()).map(|read| read.iter());
|
let read = info
|
||||||
|
.event_receipts
|
||||||
|
.values()
|
||||||
|
.filter_map(|receipts| receipts.get(self.event.event_id()))
|
||||||
|
.flat_map(|read| read.iter())
|
||||||
|
.map(|user_id| user_id.to_owned())
|
||||||
|
.collect();
|
||||||
|
|
||||||
MessageFormatter { settings, cols, orig, fill, user, date, time, read }
|
MessageFormatter { settings, cols, orig, fill, user, date, time, read }
|
||||||
} else if user_gutter + TIME_GUTTER + MIN_MSG_LEN <= width {
|
} else if user_gutter + TIME_GUTTER + MIN_MSG_LEN <= width {
|
||||||
@@ -899,7 +970,7 @@ impl Message {
|
|||||||
let fill = width - user_gutter - TIME_GUTTER;
|
let fill = width - user_gutter - TIME_GUTTER;
|
||||||
let user = self.show_sender(prev, true, info, settings);
|
let user = self.show_sender(prev, true, info, settings);
|
||||||
let time = self.timestamp.show_time();
|
let time = self.timestamp.show_time();
|
||||||
let read = None;
|
let read = Vec::new();
|
||||||
|
|
||||||
MessageFormatter { settings, cols, orig, fill, user, date, time, read }
|
MessageFormatter { settings, cols, orig, fill, user, date, time, read }
|
||||||
} else if user_gutter + MIN_MSG_LEN <= width {
|
} else if user_gutter + MIN_MSG_LEN <= width {
|
||||||
@@ -907,7 +978,7 @@ impl Message {
|
|||||||
let fill = width - user_gutter;
|
let fill = width - user_gutter;
|
||||||
let user = self.show_sender(prev, true, info, settings);
|
let user = self.show_sender(prev, true, info, settings);
|
||||||
let time = None;
|
let time = None;
|
||||||
let read = None;
|
let read = Vec::new();
|
||||||
|
|
||||||
MessageFormatter { settings, cols, orig, fill, user, date, time, read }
|
MessageFormatter { settings, cols, orig, fill, user, date, time, read }
|
||||||
} else {
|
} else {
|
||||||
@@ -915,7 +986,7 @@ impl Message {
|
|||||||
let fill = width.saturating_sub(2);
|
let fill = width.saturating_sub(2);
|
||||||
let user = self.show_sender(prev, false, info, settings);
|
let user = self.show_sender(prev, false, info, settings);
|
||||||
let time = None;
|
let time = None;
|
||||||
let read = None;
|
let read = Vec::new();
|
||||||
|
|
||||||
MessageFormatter { settings, cols, orig, fill, user, date, time, read }
|
MessageFormatter { settings, cols, orig, fill, user, date, time, read }
|
||||||
}
|
}
|
||||||
@@ -931,12 +1002,12 @@ impl Message {
|
|||||||
vwctx: &ViewportContext<MessageCursor>,
|
vwctx: &ViewportContext<MessageCursor>,
|
||||||
info: &'a RoomInfo,
|
info: &'a RoomInfo,
|
||||||
settings: &'a ApplicationSettings,
|
settings: &'a ApplicationSettings,
|
||||||
) -> (Text<'a>, Option<(&dyn Protocol, u16, u16)>) {
|
) -> (Text<'a>, [Option<ProtocolPreview<'a>>; 2]) {
|
||||||
let width = vwctx.get_width();
|
let width = vwctx.get_width();
|
||||||
|
|
||||||
let style = self.get_render_style(selected, settings);
|
let style = self.get_render_style(selected, settings);
|
||||||
let mut fmt = self.get_render_format(prev, width, info, settings);
|
let mut fmt = self.get_render_format(prev, width, info, settings);
|
||||||
let mut text = Text { lines: vec![] };
|
let mut text = Text::default();
|
||||||
let width = fmt.width();
|
let width = fmt.width();
|
||||||
|
|
||||||
// Show the message that this one replied to, if any.
|
// Show the message that this one replied to, if any.
|
||||||
@@ -944,23 +1015,21 @@ impl Message {
|
|||||||
.reply_to()
|
.reply_to()
|
||||||
.or_else(|| self.thread_root())
|
.or_else(|| self.thread_root())
|
||||||
.and_then(|e| info.get_event(&e));
|
.and_then(|e| info.get_event(&e));
|
||||||
|
let proto_reply = reply.as_ref().and_then(|r| {
|
||||||
if let Some(r) = &reply {
|
// Format the reply header, push it into the `Text` buffer, and get any image.
|
||||||
fmt.push_in_reply(r, style, &mut text, info);
|
fmt.push_in_reply(r, style, &mut text, info, settings)
|
||||||
}
|
});
|
||||||
|
|
||||||
// Now show the message contents, and the inlined reply if we couldn't find it above.
|
// Now show the message contents, and the inlined reply if we couldn't find it above.
|
||||||
let (msg, proto) = self.show_msg(
|
let (msg, proto) = self.show_msg(width, style, reply.is_some(), settings);
|
||||||
width,
|
|
||||||
style,
|
|
||||||
reply.is_some(),
|
|
||||||
settings.tunables.message_shortcode_display,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Given our text so far, determine the image offset.
|
// Given our text so far, determine the image offset.
|
||||||
let proto = proto.map(|p| {
|
let proto_main = proto.map(|p| {
|
||||||
let y_off = text.lines.len() as u16;
|
let y_off = text.lines.len() as u16;
|
||||||
let x_off = fmt.cols.user_gutter_width(settings);
|
let x_off = fmt.cols.user_gutter_width(settings);
|
||||||
|
// Adjust y_off by 1 if a date was printed before the message to account for
|
||||||
|
// the extra line we're going to print.
|
||||||
|
let y_off = if fmt.date.is_some() { y_off + 1 } else { y_off };
|
||||||
(p, x_off, y_off)
|
(p, x_off, y_off)
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -980,7 +1049,7 @@ impl Message {
|
|||||||
fmt.push_thread_reply_count(thread.len(), &mut text);
|
fmt.push_thread_reply_count(thread.len(), &mut text);
|
||||||
}
|
}
|
||||||
|
|
||||||
(text, proto)
|
(text, [proto_main, proto_reply])
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn show<'a>(
|
pub fn show<'a>(
|
||||||
@@ -994,18 +1063,18 @@ impl Message {
|
|||||||
self.show_with_preview(prev, selected, vwctx, info, settings).0
|
self.show_with_preview(prev, selected, vwctx, info, settings).0
|
||||||
}
|
}
|
||||||
|
|
||||||
fn show_msg(
|
fn show_msg<'a>(
|
||||||
&self,
|
&'a self,
|
||||||
width: usize,
|
width: usize,
|
||||||
style: Style,
|
style: Style,
|
||||||
hide_reply: bool,
|
hide_reply: bool,
|
||||||
emoji_shortcodes: bool,
|
settings: &'a ApplicationSettings,
|
||||||
) -> (Text, Option<&dyn Protocol>) {
|
) -> (Text<'a>, Option<&'a Protocol>) {
|
||||||
if let Some(html) = &self.html {
|
if let Some(html) = &self.html {
|
||||||
(html.to_text(width, style, hide_reply, emoji_shortcodes), None)
|
(html.to_text(width, style, hide_reply, settings), None)
|
||||||
} else {
|
} else {
|
||||||
let mut msg = self.event.body();
|
let mut msg = self.event.body();
|
||||||
if emoji_shortcodes {
|
if settings.tunables.message_shortcode_display {
|
||||||
msg = Cow::Owned(replace_emojis_in_str(msg.as_ref()));
|
msg = Cow::Owned(replace_emojis_in_str(msg.as_ref()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1020,8 +1089,8 @@ impl Message {
|
|||||||
placeholder_frame(Some("Downloading..."), width, image_preview_size)
|
placeholder_frame(Some("Downloading..."), width, image_preview_size)
|
||||||
},
|
},
|
||||||
ImageStatus::Loaded(backend) => {
|
ImageStatus::Loaded(backend) => {
|
||||||
proto = Some(backend.as_ref());
|
proto = Some(backend);
|
||||||
placeholder_frame(None, width, &backend.rect().into())
|
placeholder_frame(Some("Cut off..."), width, &backend.area().into())
|
||||||
},
|
},
|
||||||
ImageStatus::Error(err) => Some(format!("[Image error: {err}]\n")),
|
ImageStatus::Error(err) => Some(format!("[Image error: {err}]\n")),
|
||||||
};
|
};
|
||||||
@@ -1064,9 +1133,9 @@ impl Message {
|
|||||||
let padding = user_gutter - 2 - width;
|
let padding = user_gutter - 2 - width;
|
||||||
|
|
||||||
let sender = if align_right {
|
let sender = if align_right {
|
||||||
space(padding) + &truncated + " "
|
format!("{}{} ", space(padding), truncated)
|
||||||
} else {
|
} else {
|
||||||
truncated.into_owned() + &space(padding) + " "
|
format!("{}{} ", truncated, space(padding))
|
||||||
};
|
};
|
||||||
|
|
||||||
Span::styled(sender, style).into()
|
Span::styled(sender, style).into()
|
||||||
@@ -1075,6 +1144,8 @@ impl Message {
|
|||||||
pub fn redact(&mut self, redaction: SyncRoomRedactionEvent, version: &RoomVersionId) {
|
pub fn redact(&mut self, redaction: SyncRoomRedactionEvent, version: &RoomVersionId) {
|
||||||
self.event.redact(redaction, version);
|
self.event.redact(redaction, version);
|
||||||
self.html = None;
|
self.html = None;
|
||||||
|
self.downloaded = false;
|
||||||
|
self.image_preview = ImageStatus::None;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1120,14 +1191,37 @@ impl From<RoomMessageEvent> for Message {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ToString for Message {
|
impl From<AnySyncStateEvent> for Message {
|
||||||
fn to_string(&self) -> String {
|
fn from(event: AnySyncStateEvent) -> Self {
|
||||||
self.event.body().into_owned()
|
let timestamp = event.origin_server_ts().into();
|
||||||
|
let user_id = event.sender().to_owned();
|
||||||
|
let event = MessageEvent::State(event.into());
|
||||||
|
|
||||||
|
Message::new(event, user_id, timestamp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for Message {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||||
|
write!(f, "{}", self.event.body())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub mod tests {
|
pub mod tests {
|
||||||
|
use matrix_sdk::ruma::events::room::{
|
||||||
|
message::{
|
||||||
|
AudioInfo,
|
||||||
|
AudioMessageEventContent,
|
||||||
|
FileInfo,
|
||||||
|
FileMessageEventContent,
|
||||||
|
ImageMessageEventContent,
|
||||||
|
VideoInfo,
|
||||||
|
VideoMessageEventContent,
|
||||||
|
},
|
||||||
|
ImageInfo,
|
||||||
|
};
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::tests::*;
|
use crate::tests::*;
|
||||||
|
|
||||||
@@ -1205,7 +1299,7 @@ pub mod tests {
|
|||||||
assert_eq!(k6, &MSG1_KEY.clone());
|
assert_eq!(k6, &MSG1_KEY.clone());
|
||||||
|
|
||||||
// MessageCursor::latest() fails to convert for a room w/o messages.
|
// MessageCursor::latest() fails to convert for a room w/o messages.
|
||||||
let messages_empty = Messages::default();
|
let messages_empty = Messages::new(ReceiptThread::Main);
|
||||||
assert_eq!(mc6.to_key(&messages_empty), None);
|
assert_eq!(mc6.to_key(&messages_empty), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1236,82 +1330,6 @@ pub mod tests {
|
|||||||
assert_eq!(identity(&mc6), mc1);
|
assert_eq!(identity(&mc6), mc1);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_markdown_autolink() {
|
|
||||||
let input = "http://example.com\n";
|
|
||||||
let content = text_to_message_content(input.into());
|
|
||||||
assert_eq!(content.body, input);
|
|
||||||
assert_eq!(
|
|
||||||
content.formatted.unwrap().body,
|
|
||||||
"<p><a href=\"http://example.com\">http://example.com</a></p>\n"
|
|
||||||
);
|
|
||||||
|
|
||||||
let input = "www.example.com\n";
|
|
||||||
let content = text_to_message_content(input.into());
|
|
||||||
assert_eq!(content.body, input);
|
|
||||||
assert_eq!(
|
|
||||||
content.formatted.unwrap().body,
|
|
||||||
"<p><a href=\"http://www.example.com\">www.example.com</a></p>\n"
|
|
||||||
);
|
|
||||||
|
|
||||||
let input = "See docs (they're at https://iamb.chat)\n";
|
|
||||||
let content = text_to_message_content(input.into());
|
|
||||||
assert_eq!(content.body, input);
|
|
||||||
assert_eq!(
|
|
||||||
content.formatted.unwrap().body,
|
|
||||||
"<p>See docs (they're at <a href=\"https://iamb.chat\">https://iamb.chat</a>)</p>\n"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_markdown_message() {
|
|
||||||
let input = "**bold**\n";
|
|
||||||
let content = text_to_message_content(input.into());
|
|
||||||
assert_eq!(content.body, input);
|
|
||||||
assert_eq!(content.formatted.unwrap().body, "<p><strong>bold</strong></p>\n");
|
|
||||||
|
|
||||||
let input = "*emphasis*\n";
|
|
||||||
let content = text_to_message_content(input.into());
|
|
||||||
assert_eq!(content.body, input);
|
|
||||||
assert_eq!(content.formatted.unwrap().body, "<p><em>emphasis</em></p>\n");
|
|
||||||
|
|
||||||
let input = "`code`\n";
|
|
||||||
let content = text_to_message_content(input.into());
|
|
||||||
assert_eq!(content.body, input);
|
|
||||||
assert_eq!(content.formatted.unwrap().body, "<p><code>code</code></p>\n");
|
|
||||||
|
|
||||||
let input = "```rust\nconst A: usize = 1;\n```\n";
|
|
||||||
let content = text_to_message_content(input.into());
|
|
||||||
assert_eq!(content.body, input);
|
|
||||||
assert_eq!(
|
|
||||||
content.formatted.unwrap().body,
|
|
||||||
"<pre><code class=\"language-rust\">const A: usize = 1;\n</code></pre>\n"
|
|
||||||
);
|
|
||||||
|
|
||||||
let input = ":heart:\n";
|
|
||||||
let content = text_to_message_content(input.into());
|
|
||||||
assert_eq!(content.body, input);
|
|
||||||
assert_eq!(content.formatted.unwrap().body, "<p>\u{2764}\u{FE0F}</p>\n");
|
|
||||||
|
|
||||||
let input = "para 1\n\npara 2\n";
|
|
||||||
let content = text_to_message_content(input.into());
|
|
||||||
assert_eq!(content.body, input);
|
|
||||||
assert_eq!(content.formatted.unwrap().body, "<p>para 1</p>\n<p>para 2</p>\n");
|
|
||||||
|
|
||||||
let input = "line 1\nline 2\n";
|
|
||||||
let content = text_to_message_content(input.into());
|
|
||||||
assert_eq!(content.body, input);
|
|
||||||
assert_eq!(content.formatted.unwrap().body, "<p>line 1<br />\nline 2</p>\n");
|
|
||||||
|
|
||||||
let input = "# Heading\n## Subheading\n\ntext\n";
|
|
||||||
let content = text_to_message_content(input.into());
|
|
||||||
assert_eq!(content.body, input);
|
|
||||||
assert_eq!(
|
|
||||||
content.formatted.unwrap().body,
|
|
||||||
"<h1>Heading</h1>\n<h2>Subheading</h2>\n<p>text</p>\n"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_placeholder_frame() {
|
fn test_placeholder_frame() {
|
||||||
fn pretty_frame_test(str: &str) -> Option<String> {
|
fn pretty_frame_test(str: &str) -> Option<String> {
|
||||||
@@ -1342,6 +1360,33 @@ pub mod tests {
|
|||||||
⌌ ⌍
|
⌌ ⌍
|
||||||
OK
|
OK
|
||||||
|
|
||||||
|
⌎ ⌏
|
||||||
|
"#
|
||||||
|
)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
placeholder_frame(Some("OK"), 6, &ImagePreviewSize { width: 6, height: 6 }),
|
||||||
|
pretty_frame_test(
|
||||||
|
r#"
|
||||||
|
⌌ ⌍
|
||||||
|
|
||||||
|
OK
|
||||||
|
|
||||||
|
|
||||||
|
⌎ ⌏
|
||||||
|
"#
|
||||||
|
)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
placeholder_frame(Some("OK"), 6, &ImagePreviewSize { width: 6, height: 7 }),
|
||||||
|
pretty_frame_test(
|
||||||
|
r#"
|
||||||
|
⌌ ⌍
|
||||||
|
|
||||||
|
|
||||||
|
OK
|
||||||
|
|
||||||
|
|
||||||
⌎ ⌏
|
⌎ ⌏
|
||||||
"#
|
"#
|
||||||
)
|
)
|
||||||
@@ -1377,4 +1422,83 @@ pub mod tests {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_display_attachment_size() {
|
||||||
|
assert_eq!(
|
||||||
|
body_cow_content(&RoomMessageEventContent::new(MessageType::Image(
|
||||||
|
ImageMessageEventContent::plain(
|
||||||
|
"Alt text".to_string(),
|
||||||
|
"mxc://matrix.org/jDErsDugkNlfavzLTjJNUKAH".into()
|
||||||
|
)
|
||||||
|
.info(Some(Box::default()))
|
||||||
|
))),
|
||||||
|
"[Attached Image: Alt text]".to_string()
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut info = ImageInfo::default();
|
||||||
|
info.size = Some(442630_u32.into());
|
||||||
|
assert_eq!(
|
||||||
|
body_cow_content(&RoomMessageEventContent::new(MessageType::Image(
|
||||||
|
ImageMessageEventContent::plain(
|
||||||
|
"Alt text".to_string(),
|
||||||
|
"mxc://matrix.org/jDErsDugkNlfavzLTjJNUKAH".into()
|
||||||
|
)
|
||||||
|
.info(Some(Box::new(info)))
|
||||||
|
))),
|
||||||
|
"[Attached Image: Alt text (442.63 kB)]".to_string()
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut info = ImageInfo::default();
|
||||||
|
info.size = Some(12_u32.into());
|
||||||
|
assert_eq!(
|
||||||
|
body_cow_content(&RoomMessageEventContent::new(MessageType::Image(
|
||||||
|
ImageMessageEventContent::plain(
|
||||||
|
"Alt text".to_string(),
|
||||||
|
"mxc://matrix.org/jDErsDugkNlfavzLTjJNUKAH".into()
|
||||||
|
)
|
||||||
|
.info(Some(Box::new(info)))
|
||||||
|
))),
|
||||||
|
"[Attached Image: Alt text (12 B)]".to_string()
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut info = AudioInfo::default();
|
||||||
|
info.size = Some(4294967295_u32.into());
|
||||||
|
assert_eq!(
|
||||||
|
body_cow_content(&RoomMessageEventContent::new(MessageType::Audio(
|
||||||
|
AudioMessageEventContent::plain(
|
||||||
|
"Alt text".to_string(),
|
||||||
|
"mxc://matrix.org/jDErsDugkNlfavzLTjJNUKAH".into()
|
||||||
|
)
|
||||||
|
.info(Some(Box::new(info)))
|
||||||
|
))),
|
||||||
|
"[Attached Audio: Alt text (4.29 GB)]".to_string()
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut info = FileInfo::default();
|
||||||
|
info.size = Some(4426300_u32.into());
|
||||||
|
assert_eq!(
|
||||||
|
body_cow_content(&RoomMessageEventContent::new(MessageType::File(
|
||||||
|
FileMessageEventContent::plain(
|
||||||
|
"Alt text".to_string(),
|
||||||
|
"mxc://matrix.org/jDErsDugkNlfavzLTjJNUKAH".into()
|
||||||
|
)
|
||||||
|
.info(Some(Box::new(info)))
|
||||||
|
))),
|
||||||
|
"[Attached File: Alt text (4.43 MB)]".to_string()
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut info = VideoInfo::default();
|
||||||
|
info.size = Some(44000_u32.into());
|
||||||
|
assert_eq!(
|
||||||
|
body_cow_content(&RoomMessageEventContent::new(MessageType::Video(
|
||||||
|
VideoMessageEventContent::plain(
|
||||||
|
"Alt text".to_string(),
|
||||||
|
"mxc://matrix.org/jDErsDugkNlfavzLTjJNUKAH".into()
|
||||||
|
)
|
||||||
|
.info(Some(Box::new(info)))
|
||||||
|
))),
|
||||||
|
"[Attached Video: Alt text (44 kB)]".to_string()
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use ratatui::text::{Line, Span, Text};
|
|||||||
use unicode_segmentation::UnicodeSegmentation;
|
use unicode_segmentation::UnicodeSegmentation;
|
||||||
use unicode_width::UnicodeWidthStr;
|
use unicode_width::UnicodeWidthStr;
|
||||||
|
|
||||||
|
use crate::config::{ApplicationSettings, TunableValues};
|
||||||
use crate::util::{
|
use crate::util::{
|
||||||
replace_emojis_in_line,
|
replace_emojis_in_line,
|
||||||
replace_emojis_in_span,
|
replace_emojis_in_span,
|
||||||
@@ -25,28 +26,34 @@ pub struct TextPrinter<'a> {
|
|||||||
width: usize,
|
width: usize,
|
||||||
base_style: Style,
|
base_style: Style,
|
||||||
hide_reply: bool,
|
hide_reply: bool,
|
||||||
emoji_shortcodes: bool,
|
|
||||||
|
|
||||||
alignment: Alignment,
|
alignment: Alignment,
|
||||||
curr_spans: Vec<Span<'a>>,
|
curr_spans: Vec<Span<'a>>,
|
||||||
curr_width: usize,
|
curr_width: usize,
|
||||||
literal: bool,
|
literal: bool,
|
||||||
|
|
||||||
|
pub(super) settings: &'a ApplicationSettings,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> TextPrinter<'a> {
|
impl<'a> TextPrinter<'a> {
|
||||||
/// Create a new printer.
|
/// Create a new printer.
|
||||||
pub fn new(width: usize, base_style: Style, hide_reply: bool, emoji_shortcodes: bool) -> Self {
|
pub fn new(
|
||||||
|
width: usize,
|
||||||
|
base_style: Style,
|
||||||
|
hide_reply: bool,
|
||||||
|
settings: &'a ApplicationSettings,
|
||||||
|
) -> Self {
|
||||||
TextPrinter {
|
TextPrinter {
|
||||||
text: Text::default(),
|
text: Text::default(),
|
||||||
width,
|
width,
|
||||||
base_style,
|
base_style,
|
||||||
hide_reply,
|
hide_reply,
|
||||||
emoji_shortcodes,
|
|
||||||
|
|
||||||
alignment: Alignment::Left,
|
alignment: Alignment::Left,
|
||||||
curr_spans: vec![],
|
curr_spans: vec![],
|
||||||
curr_width: 0,
|
curr_width: 0,
|
||||||
literal: false,
|
literal: false,
|
||||||
|
settings,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,7 +76,15 @@ impl<'a> TextPrinter<'a> {
|
|||||||
|
|
||||||
/// Indicates whether emojis should be replaced by shortcodes
|
/// Indicates whether emojis should be replaced by shortcodes
|
||||||
pub fn emoji_shortcodes(&self) -> bool {
|
pub fn emoji_shortcodes(&self) -> bool {
|
||||||
self.emoji_shortcodes
|
self.tunables().message_shortcode_display
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn settings(&self) -> &ApplicationSettings {
|
||||||
|
self.settings
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn tunables(&self) -> &TunableValues {
|
||||||
|
&self.settings.tunables
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Indicates the current printer's width.
|
/// Indicates the current printer's width.
|
||||||
@@ -84,17 +99,17 @@ impl<'a> TextPrinter<'a> {
|
|||||||
width: self.width.saturating_sub(indent),
|
width: self.width.saturating_sub(indent),
|
||||||
base_style: self.base_style,
|
base_style: self.base_style,
|
||||||
hide_reply: self.hide_reply,
|
hide_reply: self.hide_reply,
|
||||||
emoji_shortcodes: self.emoji_shortcodes,
|
|
||||||
|
|
||||||
alignment: self.alignment,
|
alignment: self.alignment,
|
||||||
curr_spans: vec![],
|
curr_spans: vec![],
|
||||||
curr_width: 0,
|
curr_width: 0,
|
||||||
literal: self.literal,
|
literal: self.literal,
|
||||||
|
settings: self.settings,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn remaining(&self) -> usize {
|
fn remaining(&self) -> usize {
|
||||||
self.width - self.curr_width
|
self.width.saturating_sub(self.curr_width)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// If there is any text on the current line, start a new one.
|
/// If there is any text on the current line, start a new one.
|
||||||
@@ -179,7 +194,7 @@ impl<'a> TextPrinter<'a> {
|
|||||||
|
|
||||||
/// Push a [Span] that isn't allowed to break across lines.
|
/// Push a [Span] that isn't allowed to break across lines.
|
||||||
pub fn push_span_nobreak(&mut self, mut span: Span<'a>) {
|
pub fn push_span_nobreak(&mut self, mut span: Span<'a>) {
|
||||||
if self.emoji_shortcodes {
|
if self.emoji_shortcodes() {
|
||||||
replace_emojis_in_span(&mut span);
|
replace_emojis_in_span(&mut span);
|
||||||
}
|
}
|
||||||
let sw = UnicodeWidthStr::width(span.content.as_ref());
|
let sw = UnicodeWidthStr::width(span.content.as_ref());
|
||||||
@@ -201,6 +216,8 @@ impl<'a> TextPrinter<'a> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let tabstop = self.settings().tunables.tabstop;
|
||||||
|
|
||||||
for mut word in UnicodeSegmentation::split_word_bounds(s) {
|
for mut word in UnicodeSegmentation::split_word_bounds(s) {
|
||||||
if let "\n" | "\r\n" = word {
|
if let "\n" | "\r\n" = word {
|
||||||
if self.literal {
|
if self.literal {
|
||||||
@@ -217,11 +234,17 @@ impl<'a> TextPrinter<'a> {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let cow = if self.emoji_shortcodes {
|
let mut cow = if self.emoji_shortcodes() {
|
||||||
Cow::Owned(replace_emojis_in_str(word))
|
Cow::Owned(replace_emojis_in_str(word))
|
||||||
} else {
|
} else {
|
||||||
Cow::Borrowed(word)
|
Cow::Borrowed(word)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if cow == "\t" {
|
||||||
|
let tablen = tabstop - (self.curr_width % tabstop);
|
||||||
|
cow = Cow::Owned(" ".repeat(tablen));
|
||||||
|
}
|
||||||
|
|
||||||
let sw = UnicodeWidthStr::width(cow.as_ref());
|
let sw = UnicodeWidthStr::width(cow.as_ref());
|
||||||
|
|
||||||
if sw > self.width {
|
if sw > self.width {
|
||||||
@@ -253,7 +276,7 @@ impl<'a> TextPrinter<'a> {
|
|||||||
/// Push a [Line] into the printer.
|
/// Push a [Line] into the printer.
|
||||||
pub fn push_line(&mut self, mut line: Line<'a>) {
|
pub fn push_line(&mut self, mut line: Line<'a>) {
|
||||||
self.commit();
|
self.commit();
|
||||||
if self.emoji_shortcodes {
|
if self.emoji_shortcodes() {
|
||||||
replace_emojis_in_line(&mut line);
|
replace_emojis_in_line(&mut line);
|
||||||
}
|
}
|
||||||
self.text.lines.push(line);
|
self.text.lines.push(line);
|
||||||
@@ -262,7 +285,7 @@ impl<'a> TextPrinter<'a> {
|
|||||||
/// Push multiline [Text] into the printer.
|
/// Push multiline [Text] into the printer.
|
||||||
pub fn push_text(&mut self, mut text: Text<'a>) {
|
pub fn push_text(&mut self, mut text: Text<'a>) {
|
||||||
self.commit();
|
self.commit();
|
||||||
if self.emoji_shortcodes {
|
if self.emoji_shortcodes() {
|
||||||
for line in &mut text.lines {
|
for line in &mut text.lines {
|
||||||
replace_emojis_in_line(line);
|
replace_emojis_in_line(line);
|
||||||
}
|
}
|
||||||
@@ -276,3 +299,20 @@ impl<'a> TextPrinter<'a> {
|
|||||||
self.text
|
self.text
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::tests::mock_settings;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_push_nobreak() {
|
||||||
|
let settings = mock_settings();
|
||||||
|
let mut printer = TextPrinter::new(5, Style::default(), false, &settings);
|
||||||
|
printer.push_span_nobreak("hello world".into());
|
||||||
|
let text = printer.finish();
|
||||||
|
assert_eq!(text.lines.len(), 1);
|
||||||
|
assert_eq!(text.lines[0].spans.len(), 1);
|
||||||
|
assert_eq!(text.lines[0].spans[0].content, "hello world");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
956
src/message/state.rs
Normal file
956
src/message/state.rs
Normal file
@@ -0,0 +1,956 @@
|
|||||||
|
//! Code for displaying state events.
|
||||||
|
use std::borrow::Cow;
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
use matrix_sdk::ruma::{
|
||||||
|
events::{
|
||||||
|
room::member::MembershipChange,
|
||||||
|
AnyFullStateEventContent,
|
||||||
|
AnySyncStateEvent,
|
||||||
|
FullStateEventContent,
|
||||||
|
},
|
||||||
|
OwnedRoomId,
|
||||||
|
UserId,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::html::{StyleTree, StyleTreeNode};
|
||||||
|
use ratatui::style::{Modifier as StyleModifier, Style};
|
||||||
|
|
||||||
|
fn bold(s: impl Into<Cow<'static, str>>) -> StyleTreeNode {
|
||||||
|
let bold = Style::default().add_modifier(StyleModifier::BOLD);
|
||||||
|
let text = StyleTreeNode::Text(s.into());
|
||||||
|
StyleTreeNode::Style(Box::new(text), bold)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn body_cow_state(ev: &AnySyncStateEvent) -> Cow<'static, str> {
|
||||||
|
let event = match ev.content() {
|
||||||
|
AnyFullStateEventContent::PolicyRuleRoom(FullStateEventContent::Original {
|
||||||
|
content,
|
||||||
|
..
|
||||||
|
}) => {
|
||||||
|
let mut m = format!(
|
||||||
|
"* updated the room policy rule for {:?} to {:?}",
|
||||||
|
content.0.entity,
|
||||||
|
content.0.recommendation.as_str()
|
||||||
|
);
|
||||||
|
|
||||||
|
if !content.0.reason.is_empty() {
|
||||||
|
m.push_str(" (reason: ");
|
||||||
|
m.push_str(&content.0.reason);
|
||||||
|
m.push(')');
|
||||||
|
}
|
||||||
|
|
||||||
|
m
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::PolicyRuleServer(FullStateEventContent::Original {
|
||||||
|
content,
|
||||||
|
..
|
||||||
|
}) => {
|
||||||
|
let mut m = format!(
|
||||||
|
"* updated the server policy rule for {:?} to {:?}",
|
||||||
|
content.0.entity,
|
||||||
|
content.0.recommendation.as_str()
|
||||||
|
);
|
||||||
|
|
||||||
|
if !content.0.reason.is_empty() {
|
||||||
|
m.push_str(" (reason: ");
|
||||||
|
m.push_str(&content.0.reason);
|
||||||
|
m.push(')');
|
||||||
|
}
|
||||||
|
|
||||||
|
m
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::PolicyRuleUser(FullStateEventContent::Original {
|
||||||
|
content,
|
||||||
|
..
|
||||||
|
}) => {
|
||||||
|
let mut m = format!(
|
||||||
|
"* updated the user policy rule for {:?} to {:?}",
|
||||||
|
content.0.entity,
|
||||||
|
content.0.recommendation.as_str()
|
||||||
|
);
|
||||||
|
|
||||||
|
if !content.0.reason.is_empty() {
|
||||||
|
m.push_str(" (reason: ");
|
||||||
|
m.push_str(&content.0.reason);
|
||||||
|
m.push(')');
|
||||||
|
}
|
||||||
|
|
||||||
|
m
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomAliases(FullStateEventContent::Original {
|
||||||
|
content, ..
|
||||||
|
}) => {
|
||||||
|
let mut m = String::from("* set the room aliases to: ");
|
||||||
|
|
||||||
|
for (i, alias) in content.aliases.iter().enumerate() {
|
||||||
|
if i != 0 {
|
||||||
|
m.push_str(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
m.push_str(alias.as_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
m
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomAvatar(FullStateEventContent::Original {
|
||||||
|
content,
|
||||||
|
prev_content,
|
||||||
|
}) => {
|
||||||
|
let prev_url = prev_content.as_ref().and_then(|p| p.url.as_ref());
|
||||||
|
|
||||||
|
match (prev_url, content.url) {
|
||||||
|
(None, Some(_)) => return Cow::Borrowed("* added a room avatar"),
|
||||||
|
(Some(old), Some(new)) => {
|
||||||
|
if old != &new {
|
||||||
|
return Cow::Borrowed("* replaced the room avatar");
|
||||||
|
}
|
||||||
|
|
||||||
|
return Cow::Borrowed("* updated the room avatar state");
|
||||||
|
},
|
||||||
|
(Some(_), None) => return Cow::Borrowed("* removed the room avatar"),
|
||||||
|
(None, None) => return Cow::Borrowed("* updated the room avatar state"),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomCanonicalAlias(FullStateEventContent::Original {
|
||||||
|
content,
|
||||||
|
prev_content,
|
||||||
|
}) => {
|
||||||
|
let old_canon = prev_content.as_ref().and_then(|p| p.alias.as_ref());
|
||||||
|
let new_canon = content.alias.as_ref();
|
||||||
|
|
||||||
|
match (old_canon, new_canon) {
|
||||||
|
(None, Some(canon)) => {
|
||||||
|
format!("* updated the canonical alias for the room to: {}", canon)
|
||||||
|
},
|
||||||
|
(Some(old), Some(new)) => {
|
||||||
|
if old != new {
|
||||||
|
format!("* updated the canonical alias for the room to: {}", new)
|
||||||
|
} else {
|
||||||
|
return Cow::Borrowed("* removed the canonical alias for the room");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(Some(_), None) => {
|
||||||
|
return Cow::Borrowed("* removed the canonical alias for the room");
|
||||||
|
},
|
||||||
|
(None, None) => {
|
||||||
|
return Cow::Borrowed("* did not change the canonical alias");
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomCreate(FullStateEventContent::Original {
|
||||||
|
content, ..
|
||||||
|
}) => {
|
||||||
|
if content.federate {
|
||||||
|
return Cow::Borrowed("* created a federated room");
|
||||||
|
} else {
|
||||||
|
return Cow::Borrowed("* created a non-federated room");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomEncryption(FullStateEventContent::Original { .. }) => {
|
||||||
|
return Cow::Borrowed("* updated the encryption settings for the room");
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomGuestAccess(FullStateEventContent::Original {
|
||||||
|
content,
|
||||||
|
..
|
||||||
|
}) => {
|
||||||
|
format!("* set guest access for the room to {:?}", content.guest_access.as_str())
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomHistoryVisibility(FullStateEventContent::Original {
|
||||||
|
content,
|
||||||
|
..
|
||||||
|
}) => {
|
||||||
|
format!(
|
||||||
|
"* updated history visibility for the room to {:?}",
|
||||||
|
content.history_visibility.as_str()
|
||||||
|
)
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomJoinRules(FullStateEventContent::Original {
|
||||||
|
content,
|
||||||
|
..
|
||||||
|
}) => {
|
||||||
|
format!("* update the join rules for the room to {:?}", content.join_rule.as_str())
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomMember(FullStateEventContent::Original {
|
||||||
|
content,
|
||||||
|
prev_content,
|
||||||
|
}) => {
|
||||||
|
let Ok(state_key) = UserId::parse(ev.state_key()) else {
|
||||||
|
return Cow::Owned(format!(
|
||||||
|
"* failed to calculate membership change for {:?}",
|
||||||
|
ev.state_key()
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
let prev_details = prev_content.as_ref().map(|p| p.details());
|
||||||
|
let change = content.membership_change(prev_details, ev.sender(), &state_key);
|
||||||
|
|
||||||
|
match change {
|
||||||
|
MembershipChange::None => {
|
||||||
|
format!("* did nothing to {}", state_key)
|
||||||
|
},
|
||||||
|
MembershipChange::Error => {
|
||||||
|
format!("* failed to calculate membership change to {}", state_key)
|
||||||
|
},
|
||||||
|
MembershipChange::Joined => {
|
||||||
|
return Cow::Borrowed("* joined the room");
|
||||||
|
},
|
||||||
|
MembershipChange::Left => {
|
||||||
|
return Cow::Borrowed("* left the room");
|
||||||
|
},
|
||||||
|
MembershipChange::Banned => {
|
||||||
|
format!("* banned {} from the room", state_key)
|
||||||
|
},
|
||||||
|
MembershipChange::Unbanned => {
|
||||||
|
format!("* unbanned {} from the room", state_key)
|
||||||
|
},
|
||||||
|
MembershipChange::Kicked => {
|
||||||
|
format!("* kicked {} from the room", state_key)
|
||||||
|
},
|
||||||
|
MembershipChange::Invited => {
|
||||||
|
format!("* invited {} to the room", state_key)
|
||||||
|
},
|
||||||
|
MembershipChange::KickedAndBanned => {
|
||||||
|
format!("* kicked and banned {} from the room", state_key)
|
||||||
|
},
|
||||||
|
MembershipChange::InvitationAccepted => {
|
||||||
|
return Cow::Borrowed("* accepted an invitation to join the room");
|
||||||
|
},
|
||||||
|
MembershipChange::InvitationRejected => {
|
||||||
|
return Cow::Borrowed("* rejected an invitation to join the room");
|
||||||
|
},
|
||||||
|
MembershipChange::InvitationRevoked => {
|
||||||
|
format!("* revoked an invitation for {} to join the room", state_key)
|
||||||
|
},
|
||||||
|
MembershipChange::Knocked => {
|
||||||
|
return Cow::Borrowed("* would like to join the room");
|
||||||
|
},
|
||||||
|
MembershipChange::KnockAccepted => {
|
||||||
|
format!("* accepted the room knock from {}", state_key)
|
||||||
|
},
|
||||||
|
MembershipChange::KnockRetracted => {
|
||||||
|
return Cow::Borrowed("* retracted their room knock");
|
||||||
|
},
|
||||||
|
MembershipChange::KnockDenied => {
|
||||||
|
format!("* rejected the room knock from {}", state_key)
|
||||||
|
},
|
||||||
|
MembershipChange::ProfileChanged { displayname_change, avatar_url_change } => {
|
||||||
|
match (displayname_change, avatar_url_change) {
|
||||||
|
(Some(change), avatar_change) => {
|
||||||
|
let mut m = match (change.old, change.new) {
|
||||||
|
(None, Some(new)) => {
|
||||||
|
format!("* set their display name to {:?}", new)
|
||||||
|
},
|
||||||
|
(Some(old), Some(new)) => {
|
||||||
|
format!("* changed their display name from {old} to {new}")
|
||||||
|
},
|
||||||
|
(Some(_), None) => "* unset their display name".to_string(),
|
||||||
|
(None, None) => {
|
||||||
|
"* made an unknown change to their display name".to_string()
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if avatar_change.is_some() {
|
||||||
|
m.push_str(" and changed their user avatar");
|
||||||
|
}
|
||||||
|
|
||||||
|
m
|
||||||
|
},
|
||||||
|
(None, Some(change)) => {
|
||||||
|
match (change.old, change.new) {
|
||||||
|
(None, Some(_)) => {
|
||||||
|
return Cow::Borrowed("* added a user avatar");
|
||||||
|
},
|
||||||
|
(Some(_), Some(_)) => {
|
||||||
|
return Cow::Borrowed("* changed their user avatar");
|
||||||
|
},
|
||||||
|
(Some(_), None) => {
|
||||||
|
return Cow::Borrowed("* removed their user avatar");
|
||||||
|
},
|
||||||
|
(None, None) => {
|
||||||
|
return Cow::Borrowed(
|
||||||
|
"* made an unknown change to their user avatar",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(None, None) => {
|
||||||
|
return Cow::Borrowed("* changed their user profile");
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
ev => {
|
||||||
|
format!("* made an unknown membership change to {}: {:?}", state_key, ev)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomName(FullStateEventContent::Original { content, .. }) => {
|
||||||
|
format!("* updated the room name to {:?}", content.name)
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomPinnedEvents(FullStateEventContent::Original { .. }) => {
|
||||||
|
return Cow::Borrowed("* updated the pinned events for the room");
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomPowerLevels(FullStateEventContent::Original { .. }) => {
|
||||||
|
return Cow::Borrowed("* updated the power levels for the room");
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomServerAcl(FullStateEventContent::Original { .. }) => {
|
||||||
|
return Cow::Borrowed("* updated the room's server ACLs");
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomThirdPartyInvite(FullStateEventContent::Original {
|
||||||
|
content,
|
||||||
|
..
|
||||||
|
}) => {
|
||||||
|
format!("* sent a third-party invite to {:?}", content.display_name)
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomTombstone(FullStateEventContent::Original {
|
||||||
|
content,
|
||||||
|
..
|
||||||
|
}) => {
|
||||||
|
format!(
|
||||||
|
"* upgraded the room; replacement room is {}",
|
||||||
|
content.replacement_room.as_str()
|
||||||
|
)
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomTopic(FullStateEventContent::Original {
|
||||||
|
content, ..
|
||||||
|
}) => {
|
||||||
|
format!("* set the room topic to {:?}", content.topic)
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::SpaceChild(FullStateEventContent::Original { .. }) => {
|
||||||
|
format!("* added a space child: {}", ev.state_key())
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::SpaceParent(FullStateEventContent::Original {
|
||||||
|
content, ..
|
||||||
|
}) => {
|
||||||
|
if content.canonical {
|
||||||
|
format!("* added a canonical parent space: {}", ev.state_key())
|
||||||
|
} else {
|
||||||
|
format!("* added a parent space: {}", ev.state_key())
|
||||||
|
}
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::BeaconInfo(FullStateEventContent::Original { .. }) => {
|
||||||
|
return Cow::Borrowed("* shared beacon information");
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::CallMember(FullStateEventContent::Original { .. }) => {
|
||||||
|
return Cow::Borrowed("* updated membership for room call");
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::MemberHints(FullStateEventContent::Original {
|
||||||
|
content, ..
|
||||||
|
}) => {
|
||||||
|
let mut m = String::from("* updated the list of service members in the room hints: ");
|
||||||
|
|
||||||
|
for (i, member) in content.service_members.iter().enumerate() {
|
||||||
|
if i != 0 {
|
||||||
|
m.push_str(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
m.push_str(member.as_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
m
|
||||||
|
},
|
||||||
|
|
||||||
|
// Redacted variants of state events:
|
||||||
|
AnyFullStateEventContent::PolicyRuleRoom(FullStateEventContent::Redacted(_)) => {
|
||||||
|
return Cow::Borrowed("* updated a room policy rule (redacted)");
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::PolicyRuleServer(FullStateEventContent::Redacted(_)) => {
|
||||||
|
return Cow::Borrowed("* updated a server policy rule (redacted)");
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::PolicyRuleUser(FullStateEventContent::Redacted(_)) => {
|
||||||
|
return Cow::Borrowed("* updated a user policy rule (redacted)");
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomAliases(FullStateEventContent::Redacted(_)) => {
|
||||||
|
return Cow::Borrowed("* updated the room aliases for the room (redacted)");
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomAvatar(FullStateEventContent::Redacted(_)) => {
|
||||||
|
return Cow::Borrowed("* updated the room avatar (redacted)");
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomCanonicalAlias(FullStateEventContent::Redacted(_)) => {
|
||||||
|
return Cow::Borrowed("* updated the canonical alias for the room (redacted)");
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomCreate(FullStateEventContent::Redacted(_)) => {
|
||||||
|
return Cow::Borrowed("* created the room (redacted)");
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomEncryption(FullStateEventContent::Redacted(_)) => {
|
||||||
|
return Cow::Borrowed("* updated the encryption settings for the room (redacted)");
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomGuestAccess(FullStateEventContent::Redacted(_)) => {
|
||||||
|
return Cow::Borrowed(
|
||||||
|
"* updated the guest access configuration for the room (redacted)",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomHistoryVisibility(FullStateEventContent::Redacted(_)) => {
|
||||||
|
return Cow::Borrowed("* updated history visilibity for the room (redacted)");
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomJoinRules(FullStateEventContent::Redacted(_)) => {
|
||||||
|
return Cow::Borrowed("* updated the join rules for the room (redacted)");
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomMember(FullStateEventContent::Redacted(_)) => {
|
||||||
|
return Cow::Borrowed("* updated the room membership (redacted)");
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomName(FullStateEventContent::Redacted(_)) => {
|
||||||
|
return Cow::Borrowed("* updated the room name (redacted)");
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomPinnedEvents(FullStateEventContent::Redacted(_)) => {
|
||||||
|
return Cow::Borrowed("* updated the pinned events for the room (redacted)");
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomPowerLevels(FullStateEventContent::Redacted(_)) => {
|
||||||
|
return Cow::Borrowed("* updated the power levels for the room (redacted)");
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomServerAcl(FullStateEventContent::Redacted(_)) => {
|
||||||
|
return Cow::Borrowed("* updated the room's server ACLs (redacted)");
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomThirdPartyInvite(FullStateEventContent::Redacted(_)) => {
|
||||||
|
return Cow::Borrowed("* sent a third-party invite (redacted)");
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomTombstone(FullStateEventContent::Redacted(_)) => {
|
||||||
|
return Cow::Borrowed("* upgraded the room (redacted)");
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomTopic(FullStateEventContent::Redacted(_)) => {
|
||||||
|
return Cow::Borrowed("* updated the room topic (redacted)");
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::SpaceChild(FullStateEventContent::Redacted(_)) => {
|
||||||
|
return Cow::Borrowed("* added a space child (redacted)");
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::SpaceParent(FullStateEventContent::Redacted(_)) => {
|
||||||
|
return Cow::Borrowed("* added a parent space (redacted)");
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::BeaconInfo(FullStateEventContent::Redacted(_)) => {
|
||||||
|
return Cow::Borrowed("* shared beacon information (redacted)");
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::CallMember(FullStateEventContent::Redacted(_)) => {
|
||||||
|
return Cow::Borrowed("Call membership changed");
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::MemberHints(FullStateEventContent::Redacted(_)) => {
|
||||||
|
return Cow::Borrowed("Member hints changed");
|
||||||
|
},
|
||||||
|
|
||||||
|
// Handle unknown events:
|
||||||
|
e => {
|
||||||
|
format!("* sent an unknown state event: {:?}", e.event_type())
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return Cow::Owned(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn html_state(ev: &AnySyncStateEvent) -> StyleTree {
|
||||||
|
let children = match ev.content() {
|
||||||
|
AnyFullStateEventContent::PolicyRuleRoom(FullStateEventContent::Original {
|
||||||
|
content,
|
||||||
|
..
|
||||||
|
}) => {
|
||||||
|
let prefix = StyleTreeNode::Text("* updated the room policy rule for ".into());
|
||||||
|
let entity = bold(format!("{:?}", content.0.entity));
|
||||||
|
let middle = StyleTreeNode::Text(" to ".into());
|
||||||
|
let rec =
|
||||||
|
StyleTreeNode::Text(format!("{:?}", content.0.recommendation.as_str()).into());
|
||||||
|
let mut cs = vec![prefix, entity, middle, rec];
|
||||||
|
|
||||||
|
if !content.0.reason.is_empty() {
|
||||||
|
let reason = format!(" (reason: {})", content.0.reason);
|
||||||
|
cs.push(StyleTreeNode::Text(reason.into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
cs
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::PolicyRuleServer(FullStateEventContent::Original {
|
||||||
|
content,
|
||||||
|
..
|
||||||
|
}) => {
|
||||||
|
let prefix = StyleTreeNode::Text("* updated the server policy rule for ".into());
|
||||||
|
let entity = bold(format!("{:?}", content.0.entity));
|
||||||
|
let middle = StyleTreeNode::Text(" to ".into());
|
||||||
|
let rec =
|
||||||
|
StyleTreeNode::Text(format!("{:?}", content.0.recommendation.as_str()).into());
|
||||||
|
let mut cs = vec![prefix, entity, middle, rec];
|
||||||
|
|
||||||
|
if !content.0.reason.is_empty() {
|
||||||
|
let reason = format!(" (reason: {})", content.0.reason);
|
||||||
|
cs.push(StyleTreeNode::Text(reason.into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
cs
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::PolicyRuleUser(FullStateEventContent::Original {
|
||||||
|
content,
|
||||||
|
..
|
||||||
|
}) => {
|
||||||
|
let prefix = StyleTreeNode::Text("* updated the user policy rule for ".into());
|
||||||
|
let entity = bold(format!("{:?}", content.0.entity));
|
||||||
|
let middle = StyleTreeNode::Text(" to ".into());
|
||||||
|
let rec =
|
||||||
|
StyleTreeNode::Text(format!("{:?}", content.0.recommendation.as_str()).into());
|
||||||
|
let mut cs = vec![prefix, entity, middle, rec];
|
||||||
|
|
||||||
|
if !content.0.reason.is_empty() {
|
||||||
|
let reason = format!(" (reason: {})", content.0.reason);
|
||||||
|
cs.push(StyleTreeNode::Text(reason.into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
cs
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomAliases(FullStateEventContent::Original {
|
||||||
|
content, ..
|
||||||
|
}) => {
|
||||||
|
let prefix = StyleTreeNode::Text("* set the room aliases to: ".into());
|
||||||
|
let mut cs = vec![prefix];
|
||||||
|
|
||||||
|
for (i, alias) in content.aliases.iter().enumerate() {
|
||||||
|
if i != 0 {
|
||||||
|
cs.push(StyleTreeNode::Text(", ".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
cs.push(StyleTreeNode::RoomAlias(alias.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
cs
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomAvatar(FullStateEventContent::Original {
|
||||||
|
content,
|
||||||
|
prev_content,
|
||||||
|
}) => {
|
||||||
|
let prev_url = prev_content.as_ref().and_then(|p| p.url.as_ref());
|
||||||
|
|
||||||
|
let node = match (prev_url, content.url) {
|
||||||
|
(None, Some(_)) => StyleTreeNode::Text("* added a room avatar".into()),
|
||||||
|
(Some(old), Some(new)) => {
|
||||||
|
if old != &new {
|
||||||
|
StyleTreeNode::Text("* replaced the room avatar".into())
|
||||||
|
} else {
|
||||||
|
StyleTreeNode::Text("* updated the room avatar state".into())
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(Some(_), None) => StyleTreeNode::Text("* removed the room avatar".into()),
|
||||||
|
(None, None) => StyleTreeNode::Text("* updated the room avatar state".into()),
|
||||||
|
};
|
||||||
|
|
||||||
|
vec![node]
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomCanonicalAlias(FullStateEventContent::Original {
|
||||||
|
content,
|
||||||
|
..
|
||||||
|
}) => {
|
||||||
|
if let Some(canon) = content.alias.as_ref() {
|
||||||
|
let canon = bold(canon.to_string());
|
||||||
|
let prefix =
|
||||||
|
StyleTreeNode::Text("* updated the canonical alias for the room to: ".into());
|
||||||
|
vec![prefix, canon]
|
||||||
|
} else {
|
||||||
|
vec![StyleTreeNode::Text(
|
||||||
|
"* removed the canonical alias for the room".into(),
|
||||||
|
)]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomCreate(FullStateEventContent::Original {
|
||||||
|
content, ..
|
||||||
|
}) => {
|
||||||
|
if content.federate {
|
||||||
|
vec![StyleTreeNode::Text("* created a federated room".into())]
|
||||||
|
} else {
|
||||||
|
vec![StyleTreeNode::Text("* created a non-federated room".into())]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomEncryption(FullStateEventContent::Original { .. }) => {
|
||||||
|
vec![StyleTreeNode::Text(
|
||||||
|
"* updated the encryption settings for the room".into(),
|
||||||
|
)]
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomGuestAccess(FullStateEventContent::Original {
|
||||||
|
content,
|
||||||
|
..
|
||||||
|
}) => {
|
||||||
|
let access = bold(format!("{:?}", content.guest_access.as_str()));
|
||||||
|
let prefix = StyleTreeNode::Text("* set guest access for the room to ".into());
|
||||||
|
vec![prefix, access]
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomHistoryVisibility(FullStateEventContent::Original {
|
||||||
|
content,
|
||||||
|
..
|
||||||
|
}) => {
|
||||||
|
let prefix =
|
||||||
|
StyleTreeNode::Text("* updated history visibility for the room to ".into());
|
||||||
|
let vis = bold(format!("{:?}", content.history_visibility.as_str()));
|
||||||
|
vec![prefix, vis]
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomJoinRules(FullStateEventContent::Original {
|
||||||
|
content,
|
||||||
|
..
|
||||||
|
}) => {
|
||||||
|
let prefix = StyleTreeNode::Text("* update the join rules for the room to ".into());
|
||||||
|
let rule = bold(format!("{:?}", content.join_rule.as_str()));
|
||||||
|
vec![prefix, rule]
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomMember(FullStateEventContent::Original {
|
||||||
|
content,
|
||||||
|
prev_content,
|
||||||
|
}) => {
|
||||||
|
let Ok(state_key) = UserId::parse(ev.state_key()) else {
|
||||||
|
let prefix =
|
||||||
|
StyleTreeNode::Text("* failed to calculate membership change for ".into());
|
||||||
|
let user_id = bold(format!("{:?}", ev.state_key()));
|
||||||
|
let children = vec![prefix, user_id];
|
||||||
|
|
||||||
|
return StyleTree { children };
|
||||||
|
};
|
||||||
|
|
||||||
|
let prev_details = prev_content.as_ref().map(|p| p.details());
|
||||||
|
let change = content.membership_change(prev_details, ev.sender(), &state_key);
|
||||||
|
let user_id = StyleTreeNode::UserId(state_key.clone());
|
||||||
|
|
||||||
|
match change {
|
||||||
|
MembershipChange::None => {
|
||||||
|
let prefix = StyleTreeNode::Text("* did nothing to ".into());
|
||||||
|
vec![prefix, user_id]
|
||||||
|
},
|
||||||
|
MembershipChange::Error => {
|
||||||
|
let prefix =
|
||||||
|
StyleTreeNode::Text("* failed to calculate membership change to ".into());
|
||||||
|
vec![prefix, user_id]
|
||||||
|
},
|
||||||
|
MembershipChange::Joined => {
|
||||||
|
vec![StyleTreeNode::Text("* joined the room".into())]
|
||||||
|
},
|
||||||
|
MembershipChange::Left => {
|
||||||
|
vec![StyleTreeNode::Text("* left the room".into())]
|
||||||
|
},
|
||||||
|
MembershipChange::Banned => {
|
||||||
|
let prefix = StyleTreeNode::Text("* banned ".into());
|
||||||
|
let suffix = StyleTreeNode::Text(" from the room".into());
|
||||||
|
vec![prefix, user_id, suffix]
|
||||||
|
},
|
||||||
|
MembershipChange::Unbanned => {
|
||||||
|
let prefix = StyleTreeNode::Text("* unbanned ".into());
|
||||||
|
let suffix = StyleTreeNode::Text(" from the room".into());
|
||||||
|
vec![prefix, user_id, suffix]
|
||||||
|
},
|
||||||
|
MembershipChange::Kicked => {
|
||||||
|
let prefix = StyleTreeNode::Text("* kicked ".into());
|
||||||
|
let suffix = StyleTreeNode::Text(" from the room".into());
|
||||||
|
vec![prefix, user_id, suffix]
|
||||||
|
},
|
||||||
|
MembershipChange::Invited => {
|
||||||
|
let prefix = StyleTreeNode::Text("* invited ".into());
|
||||||
|
let suffix = StyleTreeNode::Text(" to the room".into());
|
||||||
|
vec![prefix, user_id, suffix]
|
||||||
|
},
|
||||||
|
MembershipChange::KickedAndBanned => {
|
||||||
|
let prefix = StyleTreeNode::Text("* kicked and banned ".into());
|
||||||
|
let suffix = StyleTreeNode::Text(" from the room".into());
|
||||||
|
vec![prefix, user_id, suffix]
|
||||||
|
},
|
||||||
|
MembershipChange::InvitationAccepted => {
|
||||||
|
vec![StyleTreeNode::Text(
|
||||||
|
"* accepted an invitation to join the room".into(),
|
||||||
|
)]
|
||||||
|
},
|
||||||
|
MembershipChange::InvitationRejected => {
|
||||||
|
vec![StyleTreeNode::Text(
|
||||||
|
"* rejected an invitation to join the room".into(),
|
||||||
|
)]
|
||||||
|
},
|
||||||
|
MembershipChange::InvitationRevoked => {
|
||||||
|
let prefix = StyleTreeNode::Text("* revoked an invitation for ".into());
|
||||||
|
let suffix = StyleTreeNode::Text(" to join the room".into());
|
||||||
|
vec![prefix, user_id, suffix]
|
||||||
|
},
|
||||||
|
MembershipChange::Knocked => {
|
||||||
|
vec![StyleTreeNode::Text("* would like to join the room".into())]
|
||||||
|
},
|
||||||
|
MembershipChange::KnockAccepted => {
|
||||||
|
let prefix = StyleTreeNode::Text("* accepted the room knock from ".into());
|
||||||
|
vec![prefix, user_id]
|
||||||
|
},
|
||||||
|
MembershipChange::KnockRetracted => {
|
||||||
|
vec![StyleTreeNode::Text("* retracted their room knock".into())]
|
||||||
|
},
|
||||||
|
MembershipChange::KnockDenied => {
|
||||||
|
let prefix = StyleTreeNode::Text("* rejected the room knock from ".into());
|
||||||
|
vec![prefix, user_id]
|
||||||
|
},
|
||||||
|
MembershipChange::ProfileChanged { displayname_change, avatar_url_change } => {
|
||||||
|
match (displayname_change, avatar_url_change) {
|
||||||
|
(Some(change), avatar_change) => {
|
||||||
|
let mut m = match (change.old, change.new) {
|
||||||
|
(None, Some(new)) => {
|
||||||
|
vec![
|
||||||
|
StyleTreeNode::Text("* set their display name to ".into()),
|
||||||
|
StyleTreeNode::DisplayName(new.into(), state_key),
|
||||||
|
]
|
||||||
|
},
|
||||||
|
(Some(old), Some(new)) => {
|
||||||
|
vec![
|
||||||
|
StyleTreeNode::Text(
|
||||||
|
"* changed their display name from ".into(),
|
||||||
|
),
|
||||||
|
StyleTreeNode::DisplayName(old.into(), state_key.clone()),
|
||||||
|
StyleTreeNode::Text(" to ".into()),
|
||||||
|
StyleTreeNode::DisplayName(new.into(), state_key),
|
||||||
|
]
|
||||||
|
},
|
||||||
|
(Some(_), None) => {
|
||||||
|
vec![StyleTreeNode::Text("* unset their display name".into())]
|
||||||
|
},
|
||||||
|
(None, None) => {
|
||||||
|
vec![StyleTreeNode::Text(
|
||||||
|
"* made an unknown change to their display name".into(),
|
||||||
|
)]
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if avatar_change.is_some() {
|
||||||
|
m.push(StyleTreeNode::Text(
|
||||||
|
" and changed their user avatar".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
m
|
||||||
|
},
|
||||||
|
(None, Some(change)) => {
|
||||||
|
let m = match (change.old, change.new) {
|
||||||
|
(None, Some(_)) => Cow::Borrowed("* added a user avatar"),
|
||||||
|
(Some(_), Some(_)) => Cow::Borrowed("* changed their user avatar"),
|
||||||
|
(Some(_), None) => Cow::Borrowed("* removed their user avatar"),
|
||||||
|
(None, None) => {
|
||||||
|
Cow::Borrowed("* made an unknown change to their user avatar")
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
vec![StyleTreeNode::Text(m)]
|
||||||
|
},
|
||||||
|
(None, None) => {
|
||||||
|
vec![StyleTreeNode::Text("* changed their user profile".into())]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
ev => {
|
||||||
|
let prefix =
|
||||||
|
StyleTreeNode::Text("* made an unknown membership change to ".into());
|
||||||
|
let suffix = StyleTreeNode::Text(format!(": {:?}", ev).into());
|
||||||
|
vec![prefix, user_id, suffix]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomName(FullStateEventContent::Original { content, .. }) => {
|
||||||
|
let prefix = StyleTreeNode::Text("* updated the room name to ".into());
|
||||||
|
let name = bold(format!("{:?}", content.name));
|
||||||
|
vec![prefix, name]
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomPinnedEvents(FullStateEventContent::Original { .. }) => {
|
||||||
|
vec![StyleTreeNode::Text(
|
||||||
|
"* updated the pinned events for the room".into(),
|
||||||
|
)]
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomPowerLevels(FullStateEventContent::Original { .. }) => {
|
||||||
|
vec![StyleTreeNode::Text(
|
||||||
|
"* updated the power levels for the room".into(),
|
||||||
|
)]
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomServerAcl(FullStateEventContent::Original { .. }) => {
|
||||||
|
vec![StyleTreeNode::Text(
|
||||||
|
"* updated the room's server ACLs".into(),
|
||||||
|
)]
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomThirdPartyInvite(FullStateEventContent::Original {
|
||||||
|
content,
|
||||||
|
..
|
||||||
|
}) => {
|
||||||
|
let prefix = StyleTreeNode::Text("* sent a third-party invite to ".into());
|
||||||
|
let name = bold(format!("{:?}", content.display_name));
|
||||||
|
vec![prefix, name]
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomTombstone(FullStateEventContent::Original {
|
||||||
|
content,
|
||||||
|
..
|
||||||
|
}) => {
|
||||||
|
let prefix = StyleTreeNode::Text("* upgraded the room; replacement room is ".into());
|
||||||
|
let room = StyleTreeNode::RoomId(content.replacement_room.clone());
|
||||||
|
vec![prefix, room]
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomTopic(FullStateEventContent::Original {
|
||||||
|
content, ..
|
||||||
|
}) => {
|
||||||
|
let prefix = StyleTreeNode::Text("* set the room topic to ".into());
|
||||||
|
let topic = bold(format!("{:?}", content.topic));
|
||||||
|
vec![prefix, topic]
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::SpaceChild(FullStateEventContent::Original { .. }) => {
|
||||||
|
let prefix = StyleTreeNode::Text("* added a space child: ".into());
|
||||||
|
|
||||||
|
let room_id = if let Ok(room_id) = OwnedRoomId::from_str(ev.state_key()) {
|
||||||
|
StyleTreeNode::RoomId(room_id)
|
||||||
|
} else {
|
||||||
|
bold(ev.state_key().to_string())
|
||||||
|
};
|
||||||
|
|
||||||
|
vec![prefix, room_id]
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::SpaceParent(FullStateEventContent::Original {
|
||||||
|
content, ..
|
||||||
|
}) => {
|
||||||
|
let prefix = if content.canonical {
|
||||||
|
StyleTreeNode::Text("* added a canonical parent space: ".into())
|
||||||
|
} else {
|
||||||
|
StyleTreeNode::Text("* added a parent space: ".into())
|
||||||
|
};
|
||||||
|
|
||||||
|
let room_id = if let Ok(room_id) = OwnedRoomId::from_str(ev.state_key()) {
|
||||||
|
StyleTreeNode::RoomId(room_id)
|
||||||
|
} else {
|
||||||
|
bold(ev.state_key().to_string())
|
||||||
|
};
|
||||||
|
|
||||||
|
vec![prefix, room_id]
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::BeaconInfo(FullStateEventContent::Original { .. }) => {
|
||||||
|
vec![StyleTreeNode::Text("* shared beacon information".into())]
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::CallMember(FullStateEventContent::Original { .. }) => {
|
||||||
|
vec![StyleTreeNode::Text(
|
||||||
|
"* updated membership for room call".into(),
|
||||||
|
)]
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::MemberHints(FullStateEventContent::Original {
|
||||||
|
content, ..
|
||||||
|
}) => {
|
||||||
|
let prefix = StyleTreeNode::Text(
|
||||||
|
"* updated the list of service members in the room hints: ".into(),
|
||||||
|
);
|
||||||
|
let mut cs = vec![prefix];
|
||||||
|
|
||||||
|
for (i, member) in content.service_members.iter().enumerate() {
|
||||||
|
if i != 0 {
|
||||||
|
cs.push(StyleTreeNode::Text(", ".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
cs.push(StyleTreeNode::UserId(member.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
cs
|
||||||
|
},
|
||||||
|
|
||||||
|
// Redacted variants of state events:
|
||||||
|
AnyFullStateEventContent::PolicyRuleRoom(FullStateEventContent::Redacted(_)) => {
|
||||||
|
vec![StyleTreeNode::Text(
|
||||||
|
"* updated a room policy rule (redacted)".into(),
|
||||||
|
)]
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::PolicyRuleServer(FullStateEventContent::Redacted(_)) => {
|
||||||
|
vec![StyleTreeNode::Text(
|
||||||
|
"* updated a server policy rule (redacted)".into(),
|
||||||
|
)]
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::PolicyRuleUser(FullStateEventContent::Redacted(_)) => {
|
||||||
|
vec![StyleTreeNode::Text(
|
||||||
|
"* updated a user policy rule (redacted)".into(),
|
||||||
|
)]
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomAliases(FullStateEventContent::Redacted(_)) => {
|
||||||
|
vec![StyleTreeNode::Text(
|
||||||
|
"* updated the room aliases for the room (redacted)".into(),
|
||||||
|
)]
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomAvatar(FullStateEventContent::Redacted(_)) => {
|
||||||
|
vec![StyleTreeNode::Text(
|
||||||
|
"* updated the room avatar (redacted)".into(),
|
||||||
|
)]
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomCanonicalAlias(FullStateEventContent::Redacted(_)) => {
|
||||||
|
vec![StyleTreeNode::Text(
|
||||||
|
"* updated the canonical alias for the room (redacted)".into(),
|
||||||
|
)]
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomCreate(FullStateEventContent::Redacted(_)) => {
|
||||||
|
vec![StyleTreeNode::Text("* created the room (redacted)".into())]
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomEncryption(FullStateEventContent::Redacted(_)) => {
|
||||||
|
vec![StyleTreeNode::Text(
|
||||||
|
"* updated the encryption settings for the room (redacted)".into(),
|
||||||
|
)]
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomGuestAccess(FullStateEventContent::Redacted(_)) => {
|
||||||
|
vec![StyleTreeNode::Text(
|
||||||
|
"* updated the guest access configuration for the room (redacted)".into(),
|
||||||
|
)]
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomHistoryVisibility(FullStateEventContent::Redacted(_)) => {
|
||||||
|
vec![StyleTreeNode::Text(
|
||||||
|
"* updated history visilibity for the room (redacted)".into(),
|
||||||
|
)]
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomJoinRules(FullStateEventContent::Redacted(_)) => {
|
||||||
|
vec![StyleTreeNode::Text(
|
||||||
|
"* updated the join rules for the room (redacted)".into(),
|
||||||
|
)]
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomMember(FullStateEventContent::Redacted(_)) => {
|
||||||
|
vec![StyleTreeNode::Text(
|
||||||
|
"* updated the room membership (redacted)".into(),
|
||||||
|
)]
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomName(FullStateEventContent::Redacted(_)) => {
|
||||||
|
vec![StyleTreeNode::Text(
|
||||||
|
"* updated the room name (redacted)".into(),
|
||||||
|
)]
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomPinnedEvents(FullStateEventContent::Redacted(_)) => {
|
||||||
|
vec![StyleTreeNode::Text(
|
||||||
|
"* updated the pinned events for the room (redacted)".into(),
|
||||||
|
)]
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomPowerLevels(FullStateEventContent::Redacted(_)) => {
|
||||||
|
vec![StyleTreeNode::Text(
|
||||||
|
"* updated the power levels for the room (redacted)".into(),
|
||||||
|
)]
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomServerAcl(FullStateEventContent::Redacted(_)) => {
|
||||||
|
vec![StyleTreeNode::Text(
|
||||||
|
"* updated the room's server ACLs (redacted)".into(),
|
||||||
|
)]
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomThirdPartyInvite(FullStateEventContent::Redacted(_)) => {
|
||||||
|
vec![StyleTreeNode::Text(
|
||||||
|
"* sent a third-party invite (redacted)".into(),
|
||||||
|
)]
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomTombstone(FullStateEventContent::Redacted(_)) => {
|
||||||
|
vec![StyleTreeNode::Text("* upgraded the room (redacted)".into())]
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomTopic(FullStateEventContent::Redacted(_)) => {
|
||||||
|
vec![StyleTreeNode::Text(
|
||||||
|
"* updated the room topic (redacted)".into(),
|
||||||
|
)]
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::SpaceChild(FullStateEventContent::Redacted(_)) => {
|
||||||
|
vec![StyleTreeNode::Text(
|
||||||
|
"* added a space child (redacted)".into(),
|
||||||
|
)]
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::SpaceParent(FullStateEventContent::Redacted(_)) => {
|
||||||
|
vec![StyleTreeNode::Text(
|
||||||
|
"* added a parent space (redacted)".into(),
|
||||||
|
)]
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::BeaconInfo(FullStateEventContent::Redacted(_)) => {
|
||||||
|
vec![StyleTreeNode::Text(
|
||||||
|
"* shared beacon information (redacted)".into(),
|
||||||
|
)]
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::CallMember(FullStateEventContent::Redacted(_)) => {
|
||||||
|
vec![StyleTreeNode::Text("Call membership changed".into())]
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::MemberHints(FullStateEventContent::Redacted(_)) => {
|
||||||
|
vec![StyleTreeNode::Text("Member hints changed".into())]
|
||||||
|
},
|
||||||
|
|
||||||
|
// Handle unknown events:
|
||||||
|
e => {
|
||||||
|
let prefix = StyleTreeNode::Text("* sent an unknown state event: ".into());
|
||||||
|
let event = bold(format!("{:?}", e.event_type()));
|
||||||
|
vec![prefix, event]
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
StyleTree { children }
|
||||||
|
}
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
use std::time::SystemTime;
|
use std::time::SystemTime;
|
||||||
|
|
||||||
use matrix_sdk::{
|
use matrix_sdk::{
|
||||||
|
deserialized_responses::RawAnySyncOrStrippedTimelineEvent,
|
||||||
notification_settings::{IsEncrypted, IsOneToOne, NotificationSettings, RoomNotificationMode},
|
notification_settings::{IsEncrypted, IsOneToOne, NotificationSettings, RoomNotificationMode},
|
||||||
room::Room as MatrixRoom,
|
room::Room as MatrixRoom,
|
||||||
ruma::{
|
ruma::{
|
||||||
api::client::push::get_notifications::v3::Notification,
|
|
||||||
events::{room::message::MessageType, AnyMessageLikeEventContent, AnySyncTimelineEvent},
|
events::{room::message::MessageType, AnyMessageLikeEventContent, AnySyncTimelineEvent},
|
||||||
|
serde::Raw,
|
||||||
MilliSecondsSinceUnixEpoch,
|
MilliSecondsSinceUnixEpoch,
|
||||||
|
OwnedRoomId,
|
||||||
RoomId,
|
RoomId,
|
||||||
},
|
},
|
||||||
Client,
|
Client,
|
||||||
@@ -14,10 +16,30 @@ use matrix_sdk::{
|
|||||||
use unicode_segmentation::UnicodeSegmentation;
|
use unicode_segmentation::UnicodeSegmentation;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
base::{AsyncProgramStore, IambError, IambResult},
|
base::{AsyncProgramStore, IambError, IambResult, ProgramStore},
|
||||||
config::{ApplicationSettings, NotifyVia},
|
config::{ApplicationSettings, NotifyVia},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const IAMB_XDG_NAME: &str = match option_env!("IAMB_XDG_NAME") {
|
||||||
|
None => "iamb",
|
||||||
|
Some(iamb) => iamb,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Handle for an open notification that should be closed when the user views it.
|
||||||
|
pub struct NotificationHandle(
|
||||||
|
#[cfg(all(feature = "desktop", unix, not(target_os = "macos")))]
|
||||||
|
Option<notify_rust::NotificationHandle>,
|
||||||
|
);
|
||||||
|
|
||||||
|
impl Drop for NotificationHandle {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
#[cfg(all(feature = "desktop", unix, not(target_os = "macos")))]
|
||||||
|
if let Some(handle) = self.0.take() {
|
||||||
|
handle.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn register_notifications(
|
pub async fn register_notifications(
|
||||||
client: &Client,
|
client: &Client,
|
||||||
settings: &ApplicationSettings,
|
settings: &ApplicationSettings,
|
||||||
@@ -44,11 +66,14 @@ pub async fn register_notifications(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if is_open(&store, room.room_id()).await {
|
if is_visible_room(&store, room.room_id()).await {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
match parse_notification(notification, room, show_message).await {
|
let room_id = room.room_id().to_owned();
|
||||||
|
match notification.event {
|
||||||
|
RawAnySyncOrStrippedTimelineEvent::Sync(e) => {
|
||||||
|
match parse_full_notification(e, room, show_message).await {
|
||||||
Ok((summary, body, server_ts)) => {
|
Ok((summary, body, server_ts)) => {
|
||||||
if server_ts < startup_ts {
|
if server_ts < startup_ts {
|
||||||
return;
|
return;
|
||||||
@@ -58,39 +83,90 @@ pub async fn register_notifications(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
match notify_via {
|
send_notification(
|
||||||
NotifyVia::Desktop => send_notification_desktop(summary, body),
|
¬ify_via,
|
||||||
NotifyVia::Bell => send_notification_bell(&store).await,
|
&summary,
|
||||||
}
|
body.as_deref(),
|
||||||
|
room_id,
|
||||||
|
&store,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
},
|
},
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
tracing::error!("Failed to extract notification data: {err}")
|
tracing::error!("Failed to extract notification data: {err}")
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
// Stripped events may be dropped silently because they're
|
||||||
|
// only relevant if we're not in a room, and we presumably
|
||||||
|
// don't want notifications for rooms we're not in.
|
||||||
|
RawAnySyncOrStrippedTimelineEvent::Stripped(_) => (),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn send_notification(
|
||||||
|
via: &NotifyVia,
|
||||||
|
summary: &str,
|
||||||
|
body: Option<&str>,
|
||||||
|
room_id: OwnedRoomId,
|
||||||
|
store: &AsyncProgramStore,
|
||||||
|
) {
|
||||||
|
#[cfg(feature = "desktop")]
|
||||||
|
if via.desktop {
|
||||||
|
send_notification_desktop(summary, body, room_id, store).await;
|
||||||
|
}
|
||||||
|
#[cfg(not(feature = "desktop"))]
|
||||||
|
{
|
||||||
|
let _ = (summary, body, IAMB_XDG_NAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
if via.bell {
|
||||||
|
send_notification_bell(store).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn send_notification_bell(store: &AsyncProgramStore) {
|
async fn send_notification_bell(store: &AsyncProgramStore) {
|
||||||
let mut locked = store.lock().await;
|
let mut locked = store.lock().await;
|
||||||
locked.application.ring_bell = true;
|
locked.application.ring_bell = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
fn send_notification_desktop(summary: String, body: Option<String>) {
|
#[cfg(feature = "desktop")]
|
||||||
|
async fn send_notification_desktop(
|
||||||
|
summary: &str,
|
||||||
|
body: Option<&str>,
|
||||||
|
room_id: OwnedRoomId,
|
||||||
|
_store: &AsyncProgramStore,
|
||||||
|
) {
|
||||||
let mut desktop_notification = notify_rust::Notification::new();
|
let mut desktop_notification = notify_rust::Notification::new();
|
||||||
desktop_notification
|
desktop_notification
|
||||||
.summary(&summary)
|
.summary(summary)
|
||||||
.appname("iamb")
|
.appname(IAMB_XDG_NAME)
|
||||||
.timeout(notify_rust::Timeout::Milliseconds(3000))
|
.icon(IAMB_XDG_NAME)
|
||||||
.action("default", "default");
|
.action("default", "default");
|
||||||
|
|
||||||
|
#[cfg(all(unix, not(target_os = "macos")))]
|
||||||
|
desktop_notification.urgency(notify_rust::Urgency::Normal);
|
||||||
|
|
||||||
if let Some(body) = body {
|
if let Some(body) = body {
|
||||||
desktop_notification.body(&body);
|
desktop_notification.body(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Err(err) = desktop_notification.show() {
|
match desktop_notification.show() {
|
||||||
tracing::error!("Failed to send notification: {err}")
|
Err(err) => tracing::error!("Failed to send notification: {err}"),
|
||||||
|
Ok(handle) => {
|
||||||
|
#[cfg(all(unix, not(target_os = "macos")))]
|
||||||
|
_store
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.application
|
||||||
|
.open_notifications
|
||||||
|
.entry(room_id)
|
||||||
|
.or_default()
|
||||||
|
.push(NotificationHandle(Some(handle)));
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,8 +204,7 @@ fn is_missing_mention(body: &Option<String>, mode: RoomNotificationMode, client:
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn is_open(store: &AsyncProgramStore, room_id: &RoomId) -> bool {
|
fn is_open(locked: &mut ProgramStore, room_id: &RoomId) -> bool {
|
||||||
let mut locked = store.lock().await;
|
|
||||||
if let Some(draw_curr) = locked.application.draw_curr {
|
if let Some(draw_curr) = locked.application.draw_curr {
|
||||||
let info = locked.application.get_room_info(room_id.to_owned());
|
let info = locked.application.get_room_info(room_id.to_owned());
|
||||||
if let Some(draw_last) = info.draw_last {
|
if let Some(draw_last) = info.draw_last {
|
||||||
@@ -139,12 +214,22 @@ async fn is_open(store: &AsyncProgramStore, room_id: &RoomId) -> bool {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn parse_notification(
|
fn is_focused(locked: &ProgramStore) -> bool {
|
||||||
notification: Notification,
|
locked.application.focused
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn is_visible_room(store: &AsyncProgramStore, room_id: &RoomId) -> bool {
|
||||||
|
let mut locked = store.lock().await;
|
||||||
|
|
||||||
|
is_focused(&locked) && is_open(&mut locked, room_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn parse_full_notification(
|
||||||
|
event: Raw<AnySyncTimelineEvent>,
|
||||||
room: MatrixRoom,
|
room: MatrixRoom,
|
||||||
show_body: bool,
|
show_body: bool,
|
||||||
) -> IambResult<(String, Option<String>, MilliSecondsSinceUnixEpoch)> {
|
) -> IambResult<(String, Option<String>, MilliSecondsSinceUnixEpoch)> {
|
||||||
let event = notification.event.deserialize().map_err(IambError::from)?;
|
let event = event.deserialize().map_err(IambError::from)?;
|
||||||
|
|
||||||
let server_ts = event.origin_server_ts();
|
let server_ts = event.origin_server_ts();
|
||||||
|
|
||||||
@@ -156,25 +241,27 @@ pub async fn parse_notification(
|
|||||||
.and_then(|m| m.display_name())
|
.and_then(|m| m.display_name())
|
||||||
.unwrap_or_else(|| sender_id.localpart());
|
.unwrap_or_else(|| sender_id.localpart());
|
||||||
|
|
||||||
|
let summary = if let Some(room_name) = room.cached_display_name() {
|
||||||
|
if room.is_direct().await.map_err(IambError::from)? && sender_name == room_name.to_string()
|
||||||
|
{
|
||||||
|
sender_name.to_string()
|
||||||
|
} else {
|
||||||
|
format!("{sender_name} in {room_name}")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sender_name.to_string()
|
||||||
|
};
|
||||||
|
|
||||||
let body = if show_body {
|
let body = if show_body {
|
||||||
event_notification_body(
|
event_notification_body(&event, sender_name).map(truncate)
|
||||||
&event,
|
|
||||||
sender_name,
|
|
||||||
room.is_direct().await.map_err(IambError::from)?,
|
|
||||||
)
|
|
||||||
.map(truncate)
|
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
return Ok((sender_name.to_string(), body, server_ts));
|
return Ok((summary, body, server_ts));
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn event_notification_body(
|
pub fn event_notification_body(event: &AnySyncTimelineEvent, sender_name: &str) -> Option<String> {
|
||||||
event: &AnySyncTimelineEvent,
|
|
||||||
sender_name: &str,
|
|
||||||
is_direct: bool,
|
|
||||||
) -> Option<String> {
|
|
||||||
let AnySyncTimelineEvent::MessageLike(event) = event else {
|
let AnySyncTimelineEvent::MessageLike(event) = event else {
|
||||||
return None;
|
return None;
|
||||||
};
|
};
|
||||||
@@ -185,10 +272,7 @@ pub fn event_notification_body(
|
|||||||
MessageType::Audio(_) => {
|
MessageType::Audio(_) => {
|
||||||
format!("{sender_name} sent an audio file.")
|
format!("{sender_name} sent an audio file.")
|
||||||
},
|
},
|
||||||
MessageType::Emote(content) => {
|
MessageType::Emote(content) => content.body,
|
||||||
let message = &content.body;
|
|
||||||
format!("{sender_name}: {message}")
|
|
||||||
},
|
|
||||||
MessageType::File(_) => {
|
MessageType::File(_) => {
|
||||||
format!("{sender_name} sent a file.")
|
format!("{sender_name} sent a file.")
|
||||||
},
|
},
|
||||||
@@ -198,29 +282,18 @@ pub fn event_notification_body(
|
|||||||
MessageType::Location(_) => {
|
MessageType::Location(_) => {
|
||||||
format!("{sender_name} sent their location.")
|
format!("{sender_name} sent their location.")
|
||||||
},
|
},
|
||||||
MessageType::Notice(content) => {
|
MessageType::Notice(content) => content.body,
|
||||||
let message = &content.body;
|
MessageType::ServerNotice(content) => content.body,
|
||||||
format!("{sender_name}: {message}")
|
MessageType::Text(content) => content.body,
|
||||||
},
|
|
||||||
MessageType::ServerNotice(content) => {
|
|
||||||
let message = &content.body;
|
|
||||||
format!("{sender_name}: {message}")
|
|
||||||
},
|
|
||||||
MessageType::Text(content) => {
|
|
||||||
if is_direct {
|
|
||||||
content.body
|
|
||||||
} else {
|
|
||||||
let message = &content.body;
|
|
||||||
format!("{sender_name}: {message}")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
MessageType::Video(_) => {
|
MessageType::Video(_) => {
|
||||||
format!("{sender_name} sent a video.")
|
format!("{sender_name} sent a video.")
|
||||||
},
|
},
|
||||||
MessageType::VerificationRequest(_) => {
|
MessageType::VerificationRequest(_) => {
|
||||||
format!("{sender_name} sent a verification request.")
|
format!("{sender_name} sent a verification request.")
|
||||||
},
|
},
|
||||||
_ => unimplemented!(),
|
_ => {
|
||||||
|
format!("[Unknown message type: {:?}]", &message.msgtype)
|
||||||
|
},
|
||||||
};
|
};
|
||||||
Some(body)
|
Some(body)
|
||||||
},
|
},
|
||||||
@@ -230,7 +303,7 @@ pub fn event_notification_body(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn truncate(s: String) -> String {
|
fn truncate(s: String) -> String {
|
||||||
static MAX_LENGTH: usize = 100;
|
static MAX_LENGTH: usize = 5000;
|
||||||
if s.graphemes(true).count() > MAX_LENGTH {
|
if s.graphemes(true).count() > MAX_LENGTH {
|
||||||
let truncated: String = s.graphemes(true).take(MAX_LENGTH).collect();
|
let truncated: String = s.graphemes(true).take(MAX_LENGTH).collect();
|
||||||
truncated + "..."
|
truncated + "..."
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ use std::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use matrix_sdk::{
|
use matrix_sdk::{
|
||||||
media::{MediaFormat, MediaRequest},
|
media::{MediaFormat, MediaRequestParameters},
|
||||||
ruma::{
|
ruma::{
|
||||||
events::{
|
events::{
|
||||||
room::{
|
room::{
|
||||||
@@ -63,7 +63,7 @@ pub fn spawn_insert_preview(
|
|||||||
let img = download_or_load(event_id.to_owned(), source, media, cache_dir)
|
let img = download_or_load(event_id.to_owned(), source, media, cache_dir)
|
||||||
.await
|
.await
|
||||||
.map(std::io::Cursor::new)
|
.map(std::io::Cursor::new)
|
||||||
.map(image::io::Reader::new)
|
.map(image::ImageReader::new)
|
||||||
.map_err(IambError::Matrix)
|
.map_err(IambError::Matrix)
|
||||||
.and_then(|reader| reader.with_guessed_format().map_err(IambError::IOError))
|
.and_then(|reader| reader.with_guessed_format().map_err(IambError::IOError))
|
||||||
.and_then(|reader| reader.decode().map_err(IambError::Image));
|
.and_then(|reader| reader.decode().map_err(IambError::Image));
|
||||||
@@ -100,7 +100,7 @@ pub fn spawn_insert_preview(
|
|||||||
})
|
})
|
||||||
.and_then(|(picker, msg, image_preview)| {
|
.and_then(|(picker, msg, image_preview)| {
|
||||||
picker
|
picker
|
||||||
.new_protocol(img, image_preview.size.into(), Resize::Fit)
|
.new_protocol(img, image_preview.size.into(), Resize::Fit(None))
|
||||||
.map_err(|err| IambError::Preview(format!("{err:?}")))
|
.map_err(|err| IambError::Preview(format!("{err:?}")))
|
||||||
.map(|backend| (backend, msg))
|
.map(|backend| (backend, msg))
|
||||||
}) {
|
}) {
|
||||||
@@ -157,7 +157,10 @@ async fn download_or_load(
|
|||||||
},
|
},
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
media
|
media
|
||||||
.get_media_content(&MediaRequest { source, format: MediaFormat::File }, true)
|
.get_media_content(
|
||||||
|
&MediaRequestParameters { source, format: MediaFormat::File },
|
||||||
|
true,
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.and_then(|buffer| {
|
.and_then(|buffer| {
|
||||||
if let Err(err) =
|
if let Err(err) =
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ pub fn mock_keys() -> HashMap<OwnedEventId, EventLocation> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn mock_messages() -> Messages {
|
pub fn mock_messages() -> Messages {
|
||||||
let mut messages = Messages::default();
|
let mut messages = Messages::main();
|
||||||
|
|
||||||
messages.insert(MSG1_KEY.clone(), mock_message1());
|
messages.insert(MSG1_KEY.clone(), mock_message1());
|
||||||
messages.insert(MSG2_KEY.clone(), mock_message2());
|
messages.insert(MSG2_KEY.clone(), mock_message2());
|
||||||
@@ -171,12 +171,14 @@ pub fn mock_tunables() -> TunableValues {
|
|||||||
default_room: None,
|
default_room: None,
|
||||||
log_level: Level::INFO,
|
log_level: Level::INFO,
|
||||||
message_shortcode_display: false,
|
message_shortcode_display: false,
|
||||||
|
normal_after_send: true,
|
||||||
reaction_display: true,
|
reaction_display: true,
|
||||||
reaction_shortcode_display: false,
|
reaction_shortcode_display: false,
|
||||||
read_receipt_send: true,
|
read_receipt_send: true,
|
||||||
read_receipt_display: true,
|
read_receipt_display: true,
|
||||||
request_timeout: 120,
|
request_timeout: 120,
|
||||||
sort: SortOverrides::default().values(),
|
sort: SortOverrides::default().values(),
|
||||||
|
state_event_display: true,
|
||||||
typing_notice_send: true,
|
typing_notice_send: true,
|
||||||
typing_notice_display: true,
|
typing_notice_display: true,
|
||||||
users: vec![(TEST_USER5.clone(), UserDisplayTunables {
|
users: vec![(TEST_USER5.clone(), UserDisplayTunables {
|
||||||
@@ -186,15 +188,18 @@ pub fn mock_tunables() -> TunableValues {
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.collect::<HashMap<_, _>>(),
|
.collect::<HashMap<_, _>>(),
|
||||||
open_command: None,
|
open_command: None,
|
||||||
|
external_edit_file_suffix: String::from(".md"),
|
||||||
username_display: UserDisplayStyle::Username,
|
username_display: UserDisplayStyle::Username,
|
||||||
message_user_color: false,
|
message_user_color: false,
|
||||||
|
mouse: Default::default(),
|
||||||
notifications: Notifications {
|
notifications: Notifications {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
via: NotifyVia::Desktop,
|
via: NotifyVia::default(),
|
||||||
show_message: true,
|
show_message: true,
|
||||||
},
|
},
|
||||||
image_preview: None,
|
image_preview: None,
|
||||||
user_gutter_width: 30,
|
user_gutter_width: 30,
|
||||||
|
tabstop: 4,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -128,9 +128,7 @@ pub fn space_text(width: usize, style: Style) -> Text<'static> {
|
|||||||
|
|
||||||
pub fn join_cell_text<'a>(texts: Vec<(Text<'a>, usize)>, join: Span<'a>, style: Style) -> Text<'a> {
|
pub fn join_cell_text<'a>(texts: Vec<(Text<'a>, usize)>, join: Span<'a>, style: Style) -> Text<'a> {
|
||||||
let height = texts.iter().map(|t| t.0.height()).max().unwrap_or(0);
|
let height = texts.iter().map(|t| t.0.height()).max().unwrap_or(0);
|
||||||
let mut text = Text {
|
let mut text = Text::from(vec![Line::from(vec![join.clone()]); height]);
|
||||||
lines: vec![Line::from(vec![join.clone()]); height],
|
|
||||||
};
|
|
||||||
|
|
||||||
for (mut t, w) in texts.into_iter() {
|
for (mut t, w) in texts.into_iter() {
|
||||||
for i in 0..height {
|
for i in 0..height {
|
||||||
|
|||||||
@@ -5,8 +5,9 @@
|
|||||||
//!
|
//!
|
||||||
//! Additionally, some of the iamb commands delegate behaviour to the current UI element. For
|
//! Additionally, some of the iamb commands delegate behaviour to the current UI element. For
|
||||||
//! example, [sending messages][crate::base::SendAction] delegate to the [room window][RoomState],
|
//! example, [sending messages][crate::base::SendAction] delegate to the [room window][RoomState],
|
||||||
//! where we have the message bar and room ID easily accesible and resetable.
|
//! where we have the message bar and room ID easily accessible and resettable.
|
||||||
use std::cmp::{Ord, Ordering, PartialOrd};
|
use std::cmp::{Ord, Ordering, PartialOrd};
|
||||||
|
use std::fmt::{self, Display};
|
||||||
use std::ops::Deref;
|
use std::ops::Deref;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
@@ -22,6 +23,7 @@ use matrix_sdk::{
|
|||||||
RoomAliasId,
|
RoomAliasId,
|
||||||
RoomId,
|
RoomId,
|
||||||
},
|
},
|
||||||
|
RoomState as MatrixRoomState,
|
||||||
};
|
};
|
||||||
|
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
@@ -74,11 +76,13 @@ use crate::base::{
|
|||||||
SortFieldRoom,
|
SortFieldRoom,
|
||||||
SortFieldUser,
|
SortFieldUser,
|
||||||
SortOrder,
|
SortOrder,
|
||||||
|
SpaceAction,
|
||||||
UnreadInfo,
|
UnreadInfo,
|
||||||
};
|
};
|
||||||
|
|
||||||
use self::{room::RoomState, welcome::WelcomeState};
|
use self::{room::RoomState, welcome::WelcomeState};
|
||||||
use crate::message::MessageTimeStamp;
|
use crate::message::MessageTimeStamp;
|
||||||
|
use feruca::Collator;
|
||||||
|
|
||||||
pub mod room;
|
pub mod room;
|
||||||
pub mod welcome;
|
pub mod welcome;
|
||||||
@@ -167,7 +171,12 @@ fn user_cmp(a: &MemberItem, b: &MemberItem, field: &SortFieldUser) -> Ordering {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn room_cmp<T: RoomLikeItem>(a: &T, b: &T, field: &SortFieldRoom) -> Ordering {
|
fn room_cmp<T: RoomLikeItem>(
|
||||||
|
a: &T,
|
||||||
|
b: &T,
|
||||||
|
field: &SortFieldRoom,
|
||||||
|
collator: &mut Collator,
|
||||||
|
) -> Ordering {
|
||||||
match field {
|
match field {
|
||||||
SortFieldRoom::Favorite => {
|
SortFieldRoom::Favorite => {
|
||||||
let fava = a.has_tag(TagName::Favorite);
|
let fava = a.has_tag(TagName::Favorite);
|
||||||
@@ -183,7 +192,7 @@ fn room_cmp<T: RoomLikeItem>(a: &T, b: &T, field: &SortFieldRoom) -> Ordering {
|
|||||||
// If a has LowPriority and b doesn't, it should sort later in room list.
|
// If a has LowPriority and b doesn't, it should sort later in room list.
|
||||||
lowa.cmp(&lowb)
|
lowa.cmp(&lowb)
|
||||||
},
|
},
|
||||||
SortFieldRoom::Name => a.name().cmp(b.name()),
|
SortFieldRoom::Name => collator.collate(a.name(), b.name()),
|
||||||
SortFieldRoom::Alias => some_cmp(a.alias(), b.alias(), Ord::cmp),
|
SortFieldRoom::Alias => some_cmp(a.alias(), b.alias(), Ord::cmp),
|
||||||
SortFieldRoom::RoomId => a.room_id().cmp(b.room_id()),
|
SortFieldRoom::RoomId => a.room_id().cmp(b.room_id()),
|
||||||
SortFieldRoom::Unread => {
|
SortFieldRoom::Unread => {
|
||||||
@@ -194,6 +203,10 @@ fn room_cmp<T: RoomLikeItem>(a: &T, b: &T, field: &SortFieldRoom) -> Ordering {
|
|||||||
// sort larger timestamps towards the top.
|
// sort larger timestamps towards the top.
|
||||||
some_cmp(a.recent_ts(), b.recent_ts(), |a, b| b.cmp(a))
|
some_cmp(a.recent_ts(), b.recent_ts(), |a, b| b.cmp(a))
|
||||||
},
|
},
|
||||||
|
SortFieldRoom::Invite => {
|
||||||
|
// sort invites before other rooms.
|
||||||
|
b.is_invite().cmp(&a.is_invite())
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,9 +215,10 @@ fn room_fields_cmp<T: RoomLikeItem>(
|
|||||||
a: &T,
|
a: &T,
|
||||||
b: &T,
|
b: &T,
|
||||||
fields: &[SortColumn<SortFieldRoom>],
|
fields: &[SortColumn<SortFieldRoom>],
|
||||||
|
collator: &mut Collator,
|
||||||
) -> Ordering {
|
) -> Ordering {
|
||||||
for SortColumn(field, order) in fields {
|
for SortColumn(field, order) in fields {
|
||||||
match (room_cmp(a, b, field), order) {
|
match (room_cmp(a, b, field, collator), order) {
|
||||||
(Ordering::Equal, _) => continue,
|
(Ordering::Equal, _) => continue,
|
||||||
(o, SortOrder::Ascending) => return o,
|
(o, SortOrder::Ascending) => return o,
|
||||||
(o, SortOrder::Descending) => return o.reverse(),
|
(o, SortOrder::Descending) => return o.reverse(),
|
||||||
@@ -212,7 +226,7 @@ fn room_fields_cmp<T: RoomLikeItem>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Break ties on ascending room id.
|
// Break ties on ascending room id.
|
||||||
room_cmp(a, b, &SortFieldRoom::RoomId)
|
room_cmp(a, b, &SortFieldRoom::RoomId, collator)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn user_fields_cmp(
|
fn user_fields_cmp(
|
||||||
@@ -272,6 +286,7 @@ trait RoomLikeItem {
|
|||||||
fn recent_ts(&self) -> Option<&MessageTimeStamp>;
|
fn recent_ts(&self) -> Option<&MessageTimeStamp>;
|
||||||
fn alias(&self) -> Option<&RoomAliasId>;
|
fn alias(&self) -> Option<&RoomAliasId>;
|
||||||
fn name(&self) -> &str;
|
fn name(&self) -> &str;
|
||||||
|
fn is_invite(&self) -> bool;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[inline]
|
#[inline]
|
||||||
@@ -314,6 +329,7 @@ macro_rules! delegate {
|
|||||||
IambWindow::VerifyList($id) => $e,
|
IambWindow::VerifyList($id) => $e,
|
||||||
IambWindow::Welcome($id) => $e,
|
IambWindow::Welcome($id) => $e,
|
||||||
IambWindow::ChatList($id) => $e,
|
IambWindow::ChatList($id) => $e,
|
||||||
|
IambWindow::UnreadList($id) => $e,
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -327,6 +343,7 @@ pub enum IambWindow {
|
|||||||
SpaceList(SpaceListState),
|
SpaceList(SpaceListState),
|
||||||
Welcome(WelcomeState),
|
Welcome(WelcomeState),
|
||||||
ChatList(ChatListState),
|
ChatList(ChatListState),
|
||||||
|
UnreadList(UnreadListState),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl IambWindow {
|
impl IambWindow {
|
||||||
@@ -351,6 +368,19 @@ impl IambWindow {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn space_command(
|
||||||
|
&mut self,
|
||||||
|
act: SpaceAction,
|
||||||
|
ctx: ProgramContext,
|
||||||
|
store: &mut ProgramStore,
|
||||||
|
) -> IambResult<EditInfo> {
|
||||||
|
if let IambWindow::Room(w) = self {
|
||||||
|
w.space_command(act, ctx, store).await
|
||||||
|
} else {
|
||||||
|
return Err(IambError::NoSelectedRoom.into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn room_command(
|
pub async fn room_command(
|
||||||
&mut self,
|
&mut self,
|
||||||
act: RoomAction,
|
act: RoomAction,
|
||||||
@@ -382,6 +412,7 @@ pub type DirectListState = ListState<DirectItem, IambInfo>;
|
|||||||
pub type MemberListState = ListState<MemberItem, IambInfo>;
|
pub type MemberListState = ListState<MemberItem, IambInfo>;
|
||||||
pub type RoomListState = ListState<RoomItem, IambInfo>;
|
pub type RoomListState = ListState<RoomItem, IambInfo>;
|
||||||
pub type ChatListState = ListState<GenericChatItem, IambInfo>;
|
pub type ChatListState = ListState<GenericChatItem, IambInfo>;
|
||||||
|
pub type UnreadListState = ListState<GenericChatItem, IambInfo>;
|
||||||
pub type SpaceListState = ListState<SpaceItem, IambInfo>;
|
pub type SpaceListState = ListState<SpaceItem, IambInfo>;
|
||||||
pub type VerifyListState = ListState<VerifyItem, IambInfo>;
|
pub type VerifyListState = ListState<VerifyItem, IambInfo>;
|
||||||
|
|
||||||
@@ -492,7 +523,8 @@ impl WindowOps<IambInfo> for IambWindow {
|
|||||||
.map(|room_info| DirectItem::new(room_info, store))
|
.map(|room_info| DirectItem::new(room_info, store))
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
let fields = &store.application.settings.tunables.sort.dms;
|
let fields = &store.application.settings.tunables.sort.dms;
|
||||||
items.sort_by(|a, b| room_fields_cmp(a, b, fields));
|
let collator = &mut store.application.collator;
|
||||||
|
items.sort_by(|a, b| room_fields_cmp(a, b, fields, collator));
|
||||||
|
|
||||||
state.set(items);
|
state.set(items);
|
||||||
|
|
||||||
@@ -537,7 +569,8 @@ impl WindowOps<IambInfo> for IambWindow {
|
|||||||
.map(|room_info| RoomItem::new(room_info, store))
|
.map(|room_info| RoomItem::new(room_info, store))
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
let fields = &store.application.settings.tunables.sort.rooms;
|
let fields = &store.application.settings.tunables.sort.rooms;
|
||||||
items.sort_by(|a, b| room_fields_cmp(a, b, fields));
|
let collator = &mut store.application.collator;
|
||||||
|
items.sort_by(|a, b| room_fields_cmp(a, b, fields, collator));
|
||||||
|
|
||||||
state.set(items);
|
state.set(items);
|
||||||
|
|
||||||
@@ -568,7 +601,42 @@ impl WindowOps<IambInfo> for IambWindow {
|
|||||||
items.extend(dms);
|
items.extend(dms);
|
||||||
|
|
||||||
let fields = &store.application.settings.tunables.sort.chats;
|
let fields = &store.application.settings.tunables.sort.chats;
|
||||||
items.sort_by(|a, b| room_fields_cmp(a, b, fields));
|
let collator = &mut store.application.collator;
|
||||||
|
items.sort_by(|a, b| room_fields_cmp(a, b, fields, collator));
|
||||||
|
|
||||||
|
state.set(items);
|
||||||
|
|
||||||
|
List::new(store)
|
||||||
|
.empty_message("You do not have rooms or dms yet")
|
||||||
|
.empty_alignment(Alignment::Center)
|
||||||
|
.focus(focused)
|
||||||
|
.render(area, buf, state);
|
||||||
|
},
|
||||||
|
IambWindow::UnreadList(state) => {
|
||||||
|
let mut items = store
|
||||||
|
.application
|
||||||
|
.sync_info
|
||||||
|
.rooms
|
||||||
|
.clone()
|
||||||
|
.into_iter()
|
||||||
|
.map(|room_info| GenericChatItem::new(room_info, store, false))
|
||||||
|
.filter(RoomLikeItem::is_unread)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
let dms = store
|
||||||
|
.application
|
||||||
|
.sync_info
|
||||||
|
.dms
|
||||||
|
.clone()
|
||||||
|
.into_iter()
|
||||||
|
.map(|room_info| GenericChatItem::new(room_info, store, true))
|
||||||
|
.filter(RoomLikeItem::is_unread);
|
||||||
|
|
||||||
|
items.extend(dms);
|
||||||
|
|
||||||
|
let fields = &store.application.settings.tunables.sort.chats;
|
||||||
|
let collator = &mut store.application.collator;
|
||||||
|
items.sort_by(|a, b| room_fields_cmp(a, b, fields, collator));
|
||||||
|
|
||||||
state.set(items);
|
state.set(items);
|
||||||
|
|
||||||
@@ -588,7 +656,8 @@ impl WindowOps<IambInfo> for IambWindow {
|
|||||||
.map(|room| SpaceItem::new(room, store))
|
.map(|room| SpaceItem::new(room, store))
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
let fields = &store.application.settings.tunables.sort.spaces;
|
let fields = &store.application.settings.tunables.sort.spaces;
|
||||||
items.sort_by(|a, b| room_fields_cmp(a, b, fields));
|
let collator = &mut store.application.collator;
|
||||||
|
items.sort_by(|a, b| room_fields_cmp(a, b, fields, collator));
|
||||||
|
|
||||||
state.set(items);
|
state.set(items);
|
||||||
|
|
||||||
@@ -629,6 +698,7 @@ impl WindowOps<IambInfo> for IambWindow {
|
|||||||
IambWindow::VerifyList(w) => w.dup(store).into(),
|
IambWindow::VerifyList(w) => w.dup(store).into(),
|
||||||
IambWindow::Welcome(w) => w.dup(store).into(),
|
IambWindow::Welcome(w) => w.dup(store).into(),
|
||||||
IambWindow::ChatList(w) => w.dup(store).into(),
|
IambWindow::ChatList(w) => w.dup(store).into(),
|
||||||
|
IambWindow::UnreadList(w) => w.dup(store).into(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -669,6 +739,7 @@ impl Window<IambInfo> for IambWindow {
|
|||||||
IambWindow::VerifyList(_) => IambId::VerifyList,
|
IambWindow::VerifyList(_) => IambId::VerifyList,
|
||||||
IambWindow::Welcome(_) => IambId::Welcome,
|
IambWindow::Welcome(_) => IambId::Welcome,
|
||||||
IambWindow::ChatList(_) => IambId::ChatList,
|
IambWindow::ChatList(_) => IambId::ChatList,
|
||||||
|
IambWindow::UnreadList(_) => IambId::UnreadList,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -680,6 +751,7 @@ impl Window<IambInfo> for IambWindow {
|
|||||||
IambWindow::VerifyList(_) => bold_spans("Verifications"),
|
IambWindow::VerifyList(_) => bold_spans("Verifications"),
|
||||||
IambWindow::Welcome(_) => bold_spans("Welcome to iamb"),
|
IambWindow::Welcome(_) => bold_spans("Welcome to iamb"),
|
||||||
IambWindow::ChatList(_) => bold_spans("DMs & Rooms"),
|
IambWindow::ChatList(_) => bold_spans("DMs & Rooms"),
|
||||||
|
IambWindow::UnreadList(_) => bold_spans("Unread Messages"),
|
||||||
|
|
||||||
IambWindow::Room(w) => {
|
IambWindow::Room(w) => {
|
||||||
let title = store.application.get_room_title(w.id());
|
let title = store.application.get_room_title(w.id());
|
||||||
@@ -707,6 +779,7 @@ impl Window<IambInfo> for IambWindow {
|
|||||||
IambWindow::VerifyList(_) => bold_spans("Verifications"),
|
IambWindow::VerifyList(_) => bold_spans("Verifications"),
|
||||||
IambWindow::Welcome(_) => bold_spans("Welcome to iamb"),
|
IambWindow::Welcome(_) => bold_spans("Welcome to iamb"),
|
||||||
IambWindow::ChatList(_) => bold_spans("DMs & Rooms"),
|
IambWindow::ChatList(_) => bold_spans("DMs & Rooms"),
|
||||||
|
IambWindow::UnreadList(_) => bold_spans("Unread Messages"),
|
||||||
|
|
||||||
IambWindow::Room(w) => w.get_title(store),
|
IambWindow::Room(w) => w.get_title(store),
|
||||||
IambWindow::MemberList(state, room_id, _) => {
|
IambWindow::MemberList(state, room_id, _) => {
|
||||||
@@ -768,6 +841,11 @@ impl Window<IambInfo> for IambWindow {
|
|||||||
|
|
||||||
Ok(list.into())
|
Ok(list.into())
|
||||||
},
|
},
|
||||||
|
IambId::UnreadList => {
|
||||||
|
let list = UnreadListState::new(IambBufferId::UnreadList, vec![]);
|
||||||
|
|
||||||
|
Ok(IambWindow::UnreadList(list))
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -820,7 +898,7 @@ impl GenericChatItem {
|
|||||||
let name = info.name.clone().unwrap_or_default();
|
let name = info.name.clone().unwrap_or_default();
|
||||||
let alias = room.canonical_alias();
|
let alias = room.canonical_alias();
|
||||||
let unread = info.unreads(&store.application.settings);
|
let unread = info.unreads(&store.application.settings);
|
||||||
info.tags = room_info.deref().1.clone();
|
info.tags.clone_from(&room_info.deref().1);
|
||||||
|
|
||||||
if let Some(alias) = &alias {
|
if let Some(alias) = &alias {
|
||||||
store.application.names.insert(alias.to_string(), room_id.to_owned());
|
store.application.names.insert(alias.to_string(), room_id.to_owned());
|
||||||
@@ -868,11 +946,15 @@ impl RoomLikeItem for GenericChatItem {
|
|||||||
fn is_unread(&self) -> bool {
|
fn is_unread(&self) -> bool {
|
||||||
self.unread.is_unread()
|
self.unread.is_unread()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn is_invite(&self) -> bool {
|
||||||
|
self.room().state() == MatrixRoomState::Invited
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ToString for GenericChatItem {
|
impl Display for GenericChatItem {
|
||||||
fn to_string(&self) -> String {
|
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||||
return self.name.clone();
|
write!(f, "{}", self.name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -930,7 +1012,7 @@ impl RoomItem {
|
|||||||
let name = info.name.clone().unwrap_or_default();
|
let name = info.name.clone().unwrap_or_default();
|
||||||
let alias = room.canonical_alias();
|
let alias = room.canonical_alias();
|
||||||
let unread = info.unreads(&store.application.settings);
|
let unread = info.unreads(&store.application.settings);
|
||||||
info.tags = room_info.deref().1.clone();
|
info.tags.clone_from(&room_info.deref().1);
|
||||||
|
|
||||||
if let Some(alias) = &alias {
|
if let Some(alias) = &alias {
|
||||||
store.application.names.insert(alias.to_string(), room_id.to_owned());
|
store.application.names.insert(alias.to_string(), room_id.to_owned());
|
||||||
@@ -978,11 +1060,15 @@ impl RoomLikeItem for RoomItem {
|
|||||||
fn is_unread(&self) -> bool {
|
fn is_unread(&self) -> bool {
|
||||||
self.unread.is_unread()
|
self.unread.is_unread()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn is_invite(&self) -> bool {
|
||||||
|
self.room().state() == MatrixRoomState::Invited
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ToString for RoomItem {
|
impl Display for RoomItem {
|
||||||
fn to_string(&self) -> String {
|
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||||
return self.name.clone();
|
write!(f, "{}", self.name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1034,7 +1120,7 @@ impl DirectItem {
|
|||||||
let info = store.application.rooms.get_or_default(room_id);
|
let info = store.application.rooms.get_or_default(room_id);
|
||||||
let name = info.name.clone().unwrap_or_default();
|
let name = info.name.clone().unwrap_or_default();
|
||||||
let unread = info.unreads(&store.application.settings);
|
let unread = info.unreads(&store.application.settings);
|
||||||
info.tags = room_info.deref().1.clone();
|
info.tags.clone_from(&room_info.deref().1);
|
||||||
|
|
||||||
DirectItem { room_info, name, alias, unread }
|
DirectItem { room_info, name, alias, unread }
|
||||||
}
|
}
|
||||||
@@ -1078,11 +1164,15 @@ impl RoomLikeItem for DirectItem {
|
|||||||
fn is_unread(&self) -> bool {
|
fn is_unread(&self) -> bool {
|
||||||
self.unread.is_unread()
|
self.unread.is_unread()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn is_invite(&self) -> bool {
|
||||||
|
self.room().state() == MatrixRoomState::Invited
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ToString for DirectItem {
|
impl Display for DirectItem {
|
||||||
fn to_string(&self) -> String {
|
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||||
return self.name.clone();
|
write!(f, ":verify request {}", self.name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1177,11 +1267,15 @@ impl RoomLikeItem for SpaceItem {
|
|||||||
// XXX: this needs to check whether the space contains rooms with unread messages
|
// XXX: this needs to check whether the space contains rooms with unread messages
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn is_invite(&self) -> bool {
|
||||||
|
self.room().state() == MatrixRoomState::Invited
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ToString for SpaceItem {
|
impl Display for SpaceItem {
|
||||||
fn to_string(&self) -> String {
|
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||||
return self.room_id().to_string();
|
write!(f, "{}", self.name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1300,16 +1394,18 @@ impl From<(&String, &SasVerification)> for VerifyItem {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ToString for VerifyItem {
|
impl Display for VerifyItem {
|
||||||
fn to_string(&self) -> String {
|
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||||
if self.sasv1.is_done() {
|
if self.sasv1.is_done() {
|
||||||
String::new()
|
return Ok(());
|
||||||
} else if self.sasv1.is_cancelled() {
|
}
|
||||||
format!(":verify request {}", self.sasv1.other_user_id())
|
|
||||||
|
if self.sasv1.is_cancelled() {
|
||||||
|
write!(f, ":verify request {}", self.sasv1.other_user_id())
|
||||||
} else if self.sasv1.emoji().is_some() {
|
} else if self.sasv1.emoji().is_some() {
|
||||||
format!(":verify confirm {}", self.user_dev)
|
write!(f, ":verify confirm {}", self.user_dev)
|
||||||
} else {
|
} else {
|
||||||
format!(":verify accept {}", self.user_dev)
|
write!(f, ":verify accept {}", self.user_dev)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1368,7 +1464,7 @@ impl ListItem<IambInfo> for VerifyItem {
|
|||||||
]));
|
]));
|
||||||
}
|
}
|
||||||
|
|
||||||
Text { lines }
|
Text::from(lines)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_word(&self) -> Option<String> {
|
fn get_word(&self) -> Option<String> {
|
||||||
@@ -1413,9 +1509,9 @@ impl MemberItem {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ToString for MemberItem {
|
impl Display for MemberItem {
|
||||||
fn to_string(&self) -> String {
|
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||||
self.member.user_id().to_string()
|
write!(f, "{}", self.member.user_id())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1508,6 +1604,7 @@ mod tests {
|
|||||||
alias: Option<OwnedRoomAliasId>,
|
alias: Option<OwnedRoomAliasId>,
|
||||||
name: &'static str,
|
name: &'static str,
|
||||||
unread: UnreadInfo,
|
unread: UnreadInfo,
|
||||||
|
invite: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RoomLikeItem for &TestRoomItem {
|
impl RoomLikeItem for &TestRoomItem {
|
||||||
@@ -1534,10 +1631,16 @@ mod tests {
|
|||||||
fn is_unread(&self) -> bool {
|
fn is_unread(&self) -> bool {
|
||||||
self.unread.is_unread()
|
self.unread.is_unread()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn is_invite(&self) -> bool {
|
||||||
|
self.invite
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_sort_rooms() {
|
fn test_sort_rooms() {
|
||||||
|
let mut collator = Collator::default();
|
||||||
|
let collator = &mut collator;
|
||||||
let server = server_name!("example.com");
|
let server = server_name!("example.com");
|
||||||
|
|
||||||
let room1 = TestRoomItem {
|
let room1 = TestRoomItem {
|
||||||
@@ -1546,6 +1649,7 @@ mod tests {
|
|||||||
alias: Some(room_alias_id!("#room1:example.com").to_owned()),
|
alias: Some(room_alias_id!("#room1:example.com").to_owned()),
|
||||||
name: "Z",
|
name: "Z",
|
||||||
unread: UnreadInfo::default(),
|
unread: UnreadInfo::default(),
|
||||||
|
invite: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
let room2 = TestRoomItem {
|
let room2 = TestRoomItem {
|
||||||
@@ -1554,6 +1658,7 @@ mod tests {
|
|||||||
alias: Some(room_alias_id!("#a:example.com").to_owned()),
|
alias: Some(room_alias_id!("#a:example.com").to_owned()),
|
||||||
name: "Unnamed Room",
|
name: "Unnamed Room",
|
||||||
unread: UnreadInfo::default(),
|
unread: UnreadInfo::default(),
|
||||||
|
invite: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
let room3 = TestRoomItem {
|
let room3 = TestRoomItem {
|
||||||
@@ -1562,18 +1667,19 @@ mod tests {
|
|||||||
alias: None,
|
alias: None,
|
||||||
name: "Cool Room",
|
name: "Cool Room",
|
||||||
unread: UnreadInfo::default(),
|
unread: UnreadInfo::default(),
|
||||||
|
invite: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Sort by Name ascending.
|
// Sort by Name ascending.
|
||||||
let mut rooms = vec![&room1, &room2, &room3];
|
let mut rooms = vec![&room1, &room2, &room3];
|
||||||
let fields = &[SortColumn(SortFieldRoom::Name, SortOrder::Ascending)];
|
let fields = &[SortColumn(SortFieldRoom::Name, SortOrder::Ascending)];
|
||||||
rooms.sort_by(|a, b| room_fields_cmp(a, b, fields));
|
rooms.sort_by(|a, b| room_fields_cmp(a, b, fields, collator));
|
||||||
assert_eq!(rooms, vec![&room3, &room2, &room1]);
|
assert_eq!(rooms, vec![&room3, &room2, &room1]);
|
||||||
|
|
||||||
// Sort by Name descending.
|
// Sort by Name descending.
|
||||||
let mut rooms = vec![&room1, &room2, &room3];
|
let mut rooms = vec![&room1, &room2, &room3];
|
||||||
let fields = &[SortColumn(SortFieldRoom::Name, SortOrder::Descending)];
|
let fields = &[SortColumn(SortFieldRoom::Name, SortOrder::Descending)];
|
||||||
rooms.sort_by(|a, b| room_fields_cmp(a, b, fields));
|
rooms.sort_by(|a, b| room_fields_cmp(a, b, fields, collator));
|
||||||
assert_eq!(rooms, vec![&room1, &room2, &room3]);
|
assert_eq!(rooms, vec![&room1, &room2, &room3]);
|
||||||
|
|
||||||
// Sort by Favorite and Alias before Name to show order matters.
|
// Sort by Favorite and Alias before Name to show order matters.
|
||||||
@@ -1583,7 +1689,7 @@ mod tests {
|
|||||||
SortColumn(SortFieldRoom::Alias, SortOrder::Ascending),
|
SortColumn(SortFieldRoom::Alias, SortOrder::Ascending),
|
||||||
SortColumn(SortFieldRoom::Name, SortOrder::Ascending),
|
SortColumn(SortFieldRoom::Name, SortOrder::Ascending),
|
||||||
];
|
];
|
||||||
rooms.sort_by(|a, b| room_fields_cmp(a, b, fields));
|
rooms.sort_by(|a, b| room_fields_cmp(a, b, fields, collator));
|
||||||
assert_eq!(rooms, vec![&room1, &room2, &room3]);
|
assert_eq!(rooms, vec![&room1, &room2, &room3]);
|
||||||
|
|
||||||
// Now flip order of Favorite with Descending
|
// Now flip order of Favorite with Descending
|
||||||
@@ -1593,12 +1699,14 @@ mod tests {
|
|||||||
SortColumn(SortFieldRoom::Alias, SortOrder::Ascending),
|
SortColumn(SortFieldRoom::Alias, SortOrder::Ascending),
|
||||||
SortColumn(SortFieldRoom::Name, SortOrder::Ascending),
|
SortColumn(SortFieldRoom::Name, SortOrder::Ascending),
|
||||||
];
|
];
|
||||||
rooms.sort_by(|a, b| room_fields_cmp(a, b, fields));
|
rooms.sort_by(|a, b| room_fields_cmp(a, b, fields, collator));
|
||||||
assert_eq!(rooms, vec![&room2, &room3, &room1]);
|
assert_eq!(rooms, vec![&room2, &room3, &room1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_sort_room_recents() {
|
fn test_sort_room_recents() {
|
||||||
|
let mut collator = Collator::default();
|
||||||
|
let collator = &mut collator;
|
||||||
let server = server_name!("example.com");
|
let server = server_name!("example.com");
|
||||||
|
|
||||||
let room1 = TestRoomItem {
|
let room1 = TestRoomItem {
|
||||||
@@ -1607,6 +1715,7 @@ mod tests {
|
|||||||
alias: None,
|
alias: None,
|
||||||
name: "Room 1",
|
name: "Room 1",
|
||||||
unread: UnreadInfo { unread: false, latest: None },
|
unread: UnreadInfo { unread: false, latest: None },
|
||||||
|
invite: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
let room2 = TestRoomItem {
|
let room2 = TestRoomItem {
|
||||||
@@ -1618,6 +1727,7 @@ mod tests {
|
|||||||
unread: false,
|
unread: false,
|
||||||
latest: Some(MessageTimeStamp::OriginServer(40u32.into())),
|
latest: Some(MessageTimeStamp::OriginServer(40u32.into())),
|
||||||
},
|
},
|
||||||
|
invite: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
let room3 = TestRoomItem {
|
let room3 = TestRoomItem {
|
||||||
@@ -1629,18 +1739,71 @@ mod tests {
|
|||||||
unread: false,
|
unread: false,
|
||||||
latest: Some(MessageTimeStamp::OriginServer(20u32.into())),
|
latest: Some(MessageTimeStamp::OriginServer(20u32.into())),
|
||||||
},
|
},
|
||||||
|
invite: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Sort by Recent ascending.
|
// Sort by Recent ascending.
|
||||||
let mut rooms = vec![&room1, &room2, &room3];
|
let mut rooms = vec![&room1, &room2, &room3];
|
||||||
let fields = &[SortColumn(SortFieldRoom::Recent, SortOrder::Ascending)];
|
let fields = &[SortColumn(SortFieldRoom::Recent, SortOrder::Ascending)];
|
||||||
rooms.sort_by(|a, b| room_fields_cmp(a, b, fields));
|
rooms.sort_by(|a, b| room_fields_cmp(a, b, fields, collator));
|
||||||
assert_eq!(rooms, vec![&room2, &room3, &room1]);
|
assert_eq!(rooms, vec![&room2, &room3, &room1]);
|
||||||
|
|
||||||
// Sort by Recent descending.
|
// Sort by Recent descending.
|
||||||
let mut rooms = vec![&room1, &room2, &room3];
|
let mut rooms = vec![&room1, &room2, &room3];
|
||||||
let fields = &[SortColumn(SortFieldRoom::Recent, SortOrder::Descending)];
|
let fields = &[SortColumn(SortFieldRoom::Recent, SortOrder::Descending)];
|
||||||
rooms.sort_by(|a, b| room_fields_cmp(a, b, fields));
|
rooms.sort_by(|a, b| room_fields_cmp(a, b, fields, collator));
|
||||||
assert_eq!(rooms, vec![&room1, &room3, &room2]);
|
assert_eq!(rooms, vec![&room1, &room3, &room2]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sort_room_invites() {
|
||||||
|
let mut collator = Collator::default();
|
||||||
|
let collator = &mut collator;
|
||||||
|
let server = server_name!("example.com");
|
||||||
|
|
||||||
|
let room1 = TestRoomItem {
|
||||||
|
room_id: RoomId::new(server).to_owned(),
|
||||||
|
tags: vec![],
|
||||||
|
alias: None,
|
||||||
|
name: "Old room 1",
|
||||||
|
unread: UnreadInfo::default(),
|
||||||
|
invite: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let room2 = TestRoomItem {
|
||||||
|
room_id: RoomId::new(server).to_owned(),
|
||||||
|
tags: vec![],
|
||||||
|
alias: None,
|
||||||
|
name: "Old room 2",
|
||||||
|
unread: UnreadInfo::default(),
|
||||||
|
invite: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let room3 = TestRoomItem {
|
||||||
|
room_id: RoomId::new(server).to_owned(),
|
||||||
|
tags: vec![],
|
||||||
|
alias: None,
|
||||||
|
name: "New Fancy Room",
|
||||||
|
unread: UnreadInfo::default(),
|
||||||
|
invite: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sort invites first
|
||||||
|
let mut rooms = vec![&room1, &room2, &room3];
|
||||||
|
let fields = &[
|
||||||
|
SortColumn(SortFieldRoom::Invite, SortOrder::Ascending),
|
||||||
|
SortColumn(SortFieldRoom::Name, SortOrder::Ascending),
|
||||||
|
];
|
||||||
|
rooms.sort_by(|a, b| room_fields_cmp(a, b, fields, collator));
|
||||||
|
assert_eq!(rooms, vec![&room3, &room1, &room2]);
|
||||||
|
|
||||||
|
// Sort invites after
|
||||||
|
let mut rooms = vec![&room1, &room2, &room3];
|
||||||
|
let fields = &[
|
||||||
|
SortColumn(SortFieldRoom::Invite, SortOrder::Descending),
|
||||||
|
SortColumn(SortFieldRoom::Name, SortOrder::Ascending),
|
||||||
|
];
|
||||||
|
rooms.sort_by(|a, b| room_fields_cmp(a, b, fields, collator));
|
||||||
|
assert_eq!(rooms, vec![&room1, &room2, &room3]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ use std::fs;
|
|||||||
use std::ops::Deref;
|
use std::ops::Deref;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use edit::edit as external_edit;
|
use edit::edit_with_builder as external_edit;
|
||||||
|
use edit::Builder;
|
||||||
use modalkit::editing::store::RegisterError;
|
use modalkit::editing::store::RegisterError;
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
use tokio;
|
use tokio;
|
||||||
@@ -13,7 +14,7 @@ use url::Url;
|
|||||||
|
|
||||||
use matrix_sdk::{
|
use matrix_sdk::{
|
||||||
attachment::AttachmentConfig,
|
attachment::AttachmentConfig,
|
||||||
media::{MediaFormat, MediaRequest},
|
media::{MediaFormat, MediaRequestParameters},
|
||||||
room::Room as MatrixRoom,
|
room::Room as MatrixRoom,
|
||||||
ruma::{
|
ruma::{
|
||||||
events::reaction::ReactionEventContent,
|
events::reaction::ReactionEventContent,
|
||||||
@@ -85,7 +86,14 @@ use crate::base::{
|
|||||||
SendAction,
|
SendAction,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::message::{text_to_message, Message, MessageEvent, MessageKey, MessageTimeStamp};
|
use crate::message::{
|
||||||
|
text_to_message,
|
||||||
|
Message,
|
||||||
|
MessageEvent,
|
||||||
|
MessageKey,
|
||||||
|
MessageTimeStamp,
|
||||||
|
TreeGenState,
|
||||||
|
};
|
||||||
use crate::worker::Requester;
|
use crate::worker::Requester;
|
||||||
|
|
||||||
use super::scrollback::{Scrollback, ScrollbackState};
|
use super::scrollback::{Scrollback, ScrollbackState};
|
||||||
@@ -225,10 +233,14 @@ impl ChatState {
|
|||||||
|
|
||||||
let links = if let Some(html) = &msg.html {
|
let links = if let Some(html) = &msg.html {
|
||||||
html.get_links()
|
html.get_links()
|
||||||
} else if let Ok(url) = Url::parse(&msg.event.body()) {
|
|
||||||
vec![('0', url)]
|
|
||||||
} else {
|
} else {
|
||||||
vec![]
|
linkify::LinkFinder::new()
|
||||||
|
.links(&msg.event.body())
|
||||||
|
.filter_map(|u| Url::parse(u.as_str()).ok())
|
||||||
|
.scan(TreeGenState { link_num: 0 }, |state, u| {
|
||||||
|
state.next_link_char().map(|c| (c, u))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
};
|
};
|
||||||
|
|
||||||
if links.is_empty() {
|
if links.is_empty() {
|
||||||
@@ -275,7 +287,7 @@ impl ChatState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !filename.exists() || flags.contains(DownloadFlags::FORCE) {
|
if !filename.exists() || flags.contains(DownloadFlags::FORCE) {
|
||||||
let req = MediaRequest { source, format: MediaFormat::File };
|
let req = MediaRequestParameters { source, format: MediaFormat::File };
|
||||||
|
|
||||||
let bytes =
|
let bytes =
|
||||||
media.get_media_content(&req, true).await.map_err(IambError::from)?;
|
media.get_media_content(&req, true).await.map_err(IambError::from)?;
|
||||||
@@ -357,13 +369,29 @@ impl ChatState {
|
|||||||
|
|
||||||
Ok(None)
|
Ok(None)
|
||||||
},
|
},
|
||||||
MessageAction::React(emoji) => {
|
MessageAction::React(reaction, literal) => {
|
||||||
|
let emoji = if literal {
|
||||||
|
reaction
|
||||||
|
} else if let Some(emoji) =
|
||||||
|
emojis::get(&reaction).or_else(|| emojis::get_by_shortcode(&reaction))
|
||||||
|
{
|
||||||
|
emoji.to_string()
|
||||||
|
} else {
|
||||||
|
let msg = format!("{reaction:?} is not a known Emoji shortcode; do you want to react with exactly {reaction:?}?");
|
||||||
|
let act = IambAction::Message(MessageAction::React(reaction, true));
|
||||||
|
let prompt = PromptYesNo::new(msg, vec![Action::from(act)]);
|
||||||
|
let prompt = Box::new(prompt);
|
||||||
|
|
||||||
|
return Err(UIError::NeedConfirm(prompt));
|
||||||
|
};
|
||||||
|
|
||||||
let room = self.get_joined(&store.application.worker)?;
|
let room = self.get_joined(&store.application.worker)?;
|
||||||
let event_id = match &msg.event {
|
let event_id = match &msg.event {
|
||||||
MessageEvent::EncryptedOriginal(ev) => ev.event_id.clone(),
|
MessageEvent::EncryptedOriginal(ev) => ev.event_id.clone(),
|
||||||
MessageEvent::EncryptedRedacted(ev) => ev.event_id.clone(),
|
MessageEvent::EncryptedRedacted(ev) => ev.event_id.clone(),
|
||||||
MessageEvent::Original(ev) => ev.event_id.clone(),
|
MessageEvent::Original(ev) => ev.event_id.clone(),
|
||||||
MessageEvent::Local(event_id, _) => event_id.clone(),
|
MessageEvent::Local(event_id, _) => event_id.clone(),
|
||||||
|
MessageEvent::State(ev) => ev.event_id().to_owned(),
|
||||||
MessageEvent::Redacted(_) => {
|
MessageEvent::Redacted(_) => {
|
||||||
let msg = "Cannot react to a redacted message";
|
let msg = "Cannot react to a redacted message";
|
||||||
let err = UIError::Failure(msg.into());
|
let err = UIError::Failure(msg.into());
|
||||||
@@ -372,6 +400,13 @@ impl ChatState {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if info.user_reactions_contains(&settings.profile.user_id, &event_id, &emoji) {
|
||||||
|
let msg = format!("You’ve already reacted to this message with {}", emoji);
|
||||||
|
let err = UIError::Failure(msg);
|
||||||
|
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
|
||||||
let reaction = Annotation::new(event_id, emoji);
|
let reaction = Annotation::new(event_id, emoji);
|
||||||
let msg = ReactionEventContent::new(reaction);
|
let msg = ReactionEventContent::new(reaction);
|
||||||
let _ = room.send(msg).await.map_err(IambError::from)?;
|
let _ = room.send(msg).await.map_err(IambError::from)?;
|
||||||
@@ -394,6 +429,7 @@ impl ChatState {
|
|||||||
MessageEvent::EncryptedRedacted(ev) => ev.event_id.clone(),
|
MessageEvent::EncryptedRedacted(ev) => ev.event_id.clone(),
|
||||||
MessageEvent::Original(ev) => ev.event_id.clone(),
|
MessageEvent::Original(ev) => ev.event_id.clone(),
|
||||||
MessageEvent::Local(event_id, _) => event_id.clone(),
|
MessageEvent::Local(event_id, _) => event_id.clone(),
|
||||||
|
MessageEvent::State(ev) => ev.event_id().to_owned(),
|
||||||
MessageEvent::Redacted(_) => {
|
MessageEvent::Redacted(_) => {
|
||||||
let msg = "Cannot redact already redacted message";
|
let msg = "Cannot redact already redacted message";
|
||||||
let err = UIError::Failure(msg.into());
|
let err = UIError::Failure(msg.into());
|
||||||
@@ -414,13 +450,34 @@ impl ChatState {
|
|||||||
|
|
||||||
Ok(None)
|
Ok(None)
|
||||||
},
|
},
|
||||||
MessageAction::Unreact(emoji) => {
|
MessageAction::Unreact(reaction, literal) => {
|
||||||
|
let emoji = match reaction {
|
||||||
|
reaction if literal => reaction,
|
||||||
|
Some(reaction) => {
|
||||||
|
if let Some(emoji) =
|
||||||
|
emojis::get(&reaction).or_else(|| emojis::get_by_shortcode(&reaction))
|
||||||
|
{
|
||||||
|
Some(emoji.to_string())
|
||||||
|
} else {
|
||||||
|
let msg = format!("{reaction:?} is not a known Emoji shortcode; do you want to remove exactly {reaction:?}?");
|
||||||
|
let act =
|
||||||
|
IambAction::Message(MessageAction::Unreact(Some(reaction), true));
|
||||||
|
let prompt = PromptYesNo::new(msg, vec![Action::from(act)]);
|
||||||
|
let prompt = Box::new(prompt);
|
||||||
|
|
||||||
|
return Err(UIError::NeedConfirm(prompt));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
let room = self.get_joined(&store.application.worker)?;
|
let room = self.get_joined(&store.application.worker)?;
|
||||||
let event_id = match &msg.event {
|
let event_id = match &msg.event {
|
||||||
MessageEvent::EncryptedOriginal(ev) => ev.event_id.clone(),
|
MessageEvent::EncryptedOriginal(ev) => ev.event_id.clone(),
|
||||||
MessageEvent::EncryptedRedacted(ev) => ev.event_id.clone(),
|
MessageEvent::EncryptedRedacted(ev) => ev.event_id.clone(),
|
||||||
MessageEvent::Original(ev) => ev.event_id.clone(),
|
MessageEvent::Original(ev) => ev.event_id.clone(),
|
||||||
MessageEvent::Local(event_id, _) => event_id.clone(),
|
MessageEvent::Local(event_id, _) => event_id.clone(),
|
||||||
|
MessageEvent::State(ev) => ev.event_id().to_owned(),
|
||||||
MessageEvent::Redacted(_) => {
|
MessageEvent::Redacted(_) => {
|
||||||
let msg = "Cannot unreact to a redacted message";
|
let msg = "Cannot unreact to a redacted message";
|
||||||
let err = UIError::Failure(msg.into());
|
let err = UIError::Failure(msg.into());
|
||||||
@@ -474,7 +531,16 @@ impl ChatState {
|
|||||||
let msg = self.tbox.get();
|
let msg = self.tbox.get();
|
||||||
|
|
||||||
let msg = if let SendAction::SubmitFromEditor = act {
|
let msg = if let SendAction::SubmitFromEditor = act {
|
||||||
external_edit(msg.trim_end().to_string())?
|
let suffix =
|
||||||
|
store.application.settings.tunables.external_edit_file_suffix.as_str();
|
||||||
|
let edited_msg =
|
||||||
|
external_edit(msg.trim_end().to_string(), Builder::new().suffix(suffix))?
|
||||||
|
.trim_end()
|
||||||
|
.to_string();
|
||||||
|
if edited_msg.is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
edited_msg
|
||||||
} else if msg.is_blank() {
|
} else if msg.is_blank() {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
} else {
|
} else {
|
||||||
@@ -544,7 +610,7 @@ impl ChatState {
|
|||||||
let dynimage = image::DynamicImage::ImageRgba8(imagebuf);
|
let dynimage = image::DynamicImage::ImageRgba8(imagebuf);
|
||||||
let bytes = Vec::<u8>::new();
|
let bytes = Vec::<u8>::new();
|
||||||
let mut buff = std::io::Cursor::new(bytes);
|
let mut buff = std::io::Cursor::new(bytes);
|
||||||
dynimage.write_to(&mut buff, image::ImageOutputFormat::Png)?;
|
dynimage.write_to(&mut buff, image::ImageFormat::Png)?;
|
||||||
Ok(buff.into_inner())
|
Ok(buff.into_inner())
|
||||||
})
|
})
|
||||||
.map_err(IambError::from)?;
|
.map_err(IambError::from)?;
|
||||||
@@ -554,7 +620,7 @@ impl ChatState {
|
|||||||
let config = AttachmentConfig::new();
|
let config = AttachmentConfig::new();
|
||||||
|
|
||||||
let resp = room
|
let resp = room
|
||||||
.send_attachment(name.as_ref(), &mime, bytes, config)
|
.send_attachment(name, &mime, bytes, config)
|
||||||
.await
|
.await
|
||||||
.map_err(IambError::from)?;
|
.map_err(IambError::from)?;
|
||||||
|
|
||||||
@@ -583,10 +649,7 @@ impl ChatState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn focus_toggle(&mut self) {
|
pub fn focus_toggle(&mut self) {
|
||||||
self.focus = match self.focus {
|
self.focus.toggle();
|
||||||
RoomFocus::Scrollback => RoomFocus::MessageBar,
|
|
||||||
RoomFocus::MessageBar => RoomFocus::Scrollback,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn room(&self) -> &MatrixRoom {
|
pub fn room(&self) -> &MatrixRoom {
|
||||||
@@ -597,6 +660,14 @@ impl ChatState {
|
|||||||
&self.room_id
|
&self.room_id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn auto_toggle_focus(
|
||||||
|
&mut self,
|
||||||
|
act: &EditorAction,
|
||||||
|
ctx: &ProgramContext,
|
||||||
|
) -> Option<EditorAction> {
|
||||||
|
auto_toggle_focus(&mut self.focus, act, ctx, &self.scrollback, &mut self.tbox)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn typing_notice(
|
pub fn typing_notice(
|
||||||
&self,
|
&self,
|
||||||
act: &EditorAction,
|
act: &EditorAction,
|
||||||
@@ -699,8 +770,15 @@ impl Editable<ProgramContext, ProgramStore, IambInfo> for ChatState {
|
|||||||
ctx: &ProgramContext,
|
ctx: &ProgramContext,
|
||||||
store: &mut ProgramStore,
|
store: &mut ProgramStore,
|
||||||
) -> EditResult<EditInfo, IambInfo> {
|
) -> EditResult<EditInfo, IambInfo> {
|
||||||
|
// Check whether we should automatically switch between the message bar
|
||||||
|
// or message scrollback, and use an adjusted action if we do so.
|
||||||
|
let adjusted = self.auto_toggle_focus(act, ctx);
|
||||||
|
let act = adjusted.as_ref().unwrap_or(act);
|
||||||
|
|
||||||
|
// Send typing notice if needed.
|
||||||
self.typing_notice(act, ctx, store);
|
self.typing_notice(act, ctx, store);
|
||||||
|
|
||||||
|
// And now we can finally run the editor command.
|
||||||
match delegate!(self, w => w.editor_command(act, ctx, store)) {
|
match delegate!(self, w => w.editor_command(act, ctx, store)) {
|
||||||
res @ Ok(_) => res,
|
res @ Ok(_) => res,
|
||||||
Err(EditError::WrongBuffer(IambBufferId::Room(room_id, thread, focus)))
|
Err(EditError::WrongBuffer(IambBufferId::Room(room_id, thread, focus)))
|
||||||
@@ -797,16 +875,16 @@ impl PromptActions<ProgramContext, ProgramStore, IambInfo> for ChatState {
|
|||||||
|
|
||||||
fn recall(
|
fn recall(
|
||||||
&mut self,
|
&mut self,
|
||||||
|
filter: &RecallFilter,
|
||||||
dir: &MoveDir1D,
|
dir: &MoveDir1D,
|
||||||
count: &Count,
|
count: &Count,
|
||||||
prefixed: bool,
|
|
||||||
ctx: &ProgramContext,
|
ctx: &ProgramContext,
|
||||||
_: &mut ProgramStore,
|
_: &mut ProgramStore,
|
||||||
) -> EditResult<Vec<(ProgramAction, ProgramContext)>, IambInfo> {
|
) -> EditResult<Vec<(ProgramAction, ProgramContext)>, IambInfo> {
|
||||||
let count = ctx.resolve(count);
|
let count = ctx.resolve(count);
|
||||||
let rope = self.tbox.get();
|
let rope = self.tbox.get();
|
||||||
|
|
||||||
let text = self.sent.recall(&rope, &mut self.sent_scrollback, *dir, prefixed, count);
|
let text = self.sent.recall(&rope, &mut self.sent_scrollback, filter, *dir, count);
|
||||||
|
|
||||||
if let Some(text) = text {
|
if let Some(text) = text {
|
||||||
self.tbox.set_text(text);
|
self.tbox.set_text(text);
|
||||||
@@ -830,9 +908,7 @@ impl Promptable<ProgramContext, ProgramStore, IambInfo> for ChatState {
|
|||||||
match act {
|
match act {
|
||||||
PromptAction::Submit => self.submit(ctx, store),
|
PromptAction::Submit => self.submit(ctx, store),
|
||||||
PromptAction::Abort(empty) => self.abort(*empty, ctx, store),
|
PromptAction::Abort(empty) => self.abort(*empty, ctx, store),
|
||||||
PromptAction::Recall(dir, count, prefixed) => {
|
PromptAction::Recall(filter, dir, count) => self.recall(filter, dir, count, ctx, store),
|
||||||
self.recall(dir, count, *prefixed, ctx, store)
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -854,7 +930,7 @@ impl<'a> Chat<'a> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> StatefulWidget for Chat<'a> {
|
impl StatefulWidget for Chat<'_> {
|
||||||
type State = ChatState;
|
type State = ChatState;
|
||||||
|
|
||||||
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
|
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
|
||||||
@@ -938,3 +1014,158 @@ fn cmd(open_command: &Vec<String>) -> Option<Command> {
|
|||||||
}
|
}
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn auto_toggle_focus(
|
||||||
|
focus: &mut RoomFocus,
|
||||||
|
act: &EditorAction,
|
||||||
|
ctx: &ProgramContext,
|
||||||
|
scrollback: &ScrollbackState,
|
||||||
|
tbox: &mut TextBoxState<IambInfo>,
|
||||||
|
) -> Option<EditorAction> {
|
||||||
|
let is_insert = ctx.get_insert_style().is_some();
|
||||||
|
|
||||||
|
match (focus, act) {
|
||||||
|
(f @ RoomFocus::Scrollback, _) if is_insert => {
|
||||||
|
// Insert mode commands should switch focus.
|
||||||
|
f.toggle();
|
||||||
|
None
|
||||||
|
},
|
||||||
|
(f @ RoomFocus::Scrollback, EditorAction::InsertText(_)) => {
|
||||||
|
// Pasting or otherwise inserting text should switch.
|
||||||
|
f.toggle();
|
||||||
|
None
|
||||||
|
},
|
||||||
|
(
|
||||||
|
f @ RoomFocus::Scrollback,
|
||||||
|
EditorAction::Edit(
|
||||||
|
op,
|
||||||
|
EditTarget::Motion(mov @ MoveType::Line(MoveDir1D::Next), count),
|
||||||
|
),
|
||||||
|
) if ctx.resolve(op).is_motion() => {
|
||||||
|
let count = ctx.resolve(count);
|
||||||
|
|
||||||
|
if count > 0 && scrollback.is_latest() {
|
||||||
|
// Trying to move down a line when already at the end of room history should
|
||||||
|
// switch.
|
||||||
|
f.toggle();
|
||||||
|
|
||||||
|
// And decrement the count for the action.
|
||||||
|
let count = count.saturating_sub(1).into();
|
||||||
|
let target = EditTarget::Motion(mov.clone(), count);
|
||||||
|
let dec = EditorAction::Edit(op.clone(), target);
|
||||||
|
|
||||||
|
Some(dec)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(
|
||||||
|
f @ RoomFocus::MessageBar,
|
||||||
|
EditorAction::Edit(
|
||||||
|
op,
|
||||||
|
EditTarget::Motion(mov @ MoveType::Line(MoveDir1D::Previous), count),
|
||||||
|
),
|
||||||
|
) if !is_insert && ctx.resolve(op).is_motion() => {
|
||||||
|
let count = ctx.resolve(count);
|
||||||
|
|
||||||
|
if count > 0 && tbox.get_cursor().y == 0 {
|
||||||
|
// Trying to move up a line when already at the top of the msgbar should
|
||||||
|
// switch as long as we're not in Insert mode.
|
||||||
|
f.toggle();
|
||||||
|
|
||||||
|
// And decrement the count for the action.
|
||||||
|
let count = count.saturating_sub(1).into();
|
||||||
|
let target = EditTarget::Motion(mov.clone(), count);
|
||||||
|
let dec = EditorAction::Edit(op.clone(), target);
|
||||||
|
|
||||||
|
Some(dec)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(RoomFocus::Scrollback, _) | (RoomFocus::MessageBar, _) => {
|
||||||
|
// Do not switch.
|
||||||
|
None
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
use modalkit::actions::{EditAction, InsertTextAction};
|
||||||
|
|
||||||
|
use crate::tests::{mock_store, TEST_ROOM1_ID};
|
||||||
|
|
||||||
|
macro_rules! move_line {
|
||||||
|
($dir: expr, $count: expr) => {
|
||||||
|
EditorAction::Edit(
|
||||||
|
EditAction::Motion.into(),
|
||||||
|
EditTarget::Motion(MoveType::Line($dir), $count.into()),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_auto_focus() {
|
||||||
|
let mut store = mock_store().await;
|
||||||
|
let ctx = ProgramContext::default();
|
||||||
|
|
||||||
|
let room_id = TEST_ROOM1_ID.clone();
|
||||||
|
let scrollback = ScrollbackState::new(room_id.clone(), None);
|
||||||
|
|
||||||
|
let id = IambBufferId::Room(room_id, None, RoomFocus::MessageBar);
|
||||||
|
let ebuf = store.load_buffer(id);
|
||||||
|
let mut tbox = TextBoxState::new(ebuf);
|
||||||
|
|
||||||
|
// Start out focused on the scrollback.
|
||||||
|
let mut focused = RoomFocus::Scrollback;
|
||||||
|
|
||||||
|
// Inserting text toggles:
|
||||||
|
let act = EditorAction::InsertText(InsertTextAction::Type(
|
||||||
|
Char::from('a').into(),
|
||||||
|
MoveDir1D::Next,
|
||||||
|
1.into(),
|
||||||
|
));
|
||||||
|
let res = auto_toggle_focus(&mut focused, &act, &ctx, &scrollback, &mut tbox);
|
||||||
|
assert_eq!(focused, RoomFocus::MessageBar);
|
||||||
|
assert!(res.is_none());
|
||||||
|
|
||||||
|
// Going down in message bar doesn't toggle:
|
||||||
|
let act = move_line!(MoveDir1D::Next, 1);
|
||||||
|
let res = auto_toggle_focus(&mut focused, &act, &ctx, &scrollback, &mut tbox);
|
||||||
|
assert_eq!(focused, RoomFocus::MessageBar);
|
||||||
|
assert!(res.is_none());
|
||||||
|
|
||||||
|
// But going up will:
|
||||||
|
let act = move_line!(MoveDir1D::Previous, 1);
|
||||||
|
let res = auto_toggle_focus(&mut focused, &act, &ctx, &scrollback, &mut tbox);
|
||||||
|
assert_eq!(focused, RoomFocus::Scrollback);
|
||||||
|
assert_eq!(res, Some(move_line!(MoveDir1D::Previous, 0)));
|
||||||
|
|
||||||
|
// Going up in scrollback doesn't toggle:
|
||||||
|
let act = move_line!(MoveDir1D::Previous, 1);
|
||||||
|
let res = auto_toggle_focus(&mut focused, &act, &ctx, &scrollback, &mut tbox);
|
||||||
|
assert_eq!(focused, RoomFocus::Scrollback);
|
||||||
|
assert_eq!(res, None);
|
||||||
|
|
||||||
|
// And then go back down:
|
||||||
|
let act = move_line!(MoveDir1D::Next, 1);
|
||||||
|
let res = auto_toggle_focus(&mut focused, &act, &ctx, &scrollback, &mut tbox);
|
||||||
|
assert_eq!(focused, RoomFocus::MessageBar);
|
||||||
|
assert_eq!(res, Some(move_line!(MoveDir1D::Next, 0)));
|
||||||
|
|
||||||
|
// Go up 2 will go up 1 in scrollback:
|
||||||
|
let act = move_line!(MoveDir1D::Previous, 2);
|
||||||
|
let res = auto_toggle_focus(&mut focused, &act, &ctx, &scrollback, &mut tbox);
|
||||||
|
assert_eq!(focused, RoomFocus::Scrollback);
|
||||||
|
assert_eq!(res, Some(move_line!(MoveDir1D::Previous, 1)));
|
||||||
|
|
||||||
|
// Go down 3 will go down 2 in messagebar:
|
||||||
|
let act = move_line!(MoveDir1D::Next, 3);
|
||||||
|
let res = auto_toggle_focus(&mut focused, &act, &ctx, &scrollback, &mut tbox);
|
||||||
|
assert_eq!(focused, RoomFocus::MessageBar);
|
||||||
|
assert_eq!(res, Some(move_line!(MoveDir1D::Next, 2)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,15 +1,32 @@
|
|||||||
//! # Windows for Matrix rooms and spaces
|
//! # Windows for Matrix rooms and spaces
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
use matrix_sdk::{
|
use matrix_sdk::{
|
||||||
|
notification_settings::RoomNotificationMode,
|
||||||
room::Room as MatrixRoom,
|
room::Room as MatrixRoom,
|
||||||
ruma::{
|
ruma::{
|
||||||
|
api::client::{
|
||||||
|
alias::{
|
||||||
|
create_alias::v3::Request as CreateAliasRequest,
|
||||||
|
delete_alias::v3::Request as DeleteAliasRequest,
|
||||||
|
},
|
||||||
|
error::ErrorKind as ClientApiErrorKind,
|
||||||
|
},
|
||||||
events::{
|
events::{
|
||||||
room::{name::RoomNameEventContent, topic::RoomTopicEventContent},
|
room::{
|
||||||
|
canonical_alias::RoomCanonicalAliasEventContent,
|
||||||
|
history_visibility::{HistoryVisibility, RoomHistoryVisibilityEventContent},
|
||||||
|
name::RoomNameEventContent,
|
||||||
|
topic::RoomTopicEventContent,
|
||||||
|
},
|
||||||
tag::{TagInfo, Tags},
|
tag::{TagInfo, Tags},
|
||||||
},
|
},
|
||||||
OwnedEventId,
|
OwnedEventId,
|
||||||
|
OwnedRoomAliasId,
|
||||||
|
OwnedUserId,
|
||||||
RoomId,
|
RoomId,
|
||||||
},
|
},
|
||||||
DisplayName,
|
RoomDisplayName,
|
||||||
RoomState as MatrixRoomState,
|
RoomState as MatrixRoomState,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -41,6 +58,7 @@ use crate::base::{
|
|||||||
IambId,
|
IambId,
|
||||||
IambInfo,
|
IambInfo,
|
||||||
IambResult,
|
IambResult,
|
||||||
|
MemberUpdateAction,
|
||||||
MessageAction,
|
MessageAction,
|
||||||
ProgramAction,
|
ProgramAction,
|
||||||
ProgramContext,
|
ProgramContext,
|
||||||
@@ -48,11 +66,14 @@ use crate::base::{
|
|||||||
RoomAction,
|
RoomAction,
|
||||||
RoomField,
|
RoomField,
|
||||||
SendAction,
|
SendAction,
|
||||||
|
SpaceAction,
|
||||||
};
|
};
|
||||||
|
|
||||||
use self::chat::ChatState;
|
use self::chat::ChatState;
|
||||||
use self::space::{Space, SpaceState};
|
use self::space::{Space, SpaceState};
|
||||||
|
|
||||||
|
use std::convert::TryFrom;
|
||||||
|
|
||||||
mod chat;
|
mod chat;
|
||||||
mod scrollback;
|
mod scrollback;
|
||||||
mod space;
|
mod space;
|
||||||
@@ -66,6 +87,33 @@ macro_rules! delegate {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn notification_mode(name: impl Into<String>) -> IambResult<RoomNotificationMode> {
|
||||||
|
let name = name.into();
|
||||||
|
|
||||||
|
let mode = match name.to_lowercase().as_str() {
|
||||||
|
"mute" => RoomNotificationMode::Mute,
|
||||||
|
"mentions" | "keywords" => RoomNotificationMode::MentionsAndKeywordsOnly,
|
||||||
|
"all" => RoomNotificationMode::AllMessages,
|
||||||
|
_ => return Err(IambError::InvalidNotificationLevel(name).into()),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hist_visibility_mode(name: impl Into<String>) -> IambResult<HistoryVisibility> {
|
||||||
|
let name = name.into();
|
||||||
|
|
||||||
|
let mode = match name.to_lowercase().as_str() {
|
||||||
|
"invited" => HistoryVisibility::Invited,
|
||||||
|
"joined" => HistoryVisibility::Joined,
|
||||||
|
"shared" => HistoryVisibility::Shared,
|
||||||
|
"world" | "world_readable" => HistoryVisibility::WorldReadable,
|
||||||
|
_ => return Err(IambError::InvalidHistoryVisibility(name).into()),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(mode)
|
||||||
|
}
|
||||||
|
|
||||||
/// State for a Matrix room or space.
|
/// State for a Matrix room or space.
|
||||||
///
|
///
|
||||||
/// Since spaces function as special rooms within Matrix, we wrap their window state together, so
|
/// Since spaces function as special rooms within Matrix, we wrap their window state together, so
|
||||||
@@ -92,7 +140,7 @@ impl RoomState {
|
|||||||
pub fn new(
|
pub fn new(
|
||||||
room: MatrixRoom,
|
room: MatrixRoom,
|
||||||
thread: Option<OwnedEventId>,
|
thread: Option<OwnedEventId>,
|
||||||
name: DisplayName,
|
name: RoomDisplayName,
|
||||||
tags: Option<Tags>,
|
tags: Option<Tags>,
|
||||||
store: &mut ProgramStore,
|
store: &mut ProgramStore,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
@@ -148,7 +196,7 @@ impl RoomState {
|
|||||||
let l2 = Line::from(
|
let l2 = Line::from(
|
||||||
"You can run `:invite accept` or `:invite reject` to accept or reject this invitation.",
|
"You can run `:invite accept` or `:invite reject` to accept or reject this invitation.",
|
||||||
);
|
);
|
||||||
let text = Text { lines: vec![l1, l2] };
|
let text = Text::from(vec![l1, l2]);
|
||||||
|
|
||||||
Paragraph::new(text).alignment(Alignment::Center).render(area, buf);
|
Paragraph::new(text).alignment(Alignment::Center).render(area, buf);
|
||||||
|
|
||||||
@@ -167,6 +215,18 @@ impl RoomState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn space_command(
|
||||||
|
&mut self,
|
||||||
|
act: SpaceAction,
|
||||||
|
ctx: ProgramContext,
|
||||||
|
store: &mut ProgramStore,
|
||||||
|
) -> IambResult<EditInfo> {
|
||||||
|
match self {
|
||||||
|
RoomState::Space(space) => space.space_command(act, ctx, store).await,
|
||||||
|
RoomState::Chat(_) => Err(IambError::NoSelectedSpace.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn send_command(
|
pub async fn send_command(
|
||||||
&mut self,
|
&mut self,
|
||||||
act: SendAction,
|
act: SendAction,
|
||||||
@@ -182,7 +242,7 @@ impl RoomState {
|
|||||||
pub async fn room_command(
|
pub async fn room_command(
|
||||||
&mut self,
|
&mut self,
|
||||||
act: RoomAction,
|
act: RoomAction,
|
||||||
_: ProgramContext,
|
ctx: ProgramContext,
|
||||||
store: &mut ProgramStore,
|
store: &mut ProgramStore,
|
||||||
) -> IambResult<Vec<(Action<IambInfo>, ProgramContext)>> {
|
) -> IambResult<Vec<(Action<IambInfo>, ProgramContext)>> {
|
||||||
match act {
|
match act {
|
||||||
@@ -239,6 +299,47 @@ impl RoomState {
|
|||||||
Err(IambError::NotJoined.into())
|
Err(IambError::NotJoined.into())
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
RoomAction::MemberUpdate(mua, user, reason, skip_confirm) => {
|
||||||
|
let Some(room) = store.application.worker.client.get_room(self.id()) else {
|
||||||
|
return Err(IambError::NotJoined.into());
|
||||||
|
};
|
||||||
|
|
||||||
|
let Ok(user_id) = OwnedUserId::try_from(user.as_str()) else {
|
||||||
|
let err = IambError::InvalidUserId(user);
|
||||||
|
|
||||||
|
return Err(err.into());
|
||||||
|
};
|
||||||
|
|
||||||
|
if !skip_confirm {
|
||||||
|
let msg = format!("Do you really want to {mua} {user} from this room?");
|
||||||
|
let act = RoomAction::MemberUpdate(mua, user, reason, true);
|
||||||
|
let act = IambAction::from(act);
|
||||||
|
let prompt = PromptYesNo::new(msg, vec![Action::from(act)]);
|
||||||
|
let prompt = Box::new(prompt);
|
||||||
|
|
||||||
|
return Err(UIError::NeedConfirm(prompt));
|
||||||
|
}
|
||||||
|
|
||||||
|
match mua {
|
||||||
|
MemberUpdateAction::Ban => {
|
||||||
|
room.ban_user(&user_id, reason.as_deref())
|
||||||
|
.await
|
||||||
|
.map_err(IambError::from)?;
|
||||||
|
},
|
||||||
|
MemberUpdateAction::Unban => {
|
||||||
|
room.unban_user(&user_id, reason.as_deref())
|
||||||
|
.await
|
||||||
|
.map_err(IambError::from)?;
|
||||||
|
},
|
||||||
|
MemberUpdateAction::Kick => {
|
||||||
|
room.kick_user(&user_id, reason.as_deref())
|
||||||
|
.await
|
||||||
|
.map_err(IambError::from)?;
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(vec![])
|
||||||
|
},
|
||||||
RoomAction::Members(mut cmd) => {
|
RoomAction::Members(mut cmd) => {
|
||||||
let width = Count::Exact(30);
|
let width = Count::Exact(30);
|
||||||
let act =
|
let act =
|
||||||
@@ -249,6 +350,16 @@ impl RoomState {
|
|||||||
|
|
||||||
Ok(vec![(act, cmd.context.clone())])
|
Ok(vec![(act, cmd.context.clone())])
|
||||||
},
|
},
|
||||||
|
RoomAction::SetDirect(is_direct) => {
|
||||||
|
let room = store
|
||||||
|
.application
|
||||||
|
.get_joined_room(self.id())
|
||||||
|
.ok_or(UIError::Application(IambError::NotJoined))?;
|
||||||
|
|
||||||
|
room.set_is_direct(is_direct).await.map_err(IambError::from)?;
|
||||||
|
|
||||||
|
Ok(vec![])
|
||||||
|
},
|
||||||
RoomAction::Set(field, value) => {
|
RoomAction::Set(field, value) => {
|
||||||
let room = store
|
let room = store
|
||||||
.application
|
.application
|
||||||
@@ -256,6 +367,11 @@ impl RoomState {
|
|||||||
.ok_or(UIError::Application(IambError::NotJoined))?;
|
.ok_or(UIError::Application(IambError::NotJoined))?;
|
||||||
|
|
||||||
match field {
|
match field {
|
||||||
|
RoomField::History => {
|
||||||
|
let visibility = hist_visibility_mode(value)?;
|
||||||
|
let ev = RoomHistoryVisibilityEventContent::new(visibility);
|
||||||
|
let _ = room.send_state_event(ev).await.map_err(IambError::from)?;
|
||||||
|
},
|
||||||
RoomField::Name => {
|
RoomField::Name => {
|
||||||
let ev = RoomNameEventContent::new(value);
|
let ev = RoomNameEventContent::new(value);
|
||||||
let _ = room.send_state_event(ev).await.map_err(IambError::from)?;
|
let _ = room.send_state_event(ev).await.map_err(IambError::from)?;
|
||||||
@@ -270,6 +386,100 @@ impl RoomState {
|
|||||||
let ev = RoomTopicEventContent::new(value);
|
let ev = RoomTopicEventContent::new(value);
|
||||||
let _ = room.send_state_event(ev).await.map_err(IambError::from)?;
|
let _ = room.send_state_event(ev).await.map_err(IambError::from)?;
|
||||||
},
|
},
|
||||||
|
RoomField::NotificationMode => {
|
||||||
|
let mode = notification_mode(value)?;
|
||||||
|
let client = &store.application.worker.client;
|
||||||
|
let notifications = client.notification_settings().await;
|
||||||
|
|
||||||
|
notifications
|
||||||
|
.set_room_notification_mode(self.id(), mode)
|
||||||
|
.await
|
||||||
|
.map_err(IambError::from)?;
|
||||||
|
},
|
||||||
|
RoomField::CanonicalAlias => {
|
||||||
|
let client = &mut store.application.worker.client;
|
||||||
|
|
||||||
|
let Ok(orai) = OwnedRoomAliasId::try_from(value.as_str()) else {
|
||||||
|
let err = IambError::InvalidRoomAlias(value);
|
||||||
|
|
||||||
|
return Err(err.into());
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut alt_aliases =
|
||||||
|
room.alt_aliases().into_iter().collect::<HashSet<_>>();
|
||||||
|
let canonical_old = room.canonical_alias();
|
||||||
|
|
||||||
|
// If the room's alias is already that, ignore it
|
||||||
|
if canonical_old.as_ref() == Some(&orai) {
|
||||||
|
let msg = format!("The canonical room alias is already {orai}");
|
||||||
|
|
||||||
|
return Ok(vec![(Action::ShowInfoMessage(msg.into()), ctx)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try creating the room alias on the server.
|
||||||
|
let alias_create_req =
|
||||||
|
CreateAliasRequest::new(orai.clone(), room.room_id().into());
|
||||||
|
if let Err(e) = client.send(alias_create_req).await {
|
||||||
|
if let Some(ClientApiErrorKind::Unknown) = e.client_api_error_kind() {
|
||||||
|
// Ignore when it already exists.
|
||||||
|
} else {
|
||||||
|
return Err(IambError::from(e).into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Demote the previous one to an alt alias.
|
||||||
|
alt_aliases.extend(canonical_old);
|
||||||
|
|
||||||
|
// At this point the room alias definitely exists, and we can update the
|
||||||
|
// state event.
|
||||||
|
let mut ev = RoomCanonicalAliasEventContent::new();
|
||||||
|
ev.alias = Some(orai);
|
||||||
|
ev.alt_aliases = alt_aliases.into_iter().collect();
|
||||||
|
let _ = room.send_state_event(ev).await.map_err(IambError::from)?;
|
||||||
|
},
|
||||||
|
RoomField::Alias(alias) => {
|
||||||
|
let client = &mut store.application.worker.client;
|
||||||
|
|
||||||
|
let Ok(orai) = OwnedRoomAliasId::try_from(alias.as_str()) else {
|
||||||
|
let err = IambError::InvalidRoomAlias(alias);
|
||||||
|
|
||||||
|
return Err(err.into());
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut alt_aliases =
|
||||||
|
room.alt_aliases().into_iter().collect::<HashSet<_>>();
|
||||||
|
let canonical = room.canonical_alias();
|
||||||
|
|
||||||
|
if alt_aliases.contains(&orai) || canonical.as_ref() == Some(&orai) {
|
||||||
|
let msg = format!("The alias {orai} already maps to this room");
|
||||||
|
|
||||||
|
return Ok(vec![(Action::ShowInfoMessage(msg.into()), ctx)]);
|
||||||
|
} else {
|
||||||
|
alt_aliases.insert(orai.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the room alias does not exist on the server, create it
|
||||||
|
let alias_create_req = CreateAliasRequest::new(orai, room.room_id().into());
|
||||||
|
if let Err(e) = client.send(alias_create_req).await {
|
||||||
|
if let Some(ClientApiErrorKind::Unknown) = e.client_api_error_kind() {
|
||||||
|
// Ignore when it already exists.
|
||||||
|
} else {
|
||||||
|
return Err(IambError::from(e).into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// And add it to the aliases in the state event.
|
||||||
|
let mut ev = RoomCanonicalAliasEventContent::new();
|
||||||
|
ev.alias = canonical;
|
||||||
|
ev.alt_aliases = alt_aliases.into_iter().collect();
|
||||||
|
let _ = room.send_state_event(ev).await.map_err(IambError::from)?;
|
||||||
|
},
|
||||||
|
RoomField::Aliases => {
|
||||||
|
// This never happens, aliases is only used for showing
|
||||||
|
},
|
||||||
|
RoomField::Id => {
|
||||||
|
// This never happens, id is only used for showing
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(vec![])
|
Ok(vec![])
|
||||||
@@ -281,6 +491,11 @@ impl RoomState {
|
|||||||
.ok_or(UIError::Application(IambError::NotJoined))?;
|
.ok_or(UIError::Application(IambError::NotJoined))?;
|
||||||
|
|
||||||
match field {
|
match field {
|
||||||
|
RoomField::History => {
|
||||||
|
let visibility = HistoryVisibility::Joined;
|
||||||
|
let ev = RoomHistoryVisibilityEventContent::new(visibility);
|
||||||
|
let _ = room.send_state_event(ev).await.map_err(IambError::from)?;
|
||||||
|
},
|
||||||
RoomField::Name => {
|
RoomField::Name => {
|
||||||
let ev = RoomNameEventContent::new("".into());
|
let ev = RoomNameEventContent::new("".into());
|
||||||
let _ = room.send_state_event(ev).await.map_err(IambError::from)?;
|
let _ = room.send_state_event(ev).await.map_err(IambError::from)?;
|
||||||
@@ -292,10 +507,154 @@ impl RoomState {
|
|||||||
let ev = RoomTopicEventContent::new("".into());
|
let ev = RoomTopicEventContent::new("".into());
|
||||||
let _ = room.send_state_event(ev).await.map_err(IambError::from)?;
|
let _ = room.send_state_event(ev).await.map_err(IambError::from)?;
|
||||||
},
|
},
|
||||||
|
RoomField::NotificationMode => {
|
||||||
|
let client = &store.application.worker.client;
|
||||||
|
let notifications = client.notification_settings().await;
|
||||||
|
|
||||||
|
notifications
|
||||||
|
.delete_user_defined_room_rules(self.id())
|
||||||
|
.await
|
||||||
|
.map_err(IambError::from)?;
|
||||||
|
},
|
||||||
|
RoomField::CanonicalAlias => {
|
||||||
|
let Some(alias_to_destroy) = room.canonical_alias() else {
|
||||||
|
let msg = "This room has no canonical alias to unset";
|
||||||
|
|
||||||
|
return Ok(vec![(Action::ShowInfoMessage(msg.into()), ctx)]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Remove the canonical alias from the state event.
|
||||||
|
let mut ev = RoomCanonicalAliasEventContent::new();
|
||||||
|
ev.alias = None;
|
||||||
|
ev.alt_aliases = room.alt_aliases();
|
||||||
|
let _ = room.send_state_event(ev).await.map_err(IambError::from)?;
|
||||||
|
|
||||||
|
// And then unmap it on the server.
|
||||||
|
let del_req = DeleteAliasRequest::new(alias_to_destroy);
|
||||||
|
let _ = store
|
||||||
|
.application
|
||||||
|
.worker
|
||||||
|
.client
|
||||||
|
.send(del_req)
|
||||||
|
.await
|
||||||
|
.map_err(IambError::from)?;
|
||||||
|
},
|
||||||
|
RoomField::Alias(alias) => {
|
||||||
|
let Ok(orai) = OwnedRoomAliasId::try_from(alias.as_str()) else {
|
||||||
|
let err = IambError::InvalidRoomAlias(alias);
|
||||||
|
|
||||||
|
return Err(err.into());
|
||||||
|
};
|
||||||
|
|
||||||
|
let alt_aliases = room.alt_aliases();
|
||||||
|
let canonical = room.canonical_alias();
|
||||||
|
|
||||||
|
if !alt_aliases.contains(&orai) && canonical.as_ref() != Some(&orai) {
|
||||||
|
let msg = format!("The alias {orai:?} isn't mapped to this room");
|
||||||
|
|
||||||
|
return Ok(vec![(Action::ShowInfoMessage(msg.into()), ctx)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the alias from the state event if it's in it.
|
||||||
|
let mut ev = RoomCanonicalAliasEventContent::new();
|
||||||
|
ev.alias = canonical.filter(|canon| canon != &orai);
|
||||||
|
ev.alt_aliases = alt_aliases;
|
||||||
|
ev.alt_aliases.retain(|in_orai| in_orai != &orai);
|
||||||
|
let _ = room.send_state_event(ev).await.map_err(IambError::from)?;
|
||||||
|
|
||||||
|
// And then unmap it on the server.
|
||||||
|
let del_req = DeleteAliasRequest::new(orai);
|
||||||
|
let _ = store
|
||||||
|
.application
|
||||||
|
.worker
|
||||||
|
.client
|
||||||
|
.send(del_req)
|
||||||
|
.await
|
||||||
|
.map_err(IambError::from)?;
|
||||||
|
},
|
||||||
|
RoomField::Aliases => {
|
||||||
|
// This will not happen, you cannot unset all aliases
|
||||||
|
},
|
||||||
|
RoomField::Id => {
|
||||||
|
// This never happens, id is only used for showing
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(vec![])
|
Ok(vec![])
|
||||||
},
|
},
|
||||||
|
RoomAction::Show(field) => {
|
||||||
|
let room = store
|
||||||
|
.application
|
||||||
|
.get_joined_room(self.id())
|
||||||
|
.ok_or(UIError::Application(IambError::NotJoined))?;
|
||||||
|
|
||||||
|
let msg = match field {
|
||||||
|
RoomField::History => {
|
||||||
|
let visibility = room.history_visibility();
|
||||||
|
let visibility = visibility.as_ref().map(|v| v.as_str());
|
||||||
|
format!("Room history visibility: {}", visibility.unwrap_or("<unknown>"))
|
||||||
|
},
|
||||||
|
RoomField::Id => {
|
||||||
|
let id = room.room_id();
|
||||||
|
format!("Room identifier: {id}")
|
||||||
|
},
|
||||||
|
RoomField::Name => {
|
||||||
|
match room.name() {
|
||||||
|
None => "Room has no name".into(),
|
||||||
|
Some(name) => format!("Room name: {name:?}"),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
RoomField::Topic => {
|
||||||
|
match room.topic() {
|
||||||
|
None => "Room has no topic".into(),
|
||||||
|
Some(topic) => format!("Room topic: {topic:?}"),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
RoomField::NotificationMode => {
|
||||||
|
let client = &store.application.worker.client;
|
||||||
|
let notifications = client.notification_settings().await;
|
||||||
|
let mode =
|
||||||
|
notifications.get_user_defined_room_notification_mode(self.id()).await;
|
||||||
|
|
||||||
|
let level = match mode {
|
||||||
|
Some(RoomNotificationMode::Mute) => "mute",
|
||||||
|
Some(RoomNotificationMode::MentionsAndKeywordsOnly) => "keywords",
|
||||||
|
Some(RoomNotificationMode::AllMessages) => "all",
|
||||||
|
None => "default",
|
||||||
|
};
|
||||||
|
|
||||||
|
format!("Room notification level: {level:?}")
|
||||||
|
},
|
||||||
|
RoomField::Aliases => {
|
||||||
|
let aliases = room
|
||||||
|
.alt_aliases()
|
||||||
|
.iter()
|
||||||
|
.map(OwnedRoomAliasId::to_string)
|
||||||
|
.collect::<Vec<String>>();
|
||||||
|
|
||||||
|
if aliases.is_empty() {
|
||||||
|
"No alternative aliases in room".into()
|
||||||
|
} else {
|
||||||
|
format!("Alternative aliases: {}.", aliases.join(", "))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
RoomField::CanonicalAlias => {
|
||||||
|
match room.canonical_alias() {
|
||||||
|
None => "No canonical alias for room".into(),
|
||||||
|
Some(can) => format!("Canonical alias: {can}"),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
RoomField::Tag(_) => "Cannot currently show value for a tag".into(),
|
||||||
|
RoomField::Alias(_) => {
|
||||||
|
"Cannot show a single alias; use `:room aliases show` instead.".into()
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let msg = InfoMessage::Pager(msg);
|
||||||
|
let act = Action::ShowInfoMessage(msg);
|
||||||
|
|
||||||
|
Ok(vec![(act, ctx)])
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -462,3 +821,27 @@ impl WindowOps<IambInfo> for RoomState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_room_notification_level() {
|
||||||
|
let tests = vec![
|
||||||
|
("mute", RoomNotificationMode::Mute),
|
||||||
|
("mentions", RoomNotificationMode::MentionsAndKeywordsOnly),
|
||||||
|
("keywords", RoomNotificationMode::MentionsAndKeywordsOnly),
|
||||||
|
("all", RoomNotificationMode::AllMessages),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (input, expect) in tests {
|
||||||
|
let res = notification_mode(input).unwrap();
|
||||||
|
assert_eq!(expect, res);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(notification_mode("invalid").is_err());
|
||||||
|
assert!(notification_mode("not a level").is_err());
|
||||||
|
assert!(notification_mode("@user:example.com").is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -79,14 +79,20 @@ fn nth_key_before(pos: MessageKey, n: usize, thread: &Messages) -> MessageKey {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn nth_before(pos: MessageKey, n: usize, thread: &Messages) -> MessageCursor {
|
fn nth_before(pos: MessageKey, n: usize, thread: &Messages) -> MessageCursor {
|
||||||
nth_key_before(pos, n, thread).into()
|
let key = nth_key_before(pos, n, thread);
|
||||||
|
|
||||||
|
if matches!(thread.last_key_value(), Some((last, _)) if &key == last) {
|
||||||
|
MessageCursor::latest()
|
||||||
|
} else {
|
||||||
|
MessageCursor::from(key)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn nth_key_after(pos: MessageKey, n: usize, thread: &Messages) -> MessageKey {
|
fn nth_key_after(pos: MessageKey, n: usize, thread: &Messages) -> Option<MessageKey> {
|
||||||
let mut end = &pos;
|
let mut end = &pos;
|
||||||
let iter = thread.range(&pos..).enumerate();
|
let mut iter = thread.range(&pos..).enumerate();
|
||||||
|
|
||||||
for (i, (key, _)) in iter {
|
for (i, (key, _)) in iter.by_ref() {
|
||||||
end = key;
|
end = key;
|
||||||
|
|
||||||
if i >= n {
|
if i >= n {
|
||||||
@@ -94,11 +100,12 @@ fn nth_key_after(pos: MessageKey, n: usize, thread: &Messages) -> MessageKey {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
end.clone()
|
// Avoid returning the key if it's at the end.
|
||||||
|
iter.next().map(|_| end.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn nth_after(pos: MessageKey, n: usize, thread: &Messages) -> MessageCursor {
|
fn nth_after(pos: MessageKey, n: usize, thread: &Messages) -> MessageCursor {
|
||||||
nth_key_after(pos, n, thread).into()
|
nth_key_after(pos, n, thread).map(MessageCursor::from).unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn prevmsg<'a>(key: &MessageKey, thread: &'a Messages) -> Option<&'a Message> {
|
fn prevmsg<'a>(key: &MessageKey, thread: &'a Messages) -> Option<&'a Message> {
|
||||||
@@ -150,6 +157,10 @@ impl ScrollbackState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn is_latest(&self) -> bool {
|
||||||
|
self.cursor.timestamp.is_none()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn goto_latest(&mut self) {
|
pub fn goto_latest(&mut self) {
|
||||||
self.cursor = MessageCursor::latest();
|
self.cursor = MessageCursor::latest();
|
||||||
}
|
}
|
||||||
@@ -673,8 +684,7 @@ impl EditorActions<ProgramContext, ProgramStore, IambInfo> for ScrollbackState {
|
|||||||
let dir = ctx.get_search_regex_dir();
|
let dir = ctx.get_search_regex_dir();
|
||||||
let dir = flip.resolve(&dir);
|
let dir = flip.resolve(&dir);
|
||||||
|
|
||||||
let lsearch = store.registers.get(&Register::LastSearch)?;
|
let lsearch = store.registers.get_last_search().to_string();
|
||||||
let lsearch = lsearch.value.to_string();
|
|
||||||
let needle = Regex::new(lsearch.as_ref())?;
|
let needle = Regex::new(lsearch.as_ref())?;
|
||||||
|
|
||||||
let (mc, needs_load) = self.find_message(key, dir, &needle, count, info);
|
let (mc, needs_load) = self.find_message(key, dir, &needle, count, info);
|
||||||
@@ -753,8 +763,7 @@ impl EditorActions<ProgramContext, ProgramStore, IambInfo> for ScrollbackState {
|
|||||||
let dir = ctx.get_search_regex_dir();
|
let dir = ctx.get_search_regex_dir();
|
||||||
let dir = flip.resolve(&dir);
|
let dir = flip.resolve(&dir);
|
||||||
|
|
||||||
let lsearch = store.registers.get(&Register::LastSearch)?;
|
let lsearch = store.registers.get_last_search().to_string();
|
||||||
let lsearch = lsearch.value.to_string();
|
|
||||||
let needle = Regex::new(lsearch.as_ref())?;
|
let needle = Regex::new(lsearch.as_ref())?;
|
||||||
|
|
||||||
let (mc, needs_load) = self.find_message(key, dir, &needle, count, info);
|
let (mc, needs_load) = self.find_message(key, dir, &needle, count, info);
|
||||||
@@ -831,8 +840,8 @@ impl EditorActions<ProgramContext, ProgramStore, IambInfo> for ScrollbackState {
|
|||||||
|
|
||||||
fn complete(
|
fn complete(
|
||||||
&mut self,
|
&mut self,
|
||||||
|
_: &CompletionStyle,
|
||||||
_: &CompletionType,
|
_: &CompletionType,
|
||||||
_: &CompletionSelection,
|
|
||||||
_: &CompletionDisplay,
|
_: &CompletionDisplay,
|
||||||
_: &ProgramContext,
|
_: &ProgramContext,
|
||||||
_: &mut ProgramStore,
|
_: &mut ProgramStore,
|
||||||
@@ -1286,7 +1295,7 @@ impl<'a> Scrollback<'a> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> StatefulWidget for Scrollback<'a> {
|
impl StatefulWidget for Scrollback<'_> {
|
||||||
type State = ScrollbackState;
|
type State = ScrollbackState;
|
||||||
|
|
||||||
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
|
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
|
||||||
@@ -1342,7 +1351,7 @@ impl<'a> StatefulWidget for Scrollback<'a> {
|
|||||||
|
|
||||||
for (key, item) in thread.range(&corner_key..) {
|
for (key, item) in thread.range(&corner_key..) {
|
||||||
let sel = key == cursor_key;
|
let sel = key == cursor_key;
|
||||||
let (txt, mut msg_preview) =
|
let (txt, [mut msg_preview, mut reply_preview]) =
|
||||||
item.show_with_preview(prev, foc && sel, &state.viewctx, info, settings);
|
item.show_with_preview(prev, foc && sel, &state.viewctx, info, settings);
|
||||||
|
|
||||||
let incomplete_ok = !full || !sel;
|
let incomplete_ok = !full || !sel;
|
||||||
@@ -1359,11 +1368,17 @@ impl<'a> StatefulWidget for Scrollback<'a> {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let line_preview = match msg_preview {
|
|
||||||
// Only take the preview into the matching row number.
|
// Only take the preview into the matching row number.
|
||||||
|
// `reply` and `msg` previews are on rows,
|
||||||
|
// so an `or` works to pick the one that matches (if any)
|
||||||
|
let line_preview = match msg_preview {
|
||||||
Some((_, _, y)) if y as usize == row => msg_preview.take(),
|
Some((_, _, y)) if y as usize == row => msg_preview.take(),
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
}
|
||||||
|
.or(match reply_preview {
|
||||||
|
Some((_, _, y)) if y as usize == row => reply_preview.take(),
|
||||||
|
_ => None,
|
||||||
|
});
|
||||||
|
|
||||||
lines.push((key, row, line, line_preview));
|
lines.push((key, row, line, line_preview));
|
||||||
sawit |= sel;
|
sawit |= sel;
|
||||||
@@ -1398,7 +1413,7 @@ impl<'a> StatefulWidget for Scrollback<'a> {
|
|||||||
// line.
|
// line.
|
||||||
for (x, y, backend) in image_previews {
|
for (x, y, backend) in image_previews {
|
||||||
let image_widget = Image::new(backend);
|
let image_widget = Image::new(backend);
|
||||||
let mut rect = backend.rect();
|
let mut rect = backend.area();
|
||||||
rect.x = x;
|
rect.x = x;
|
||||||
rect.y = y;
|
rect.y = y;
|
||||||
// Don't render outside of scrollback area
|
// Don't render outside of scrollback area
|
||||||
@@ -1413,7 +1428,7 @@ impl<'a> StatefulWidget for Scrollback<'a> {
|
|||||||
{
|
{
|
||||||
// If the cursor is at the last message, then update the read marker.
|
// If the cursor is at the last message, then update the read marker.
|
||||||
if let Some((k, _)) = thread.last_key_value() {
|
if let Some((k, _)) = thread.last_key_value() {
|
||||||
info.set_receipt(settings.profile.user_id.clone(), k.1.clone());
|
info.set_receipt(thread.1.clone(), settings.profile.user_id.clone(), k.1.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1452,16 +1467,16 @@ mod tests {
|
|||||||
// MSG4: "help"
|
// MSG4: "help"
|
||||||
// MSG5: "character"
|
// MSG5: "character"
|
||||||
// MSG1: "writhe"
|
// MSG1: "writhe"
|
||||||
store.set_last_search("he");
|
store.registers.set_last_search("he");
|
||||||
|
|
||||||
assert_eq!(scrollback.cursor, MessageCursor::latest());
|
assert_eq!(scrollback.cursor, MessageCursor::latest());
|
||||||
|
|
||||||
// Search backwards to MSG4.
|
// Search backwards to MSG4.
|
||||||
scrollback.search(prev.clone(), 1.into(), &ctx, &mut store).unwrap();
|
scrollback.search(prev, 1.into(), &ctx, &mut store).unwrap();
|
||||||
assert_eq!(scrollback.cursor, MSG4_KEY.clone().into());
|
assert_eq!(scrollback.cursor, MSG4_KEY.clone().into());
|
||||||
|
|
||||||
// Search backwards to MSG2.
|
// Search backwards to MSG2.
|
||||||
scrollback.search(prev.clone(), 1.into(), &ctx, &mut store).unwrap();
|
scrollback.search(prev, 1.into(), &ctx, &mut store).unwrap();
|
||||||
assert_eq!(scrollback.cursor, MSG2_KEY.clone().into());
|
assert_eq!(scrollback.cursor, MSG2_KEY.clone().into());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
std::mem::take(&mut store.application.need_load)
|
std::mem::take(&mut store.application.need_load)
|
||||||
@@ -1472,7 +1487,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Can't go any further; need_load now contains the room ID.
|
// Can't go any further; need_load now contains the room ID.
|
||||||
scrollback.search(prev.clone(), 1.into(), &ctx, &mut store).unwrap();
|
scrollback.search(prev, 1.into(), &ctx, &mut store).unwrap();
|
||||||
assert_eq!(scrollback.cursor, MSG2_KEY.clone().into());
|
assert_eq!(scrollback.cursor, MSG2_KEY.clone().into());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
std::mem::take(&mut store.application.need_load)
|
std::mem::take(&mut store.application.need_load)
|
||||||
@@ -1482,11 +1497,11 @@ mod tests {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Search forward twice to MSG1.
|
// Search forward twice to MSG1.
|
||||||
scrollback.search(next.clone(), 2.into(), &ctx, &mut store).unwrap();
|
scrollback.search(next, 2.into(), &ctx, &mut store).unwrap();
|
||||||
assert_eq!(scrollback.cursor, MSG1_KEY.clone().into());
|
assert_eq!(scrollback.cursor, MSG1_KEY.clone().into());
|
||||||
|
|
||||||
// Can't go any further.
|
// Can't go any further.
|
||||||
scrollback.search(next.clone(), 2.into(), &ctx, &mut store).unwrap();
|
scrollback.search(next, 2.into(), &ctx, &mut store).unwrap();
|
||||||
assert_eq!(scrollback.cursor, MSG1_KEY.clone().into());
|
assert_eq!(scrollback.cursor, MSG1_KEY.clone().into());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1520,8 +1535,9 @@ mod tests {
|
|||||||
scrollback.edit(&EditAction::Motion, &next(1), &ctx, &mut store).unwrap();
|
scrollback.edit(&EditAction::Motion, &next(1), &ctx, &mut store).unwrap();
|
||||||
assert_eq!(scrollback.cursor, MSG5_KEY.clone().into());
|
assert_eq!(scrollback.cursor, MSG5_KEY.clone().into());
|
||||||
|
|
||||||
|
// And one more becomes "latest" cursor:
|
||||||
scrollback.edit(&EditAction::Motion, &next(1), &ctx, &mut store).unwrap();
|
scrollback.edit(&EditAction::Motion, &next(1), &ctx, &mut store).unwrap();
|
||||||
assert_eq!(scrollback.cursor, MSG1_KEY.clone().into());
|
assert_eq!(scrollback.cursor, MessageCursor::latest());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -1555,7 +1571,7 @@ mod tests {
|
|||||||
// MSG1: | XXXday, Month NN 20XX |
|
// MSG1: | XXXday, Month NN 20XX |
|
||||||
// | @user1:example.com writhe |
|
// | @user1:example.com writhe |
|
||||||
// |------------------------------------------------------------|
|
// |------------------------------------------------------------|
|
||||||
let area = Rect::new(0, 0, 60, 4);
|
let area = Rect::new(0, 0, 60, 5);
|
||||||
let mut buffer = Buffer::empty(area);
|
let mut buffer = Buffer::empty(area);
|
||||||
scrollback.draw(area, &mut buffer, true, &mut store);
|
scrollback.draw(area, &mut buffer, true, &mut store);
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,14 @@
|
|||||||
use std::ops::{Deref, DerefMut};
|
use std::ops::{Deref, DerefMut};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use matrix_sdk::ruma::events::space::child::SpaceChildEventContent;
|
||||||
|
use matrix_sdk::ruma::events::StateEventType;
|
||||||
use matrix_sdk::{
|
use matrix_sdk::{
|
||||||
room::Room as MatrixRoom,
|
room::Room as MatrixRoom,
|
||||||
ruma::{OwnedRoomId, RoomId},
|
ruma::{OwnedRoomId, RoomId},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use modalkit::prelude::{EditInfo, InfoMessage};
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
buffer::Buffer,
|
buffer::Buffer,
|
||||||
layout::Rect,
|
layout::Rect,
|
||||||
@@ -22,9 +25,18 @@ use modalkit_ratatui::{
|
|||||||
WindowOps,
|
WindowOps,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::base::{IambBufferId, IambInfo, ProgramStore, RoomFocus};
|
use crate::base::{
|
||||||
|
IambBufferId,
|
||||||
|
IambError,
|
||||||
|
IambInfo,
|
||||||
|
IambResult,
|
||||||
|
ProgramContext,
|
||||||
|
ProgramStore,
|
||||||
|
RoomFocus,
|
||||||
|
SpaceAction,
|
||||||
|
};
|
||||||
|
|
||||||
use crate::windows::{room_fields_cmp, RoomItem};
|
use crate::windows::{room_fields_cmp, RoomItem, RoomLikeItem};
|
||||||
|
|
||||||
const SPACE_HIERARCHY_DEBOUNCE: Duration = Duration::from_secs(5);
|
const SPACE_HIERARCHY_DEBOUNCE: Duration = Duration::from_secs(5);
|
||||||
|
|
||||||
@@ -68,6 +80,71 @@ impl SpaceState {
|
|||||||
last_fetch: self.last_fetch,
|
last_fetch: self.last_fetch,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn space_command(
|
||||||
|
&mut self,
|
||||||
|
act: SpaceAction,
|
||||||
|
_: ProgramContext,
|
||||||
|
store: &mut ProgramStore,
|
||||||
|
) -> IambResult<EditInfo> {
|
||||||
|
match act {
|
||||||
|
SpaceAction::SetChild(child_id, order, suggested) => {
|
||||||
|
if !self
|
||||||
|
.room
|
||||||
|
.can_user_send_state(
|
||||||
|
&store.application.settings.profile.user_id,
|
||||||
|
StateEventType::SpaceChild,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(IambError::from)?
|
||||||
|
{
|
||||||
|
return Err(IambError::InsufficientPermission.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let via = self.room.route().await.map_err(IambError::from)?;
|
||||||
|
let mut ev = SpaceChildEventContent::new(via);
|
||||||
|
ev.order = order;
|
||||||
|
ev.suggested = suggested;
|
||||||
|
let _ = self
|
||||||
|
.room
|
||||||
|
.send_state_event_for_key(&child_id, ev)
|
||||||
|
.await
|
||||||
|
.map_err(IambError::from)?;
|
||||||
|
|
||||||
|
Ok(InfoMessage::from("Space updated").into())
|
||||||
|
},
|
||||||
|
SpaceAction::RemoveChild => {
|
||||||
|
let space = self.list.get().ok_or(IambError::NoSelectedRoomOrSpaceItem)?;
|
||||||
|
if !self
|
||||||
|
.room
|
||||||
|
.can_user_send_state(
|
||||||
|
&store.application.settings.profile.user_id,
|
||||||
|
StateEventType::SpaceChild,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(IambError::from)?
|
||||||
|
{
|
||||||
|
return Err(IambError::InsufficientPermission.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let ev = SpaceChildEventContent::new(vec![]);
|
||||||
|
let event_id = self
|
||||||
|
.room
|
||||||
|
.send_state_event_for_key(&space.room_id().to_owned(), ev)
|
||||||
|
.await
|
||||||
|
.map_err(IambError::from)?;
|
||||||
|
|
||||||
|
// Fix for element (see https://github.com/element-hq/element-web/issues/29606)
|
||||||
|
let _ = self
|
||||||
|
.room
|
||||||
|
.redact(&event_id.event_id, Some("workaround for element bug"), None)
|
||||||
|
.await
|
||||||
|
.map_err(IambError::from)?;
|
||||||
|
|
||||||
|
Ok(InfoMessage::from("Room removed").into())
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TerminalCursor for SpaceState {
|
impl TerminalCursor for SpaceState {
|
||||||
@@ -107,7 +184,7 @@ impl<'a> Space<'a> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> StatefulWidget for Space<'a> {
|
impl StatefulWidget for Space<'_> {
|
||||||
type State = SpaceState;
|
type State = SpaceState;
|
||||||
|
|
||||||
fn render(self, area: Rect, buffer: &mut Buffer, state: &mut Self::State) {
|
fn render(self, area: Rect, buffer: &mut Buffer, state: &mut Self::State) {
|
||||||
@@ -137,7 +214,8 @@ impl<'a> StatefulWidget for Space<'a> {
|
|||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
let fields = &self.store.application.settings.tunables.sort.rooms;
|
let fields = &self.store.application.settings.tunables.sort.rooms;
|
||||||
items.sort_by(|a, b| room_fields_cmp(a, b, fields));
|
let collator = &mut self.store.application.collator;
|
||||||
|
items.sort_by(|a, b| room_fields_cmp(a, b, fields, collator));
|
||||||
|
|
||||||
state.list.set(items);
|
state.list.set(items);
|
||||||
state.last_fetch = Some(Instant::now());
|
state.last_fetch = Some(Instant::now());
|
||||||
@@ -148,7 +226,7 @@ impl<'a> StatefulWidget for Space<'a> {
|
|||||||
Span::styled(e.to_string(), Style::default().fg(Color::Red)).into(),
|
Span::styled(e.to_string(), Style::default().fg(Color::Red)).into(),
|
||||||
];
|
];
|
||||||
|
|
||||||
empty_message = Text { lines }.into();
|
empty_message = Text::from(lines).into();
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,10 +37,10 @@ The different subcommands are:
|
|||||||
|
|
||||||
## Additional Configuration
|
## Additional Configuration
|
||||||
|
|
||||||
You can customize iamb in your `$CONFIG_DIR/iamb/config.json` file, where
|
You can customize iamb in your `$CONFIG_DIR/iamb/config.toml` file, where
|
||||||
`$CONFIG_DIR` is your system's per-user configuration directory.
|
`$CONFIG_DIR` is your system's per-user configuration directory. For example,
|
||||||
|
this is typically `~/.config/iamb/config.toml` on systems that use the XDG
|
||||||
|
Base Directory Specification.
|
||||||
|
|
||||||
You can edit the following values in the file:
|
See the manual pages or <https://iamb.chat> for more details on how to
|
||||||
|
further configure or use iamb.
|
||||||
- `"default_profile"`, a profile name to use when starting iamb if one wasn't specified
|
|
||||||
- `"cache"`, a directory for cached iamb
|
|
||||||
|
|||||||
129
src/worker.rs
129
src/worker.rs
@@ -20,11 +20,12 @@ use tracing::{error, warn};
|
|||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
use matrix_sdk::{
|
use matrix_sdk::{
|
||||||
|
authentication::matrix::MatrixSession,
|
||||||
config::{RequestConfig, SyncSettings},
|
config::{RequestConfig, SyncSettings},
|
||||||
|
deserialized_responses::DisplayName,
|
||||||
encryption::verification::{SasVerification, Verification},
|
encryption::verification::{SasVerification, Verification},
|
||||||
encryption::{BackupDownloadStrategy, EncryptionSettings},
|
encryption::{BackupDownloadStrategy, EncryptionSettings},
|
||||||
event_handler::Ctx,
|
event_handler::Ctx,
|
||||||
matrix_auth::MatrixSession,
|
|
||||||
reqwest,
|
reqwest,
|
||||||
room::{Messages, MessagesOptions, Room as MatrixRoom, RoomMember},
|
room::{Messages, MessagesOptions, Room as MatrixRoom, RoomMember},
|
||||||
ruma::{
|
ruma::{
|
||||||
@@ -58,6 +59,7 @@ use matrix_sdk::{
|
|||||||
typing::SyncTypingEvent,
|
typing::SyncTypingEvent,
|
||||||
AnyInitialStateEvent,
|
AnyInitialStateEvent,
|
||||||
AnyMessageLikeEvent,
|
AnyMessageLikeEvent,
|
||||||
|
AnySyncStateEvent,
|
||||||
AnyTimelineEvent,
|
AnyTimelineEvent,
|
||||||
EmptyStateKey,
|
EmptyStateKey,
|
||||||
InitialStateEvent,
|
InitialStateEvent,
|
||||||
@@ -78,8 +80,8 @@ use matrix_sdk::{
|
|||||||
},
|
},
|
||||||
Client,
|
Client,
|
||||||
ClientBuildError,
|
ClientBuildError,
|
||||||
DisplayName,
|
|
||||||
Error as MatrixError,
|
Error as MatrixError,
|
||||||
|
RoomDisplayName,
|
||||||
RoomMemberships,
|
RoomMemberships,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -114,8 +116,7 @@ const IAMB_DEVICE_NAME: &str = "iamb";
|
|||||||
const IAMB_USER_AGENT: &str = "iamb";
|
const IAMB_USER_AGENT: &str = "iamb";
|
||||||
const MIN_MSG_LOAD: u32 = 50;
|
const MIN_MSG_LOAD: u32 = 50;
|
||||||
|
|
||||||
type MessageFetchResult =
|
type MessageFetchResult = IambResult<(Option<String>, Vec<(AnyTimelineEvent, Vec<OwnedUserId>)>)>;
|
||||||
IambResult<(Option<String>, Vec<(AnyMessageLikeEvent, Vec<OwnedUserId>)>)>;
|
|
||||||
|
|
||||||
fn initial_devname() -> String {
|
fn initial_devname() -> String {
|
||||||
format!("{} on {}", IAMB_DEVICE_NAME, gethostname().to_string_lossy())
|
format!("{} on {}", IAMB_DEVICE_NAME, gethostname().to_string_lossy())
|
||||||
@@ -209,7 +210,7 @@ async fn update_event_receipts(info: &mut RoomInfo, room: &MatrixRoom, event_id:
|
|||||||
};
|
};
|
||||||
|
|
||||||
for (user_id, _) in receipts {
|
for (user_id, _) in receipts {
|
||||||
info.set_receipt(user_id, event_id.to_owned());
|
info.set_receipt(ReceiptThread::Main, user_id, event_id.to_owned());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,10 +294,8 @@ async fn load_older_one(
|
|||||||
let mut msgs = vec![];
|
let mut msgs = vec![];
|
||||||
|
|
||||||
for ev in chunk.into_iter() {
|
for ev in chunk.into_iter() {
|
||||||
let msg = match ev.event.deserialize() {
|
let Ok(msg) = ev.into_raw().deserialize() else {
|
||||||
Ok(AnyTimelineEvent::MessageLike(msg)) => msg,
|
continue;
|
||||||
Ok(AnyTimelineEvent::State(_)) => continue,
|
|
||||||
Err(_) => continue,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let event_id = msg.event_id();
|
let event_id = msg.event_id();
|
||||||
@@ -311,6 +310,7 @@ async fn load_older_one(
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let msg = msg.into_full_event(room_id.to_owned());
|
||||||
msgs.push((msg, receipts));
|
msgs.push((msg, receipts));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,27 +338,34 @@ fn load_insert(
|
|||||||
let _ = presences.get_or_default(sender);
|
let _ = presences.get_or_default(sender);
|
||||||
|
|
||||||
for user_id in receipts {
|
for user_id in receipts {
|
||||||
info.set_receipt(user_id, msg.event_id().to_owned());
|
info.set_receipt(ReceiptThread::Main, user_id, msg.event_id().to_owned());
|
||||||
}
|
}
|
||||||
|
|
||||||
match msg {
|
match msg {
|
||||||
AnyMessageLikeEvent::RoomEncrypted(msg) => {
|
AnyTimelineEvent::MessageLike(AnyMessageLikeEvent::RoomEncrypted(msg)) => {
|
||||||
info.insert_encrypted(msg);
|
info.insert_encrypted(msg);
|
||||||
},
|
},
|
||||||
AnyMessageLikeEvent::RoomMessage(msg) => {
|
AnyTimelineEvent::MessageLike(AnyMessageLikeEvent::RoomMessage(msg)) => {
|
||||||
info.insert_with_preview(
|
info.insert_with_preview(
|
||||||
room_id.clone(),
|
room_id.clone(),
|
||||||
store.clone(),
|
store.clone(),
|
||||||
*picker,
|
picker.clone(),
|
||||||
msg,
|
msg,
|
||||||
settings,
|
settings,
|
||||||
client.media(),
|
client.media(),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
AnyMessageLikeEvent::Reaction(ev) => {
|
AnyTimelineEvent::MessageLike(AnyMessageLikeEvent::Reaction(ev)) => {
|
||||||
info.insert_reaction(ev);
|
info.insert_reaction(ev);
|
||||||
},
|
},
|
||||||
_ => continue,
|
AnyTimelineEvent::MessageLike(_) => {
|
||||||
|
continue;
|
||||||
|
},
|
||||||
|
AnyTimelineEvent::State(msg) => {
|
||||||
|
if settings.tunables.state_event_display {
|
||||||
|
info.insert_any_state(msg.into());
|
||||||
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -440,7 +447,7 @@ async fn refresh_rooms(client: &Client, store: &AsyncProgramStore) {
|
|||||||
let mut dms = vec![];
|
let mut dms = vec![];
|
||||||
|
|
||||||
for room in client.invited_rooms().into_iter() {
|
for room in client.invited_rooms().into_iter() {
|
||||||
let name = room.display_name().await.unwrap_or(DisplayName::Empty).to_string();
|
let name = room.cached_display_name().unwrap_or(RoomDisplayName::Empty).to_string();
|
||||||
let tags = room.tags().await.unwrap_or_default();
|
let tags = room.tags().await.unwrap_or_default();
|
||||||
|
|
||||||
names.push((room.room_id().to_owned(), name));
|
names.push((room.room_id().to_owned(), name));
|
||||||
@@ -455,7 +462,7 @@ async fn refresh_rooms(client: &Client, store: &AsyncProgramStore) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for room in client.joined_rooms().into_iter() {
|
for room in client.joined_rooms().into_iter() {
|
||||||
let name = room.display_name().await.unwrap_or(DisplayName::Empty).to_string();
|
let name = room.cached_display_name().unwrap_or(RoomDisplayName::Empty).to_string();
|
||||||
let tags = room.tags().await.unwrap_or_default();
|
let tags = room.tags().await.unwrap_or_default();
|
||||||
|
|
||||||
names.push((room.room_id().to_owned(), name));
|
names.push((room.room_id().to_owned(), name));
|
||||||
@@ -490,31 +497,36 @@ async fn refresh_rooms_forever(client: &Client, store: &AsyncProgramStore) {
|
|||||||
|
|
||||||
async fn send_receipts_forever(client: &Client, store: &AsyncProgramStore) {
|
async fn send_receipts_forever(client: &Client, store: &AsyncProgramStore) {
|
||||||
let mut interval = tokio::time::interval(Duration::from_secs(2));
|
let mut interval = tokio::time::interval(Duration::from_secs(2));
|
||||||
let mut sent = HashMap::<OwnedRoomId, OwnedEventId>::default();
|
let mut sent: HashMap<OwnedRoomId, HashMap<ReceiptThread, OwnedEventId>> = Default::default();
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
interval.tick().await;
|
interval.tick().await;
|
||||||
|
|
||||||
let locked = store.lock().await;
|
let mut locked = store.lock().await;
|
||||||
let user_id = &locked.application.settings.profile.user_id;
|
let ChatStore { settings, open_notifications, rooms, .. } = &mut locked.application;
|
||||||
let updates = client
|
let user_id = &settings.profile.user_id;
|
||||||
.joined_rooms()
|
|
||||||
.into_iter()
|
let mut updates = Vec::new();
|
||||||
.filter_map(|room| {
|
for room in client.joined_rooms() {
|
||||||
let room_id = room.room_id().to_owned();
|
let room_id = room.room_id();
|
||||||
let info = locked.application.rooms.get(&room_id)?;
|
let Some(info) = rooms.get(room_id) else {
|
||||||
let new_receipt = info.get_receipt(user_id)?;
|
continue;
|
||||||
let old_receipt = sent.get(&room_id);
|
};
|
||||||
if Some(new_receipt) != old_receipt {
|
|
||||||
Some((room_id, new_receipt.clone()))
|
let changed = info.receipts(user_id).filter_map(|(thread, new_receipt)| {
|
||||||
} else {
|
let old_receipt = sent.get(room_id).and_then(|ts| ts.get(thread));
|
||||||
None
|
let changed = Some(new_receipt) != old_receipt;
|
||||||
|
if changed {
|
||||||
|
open_notifications.remove(room_id);
|
||||||
|
}
|
||||||
|
changed.then(|| (room_id.to_owned(), thread.to_owned(), new_receipt.to_owned()))
|
||||||
|
});
|
||||||
|
|
||||||
|
updates.extend(changed);
|
||||||
}
|
}
|
||||||
})
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
drop(locked);
|
drop(locked);
|
||||||
|
|
||||||
for (room_id, new_receipt) in updates {
|
for (room_id, thread, new_receipt) in updates {
|
||||||
use matrix_sdk::ruma::api::client::receipt::create_receipt::v3::ReceiptType;
|
use matrix_sdk::ruma::api::client::receipt::create_receipt::v3::ReceiptType;
|
||||||
|
|
||||||
let Some(room) = client.get_room(&room_id) else {
|
let Some(room) = client.get_room(&room_id) else {
|
||||||
@@ -522,15 +534,11 @@ async fn send_receipts_forever(client: &Client, store: &AsyncProgramStore) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
match room
|
match room
|
||||||
.send_single_receipt(
|
.send_single_receipt(ReceiptType::Read, thread.to_owned(), new_receipt.clone())
|
||||||
ReceiptType::Read,
|
|
||||||
ReceiptThread::Unthreaded,
|
|
||||||
new_receipt.clone(),
|
|
||||||
)
|
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
sent.insert(room_id, new_receipt);
|
sent.entry(room_id).or_default().insert(thread, new_receipt);
|
||||||
},
|
},
|
||||||
Err(e) => tracing::warn!(?room_id, "Failed to set read receipt: {e}"),
|
Err(e) => tracing::warn!(?room_id, "Failed to set read receipt: {e}"),
|
||||||
}
|
}
|
||||||
@@ -603,7 +611,7 @@ fn oneshot<T>() -> (ClientReply<T>, ClientResponse<T>) {
|
|||||||
return (reply, response);
|
return (reply, response);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub type FetchedRoom = (MatrixRoom, DisplayName, Option<Tags>);
|
pub type FetchedRoom = (MatrixRoom, RoomDisplayName, Option<Tags>);
|
||||||
|
|
||||||
pub enum WorkerTask {
|
pub enum WorkerTask {
|
||||||
Init(AsyncProgramStore, ClientReply<()>),
|
Init(AsyncProgramStore, ClientReply<()>),
|
||||||
@@ -1001,7 +1009,7 @@ impl ClientWorker {
|
|||||||
info.insert_with_preview(
|
info.insert_with_preview(
|
||||||
room_id.to_owned(),
|
room_id.to_owned(),
|
||||||
store.clone(),
|
store.clone(),
|
||||||
*picker,
|
picker.clone(),
|
||||||
full_ev,
|
full_ev,
|
||||||
settings,
|
settings,
|
||||||
client.media(),
|
client.media(),
|
||||||
@@ -1043,14 +1051,32 @@ impl ClientWorker {
|
|||||||
let Some(receipts) = receipts.get(&ReceiptType::Read) else {
|
let Some(receipts) = receipts.get(&ReceiptType::Read) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
for user_id in receipts.keys() {
|
for (user_id, rcpt) in receipts.iter() {
|
||||||
info.set_receipt(user_id.to_owned(), event_id.clone());
|
info.set_receipt(
|
||||||
|
rcpt.thread.clone(),
|
||||||
|
user_id.to_owned(),
|
||||||
|
event_id.clone(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if self.settings.tunables.state_event_display {
|
||||||
|
let _ = self.client.add_event_handler(
|
||||||
|
|ev: AnySyncStateEvent, room: MatrixRoom, store: Ctx<AsyncProgramStore>| {
|
||||||
|
async move {
|
||||||
|
let room_id = room.room_id();
|
||||||
|
let mut locked = store.lock().await;
|
||||||
|
|
||||||
|
let info = locked.application.get_room_info(room_id.to_owned());
|
||||||
|
info.insert_any_state(ev);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let _ = self.client.add_event_handler(
|
let _ = self.client.add_event_handler(
|
||||||
|ev: OriginalSyncRoomRedactionEvent,
|
|ev: OriginalSyncRoomRedactionEvent,
|
||||||
room: MatrixRoom,
|
room: MatrixRoom,
|
||||||
@@ -1076,11 +1102,12 @@ impl ClientWorker {
|
|||||||
let room_id = room.room_id();
|
let room_id = room.room_id();
|
||||||
let user_id = ev.state_key;
|
let user_id = ev.state_key;
|
||||||
|
|
||||||
let ambiguous_name =
|
let ambiguous_name = DisplayName::new(
|
||||||
ev.content.displayname.as_deref().unwrap_or_else(|| user_id.localpart());
|
ev.content.displayname.as_deref().unwrap_or_else(|| user_id.as_str()),
|
||||||
|
);
|
||||||
let ambiguous = client
|
let ambiguous = client
|
||||||
.store()
|
.store()
|
||||||
.get_users_with_display_name(room_id, ambiguous_name)
|
.get_users_with_display_name(room_id, &ambiguous_name)
|
||||||
.await
|
.await
|
||||||
.map(|users| users.len() > 1)
|
.map(|users| users.len() > 1)
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
@@ -1309,7 +1336,7 @@ impl ClientWorker {
|
|||||||
// Remove the session.json file.
|
// Remove the session.json file.
|
||||||
std::fs::remove_file(&self.settings.session_json)?;
|
std::fs::remove_file(&self.settings.session_json)?;
|
||||||
|
|
||||||
Ok(Some(InfoMessage::from("Sucessfully logged out")))
|
Ok(Some(InfoMessage::from("Successfully logged out")))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn direct_message(&mut self, user: OwnedUserId) -> IambResult<OwnedRoomId> {
|
async fn direct_message(&mut self, user: OwnedUserId) -> IambResult<OwnedRoomId> {
|
||||||
@@ -1346,7 +1373,7 @@ impl ClientWorker {
|
|||||||
|
|
||||||
async fn get_room(&mut self, room_id: OwnedRoomId) -> IambResult<FetchedRoom> {
|
async fn get_room(&mut self, room_id: OwnedRoomId) -> IambResult<FetchedRoom> {
|
||||||
if let Some(room) = self.client.get_room(&room_id) {
|
if let Some(room) = self.client.get_room(&room_id) {
|
||||||
let name = room.display_name().await.map_err(IambError::from)?;
|
let name = room.cached_display_name().ok_or_else(|| IambError::UnknownRoom(room_id))?;
|
||||||
let tags = room.tags().await.map_err(IambError::from)?;
|
let tags = room.tags().await.map_err(IambError::from)?;
|
||||||
|
|
||||||
Ok((room, name, tags))
|
Ok((room, name, tags))
|
||||||
@@ -1389,7 +1416,7 @@ impl ClientWorker {
|
|||||||
req.limit = Some(1000u32.into());
|
req.limit = Some(1000u32.into());
|
||||||
req.max_depth = Some(1u32.into());
|
req.max_depth = Some(1u32.into());
|
||||||
|
|
||||||
let resp = self.client.send(req, None).await.map_err(IambError::from)?;
|
let resp = self.client.send(req).await.map_err(IambError::from)?;
|
||||||
|
|
||||||
let rooms = resp.rooms.into_iter().map(|chunk| chunk.room_id).collect();
|
let rooms = resp.rooms.into_iter().map(|chunk| chunk.room_id).collect();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user