Compare commits
188 Commits
v0.0.7
...
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 | ||
|
|
82645c8828 | ||
|
|
5a2a7b028d | ||
|
|
2327658e8c | ||
|
|
b4e9c213e6 | ||
|
|
79f6b5b75c | ||
|
|
6600685dd5 | ||
|
|
ed1b88c197 | ||
|
|
99996e275b | ||
|
|
db9cb92737 | ||
|
|
d3b717d1be | ||
|
|
2ac71da9a6 | ||
|
|
1e9b6cc271 | ||
|
|
46e081b1e4 | ||
|
|
23a729e565 | ||
|
|
0c52375e06 | ||
|
|
c63f8d98d5 | ||
|
|
013214899a | ||
|
|
8a5049fb25 | ||
|
|
9c6ff58b96 | ||
|
|
b41faff9b7 | ||
|
|
e7f158ffcd | ||
|
|
ef868175cb | ||
|
|
8ee203c9a9 | ||
|
|
95f2c7af30 | ||
|
|
c71cec1f54 | ||
|
|
ec81b72f2c | ||
|
|
dd001af365 | ||
|
|
9732971fc2 | ||
|
|
1948d80ec8 | ||
|
|
84bc6be822 | ||
|
|
c5999bffc8 | ||
|
|
aa878f7569 | ||
|
|
a2a708f1ae | ||
|
|
3ed87aae05 | ||
|
|
1325295d2b | ||
|
|
1cb280df8b | ||
|
|
5be886301b | ||
|
|
3e3b771b2e | ||
|
|
b7ae01499b | ||
|
|
88af9bfec3 | ||
|
|
999399a70f | ||
|
|
b33759cbc3 | ||
|
|
4236d9f53e | ||
|
|
1ae22086f6 | ||
|
|
221faa828d | ||
|
|
974775b29b | ||
|
|
25eef55eb7 | ||
|
|
8943909f06 | ||
|
|
443ad241b4 | ||
|
|
3b86be0545 | ||
|
|
b2b47ed7a0 | ||
|
|
df3148b9f5 | ||
|
|
95af00ba93 | ||
|
|
9197864c5c | ||
|
|
2673cfaeb9 | ||
|
|
c7864cb869 | ||
|
|
7fdb5f98e3 | ||
|
|
0565b6eb05 | ||
|
|
47e650c2be | ||
|
|
89bb107c87 | ||
|
|
ca4c0034d9 | ||
|
|
bb30cecc63 | ||
|
|
7b050f82aa | ||
|
|
b1ccec6732 | ||
|
|
6e8e12b579 | ||
|
|
3da9835a17 | ||
|
|
64891ec68f | ||
|
|
61aba80be1 | ||
|
|
8d4539831f | ||
|
|
7c39e88ba2 | ||
|
|
758397eb5a | ||
|
|
1a0af6df37 | ||
|
|
885b56038f | ||
|
|
430c601ff2 | ||
|
|
0ddefcd7b3 | ||
|
|
2a573b6056 | ||
|
|
a020b860dd | ||
|
|
6c031f589e | ||
|
|
b0256d7120 | ||
|
|
0f870367b3 | ||
|
|
8d22b83d85 | ||
|
|
529073f4d4 | ||
|
|
17c87a617e | ||
|
|
2899d4f45a | ||
|
|
ad8b4a60d2 | ||
|
|
4935899aed | ||
|
|
cc1d2f3bf8 | ||
|
|
5df9fe7960 | ||
|
|
a5c25f2487 | ||
|
|
50023bad40 | ||
|
|
b6a318dfe3 | ||
|
|
ad3b40d538 | ||
|
|
953be6a195 | ||
|
|
463d46b8ab | ||
|
|
274234ce4c |
4
.gitattributes
vendored
4
.gitattributes
vendored
@@ -1 +1,3 @@
|
||||
* text eol=lf
|
||||
*.rs text eol=lf
|
||||
*.toml text eol=lf
|
||||
*.md text eol=lf
|
||||
|
||||
1
.github/FUNDING.yml
vendored
Normal file
1
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1 @@
|
||||
ko_fi: ulyssam
|
||||
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
|
||||
47
.github/workflows/ci.yml
vendored
47
.github/workflows/ci.yml
vendored
@@ -9,52 +9,39 @@ on:
|
||||
name: CI
|
||||
|
||||
jobs:
|
||||
clippy_check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
submodules: true
|
||||
- name: Install Rust
|
||||
uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
components: clippy
|
||||
override: true
|
||||
- name: Check Clippy
|
||||
uses: actions-rs/clippy-check@v1
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
toolchain: stable
|
||||
args:
|
||||
test:
|
||||
strategy:
|
||||
matrix:
|
||||
platform: [ubuntu-latest, windows-latest, macos-latest]
|
||||
runs-on: ${{ matrix.platform }}
|
||||
env:
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
RUSTC_WRAPPER: "sccache"
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
submodules: true
|
||||
- name: Install Rust
|
||||
uses: actions-rs/toolchain@v1
|
||||
- name: Install Rust (1.83 w/ clippy)
|
||||
uses: dtolnay/rust-toolchain@1.83
|
||||
with:
|
||||
toolchain: nightly
|
||||
override: true
|
||||
components: rustfmt, clippy
|
||||
components: clippy
|
||||
- name: Install Rust (nightly w/ rustfmt)
|
||||
run: rustup toolchain install nightly --component rustfmt
|
||||
- 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: Check formatting
|
||||
uses: actions-rs/cargo@v1
|
||||
run: cargo +nightly fmt --all -- --check
|
||||
- name: Check Clippy
|
||||
if: matrix.platform == 'ubuntu-latest'
|
||||
uses: giraffate/clippy-action@v1
|
||||
with:
|
||||
command: fmt
|
||||
args: --all -- --check
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
reporter: 'github-check'
|
||||
- name: Run tests
|
||||
uses: actions-rs/cargo@v1
|
||||
with:
|
||||
command: test
|
||||
run: cargo test --locked
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,2 +1,4 @@
|
||||
/target
|
||||
/result
|
||||
/TODO
|
||||
.direnv
|
||||
|
||||
5726
Cargo.lock
generated
5726
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
99
Cargo.toml
99
Cargo.toml
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "iamb"
|
||||
version = "0.0.7"
|
||||
version = "0.0.11-alpha.1"
|
||||
edition = "2018"
|
||||
authors = ["Ulyssa <git@ulyssa.dev>"]
|
||||
repository = "https://github.com/ulyssa/iamb"
|
||||
@@ -11,40 +11,87 @@ license = "Apache-2.0"
|
||||
exclude = [".github", "CONTRIBUTING.md"]
|
||||
keywords = ["matrix", "chat", "tui", "vim"]
|
||||
categories = ["command-line-utilities"]
|
||||
rust-version = "1.66"
|
||||
rust-version = "1.83"
|
||||
build = "build.rs"
|
||||
|
||||
[features]
|
||||
default = ["bundled", "desktop"]
|
||||
bundled = ["matrix-sdk/bundled-sqlite", "rustls-tls"]
|
||||
desktop = ["dep:notify-rust", "modalkit/clipboard"]
|
||||
native-tls = ["matrix-sdk/native-tls"]
|
||||
rustls-tls = ["matrix-sdk/rustls-tls"]
|
||||
|
||||
[build-dependencies.vergen]
|
||||
version = "8"
|
||||
default-features = false
|
||||
features = ["build", "git", "gitcl",]
|
||||
|
||||
[dependencies]
|
||||
bitflags = "1.3.2"
|
||||
anyhow = "1.0"
|
||||
bitflags = "^2.3"
|
||||
chrono = "0.4"
|
||||
clap = {version = "4.0", features = ["derive"]}
|
||||
clap = {version = "~4.3", features = ["derive"]}
|
||||
css-color-parser = "0.1.2"
|
||||
dirs = "4.0.0"
|
||||
emojis = "~0.5.2"
|
||||
emojis = "0.5"
|
||||
feruca = "0.10.1"
|
||||
futures = "0.3"
|
||||
gethostname = "0.4.1"
|
||||
html5ever = "0.26.0"
|
||||
image = "^0.25.6"
|
||||
libc = "0.2"
|
||||
markup5ever_rcdom = "0.2.0"
|
||||
mime = "^0.3.16"
|
||||
mime_guess = "^2.0.4"
|
||||
nom = "7.0.0"
|
||||
open = "3.2.0"
|
||||
rand = "0.8.5"
|
||||
ratatui = "0.29.0"
|
||||
ratatui-image = { version = "~8.0.1", features = ["serde"] }
|
||||
regex = "^1.5"
|
||||
rpassword = "^7.2"
|
||||
serde = "^1.0"
|
||||
serde_json = "^1.0"
|
||||
sled = "0.34.7"
|
||||
temp-dir = "0.1.12"
|
||||
thiserror = "^1.0.37"
|
||||
toml = "^0.8.12"
|
||||
tracing = "~0.1.36"
|
||||
tracing-appender = "~0.2.2"
|
||||
tracing-subscriber = "0.3.16"
|
||||
unicode-segmentation = "^1.7"
|
||||
unicode-width = "0.1.10"
|
||||
url = {version = "^2.2.2", features = ["serde"]}
|
||||
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]
|
||||
version = "0.0.14"
|
||||
version = "0.0.23"
|
||||
default-features = false
|
||||
#git = "https://github.com/ulyssa/modalkit"
|
||||
#rev = "e40dbb0bfeabe4cfd08facd2acb446080a330d75"
|
||||
|
||||
[dependencies.modalkit-ratatui]
|
||||
version = "0.0.23"
|
||||
#git = "https://github.com/ulyssa/modalkit"
|
||||
#rev = "e40dbb0bfeabe4cfd08facd2acb446080a330d75"
|
||||
|
||||
[dependencies.matrix-sdk]
|
||||
version = "0.6"
|
||||
version = "0.10.0"
|
||||
default-features = false
|
||||
features = ["e2e-encryption", "markdown", "sled", "rustls-tls"]
|
||||
features = ["e2e-encryption", "sqlite", "sso-login"]
|
||||
|
||||
[dependencies.tokio]
|
||||
version = "1.24.1"
|
||||
@@ -52,7 +99,39 @@ features = ["macros", "net", "rt-multi-thread", "sync", "time"]
|
||||
|
||||
[dev-dependencies]
|
||||
lazy_static = "1.4.0"
|
||||
pretty_assertions = "1.4.0"
|
||||
|
||||
[profile.release]
|
||||
lto = true
|
||||
[profile.release-lto]
|
||||
inherits = "release"
|
||||
incremental = false
|
||||
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
|
||||
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");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
||||
44
PACKAGING.md
Normal file
44
PACKAGING.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# Notes For Package Maintainers
|
||||
|
||||
## Linking Against System Packages
|
||||
|
||||
The default Cargo features for __iamb__ will bundle SQLite and use [rustls] for
|
||||
TLS. Package maintainers may want to link against the system's native SQLite
|
||||
and TLS libraries instead. To do so, you'll want to build without the default
|
||||
features and specify that it should build with `native-tls`:
|
||||
|
||||
```
|
||||
% cargo build --release --no-default-features --features=native-tls
|
||||
```
|
||||
|
||||
## Enabling LTO
|
||||
|
||||
Enabling LTO can result in smaller binaries. There is a separate profile to
|
||||
enable it when building:
|
||||
|
||||
```
|
||||
% cargo build --profile release-lto
|
||||
```
|
||||
|
||||
Note that this [can fail][ring-lto] in some build environments if both Clang
|
||||
and GCC are present.
|
||||
|
||||
## Documentation
|
||||
|
||||
In addition to the compiled binary, there are other files in the repo that
|
||||
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) |
|
||||
| ----------------------- | ----------------------------------------------- |
|
||||
| /iamb.desktop | /usr/share/applications/iamb.desktop |
|
||||
| /config.example.toml | /usr/share/iamb/config.example.toml |
|
||||
| /docs/iamb-256x256.png | /usr/share/icons/hicolor/256x256/apps/iamb.png |
|
||||
| /docs/iamb-512x512.png | /usr/share/icons/hicolor/512x512/apps/iamb.png |
|
||||
| /docs/iamb.svg | /usr/share/icons/hicolor/scalable/apps/iamb.svg |
|
||||
| /docs/iamb.1 | /usr/share/man/man1/iamb.1 |
|
||||
| /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
|
||||
[rustls]: https://crates.io/crates/rustls
|
||||
172
README.md
172
README.md
@@ -1,32 +1,112 @@
|
||||
# iamb
|
||||
<div align="center">
|
||||
<h1><img width="200" height="200" src="docs/iamb.svg"></h1>
|
||||
|
||||
[](https://github.com/ulyssa/iamb/actions?query=workflow%3ACI+)
|
||||
[](https://crates.io/crates/iamb)
|
||||
[](https://github.com/ulyssa/iamb/actions?query=workflow%3ACI+)
|
||||
[][crates-io-iamb]
|
||||
[](https://matrix.to/#/#iamb:0x.badd.cafe)
|
||||
[](https://crates.io/crates/iamb)
|
||||
[][crates-io-iamb]
|
||||
[](https://snapcraft.io/iamb)
|
||||
|
||||

|
||||
|
||||
</div>
|
||||
|
||||
## About
|
||||
|
||||
`iamb` is a Matrix client for the terminal that uses Vim keybindings.
|
||||
`iamb` is a Matrix client for the terminal that uses Vim keybindings. It includes support for:
|
||||
|
||||
This project is a work-in-progress, and there's still a lot to be implemented,
|
||||
but much of the basic client functionality is already present.
|
||||
- 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
|
||||
- Notifications via terminal bell or desktop environment
|
||||
- Send Markdown, HTML or plaintext messages
|
||||
- Creating, joining, and leaving rooms
|
||||
- Sending and accepting room invitations
|
||||
- Editing, redacting, and reacting to messages
|
||||
- Custom keybindings
|
||||
- Multiple profiles
|
||||
|
||||

|
||||
_You may want to [see this page as it was when the latest version was published][crates-io-iamb]._
|
||||
|
||||
## Documentation
|
||||
|
||||
You can find documentation for installing, configuring, and using iamb on its
|
||||
website, [iamb.chat].
|
||||
|
||||
## Installation
|
||||
## Configuration
|
||||
|
||||
Install Rust and Cargo, and then run:
|
||||
You can create a basic configuration in `$CONFIG_DIR/iamb/config.toml` that looks like:
|
||||
|
||||
```toml
|
||||
[profiles."example.com"]
|
||||
user_id = "@user:example.com"
|
||||
```
|
||||
|
||||
If you homeserver is located on a different domain than the server part of the
|
||||
`user_id` and you don't have a [`/.well-known`][well_known_entry] entry, then
|
||||
you can explicitly specify the homeserver URL to use:
|
||||
|
||||
```toml
|
||||
[profiles."example.com"]
|
||||
url = "https://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:
|
||||
@@ -35,79 +115,33 @@ On NetBSD a package is available from the official repositories. To install it s
|
||||
pkgin install iamb
|
||||
```
|
||||
|
||||
### Arch Linux
|
||||
|
||||
On Arch Linux a package is available in the Arch User Repositories (AUR). To install it simply run with your favorite AUR helper:
|
||||
### Nix / NixOS (flake)
|
||||
|
||||
```
|
||||
paru iamb-git
|
||||
nix profile install "github:ulyssa/iamb"
|
||||
```
|
||||
|
||||
## Configuration
|
||||
### openSUSE Tumbleweed
|
||||
|
||||
You can create a basic configuration in `$CONFIG_DIR/iamb/config.json` that looks like:
|
||||
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:
|
||||
|
||||
```json
|
||||
{
|
||||
"profiles": {
|
||||
"example.com": {
|
||||
"url": "https://example.com",
|
||||
"user_id": "@user:example.com"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
zypper install iamb
|
||||
```
|
||||
|
||||
## Comparison With Other Clients
|
||||
### Snap
|
||||
|
||||
To get an idea of what is and isn't yet implemented, here is a subset of the
|
||||
Matrix website's [features comparison table][client-comparison-matrix], showing
|
||||
two other TUI clients and Element Web:
|
||||
A snap for Linux distributions which [support](https://snapcraft.io/docs/installing-snapd) the packaging system.
|
||||
|
||||
| | iamb | [gomuks] | [weechat-matrix] | Element Web/Desktop |
|
||||
| --------------------------------------- | :---------- | :------: | :--------------: | :-----------------: |
|
||||
| Room directory | ❌ ([#14]) | ❌ | ✔️ | ✔️ |
|
||||
| Room tag showing | ✔️ | ✔️ | ❌ | ✔️ |
|
||||
| Room tag editing | ✔️ | ✔️ | ❌ | ✔️ |
|
||||
| Search joined rooms | ❌ ([#16]) | ✔️ | ❌ | ✔️ |
|
||||
| Room user list | ✔️ | ✔️ | ✔️ | ✔️ |
|
||||
| Display Room Description | ✔️ | ✔️ | ✔️ | ✔️ |
|
||||
| Edit Room Description | ✔️ | ❌ | ✔️ | ✔️ |
|
||||
| Highlights | ❌ ([#8]) | ✔️ | ✔️ | ✔️ |
|
||||
| Pushrules | ❌ | ✔️ | ❌ | ✔️ |
|
||||
| Send read markers | ✔️ | ✔️ | ✔️ | ✔️ |
|
||||
| Display read markers | ✔️ | ❌ | ❌ | ✔️ |
|
||||
| Sending Invites | ✔️ | ✔️ | ✔️ | ✔️ |
|
||||
| Accepting Invites | ✔️ | ✔️ | ✔️ | ✔️ |
|
||||
| Typing Notification | ✔️ | ✔️ | ✔️ | ✔️ |
|
||||
| E2E | ✔️ | ✔️ | ✔️ | ✔️ |
|
||||
| Replies | ✔️ | ✔️ | ❌ | ✔️ |
|
||||
| Attachment uploading | ✔️ | ❌ | ✔️ | ✔️ |
|
||||
| Attachment downloading | ✔️ | ✔️ | ✔️ | ✔️ |
|
||||
| Send stickers | ❌ | ❌ | ❌ | ✔️ |
|
||||
| Send formatted messages (markdown) | ✔️ | ✔️ | ✔️ | ✔️ |
|
||||
| Rich Text Editor for formatted messages | ❌ | ❌ | ❌ | ✔️ |
|
||||
| Display formatted messages | ✔️ | ✔️ | ✔️ | ✔️ |
|
||||
| Redacting | ✔️ | ✔️ | ✔️ | ✔️ |
|
||||
| Multiple Matrix Accounts | ✔️ | ❌ | ✔️ | ❌ |
|
||||
| New user registration | ❌ | ❌ | ❌ | ✔️ |
|
||||
| VOIP | ❌ | ❌ | ❌ | ✔️ |
|
||||
| Reactions | ✔️ | ✔️ | ❌ | ✔️ |
|
||||
| Message editing | ✔️ | ✔️ | ❌ | ✔️ |
|
||||
| Room upgrades | ❌ ([#41]) | ✔️ | ❌ | ✔️ |
|
||||
| Localisations | ❌ | 1 | ❌ | 44 |
|
||||
| SSO Support | ❌ | ✔️ | ✔️ | ✔️ |
|
||||
```
|
||||
snap install iamb
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
iamb is released under the [Apache License, Version 2.0].
|
||||
|
||||
[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
|
||||
[iamb.chat]: https://iamb.chat
|
||||
[gomuks]: https://github.com/tulir/gomuks
|
||||
[weechat-matrix]: https://github.com/poljar/weechat-matrix
|
||||
[#8]: https://github.com/ulyssa/iamb/issues/8
|
||||
[#14]: https://github.com/ulyssa/iamb/issues/14
|
||||
[#16]: https://github.com/ulyssa/iamb/issues/16
|
||||
[#41]: https://github.com/ulyssa/iamb/issues/41
|
||||
[well_known_entry]: https://spec.matrix.org/latest/client-server-api/#getwell-knownmatrixclient
|
||||
|
||||
9
build.rs
Normal file
9
build.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
use std::error::Error;
|
||||
|
||||
use vergen::EmitBuilder;
|
||||
|
||||
fn main() -> Result<(), Box<dyn Error>> {
|
||||
EmitBuilder::builder().git_sha(true).emit()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
58
config.example.toml
Normal file
58
config.example.toml
Normal file
@@ -0,0 +1,58 @@
|
||||
default_profile = "default"
|
||||
|
||||
[profiles.default]
|
||||
user_id = "@user:matrix.org"
|
||||
url = "https://matrix.org"
|
||||
|
||||
[settings]
|
||||
default_room = "#iamb-users:0x.badd.cafe"
|
||||
external_edit_file_suffix = ".md"
|
||||
log_level = "warn"
|
||||
message_shortcode_display = false
|
||||
open_command = ["my-open", "--file"]
|
||||
reaction_display = true
|
||||
reaction_shortcode_display = false
|
||||
read_receipt_display = true
|
||||
read_receipt_send = true
|
||||
request_timeout = 10000
|
||||
typing_notice_display = true
|
||||
typing_notice_send = true
|
||||
user_gutter_width = 30
|
||||
username_display = "username"
|
||||
|
||||
[settings.image_preview]
|
||||
protocol.type = "sixel"
|
||||
size = { "width" = 66, "height" = 10 }
|
||||
|
||||
[settings.sort]
|
||||
rooms = ["favorite", "lowpriority", "unread", "name"]
|
||||
members = ["power", "id"]
|
||||
|
||||
[settings.users]
|
||||
"@user:matrix.org" = { "name" = "John Doe", "color" = "magenta" }
|
||||
|
||||
[layout]
|
||||
style = "config"
|
||||
|
||||
[[layout.tabs]]
|
||||
window = "iamb://dms"
|
||||
|
||||
[[layout.tabs]]
|
||||
window = "iamb://rooms"
|
||||
|
||||
[[layout.tabs]]
|
||||
split = [
|
||||
{ "window" = "#iamb-users:0x.badd.cafe" },
|
||||
{ "window" = "#iamb-dev:0x.badd.cafe" }
|
||||
]
|
||||
|
||||
[macros.insert]
|
||||
"jj" = "<Esc>"
|
||||
|
||||
[macros."normal|visual"]
|
||||
"V" = "<C-W>m"
|
||||
|
||||
[dirs]
|
||||
cache = "/home/user/.cache/iamb/"
|
||||
logs = "/home/user/.local/share/iamb/logs/"
|
||||
downloads = "/home/user/Downloads/"
|
||||
BIN
docs/iamb-256x256.png
Normal file
BIN
docs/iamb-256x256.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.5 KiB |
BIN
docs/iamb-512x512.png
Normal file
BIN
docs/iamb-512x512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
321
docs/iamb.1
Normal file
321
docs/iamb.1
Normal file
@@ -0,0 +1,321 @@
|
||||
.\" iamb(1) manual page
|
||||
.\"
|
||||
.\" This manual page is written using the mdoc(7) macros. For more
|
||||
.\" information, see <https://manpages.bsd.lv/mdoc.html>.
|
||||
.\"
|
||||
.\" You can preview this file with:
|
||||
.\" $ man ./docs/iamb.1
|
||||
.Dd Mar 24, 2024
|
||||
.Dt IAMB 1
|
||||
.Os
|
||||
.Sh NAME
|
||||
.Nm iamb
|
||||
.Nd a terminal-based client for Matrix for the Vim addict
|
||||
.Sh SYNOPSIS
|
||||
.Nm
|
||||
.Op Fl hV
|
||||
.Op Fl P Ar profile
|
||||
.Op Fl C Ar dir
|
||||
.Sh DESCRIPTION
|
||||
.Nm
|
||||
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.
|
||||
.Pp
|
||||
This manual page includes a quick rundown of the available commands in
|
||||
.Nm .
|
||||
For example usage and a full description of each one and its arguments, please
|
||||
refer to the full documentation online.
|
||||
.Sh OPTIONS
|
||||
.Bl -tag -width Ds
|
||||
.It Fl P , Fl Fl profile
|
||||
The profile to start
|
||||
.Nm
|
||||
with.
|
||||
If this flag is not specified,
|
||||
then it defaults to using
|
||||
.Sy default_profile
|
||||
(see
|
||||
.Xr iamb 5 ) .
|
||||
.It Fl C , Fl Fl config-directory
|
||||
Path to the directory the configuration file is located in.
|
||||
.It Fl h , Fl Fl help
|
||||
Show the help text and quit.
|
||||
.It Fl V , Fl Fl version
|
||||
Show the current
|
||||
.Nm
|
||||
version and quit.
|
||||
.El
|
||||
|
||||
.Sh "GENERAL COMMANDS"
|
||||
.Bl -tag -width Ds
|
||||
.It Sy ":chats"
|
||||
View a list of joined rooms and direct messages.
|
||||
.It Sy ":dms"
|
||||
View a list of direct messages.
|
||||
.It Sy ":logout [user id]"
|
||||
Log out of
|
||||
.Nm .
|
||||
.It Sy ":rooms"
|
||||
View a list of joined rooms.
|
||||
.It Sy ":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"
|
||||
View the startup Welcome window.
|
||||
.El
|
||||
|
||||
.Sh "E2EE COMMANDS"
|
||||
.Bl -tag -width Ds
|
||||
.It Sy ":keys export [path] [passphrase]"
|
||||
Export and encrypt keys to
|
||||
.Pa path .
|
||||
.It Sy ":keys import [path] [passphrase]"
|
||||
Import and decrypt keys from
|
||||
.Pa path .
|
||||
.It Sy ":verify"
|
||||
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
|
||||
|
||||
.Sh "MESSAGE COMMANDS"
|
||||
.Bl -tag -width Ds
|
||||
.It Sy ":download [path]"
|
||||
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"
|
||||
Edit the selected message.
|
||||
.It Sy ":editor"
|
||||
Open an external
|
||||
.Ev $EDITOR
|
||||
to compose a message.
|
||||
.It Sy ":react [shortcode]"
|
||||
React to the selected message with an Emoji.
|
||||
.It Sy ":unreact [shortcode]"
|
||||
Remove your reaction from the selected message.
|
||||
When no arguments are given, remove all of your reactions from the message.
|
||||
.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.
|
||||
.El
|
||||
|
||||
.Sh "ROOM COMMANDS"
|
||||
.Bl -tag -width Ds
|
||||
.It Sy ":create [arguments]"
|
||||
Create a new room. Arguments can be
|
||||
.Dq ++alias=[alias] ,
|
||||
.Dq ++public ,
|
||||
.Dq ++space ,
|
||||
and
|
||||
.Dq ++encrypted .
|
||||
.It Sy ":invite accept"
|
||||
Accept an invitation to the currently focused room.
|
||||
.It Sy ":invite reject"
|
||||
Reject an invitation to the currently focused room.
|
||||
.It Sy ":invite send [user]"
|
||||
Send an invitation to a user to join the currently focused room.
|
||||
.It Sy ":join [room]"
|
||||
Join a room or open it if you are already joined.
|
||||
.It Sy ":leave"
|
||||
Leave the currently focused room.
|
||||
.It Sy ":members"
|
||||
View a list of members of the currently focused room.
|
||||
.It Sy ":room name set [name]"
|
||||
Set the name of the currently focused room.
|
||||
.It Sy ":room name unset"
|
||||
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]"
|
||||
Add a tag to the currently focused room.
|
||||
.It Sy ":room tag unset [tag]"
|
||||
Remove a tag from the currently focused room.
|
||||
.It Sy ":room topic set [topic]"
|
||||
Set the topic of the currently focused room.
|
||||
.It Sy ":room topic unset"
|
||||
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
|
||||
|
||||
.Sh "WINDOW COMMANDS"
|
||||
.Bl -tag -width Ds
|
||||
.It Sy ":horizontal [cmd]"
|
||||
Change the behaviour of the given command to be horizontal.
|
||||
.It Sy ":leftabove [cmd]"
|
||||
Change the behaviour of the given command to open before the current window.
|
||||
.It Sy ":only" , Sy ":on"
|
||||
Quit all but one window in the current tab.
|
||||
.It Sy ":quit" , Sy ":q"
|
||||
Quit a window.
|
||||
.It Sy ":quitall" , Sy ":qa"
|
||||
Quit all windows in the current tab.
|
||||
.It Sy ":resize"
|
||||
Resize a window.
|
||||
.It Sy ":rightbelow [cmd]"
|
||||
Change the behaviour of the given command to open after the current window.
|
||||
.It Sy ":split" , Sy ":sp"
|
||||
Horizontally split a window.
|
||||
.It Sy ":vertical [cmd]"
|
||||
Change the layout of the following command to be vertical.
|
||||
.It Sy ":vsplit" , Sy ":vsp"
|
||||
Vertically split a window.
|
||||
.El
|
||||
|
||||
.Sh "TAB COMMANDS"
|
||||
.Bl -tag -width Ds
|
||||
.It Sy ":tab [cmd]"
|
||||
Run a command that opens a window in a new tab.
|
||||
.It Sy ":tabclose" , Sy ":tabc"
|
||||
Close a tab.
|
||||
.It Sy ":tabedit [room]" , Sy ":tabe"
|
||||
Open a room in a new tab.
|
||||
.It Sy ":tabrewind" , Sy ":tabr"
|
||||
Go to the first tab.
|
||||
.It Sy ":tablast" , Sy ":tabl"
|
||||
Go to the last tab.
|
||||
.It Sy ":tabnext" , Sy ":tabn"
|
||||
Go to the next tab.
|
||||
.It Sy ":tabonly" , Sy ":tabo"
|
||||
Close all but one tab.
|
||||
.It Sy ":tabprevious" , Sy ":tabp"
|
||||
Go to the preview tab.
|
||||
.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
|
||||
.Ss Example 1: Starting with a specific profile
|
||||
To start with a profile named
|
||||
.Sy personal
|
||||
instead of the
|
||||
.Sy default_profile
|
||||
value:
|
||||
.Bd -literal -offset indent
|
||||
$ iamb -P personal
|
||||
.Ed
|
||||
.Ss Example 2: Using an alternate configuration directory
|
||||
By default,
|
||||
.Nm
|
||||
will use the XDG directories, but you may sometimes want to store
|
||||
your configuration elsewhere.
|
||||
.Bd -literal -offset indent
|
||||
$ iamb -C ~/src/iamb-dev/dev-config/
|
||||
.Ed
|
||||
.Sh "REPORTING BUGS"
|
||||
Please report bugs in
|
||||
.Nm
|
||||
or its manual pages at
|
||||
.Lk https://github.com/ulyssa/iamb/issues
|
||||
.Sh "SEE ALSO"
|
||||
.Xr iamb 5
|
||||
.Pp
|
||||
Extended documentation is available online at
|
||||
.Lk https://iamb.chat
|
||||
590
docs/iamb.5
Normal file
590
docs/iamb.5
Normal file
@@ -0,0 +1,590 @@
|
||||
.\" iamb(7) manual page
|
||||
.\"
|
||||
.\" This manual page is written using the mdoc(7) macros. For more
|
||||
.\" information, see <https://manpages.bsd.lv/mdoc.html>.
|
||||
.\"
|
||||
.\" You can preview this file with:
|
||||
.\" $ man ./docs/iamb.1
|
||||
.Dd Mar 24, 2024
|
||||
.Dt IAMB 5
|
||||
.Os
|
||||
.Sh NAME
|
||||
.Nm config.toml
|
||||
.Nd configuration file for
|
||||
.Sy iamb
|
||||
.Sh DESCRIPTION
|
||||
Configuration must be placed under
|
||||
.Pa ~/.config/iamb/
|
||||
and named
|
||||
.Nm .
|
||||
(If
|
||||
.Ev $XDG_CONFIG_HOME
|
||||
is set, then
|
||||
.Sy iamb
|
||||
will look for a directory named
|
||||
.Pa iamb
|
||||
there instead.)
|
||||
.Pp
|
||||
Example configuration usually comes bundled with your installation and can
|
||||
typically be found in
|
||||
.Pa /usr/share/iamb .
|
||||
.Pp
|
||||
As implied by the filename, the configuration is formatted in TOML.
|
||||
It's structure and fields are described below.
|
||||
.Sh CONFIGURATION
|
||||
These options are sections at the top-level of the file.
|
||||
.Bl -tag -width Ds
|
||||
.It Sy profiles
|
||||
A map of profile names containing per-account information.
|
||||
See
|
||||
.Sx PROFILES .
|
||||
.It Sy default_profile
|
||||
The name of the default profile to connect to, unless overwritten by a
|
||||
commandline switch.
|
||||
It should be one of the names defined in the
|
||||
.Sy profiles
|
||||
section.
|
||||
.It Sy settings
|
||||
Overwrite general settings for
|
||||
.Sy iamb .
|
||||
See
|
||||
.Sx SETTINGS
|
||||
for a description of possible values.
|
||||
.It Sy layout
|
||||
Configure the default window layout to use when starting
|
||||
.Sy iamb .
|
||||
See
|
||||
.Sx "STARTUP LAYOUT"
|
||||
for more information on how to configure this object.
|
||||
.It Sy macros
|
||||
Map keybindings to other keybindings.
|
||||
See
|
||||
.Sx "CUSTOM KEYBINDINGS"
|
||||
for how to configure this object.
|
||||
.It Sy dirs
|
||||
Configure the directories to use for data, logs, and more.
|
||||
See
|
||||
.Sx DIRECTORIES
|
||||
for the possible values you can set in this object.
|
||||
.El
|
||||
.Sh PROFILES
|
||||
These options are configured as fields in the
|
||||
.Sy profiles
|
||||
object.
|
||||
.Bl -tag -width Ds
|
||||
.It Sy user_id
|
||||
The user ID to use when connecting to the server.
|
||||
For example "user" in "@user:matrix.org".
|
||||
.It Sy url
|
||||
The URL of the user's server.
|
||||
(For example "https://matrix.org" for "@user:matrix.org".)
|
||||
This is only needed when the server does not have a
|
||||
.Pa /.well-known/matrix/client
|
||||
entry.
|
||||
.El
|
||||
.Pp
|
||||
In addition to the above fields, you can also reuse the following fields to set
|
||||
per-profile overrides of their global values:
|
||||
.Bl -bullet -offset indent -width 1m
|
||||
.It
|
||||
.Sy dirs
|
||||
.It
|
||||
.Sy layout
|
||||
.It
|
||||
.Sy macros
|
||||
.It
|
||||
.Sy settings
|
||||
.El
|
||||
.Ss Example 1: A single profile
|
||||
.Bd -literal -offset indent
|
||||
[profiles.personal]
|
||||
user_id = "@user:matrix.org"
|
||||
.Ed
|
||||
.Ss Example 2: Two profiles with a default
|
||||
In the following example, there are two profiles,
|
||||
.Dq personal
|
||||
(set to be the default) and
|
||||
.Dq work .
|
||||
The
|
||||
.Dq work
|
||||
profile has an explicit URL set for its homeserver.
|
||||
.Bd -literal -offset indent
|
||||
default_profile = "personal"
|
||||
|
||||
[profiles.personal]
|
||||
user_id = "@user:matrix.org"
|
||||
|
||||
[profiles.work]
|
||||
user_id = "@user:example.com"
|
||||
url = "https://matrix.example.com"
|
||||
.Ed
|
||||
.Sh SETTINGS
|
||||
These options are configured as an object under the
|
||||
.Sy settings
|
||||
key and can be overridden as described in
|
||||
.Sx PROFILES .
|
||||
.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
|
||||
The room to show by default instead of the
|
||||
.Sy :welcome
|
||||
window.
|
||||
|
||||
.It Sy image_preview
|
||||
Enable image previews and configure it.
|
||||
An empty object will enable the feature with default settings, omitting it will disable the feature.
|
||||
The available fields in this object are:
|
||||
.Bl -tag -width Ds
|
||||
.It Sy size
|
||||
An optional object with
|
||||
.Sy width
|
||||
and
|
||||
.Sy height
|
||||
fields to specify the preview size in cells.
|
||||
Defaults to 66 and 10.
|
||||
.It Sy protocol
|
||||
An optional object to override settings that will normally be guessed automatically:
|
||||
.Bl -tag -width Ds
|
||||
.It Sy type
|
||||
An optional string set to one of the protocol types:
|
||||
.Dq Sy sixel ,
|
||||
.Dq Sy kitty , and
|
||||
.Dq Sy halfblocks .
|
||||
.It Sy font_size
|
||||
An optional list of two numbers representing font width and height in pixels.
|
||||
.El
|
||||
.El
|
||||
.It Sy log_level
|
||||
Specifies the lowest log level that should be shown.
|
||||
Possible values are:
|
||||
.Dq Sy trace ,
|
||||
.Dq Sy debug ,
|
||||
.Dq Sy info ,
|
||||
.Dq Sy warn , and
|
||||
.Dq Sy error .
|
||||
|
||||
.It Sy message_shortcode_display
|
||||
Defines whether or not Emoji characters in messages should be replaced by their
|
||||
respective shortcodes.
|
||||
|
||||
.It Sy message_user_color
|
||||
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
|
||||
When this subsection is present, you can enable and configure push notifications.
|
||||
See
|
||||
.Sx NOTIFICATIONS
|
||||
for more details.
|
||||
|
||||
.It Sy open_command
|
||||
Defines a custom command and its arguments to run when opening downloads instead of the default.
|
||||
(For example,
|
||||
.Sy ["my-open",\ "--file"] . )
|
||||
|
||||
.It Sy reaction_display
|
||||
Defines whether or not reactions should be shown.
|
||||
|
||||
.It Sy reaction_shortcode_display
|
||||
Defines whether or not reactions should be shown as their respective shortcode.
|
||||
|
||||
.It Sy read_receipt_send
|
||||
Defines whether or not read confirmations are sent.
|
||||
|
||||
.It Sy read_receipt_display
|
||||
Defines whether or not read confirmations are displayed.
|
||||
|
||||
.It Sy request_timeout
|
||||
Defines the maximum time per request in seconds.
|
||||
|
||||
.It Sy sort
|
||||
Configures how to sort the lists shown in windows like
|
||||
.Sy :rooms
|
||||
or
|
||||
.Sy :members .
|
||||
See
|
||||
.Sx "SORTING LISTS"
|
||||
for more details.
|
||||
|
||||
.It Sy state_event_display
|
||||
Defines whether the state events like joined or left are shown.
|
||||
|
||||
.It Sy typing_notice_send
|
||||
Defines whether or not the typing state is sent.
|
||||
|
||||
.It Sy typing_notice_display
|
||||
Defines whether or not the typing state is displayed.
|
||||
|
||||
.It Sy user
|
||||
Overrides values for the specified user.
|
||||
See
|
||||
.Sx "USER OVERRIDES"
|
||||
for details on the format.
|
||||
|
||||
.It Sy username_display
|
||||
Defines how usernames are shown for message senders.
|
||||
Possible values are
|
||||
.Dq Sy username ,
|
||||
.Dq Sy localpart , or
|
||||
.Dq Sy displayname .
|
||||
|
||||
.It Sy user_gutter_width
|
||||
Specify the width of the column where usernames are displayed in a room.
|
||||
Usernames that are too long are truncated.
|
||||
Defaults to 30.
|
||||
|
||||
.It Sy tabstop
|
||||
Number of spaces that a <Tab> counts for.
|
||||
Defaults to 4.
|
||||
.El
|
||||
|
||||
.Ss Example 1: Avoid showing Emojis (useful for terminals w/o support)
|
||||
.Bd -literal -offset indent
|
||||
[settings]
|
||||
username = "username"
|
||||
message_shortcode_display = true
|
||||
reaction_shortcode_display = true
|
||||
.Ed
|
||||
|
||||
.Ss Example 2: Increase request timeout to 2 minutes for a slow homeserver
|
||||
.Bd -literal -offset indent
|
||||
[settings]
|
||||
request_timeout = 120
|
||||
.Ed
|
||||
|
||||
.Sh NOTIFICATIONS
|
||||
|
||||
The
|
||||
.Sy settings.notifications
|
||||
subsection allows configuring how notifications for new messages behave.
|
||||
|
||||
The available fields in this subsection are:
|
||||
.Bl -tag -width Ds
|
||||
.It Sy enabled
|
||||
Defaults to
|
||||
.Sy false .
|
||||
Setting this field to
|
||||
.Sy true
|
||||
enables notifications.
|
||||
|
||||
.It Sy via
|
||||
Defaults to
|
||||
.Dq Sy desktop
|
||||
to use the desktop mechanism (default).
|
||||
Setting this field to
|
||||
.Dq Sy bell
|
||||
will use the terminal bell instead.
|
||||
Both can be used via
|
||||
.Dq Sy desktop|bell .
|
||||
|
||||
.It Sy show_message
|
||||
controls whether to show the message in the desktop notification, and defaults to
|
||||
.Sy true .
|
||||
Messages are truncated beyond a small length.
|
||||
The notification rules are stored server side, loaded once at startup, and are currently not configurable in iamb.
|
||||
In other words, you can simply change the rules with another client.
|
||||
.El
|
||||
|
||||
.Ss Example 1: Enable notifications with default options
|
||||
.Bd -literal -offset indent
|
||||
[settings]
|
||||
notifications = {}
|
||||
.Ed
|
||||
.Ss Example 2: Enable notifications using terminal bell
|
||||
.Bd -literal -offset indent
|
||||
[settings.notifications]
|
||||
via = "bell"
|
||||
show_message = false
|
||||
.Ed
|
||||
|
||||
.Sh "SORTING LISTS"
|
||||
|
||||
The
|
||||
.Sy settings.sort
|
||||
subsection allows configuring how different windows have their contents sorted.
|
||||
|
||||
Fields available within this subsection are:
|
||||
.Bl -tag -width Ds
|
||||
.It Sy rooms
|
||||
How to sort the
|
||||
.Sy :rooms
|
||||
window.
|
||||
Defaults to
|
||||
.Sy ["favorite",\ "lowpriority",\ "unread",\ "name"] .
|
||||
.It Sy chats
|
||||
How to sort the
|
||||
.Sy :chats
|
||||
window.
|
||||
Defaults to the
|
||||
.Sy rooms
|
||||
value.
|
||||
.It Sy dms
|
||||
How to sort the
|
||||
.Sy :dms
|
||||
window.
|
||||
Defaults to the
|
||||
.Sy rooms
|
||||
value.
|
||||
.It Sy spaces
|
||||
How to sort the
|
||||
.Sy :spaces
|
||||
window.
|
||||
Defaults to the
|
||||
.Sy rooms
|
||||
value.
|
||||
.It Sy members
|
||||
How to sort the
|
||||
.Sy :members
|
||||
window.
|
||||
Defaults to
|
||||
.Sy ["power",\ "id"] .
|
||||
.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
|
||||
|
||||
.Ss Example 1: Group room members by their server first
|
||||
.Bd -literal -offset indent
|
||||
[settings.sort]
|
||||
members = ["server", "localpart"]
|
||||
.Ed
|
||||
|
||||
.Sh "USER OVERRIDES"
|
||||
|
||||
The
|
||||
.Sy settings.users
|
||||
subsections allows overriding how specific senders are displayed.
|
||||
Overrides are mapped onto Matrix User IDs such as
|
||||
.Sy @user:matrix.org ,
|
||||
and are typically written as inline tables containing the following keys:
|
||||
|
||||
.Bl -tag -width Ds
|
||||
.It Sy name
|
||||
Change the display name of the user.
|
||||
|
||||
.It Sy color
|
||||
Change the color the user is shown as.
|
||||
Possible values are:
|
||||
.Dq Sy black ,
|
||||
.Dq Sy blue ,
|
||||
.Dq Sy cyan ,
|
||||
.Dq Sy dark-gray ,
|
||||
.Dq Sy gray ,
|
||||
.Dq Sy green ,
|
||||
.Dq Sy light-blue ,
|
||||
.Dq Sy light-cyan ,
|
||||
.Dq Sy light-green ,
|
||||
.Dq Sy light-magenta ,
|
||||
.Dq Sy light-red ,
|
||||
.Dq Sy light-yellow ,
|
||||
.Dq Sy magenta ,
|
||||
.Dq Sy none ,
|
||||
.Dq Sy red ,
|
||||
.Dq Sy white ,
|
||||
and
|
||||
.Dq Sy yellow .
|
||||
.El
|
||||
|
||||
.Ss Example 1: Override how @ada:example.com appears in chat
|
||||
.Bd -literal -offset indent
|
||||
[settings.users]
|
||||
"@ada:example.com" = { name = "Ada Lovelace", color = "light-red" }
|
||||
.Ed
|
||||
|
||||
.Sh STARTUP LAYOUT
|
||||
|
||||
The
|
||||
.Sy layout
|
||||
section allows configuring the initial set of tabs and windows to show when
|
||||
starting the client.
|
||||
|
||||
.Bl -tag -width Ds
|
||||
.It Sy style
|
||||
Specifies what window layout to load when starting.
|
||||
Valid values are
|
||||
.Dq Sy restore
|
||||
to restore the layout from the last time the client was exited,
|
||||
.Dq Sy new
|
||||
to open a single window (uses the value of
|
||||
.Sy default_room
|
||||
if set), or
|
||||
.Dq Sy config
|
||||
to open the layout described under
|
||||
.Sy tabs .
|
||||
|
||||
.It Sy tabs
|
||||
If
|
||||
.Sy style
|
||||
is set to
|
||||
.Sy config ,
|
||||
then this value will be used to open a set of tabs and windows at startup.
|
||||
Each object can contain either a
|
||||
.Sy window
|
||||
key specifying a username, room identifier or room alias to show, or a
|
||||
.Sy split
|
||||
key specifying an array of window objects.
|
||||
.El
|
||||
|
||||
.Ss Example 1: Show a single room every startup
|
||||
.Bd -literal -offset indent
|
||||
[settings]
|
||||
default_room = "#iamb-users:0x.badd.cafe"
|
||||
|
||||
[layout]
|
||||
style = "new"
|
||||
.Ed
|
||||
.Ss Example 2: Show a specific layout every startup
|
||||
.Bd -literal -offset indent
|
||||
[layout]
|
||||
style = "config"
|
||||
|
||||
[[layout.tabs]]
|
||||
window = "iamb://dms"
|
||||
|
||||
[[layout.tabs]]
|
||||
window = "iamb://rooms"
|
||||
|
||||
[[layout.tabs]]
|
||||
split = [
|
||||
{ "window" = "#iamb-users:0x.badd.cafe" },
|
||||
{ "window" = "#iamb-dev:0x.badd.cafe" }
|
||||
]
|
||||
.Ed
|
||||
|
||||
.Sh "CUSTOM KEYBINDINGS"
|
||||
|
||||
The
|
||||
.Sy macros
|
||||
subsections allow configuring custom keybindings.
|
||||
Available subsections are:
|
||||
|
||||
.Bl -tag -width Ds
|
||||
.It Sy insert , Sy i
|
||||
Map the key sequences in this section in
|
||||
.Sy Insert
|
||||
mode.
|
||||
|
||||
.It Sy normal , Sy n
|
||||
Map the key sequences in this section in
|
||||
.Sy Normal
|
||||
mode.
|
||||
|
||||
.It Sy visual , Sy v
|
||||
Map the key sequences in this section in
|
||||
.Sy Visual
|
||||
mode.
|
||||
|
||||
.It Sy select
|
||||
Map the key sequences in this section in
|
||||
.Sy Select
|
||||
mode.
|
||||
|
||||
.It Sy command , Sy c
|
||||
Map the key sequences in this section in
|
||||
.Sy Visual
|
||||
mode.
|
||||
|
||||
.It Sy operator-pending
|
||||
Map the key sequences in this section in
|
||||
.Sy "Operator Pending"
|
||||
mode.
|
||||
.El
|
||||
|
||||
Multiple modes can be given together by separating their names with
|
||||
.Dq Sy | .
|
||||
|
||||
.Ss Example 1: Use "jj" to exit Insert mode
|
||||
.Bd -literal -offset indent
|
||||
[macros.insert]
|
||||
"jj" = "<Esc>"
|
||||
.Ed
|
||||
|
||||
.Ss Example 2: Use "V" for switching between message bar and room history
|
||||
.Bd -literal -offset indent
|
||||
[macros."normal|visual"]
|
||||
"V" = "<C-W>m"
|
||||
.Ed
|
||||
|
||||
.Sh DIRECTORIES
|
||||
|
||||
Specifies the directories to save data in.
|
||||
Configured as an object under the key
|
||||
.Sy dirs .
|
||||
|
||||
.Bl -tag -width Ds
|
||||
.It Sy cache
|
||||
Specifies where to store assets and temporary data in.
|
||||
(For example,
|
||||
.Sy image_preview
|
||||
and
|
||||
.Sy logs
|
||||
will also go in here by default.)
|
||||
Defaults to
|
||||
.Ev $XDG_CACHE_HOME/iamb .
|
||||
|
||||
.It Sy data
|
||||
Specifies where to store persistent data in, such as E2EE room keys.
|
||||
Defaults to
|
||||
.Ev $XDG_DATA_HOME/iamb .
|
||||
|
||||
.It Sy downloads
|
||||
Specifies where to store downloaded files.
|
||||
Defaults to
|
||||
.Ev $XDG_DOWNLOAD_DIR .
|
||||
|
||||
.It Sy image_previews
|
||||
Specifies where to store automatically downloaded image previews.
|
||||
Defaults to
|
||||
.Ev ${cache}/image_preview_downloads .
|
||||
|
||||
.It Sy logs
|
||||
Specifies where to store log files.
|
||||
Defaults to
|
||||
.Ev ${cache}/logs .
|
||||
.El
|
||||
.Sh FILES
|
||||
.Bl -tag -width Ds
|
||||
.It Pa ~/.config/iamb/config.toml
|
||||
The TOML configuration file that
|
||||
.Sy iamb
|
||||
loads by default.
|
||||
.It Pa ~/.config/iamb/config.json
|
||||
A JSON configuration file that
|
||||
.Sy iamb
|
||||
will load if the TOML one is not found.
|
||||
.It Pa /usr/share/iamb/config.example.toml
|
||||
A sample configuration file with examples of how to set different values.
|
||||
.El
|
||||
.Sh "REPORTING BUGS"
|
||||
Please report bugs in
|
||||
.Sy iamb
|
||||
or its manual pages at
|
||||
.Lk https://github.com/ulyssa/iamb/issues
|
||||
.Sh SEE ALSO
|
||||
.Xr iamb 1
|
||||
.Pp
|
||||
Extended documentation is available online at
|
||||
.Lk https://iamb.chat
|
||||
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>
|
||||
BIN
docs/iamb.png
Normal file
BIN
docs/iamb.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
128
docs/iamb.svg
Normal file
128
docs/iamb.svg
Normal file
@@ -0,0 +1,128 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="120"
|
||||
height="120"
|
||||
viewBox="0 0 120 120"
|
||||
version="1.1"
|
||||
id="svg5"
|
||||
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
|
||||
sodipodi:docname="iamb.svg"
|
||||
inkscape:export-filename="iamb.png"
|
||||
inkscape:export-xdpi="288"
|
||||
inkscape:export-ydpi="288"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<sodipodi:namedview
|
||||
id="namedview7"
|
||||
pagecolor="#505050"
|
||||
bordercolor="#eeeeee"
|
||||
borderopacity="1"
|
||||
inkscape:showpageshadow="0"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#505050"
|
||||
inkscape:document-units="px"
|
||||
showgrid="false"
|
||||
inkscape:zoom="4.3724198"
|
||||
inkscape:cx="2.5157694"
|
||||
inkscape:cy="43.11114"
|
||||
inkscape:window-width="1850"
|
||||
inkscape:window-height="1016"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="0"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="layer1" />
|
||||
<defs
|
||||
id="defs2">
|
||||
<rect
|
||||
x="69.359197"
|
||||
y="2.6803692"
|
||||
width="66.742953"
|
||||
height="18.624167"
|
||||
id="rect15628" />
|
||||
<rect
|
||||
x="2.8780095"
|
||||
y="32.203989"
|
||||
width="116.94288"
|
||||
height="87.251209"
|
||||
id="rect14838" />
|
||||
</defs>
|
||||
<g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1">
|
||||
<rect
|
||||
id="rect111"
|
||||
width="119.99836"
|
||||
height="119.79127"
|
||||
x="0.0058150524"
|
||||
y="0.21117544"
|
||||
ry="18.295183"
|
||||
style="fill:#201127;fill-opacity:1;stroke-width:0.997728" />
|
||||
<path
|
||||
id="rect111-3"
|
||||
style="fill:#1b1e34;fill-opacity:1;stroke-width:0.997728"
|
||||
d="m 18.321605,-0.01480561 c -10.1355215,0 -18.29492247,8.15940011 -18.29492247,18.29492161 v 4.564453 H 119.99738 V 17.733241 C 119.70734,7.8552235 111.68056,-0.01480561 101.7298,-0.01480561 Z" />
|
||||
<ellipse
|
||||
style="fill:#c24b6e;fill-opacity:1"
|
||||
id="path4855"
|
||||
cx="105.25824"
|
||||
cy="12.000000"
|
||||
rx="5.9108677"
|
||||
ry="5.9019933" />
|
||||
<ellipse
|
||||
style="fill:#ffeb99;fill-opacity:1"
|
||||
id="path4855-6"
|
||||
cx="91.251190"
|
||||
cy="12.000000"
|
||||
rx="5.9108677"
|
||||
ry="5.9019933" />
|
||||
<ellipse
|
||||
style="fill:#6aaf9d;fill-opacity:1"
|
||||
id="path4855-7"
|
||||
cx="77.244141"
|
||||
cy="12.000000"
|
||||
rx="5.9108677"
|
||||
ry="5.9019933" />
|
||||
<g
|
||||
aria-label="◡–"
|
||||
transform="translate(-0.25103084,-17.617149)"
|
||||
id="text14836"
|
||||
style="font-size:77.3333px;line-height:1.25;font-family:monospace;-inkscape-font-specification:'monospace, Normal';text-align:center;letter-spacing:0px;word-spacing:0px;white-space:pre;shape-inside:url(#rect14838);shape-padding:0.114105;display:inline">
|
||||
<path
|
||||
d="m 38.072257,96.572677 q -4.485331,0 -8.351996,-2.319999 -3.866665,-2.397332 -6.263997,-6.263997 -2.319999,-3.866665 -2.319999,-8.506662 h 3.247999 q 0,3.711998 1.855999,6.882663 1.933332,3.093332 5.026664,5.026664 3.170665,1.933333 6.80533,1.933333 3.711999,0 6.882664,-1.933333 3.170665,-1.933332 5.026664,-5.026664 1.933333,-3.170665 1.933333,-6.882663 h 3.247998 q 0,4.485331 -2.319999,8.429329 -2.319999,3.866665 -6.263997,6.263997 -3.866665,2.397332 -8.506663,2.397332 z"
|
||||
style="display:inline;fill:#ec9a6d"
|
||||
id="path809" />
|
||||
<path
|
||||
d="m 69.08294,84.895349 v -6.186663 h 30.93332 v 6.186663 z"
|
||||
style="display:inline;fill:#ec9a6d"
|
||||
id="path811" />
|
||||
</g>
|
||||
<g
|
||||
aria-label="iamb"
|
||||
transform="translate(-55.871719,2.2068568)"
|
||||
id="text15626"
|
||||
style="font-size:13.3333px;line-height:1.25;letter-spacing:0px;word-spacing:0px;white-space:pre;shape-inside:url(#rect15628);display:inline">
|
||||
<path
|
||||
d="m 71.026037,7.5196777 h 3.066399 v 6.3606613 h 2.376296 v 0.930987 h -5.950506 v -0.930987 h 2.376296 V 8.4506649 h -1.868485 z m 1.868485,-2.8385345 h 1.197914 v 1.5169233 h -1.197914 z"
|
||||
style="font-family:'Bitstream Vera Sans Mono';-inkscape-font-specification:'Bitstream Vera Sans Mono, Normal';fill:#ec9a6d"
|
||||
id="path800" />
|
||||
<path
|
||||
d="m 81.957,11.145971 h -0.397135 q -1.048174,0 -1.582027,0.371092 -0.527342,0.364583 -0.527342,1.093748 0,0.65755 0.397134,1.022133 0.397134,0.364582 1.100258,0.364582 0.98958,0 1.555985,-0.683592 0.566405,-0.690103 0.572916,-1.901037 v -0.266926 z m 2.324213,-0.494791 v 4.160146 H 83.076789 V 13.7306 q -0.384114,0.65104 -0.97005,0.963539 -0.579426,0.305989 -1.412757,0.305989 -1.113278,0 -1.777339,-0.624999 -0.664061,-0.631509 -0.664061,-1.686194 0,-1.217444 0.8138,-1.848953 0.82031,-0.631509 2.402338,-0.631509 h 1.608069 v -0.188802 q -0.0065,-0.8723932 -0.442708,-1.2630172 -0.436197,-0.3971345 -1.393225,-0.3971345 -0.611978,0 -1.236976,0.1757808 -0.624999,0.1757809 -1.217445,0.5143217 V 7.8517081 q 0.664061,-0.2539056 1.269528,-0.3776032 0.611977,-0.130208 1.184893,-0.130208 0.904945,0 1.542964,0.2669264 0.64453,0.2669264 1.041665,0.8007792 0.247395,0.3255201 0.351561,0.8072897 0.104167,0.4752592 0.104167,1.4322878 z"
|
||||
style="font-family:'Bitstream Vera Sans Mono';-inkscape-font-specification:'Bitstream Vera Sans Mono, Normal';fill:#ec9a6d"
|
||||
id="path802" />
|
||||
<path
|
||||
d="m 89.815053,8.2618633 q 0.221354,-0.4687488 0.559894,-0.6901024 0.345052,-0.227864 0.826821,-0.227864 0.878904,0 1.236976,0.683592 0.364583,0.6770817 0.364583,2.5585871 v 4.22525 h -1.093748 v -4.173167 q 0,-1.5429644 -0.17578,-1.9140572 -0.169271,-0.3776033 -0.624999,-0.3776033 -0.520832,0 -0.716144,0.4036449 -0.188801,0.3971344 -0.188801,1.8880156 v 4.173167 h -1.093748 v -4.173167 q 0,-1.5624956 -0.188801,-1.927078 -0.182291,-0.3645825 -0.664061,-0.3645825 -0.475259,0 -0.664061,0.4036449 -0.182291,0.3971344 -0.182291,1.8880156 v 4.173167 H 86.123656 V 7.5196777 h 1.087237 v 0.6249984 q 0.214843,-0.390624 0.533853,-0.5924464 0.32552,-0.2083328 0.735675,-0.2083328 0.49479,0 0.82031,0.227864 0.332031,0.227864 0.514322,0.6901024 z"
|
||||
style="font-family:'Bitstream Vera Sans Mono';-inkscape-font-specification:'Bitstream Vera Sans Mono, Normal';fill:#ec9a6d"
|
||||
id="path804" />
|
||||
<path
|
||||
d="m 99.417893,11.172012 q 0,-1.3932254 -0.442708,-2.102859 -0.442707,-0.7096337 -1.30859,-0.7096337 -0.872394,0 -1.321611,0.7161441 -0.449218,0.7096336 -0.449218,2.0963486 0,1.380205 0.449218,2.096349 0.449217,0.716144 1.321611,0.716144 0.865883,0 1.30859,-0.709633 0.442708,-0.709634 0.442708,-2.10286 z M 95.895766,8.4506649 q 0.286458,-0.5338528 0.787759,-0.8203104 0.507811,-0.2864576 1.171872,-0.2864576 1.3151,0 2.070307,1.0156224 0.755206,1.0091121 0.755206,2.7864517 0,1.80338 -0.761717,2.832024 -0.755206,1.022133 -2.076817,1.022133 -0.65104,0 -1.152341,-0.279948 -0.49479,-0.286457 -0.794269,-0.82682 v 0.917966 H 94.697852 V 4.6811432 h 1.197914 z"
|
||||
style="font-family:'Bitstream Vera Sans Mono';-inkscape-font-specification:'Bitstream Vera Sans Mono, Normal';fill:#ec9a6d"
|
||||
id="path806" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7.1 KiB |
96
flake.lock
generated
Normal file
96
flake.lock
generated
Normal file
@@ -0,0 +1,96 @@
|
||||
{
|
||||
"nodes": {
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1731533236,
|
||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1736883708,
|
||||
"narHash": "sha256-uQ+NQ0/xYU0N1CnXsa2zghgNaOPxWpMJXSUJJ9W7140=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "eb62e6aa39ea67e0b8018ba8ea077efe65807dc8",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nixos",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs_2": {
|
||||
"locked": {
|
||||
"lastModified": 1736320768,
|
||||
"narHash": "sha256-nIYdTAiKIGnFNugbomgBJR+Xv5F1ZQU+HfaBqJKroC0=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "4bc9c909d9ac828a039f288cf872d16d38185db8",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixpkgs-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs",
|
||||
"rust-overlay": "rust-overlay"
|
||||
}
|
||||
},
|
||||
"rust-overlay": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs_2"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1736994333,
|
||||
"narHash": "sha256-v4Jrok5yXsZ6dwj2+2uo5cSyUi9fBTurHqHvNHLT1XA=",
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"rev": "848db855cb9e88785996e961951659570fc58814",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"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",
|
||||
"version": 7
|
||||
}
|
||||
45
flake.nix
Normal file
45
flake.nix
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
description = "iamb";
|
||||
nixConfig.bash-prompt = "\[nix-develop\]$ ";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
rust-overlay.url = "github:oxalica/rust-overlay";
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, flake-utils, rust-overlay, ... }:
|
||||
flake-utils.lib.eachDefaultSystem (system:
|
||||
let
|
||||
# We only need the nightly overlay in the devShell because .rs files are formatted with nightly.
|
||||
overlays = [ (import rust-overlay) ];
|
||||
pkgs = import nixpkgs { inherit system overlays; };
|
||||
rustNightly = pkgs.rust-bin.nightly."2024-12-12".default;
|
||||
in
|
||||
with pkgs;
|
||||
{
|
||||
packages.default = rustPlatform.buildRustPackage {
|
||||
pname = "iamb";
|
||||
version = self.shortRev or self.dirtyShortRev;
|
||||
src = ./.;
|
||||
cargoLock = {
|
||||
lockFile = ./Cargo.lock;
|
||||
};
|
||||
nativeBuildInputs = [ pkg-config ];
|
||||
buildInputs = [ openssl ] ++ lib.optionals stdenv.isDarwin
|
||||
(with darwin.apple_sdk.frameworks; [ AppKit Security Cocoa ]);
|
||||
};
|
||||
|
||||
devShell = mkShell {
|
||||
buildInputs = [
|
||||
(rustNightly.override {
|
||||
extensions = [ "rust-src" "rust-analyzer-preview" "rustfmt" "clippy" ];
|
||||
})
|
||||
pkg-config
|
||||
cargo-tarpaulin
|
||||
cargo-watch
|
||||
sqlite
|
||||
];
|
||||
};
|
||||
});
|
||||
}
|
||||
12
iamb.desktop
Normal file
12
iamb.desktop
Normal file
@@ -0,0 +1,12 @@
|
||||
[Desktop Entry]
|
||||
Categories=Network;InstantMessaging;Chat;
|
||||
Comment=A Matrix client for Vim addicts
|
||||
Exec=iamb
|
||||
GenericName=Matrix Client
|
||||
Keywords=Matrix;matrix.org;chat;communications;talk;
|
||||
Name=iamb
|
||||
Icon=iamb
|
||||
StartupNotify=false
|
||||
Terminal=true
|
||||
TryExec=iamb
|
||||
Type=Application
|
||||
3
rust-toolchain.toml
Normal file
3
rust-toolchain.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
[toolchain]
|
||||
channel = "1.83"
|
||||
components = [ "clippy" ]
|
||||
1687
src/base.rs
1687
src/base.rs
File diff suppressed because it is too large
Load Diff
664
src/commands.rs
664
src/commands.rs
@@ -1,12 +1,15 @@
|
||||
use std::convert::TryFrom;
|
||||
//! # Default Commands
|
||||
//!
|
||||
//! 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.
|
||||
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::{
|
||||
editing::base::OpenTarget,
|
||||
commands::{CommandError, CommandResult, CommandStep},
|
||||
env::vim::command::{CommandContext, CommandDescription, OptionType},
|
||||
input::commands::{CommandError, CommandResult, CommandStep},
|
||||
input::InputContext,
|
||||
prelude::OpenTarget,
|
||||
};
|
||||
|
||||
use crate::base::{
|
||||
@@ -16,22 +19,24 @@ use crate::base::{
|
||||
HomeserverAction,
|
||||
IambAction,
|
||||
IambId,
|
||||
KeysAction,
|
||||
MemberUpdateAction,
|
||||
MessageAction,
|
||||
ProgramCommand,
|
||||
ProgramCommands,
|
||||
ProgramContext,
|
||||
RoomAction,
|
||||
RoomField,
|
||||
SendAction,
|
||||
SpaceAction,
|
||||
VerifyAction,
|
||||
};
|
||||
|
||||
type ProgContext = CommandContext<ProgramContext>;
|
||||
type ProgContext = CommandContext;
|
||||
type ProgResult = CommandResult<ProgramCommand>;
|
||||
|
||||
/// Convert strings the user types into a tag name.
|
||||
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,
|
||||
"low" | "lowpriority" | "low_priority" | "low-priority" | "m.lowpriority" => {
|
||||
TagName::LowPriority
|
||||
@@ -95,7 +100,30 @@ fn iamb_invite(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
||||
};
|
||||
|
||||
let iact = IambAction::from(ract);
|
||||
let step = CommandStep::Continue(iact.into(), ctx.context.take());
|
||||
let step = CommandStep::Continue(iact.into(), ctx.context.clone());
|
||||
|
||||
return Ok(step);
|
||||
}
|
||||
|
||||
fn iamb_keys(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
||||
let mut args = desc.arg.strings()?;
|
||||
|
||||
if args.len() != 3 {
|
||||
return Err(CommandError::InvalidArgument);
|
||||
}
|
||||
|
||||
let act = args.remove(0);
|
||||
let path = args.remove(0);
|
||||
let passphrase = args.remove(0);
|
||||
|
||||
let act = match act.as_str() {
|
||||
"export" => KeysAction::Export(path, passphrase),
|
||||
"import" => KeysAction::Import(path, passphrase),
|
||||
_ => return Err(CommandError::InvalidArgument),
|
||||
};
|
||||
|
||||
let vact = IambAction::Keys(act);
|
||||
let step = CommandStep::Continue(vact.into(), ctx.context.clone());
|
||||
|
||||
return Ok(step);
|
||||
}
|
||||
@@ -106,7 +134,7 @@ fn iamb_verify(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
||||
match args.len() {
|
||||
0 => {
|
||||
let open = ctx.switch(OpenTarget::Application(IambId::VerifyList));
|
||||
let step = CommandStep::Continue(open, ctx.context.take());
|
||||
let step = CommandStep::Continue(open, ctx.context.clone());
|
||||
|
||||
return Ok(step);
|
||||
},
|
||||
@@ -121,7 +149,7 @@ fn iamb_verify(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
||||
"mismatch" => VerifyAction::Mismatch,
|
||||
"request" => {
|
||||
let iact = IambAction::VerifyRequest(args.remove(1));
|
||||
let step = CommandStep::Continue(iact.into(), ctx.context.take());
|
||||
let step = CommandStep::Continue(iact.into(), ctx.context.clone());
|
||||
|
||||
return Ok(step);
|
||||
},
|
||||
@@ -129,7 +157,7 @@ fn iamb_verify(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
||||
};
|
||||
|
||||
let vact = IambAction::Verify(act, args.remove(1));
|
||||
let step = CommandStep::Continue(vact.into(), ctx.context.take());
|
||||
let step = CommandStep::Continue(vact.into(), ctx.context.clone());
|
||||
|
||||
return Ok(step);
|
||||
},
|
||||
@@ -145,7 +173,7 @@ fn iamb_dms(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
||||
}
|
||||
|
||||
let open = ctx.switch(OpenTarget::Application(IambId::DirectList));
|
||||
let step = CommandStep::Continue(open, ctx.context.take());
|
||||
let step = CommandStep::Continue(open, ctx.context.clone());
|
||||
|
||||
return Ok(step);
|
||||
}
|
||||
@@ -156,7 +184,18 @@ fn iamb_members(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
||||
}
|
||||
|
||||
let open = IambAction::Room(RoomAction::Members(ctx.clone().into()));
|
||||
let step = CommandStep::Continue(open.into(), ctx.context.take());
|
||||
let step = CommandStep::Continue(open.into(), ctx.context.clone());
|
||||
|
||||
return Ok(step);
|
||||
}
|
||||
|
||||
fn iamb_leave(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
||||
if !desc.arg.text.is_empty() {
|
||||
return Result::Err(CommandError::InvalidArgument);
|
||||
}
|
||||
|
||||
let leave = IambAction::Room(RoomAction::Leave(desc.bang));
|
||||
let step = CommandStep::Continue(leave.into(), ctx.context.clone());
|
||||
|
||||
return Ok(step);
|
||||
}
|
||||
@@ -166,8 +205,8 @@ fn iamb_cancel(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
||||
return Result::Err(CommandError::InvalidArgument);
|
||||
}
|
||||
|
||||
let mact = IambAction::from(MessageAction::Cancel);
|
||||
let step = CommandStep::Continue(mact.into(), ctx.context.take());
|
||||
let mact = IambAction::from(MessageAction::Cancel(desc.bang));
|
||||
let step = CommandStep::Continue(mact.into(), ctx.context.clone());
|
||||
|
||||
return Ok(step);
|
||||
}
|
||||
@@ -178,30 +217,23 @@ fn iamb_edit(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
||||
}
|
||||
|
||||
let mact = IambAction::from(MessageAction::Edit);
|
||||
let step = CommandStep::Continue(mact.into(), ctx.context.take());
|
||||
let step = CommandStep::Continue(mact.into(), ctx.context.clone());
|
||||
|
||||
return Ok(step);
|
||||
}
|
||||
|
||||
fn iamb_react(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
||||
let args = desc.arg.strings()?;
|
||||
let mut args = desc.arg.strings()?;
|
||||
|
||||
if args.len() != 1 {
|
||||
return Result::Err(CommandError::InvalidArgument);
|
||||
}
|
||||
|
||||
let k = args[0].as_str();
|
||||
|
||||
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.take());
|
||||
let react = args.remove(0);
|
||||
let mact = IambAction::from(MessageAction::React(react, desc.bang));
|
||||
let step = CommandStep::Continue(mact.into(), ctx.context.clone());
|
||||
|
||||
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 {
|
||||
@@ -211,21 +243,9 @@ fn iamb_unreact(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
||||
return Result::Err(CommandError::InvalidArgument);
|
||||
}
|
||||
|
||||
let mact = if let Some(k) = args.pop() {
|
||||
let k = k.as_str();
|
||||
|
||||
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.take());
|
||||
let reaction = args.pop();
|
||||
let mact = IambAction::from(MessageAction::Unreact(reaction, desc.bang));
|
||||
let step = CommandStep::Continue(mact.into(), ctx.context.clone());
|
||||
|
||||
return Ok(step);
|
||||
}
|
||||
@@ -237,8 +257,9 @@ fn iamb_redact(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
||||
return Result::Err(CommandError::InvalidArgument);
|
||||
}
|
||||
|
||||
let ract = IambAction::from(MessageAction::Redact(args.into_iter().next()));
|
||||
let step = CommandStep::Continue(ract.into(), ctx.context.take());
|
||||
let reason = args.into_iter().next();
|
||||
let ract = IambAction::from(MessageAction::Redact(reason, desc.bang));
|
||||
let step = CommandStep::Continue(ract.into(), ctx.context.clone());
|
||||
|
||||
return Ok(step);
|
||||
}
|
||||
@@ -249,7 +270,18 @@ fn iamb_reply(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
||||
}
|
||||
|
||||
let ract = IambAction::from(MessageAction::Reply);
|
||||
let step = CommandStep::Continue(ract.into(), ctx.context.take());
|
||||
let step = CommandStep::Continue(ract.into(), ctx.context.clone());
|
||||
|
||||
return Ok(step);
|
||||
}
|
||||
|
||||
fn iamb_editor(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
||||
if !desc.arg.text.is_empty() {
|
||||
return Result::Err(CommandError::InvalidArgument);
|
||||
}
|
||||
|
||||
let sact = IambAction::from(SendAction::SubmitFromEditor);
|
||||
let step = CommandStep::Continue(sact.into(), ctx.context.clone());
|
||||
|
||||
return Ok(step);
|
||||
}
|
||||
@@ -260,18 +292,53 @@ fn iamb_rooms(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
||||
}
|
||||
|
||||
let open = ctx.switch(OpenTarget::Application(IambId::RoomList));
|
||||
let step = CommandStep::Continue(open, ctx.context.take());
|
||||
let step = CommandStep::Continue(open, ctx.context.clone());
|
||||
|
||||
return Ok(step);
|
||||
}
|
||||
|
||||
fn iamb_chats(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
||||
if !desc.arg.text.is_empty() {
|
||||
return Result::Err(CommandError::InvalidArgument);
|
||||
}
|
||||
|
||||
let open = ctx.switch(OpenTarget::Application(IambId::ChatList));
|
||||
let step = CommandStep::Continue(open, ctx.context.clone());
|
||||
|
||||
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 {
|
||||
if !desc.arg.text.is_empty() {
|
||||
return Result::Err(CommandError::InvalidArgument);
|
||||
}
|
||||
|
||||
let open = ctx.switch(OpenTarget::Application(IambId::SpaceList));
|
||||
let step = CommandStep::Continue(open, ctx.context.take());
|
||||
let step = CommandStep::Continue(open, ctx.context.clone());
|
||||
|
||||
return Ok(step);
|
||||
}
|
||||
@@ -282,7 +349,7 @@ fn iamb_welcome(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
||||
}
|
||||
|
||||
let open = ctx.switch(OpenTarget::Application(IambId::Welcome));
|
||||
let step = CommandStep::Continue(open, ctx.context.take());
|
||||
let step = CommandStep::Continue(open, ctx.context.clone());
|
||||
|
||||
return Ok(step);
|
||||
}
|
||||
@@ -295,7 +362,7 @@ fn iamb_join(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
||||
}
|
||||
|
||||
let open = ctx.switch(args.remove(0));
|
||||
let step = CommandStep::Continue(open, ctx.context.take());
|
||||
let step = CommandStep::Continue(open, ctx.context.clone());
|
||||
|
||||
return Ok(step);
|
||||
}
|
||||
@@ -342,7 +409,7 @@ fn iamb_create(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
||||
|
||||
let hact = HomeserverAction::CreateRoom(alias, ct, flags);
|
||||
let iact = IambAction::from(hact);
|
||||
let step = CommandStep::Continue(iact.into(), ctx.context.take());
|
||||
let step = CommandStep::Continue(iact.into(), ctx.context.clone());
|
||||
|
||||
return Ok(step);
|
||||
}
|
||||
@@ -362,6 +429,37 @@ fn iamb_room(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
||||
}
|
||||
|
||||
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>
|
||||
("name", "set", Some(s)) => RoomAction::Set(RoomField::Name, s).into(),
|
||||
("name", "set", None) => return Result::Err(CommandError::InvalidArgument),
|
||||
@@ -378,6 +476,10 @@ fn iamb_room(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
||||
("topic", "unset", None) => RoomAction::Unset(RoomField::Topic).into(),
|
||||
("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>
|
||||
("tag", "set", Some(s)) => RoomAction::Set(RoomField::Tag(tag_name(s)?), "".into()).into(),
|
||||
("tag", "set", None) => return Result::Err(CommandError::InvalidArgument),
|
||||
@@ -386,10 +488,143 @@ fn iamb_room(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
||||
("tag", "unset", Some(s)) => RoomAction::Unset(RoomField::Tag(tag_name(s)?)).into(),
|
||||
("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.take());
|
||||
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),
|
||||
};
|
||||
|
||||
let step = CommandStep::Continue(act.into(), ctx.context.clone());
|
||||
|
||||
return Ok(step);
|
||||
}
|
||||
@@ -403,7 +638,7 @@ fn iamb_upload(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
||||
|
||||
let sact = SendAction::Upload(args.remove(0));
|
||||
let iact = IambAction::from(sact);
|
||||
let step = CommandStep::Continue(iact.into(), ctx.context.take());
|
||||
let step = CommandStep::Continue(iact.into(), ctx.context.clone());
|
||||
|
||||
return Ok(step);
|
||||
}
|
||||
@@ -421,7 +656,7 @@ fn iamb_download(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult
|
||||
};
|
||||
let mact = MessageAction::Download(args.pop(), flags);
|
||||
let iact = IambAction::from(mact);
|
||||
let step = CommandStep::Continue(iact.into(), ctx.context.take());
|
||||
let step = CommandStep::Continue(iact.into(), ctx.context.clone());
|
||||
|
||||
return Ok(step);
|
||||
}
|
||||
@@ -439,7 +674,23 @@ fn iamb_open(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
||||
};
|
||||
let mact = MessageAction::Download(args.pop(), flags);
|
||||
let iact = IambAction::from(mact);
|
||||
let step = CommandStep::Continue(iact.into(), ctx.context.take());
|
||||
let step = CommandStep::Continue(iact.into(), ctx.context.clone());
|
||||
|
||||
return Ok(step);
|
||||
}
|
||||
|
||||
fn iamb_logout(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
||||
let args = desc.arg.strings()?;
|
||||
|
||||
if args.is_empty() {
|
||||
return Result::Err(CommandError::Error("Missing username".to_string()));
|
||||
}
|
||||
if args.len() != 1 {
|
||||
return Result::Err(CommandError::InvalidArgument);
|
||||
}
|
||||
|
||||
let iact = IambAction::from(HomeserverAction::Logout(args[0].clone(), desc.bang));
|
||||
let step = CommandStep::Continue(iact.into(), ctx.context.clone());
|
||||
|
||||
return Ok(step);
|
||||
}
|
||||
@@ -455,6 +706,11 @@ fn add_iamb_commands(cmds: &mut ProgramCommands) {
|
||||
aliases: vec![],
|
||||
f: iamb_create,
|
||||
});
|
||||
cmds.add_command(ProgramCommand {
|
||||
name: "chats".into(),
|
||||
aliases: vec![],
|
||||
f: iamb_chats,
|
||||
});
|
||||
cmds.add_command(ProgramCommand { name: "dms".into(), aliases: vec![], f: iamb_dms });
|
||||
cmds.add_command(ProgramCommand {
|
||||
name: "download".into(),
|
||||
@@ -469,6 +725,12 @@ fn add_iamb_commands(cmds: &mut ProgramCommands) {
|
||||
f: iamb_invite,
|
||||
});
|
||||
cmds.add_command(ProgramCommand { name: "join".into(), aliases: vec![], f: iamb_join });
|
||||
cmds.add_command(ProgramCommand { name: "keys".into(), aliases: vec![], f: iamb_keys });
|
||||
cmds.add_command(ProgramCommand {
|
||||
name: "leave".into(),
|
||||
aliases: vec![],
|
||||
f: iamb_leave,
|
||||
});
|
||||
cmds.add_command(ProgramCommand {
|
||||
name: "members".into(),
|
||||
aliases: vec![],
|
||||
@@ -495,11 +757,21 @@ fn add_iamb_commands(cmds: &mut ProgramCommands) {
|
||||
f: iamb_rooms,
|
||||
});
|
||||
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 {
|
||||
name: "spaces".into(),
|
||||
aliases: vec![],
|
||||
f: iamb_spaces,
|
||||
});
|
||||
cmds.add_command(ProgramCommand {
|
||||
name: "unreads".into(),
|
||||
aliases: vec![],
|
||||
f: iamb_unreads,
|
||||
});
|
||||
cmds.add_command(ProgramCommand {
|
||||
name: "unreact".into(),
|
||||
aliases: vec![],
|
||||
@@ -520,8 +792,19 @@ fn add_iamb_commands(cmds: &mut ProgramCommands) {
|
||||
aliases: vec![],
|
||||
f: iamb_welcome,
|
||||
});
|
||||
cmds.add_command(ProgramCommand {
|
||||
name: "editor".into(),
|
||||
aliases: vec![],
|
||||
f: iamb_editor,
|
||||
});
|
||||
cmds.add_command(ProgramCommand {
|
||||
name: "logout".into(),
|
||||
aliases: vec![],
|
||||
f: iamb_logout,
|
||||
});
|
||||
}
|
||||
|
||||
/// Initialize the default command state.
|
||||
pub fn setup_commands() -> ProgramCommands {
|
||||
let mut cmds = ProgramCommands::default();
|
||||
|
||||
@@ -533,13 +816,14 @@ pub fn setup_commands() -> ProgramCommands {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use matrix_sdk::ruma::user_id;
|
||||
use modalkit::editing::action::WindowAction;
|
||||
use matrix_sdk::ruma::{room_id, user_id};
|
||||
use modalkit::actions::WindowAction;
|
||||
use modalkit::editing::context::EditContext;
|
||||
|
||||
#[test]
|
||||
fn test_cmd_verify() {
|
||||
let mut cmds = setup_commands();
|
||||
let ctx = ProgramContext::default();
|
||||
let ctx = EditContext::default();
|
||||
|
||||
let res = cmds.input_cmd(":verify", ctx.clone()).unwrap();
|
||||
let act = WindowAction::Switch(OpenTarget::Application(IambId::VerifyList));
|
||||
@@ -586,7 +870,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_cmd_join() {
|
||||
let mut cmds = setup_commands();
|
||||
let ctx = ProgramContext::default();
|
||||
let ctx = EditContext::default();
|
||||
|
||||
let res = cmds.input_cmd("join #foobar:example.com", ctx.clone()).unwrap();
|
||||
let act = WindowAction::Switch(OpenTarget::Name("#foobar:example.com".into()));
|
||||
@@ -606,7 +890,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_cmd_room_invalid() {
|
||||
let mut cmds = setup_commands();
|
||||
let ctx = ProgramContext::default();
|
||||
let ctx = EditContext::default();
|
||||
|
||||
let res = cmds.input_cmd("room", ctx.clone());
|
||||
assert_eq!(res, Err(CommandError::InvalidArgument));
|
||||
@@ -621,7 +905,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_cmd_room_topic_set() {
|
||||
let mut cmds = setup_commands();
|
||||
let ctx = ProgramContext::default();
|
||||
let ctx = EditContext::default();
|
||||
|
||||
let res = cmds
|
||||
.input_cmd("room topic set \"Lots of fun discussion!\"", ctx.clone())
|
||||
@@ -652,7 +936,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_cmd_room_name_invalid() {
|
||||
let mut cmds = setup_commands();
|
||||
let ctx = ProgramContext::default();
|
||||
let ctx = EditContext::default();
|
||||
|
||||
let res = cmds.input_cmd("room name", ctx.clone());
|
||||
assert_eq!(res, Err(CommandError::InvalidArgument));
|
||||
@@ -664,7 +948,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_cmd_room_name_set() {
|
||||
let mut cmds = setup_commands();
|
||||
let ctx = ProgramContext::default();
|
||||
let ctx = EditContext::default();
|
||||
|
||||
let res = cmds.input_cmd("room name set Development", ctx.clone()).unwrap();
|
||||
let act = RoomAction::Set(RoomField::Name, "Development".into());
|
||||
@@ -683,7 +967,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_cmd_room_name_unset() {
|
||||
let mut cmds = setup_commands();
|
||||
let ctx = ProgramContext::default();
|
||||
let ctx = EditContext::default();
|
||||
|
||||
let res = cmds.input_cmd("room name unset", ctx.clone()).unwrap();
|
||||
let act = RoomAction::Unset(RoomField::Name);
|
||||
@@ -693,10 +977,36 @@ mod tests {
|
||||
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]
|
||||
fn test_cmd_room_tag_set() {
|
||||
let mut cmds = setup_commands();
|
||||
let ctx = ProgramContext::default();
|
||||
let ctx = EditContext::default();
|
||||
|
||||
let res = cmds.input_cmd("room tag set favourite", ctx.clone()).unwrap();
|
||||
let act = RoomAction::Set(RoomField::Tag(TagName::Favorite), "".into());
|
||||
@@ -765,7 +1075,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_cmd_room_tag_unset() {
|
||||
let mut cmds = setup_commands();
|
||||
let ctx = ProgramContext::default();
|
||||
let ctx = EditContext::default();
|
||||
|
||||
let res = cmds.input_cmd("room tag unset favourite", ctx.clone()).unwrap();
|
||||
let act = RoomAction::Unset(RoomField::Tag(TagName::Favorite));
|
||||
@@ -827,10 +1137,128 @@ 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]
|
||||
fn test_cmd_invite() {
|
||||
let mut cmds = setup_commands();
|
||||
let ctx = ProgramContext::default();
|
||||
let ctx = EditContext::default();
|
||||
|
||||
let res = cmds.input_cmd("invite accept", ctx.clone()).unwrap();
|
||||
let act = IambAction::Room(RoomAction::InviteAccept);
|
||||
@@ -864,24 +1292,118 @@ mod tests {
|
||||
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]
|
||||
fn test_cmd_redact() {
|
||||
let mut cmds = setup_commands();
|
||||
let ctx = ProgramContext::default();
|
||||
let ctx = EditContext::default();
|
||||
|
||||
let res = cmds.input_cmd("redact", ctx.clone()).unwrap();
|
||||
let act = IambAction::Message(MessageAction::Redact(None));
|
||||
let act = IambAction::Message(MessageAction::Redact(None, false));
|
||||
assert_eq!(res, vec![(act.into(), ctx.clone())]);
|
||||
|
||||
let res = cmds.input_cmd("redact!", ctx.clone()).unwrap();
|
||||
let act = IambAction::Message(MessageAction::Redact(None, true));
|
||||
assert_eq!(res, vec![(act.into(), ctx.clone())]);
|
||||
|
||||
let res = cmds.input_cmd("redact Removed", ctx.clone()).unwrap();
|
||||
let act = IambAction::Message(MessageAction::Redact(Some("Removed".into())));
|
||||
let act = IambAction::Message(MessageAction::Redact(Some("Removed".into()), false));
|
||||
assert_eq!(res, vec![(act.into(), ctx.clone())]);
|
||||
|
||||
let res = cmds.input_cmd("redact \"Removed\"", ctx.clone()).unwrap();
|
||||
let act = IambAction::Message(MessageAction::Redact(Some("Removed".into())));
|
||||
let act = IambAction::Message(MessageAction::Redact(Some("Removed".into()), false));
|
||||
assert_eq!(res, vec![(act.into(), ctx.clone())]);
|
||||
|
||||
let res = cmds.input_cmd("redact Removed Removed", ctx.clone());
|
||||
assert_eq!(res, Err(CommandError::InvalidArgument));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cmd_keys() {
|
||||
let mut cmds = setup_commands();
|
||||
let ctx = EditContext::default();
|
||||
|
||||
let res = cmds.input_cmd("keys import /a/b/c pword", ctx.clone()).unwrap();
|
||||
let act = IambAction::Keys(KeysAction::Import("/a/b/c".into(), "pword".into()));
|
||||
assert_eq!(res, vec![(act.into(), ctx.clone())]);
|
||||
|
||||
let res = cmds.input_cmd("keys export /a/b/c pword", ctx.clone()).unwrap();
|
||||
let act = IambAction::Keys(KeysAction::Export("/a/b/c".into(), "pword".into()));
|
||||
assert_eq!(res, vec![(act.into(), ctx.clone())]);
|
||||
|
||||
// Invalid invocations.
|
||||
let res = cmds.input_cmd("keys", ctx.clone());
|
||||
assert_eq!(res, Err(CommandError::InvalidArgument));
|
||||
|
||||
let res = cmds.input_cmd("keys import", ctx.clone());
|
||||
assert_eq!(res, Err(CommandError::InvalidArgument));
|
||||
|
||||
let res = cmds.input_cmd("keys import foo", ctx.clone());
|
||||
assert_eq!(res, Err(CommandError::InvalidArgument));
|
||||
|
||||
let res = cmds.input_cmd("keys import foo bar baz", ctx.clone());
|
||||
assert_eq!(res, Err(CommandError::InvalidArgument));
|
||||
}
|
||||
}
|
||||
|
||||
905
src/config.rs
905
src/config.rs
File diff suppressed because it is too large
Load Diff
@@ -1,15 +1,27 @@
|
||||
//! # Default Keybindings
|
||||
//!
|
||||
//! The keybindings are set up here. We define some iamb-specific keybindings, but the default Vim
|
||||
//! keys come from [modalkit::env::vim::keybindings].
|
||||
use modalkit::{
|
||||
editing::action::WindowAction,
|
||||
actions::{InsertTextAction, MacroAction, WindowAction},
|
||||
env::vim::keybindings::{InputStep, VimBindings},
|
||||
env::vim::VimMode,
|
||||
input::bindings::{EdgeEvent, EdgeRepeat, InputBindings},
|
||||
input::key::TerminalKey,
|
||||
env::CommonKeyClass,
|
||||
key::TerminalKey,
|
||||
keybindings::{EdgeEvent, EdgeRepeat, InputBindings},
|
||||
prelude::*,
|
||||
};
|
||||
|
||||
use crate::base::{IambAction, IambInfo, Keybindings, MATRIX_ID_WORD};
|
||||
use crate::config::{ApplicationSettings, Keys};
|
||||
|
||||
type IambStep = InputStep<IambInfo>;
|
||||
pub type IambStep = InputStep<IambInfo>;
|
||||
|
||||
fn once(key: &TerminalKey) -> (EdgeRepeat, EdgeEvent<TerminalKey, CommonKeyClass>) {
|
||||
(EdgeRepeat::Once, EdgeEvent::Key(*key))
|
||||
}
|
||||
|
||||
/// Initialize the default keybinding state.
|
||||
pub fn setup_keybindings() -> Keybindings {
|
||||
let mut ism = Keybindings::empty();
|
||||
|
||||
@@ -19,20 +31,15 @@ pub fn setup_keybindings() -> Keybindings {
|
||||
|
||||
vim.setup(&mut ism);
|
||||
|
||||
let ctrl_w = EdgeEvent::Key("<C-W>".parse::<TerminalKey>().unwrap());
|
||||
let ctrl_m = EdgeEvent::Key("<C-M>".parse::<TerminalKey>().unwrap());
|
||||
let ctrl_z = EdgeEvent::Key("<C-Z>".parse::<TerminalKey>().unwrap());
|
||||
let key_m_lc = EdgeEvent::Key("m".parse::<TerminalKey>().unwrap());
|
||||
let key_z_lc = EdgeEvent::Key("z".parse::<TerminalKey>().unwrap());
|
||||
let ctrl_w = "<C-W>".parse::<TerminalKey>().unwrap();
|
||||
let ctrl_m = "<C-M>".parse::<TerminalKey>().unwrap();
|
||||
let ctrl_z = "<C-Z>".parse::<TerminalKey>().unwrap();
|
||||
let key_m_lc = "m".parse::<TerminalKey>().unwrap();
|
||||
let key_z_lc = "z".parse::<TerminalKey>().unwrap();
|
||||
let shift_enter = "<S-Enter>".parse::<TerminalKey>().unwrap();
|
||||
|
||||
let cwz = vec![
|
||||
(EdgeRepeat::Once, ctrl_w.clone()),
|
||||
(EdgeRepeat::Once, key_z_lc),
|
||||
];
|
||||
let cwcz = vec![
|
||||
(EdgeRepeat::Once, ctrl_w.clone()),
|
||||
(EdgeRepeat::Once, ctrl_z),
|
||||
];
|
||||
let cwz = vec![once(&ctrl_w), once(&key_z_lc)];
|
||||
let cwcz = vec![once(&ctrl_w), once(&ctrl_z)];
|
||||
let zoom = IambStep::new()
|
||||
.actions(vec![WindowAction::ZoomToggle.into()])
|
||||
.goto(VimMode::Normal);
|
||||
@@ -42,11 +49,8 @@ pub fn setup_keybindings() -> Keybindings {
|
||||
ism.add_mapping(VimMode::Normal, &cwcz, &zoom);
|
||||
ism.add_mapping(VimMode::Visual, &cwcz, &zoom);
|
||||
|
||||
let cwm = vec![
|
||||
(EdgeRepeat::Once, ctrl_w.clone()),
|
||||
(EdgeRepeat::Once, key_m_lc),
|
||||
];
|
||||
let cwcm = vec![(EdgeRepeat::Once, ctrl_w), (EdgeRepeat::Once, ctrl_m)];
|
||||
let cwm = vec![once(&ctrl_w), once(&key_m_lc)];
|
||||
let cwcm = vec![once(&ctrl_w), once(&ctrl_m)];
|
||||
let stoggle = IambStep::new()
|
||||
.actions(vec![IambAction::ToggleScrollbackFocus.into()])
|
||||
.goto(VimMode::Normal);
|
||||
@@ -55,5 +59,31 @@ pub fn setup_keybindings() -> Keybindings {
|
||||
ism.add_mapping(VimMode::Normal, &cwcm, &stoggle);
|
||||
ism.add_mapping(VimMode::Visual, &cwcm, &stoggle);
|
||||
|
||||
return ism;
|
||||
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
|
||||
}
|
||||
|
||||
impl InputBindings<TerminalKey, IambStep> for ApplicationSettings {
|
||||
fn setup(&self, bindings: &mut Keybindings) {
|
||||
for (modes, keys) in &self.macros {
|
||||
for (Keys(input, _), Keys(_, run)) in keys {
|
||||
let act = MacroAction::Run(run.clone(), Count::Contextual);
|
||||
let step = IambStep::new().actions(vec![act.into()]);
|
||||
let input = input.iter().map(once).collect::<Vec<_>>();
|
||||
|
||||
for mode in &modes.0 {
|
||||
bindings.add_mapping(*mode, &input, &step);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
728
src/main.rs
728
src/main.rs
@@ -1,3 +1,16 @@
|
||||
//! # iamb
|
||||
//!
|
||||
//! The iamb client loops over user input and commands, and turns them into actions, [some of
|
||||
//! which][IambAction] are specific to iamb, and [some of which][Action] come from [modalkit]. When
|
||||
//! adding new functionality, you will usually want to extend [IambAction] or one of its variants
|
||||
//! (like [RoomAction][base::RoomAction]), and then add an appropriate [command][commands] or
|
||||
//! [keybinding][keybindings].
|
||||
//!
|
||||
//! For more complicated changes, you may need to update [the async worker thread][worker], which
|
||||
//! handles background Matrix tasks with [matrix-rust-sdk][matrix_sdk].
|
||||
//!
|
||||
//! Most rendering logic lives under the [windows] module, but [Matrix messages][message] have
|
||||
//! their own module.
|
||||
#![allow(clippy::manual_range_contains)]
|
||||
#![allow(clippy::needless_return)]
|
||||
#![allow(clippy::result_large_err)]
|
||||
@@ -6,31 +19,50 @@ use std::collections::VecDeque;
|
||||
use std::convert::TryFrom;
|
||||
use std::fmt::Display;
|
||||
use std::fs::{create_dir_all, File};
|
||||
use std::io::{stdout, BufReader, Stdout};
|
||||
use std::io::{stdout, BufWriter, Stdout, Write};
|
||||
use std::ops::DerefMut;
|
||||
use std::process;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use clap::Parser;
|
||||
use matrix_sdk::crypto::encrypt_room_key_export;
|
||||
use matrix_sdk::ruma::api::client::error::ErrorKind;
|
||||
use matrix_sdk::ruma::OwnedUserId;
|
||||
use modalkit::keybindings::InputBindings;
|
||||
use rand::{distributions::Alphanumeric, Rng};
|
||||
use temp_dir::TempDir;
|
||||
use tokio::sync::Mutex as AsyncMutex;
|
||||
use tracing_subscriber::FmtSubscriber;
|
||||
|
||||
use matrix_sdk::ruma::OwnedUserId;
|
||||
|
||||
use modalkit::crossterm::{
|
||||
self,
|
||||
cursor::Show as CursorShow,
|
||||
event::{poll, read, DisableBracketedPaste, EnableBracketedPaste, Event},
|
||||
event::{
|
||||
poll,
|
||||
read,
|
||||
DisableBracketedPaste,
|
||||
DisableFocusChange,
|
||||
DisableMouseCapture,
|
||||
EnableBracketedPaste,
|
||||
EnableFocusChange,
|
||||
EnableMouseCapture,
|
||||
Event,
|
||||
KeyEventKind,
|
||||
KeyboardEnhancementFlags,
|
||||
MouseEventKind,
|
||||
PopKeyboardEnhancementFlags,
|
||||
PushKeyboardEnhancementFlags,
|
||||
},
|
||||
execute,
|
||||
terminal::{EnterAlternateScreen, LeaveAlternateScreen, SetTitle},
|
||||
};
|
||||
|
||||
use modalkit::tui::{
|
||||
use ratatui::{
|
||||
backend::CrosstermBackend,
|
||||
layout::Rect,
|
||||
style::{Color, Style},
|
||||
style::{Color, Modifier, Style},
|
||||
text::Span,
|
||||
widgets::Paragraph,
|
||||
Terminal,
|
||||
@@ -41,6 +73,9 @@ mod commands;
|
||||
mod config;
|
||||
mod keybindings;
|
||||
mod message;
|
||||
mod notifications;
|
||||
mod preview;
|
||||
mod sled_export;
|
||||
mod util;
|
||||
mod windows;
|
||||
mod worker;
|
||||
@@ -54,10 +89,12 @@ use crate::{
|
||||
ChatStore,
|
||||
HomeserverAction,
|
||||
IambAction,
|
||||
IambCompleter,
|
||||
IambError,
|
||||
IambId,
|
||||
IambInfo,
|
||||
IambResult,
|
||||
KeysAction,
|
||||
ProgramAction,
|
||||
ProgramContext,
|
||||
ProgramStore,
|
||||
@@ -68,45 +105,164 @@ use crate::{
|
||||
};
|
||||
|
||||
use modalkit::{
|
||||
editing::{
|
||||
action::{
|
||||
actions::{
|
||||
Action,
|
||||
Commandable,
|
||||
EditError,
|
||||
EditInfo,
|
||||
Editable,
|
||||
EditorAction,
|
||||
InsertTextAction,
|
||||
Jumpable,
|
||||
Promptable,
|
||||
Scrollable,
|
||||
TabAction,
|
||||
TabContainer,
|
||||
TabCount,
|
||||
WindowAction,
|
||||
WindowContainer,
|
||||
},
|
||||
base::{MoveDir1D, OpenTarget, RepeatType},
|
||||
context::Resolve,
|
||||
key::KeyManager,
|
||||
store::Store,
|
||||
editing::{context::Resolve, key::KeyManager, store::Store},
|
||||
errors::{EditError, UIError},
|
||||
key::TerminalKey,
|
||||
keybindings::{
|
||||
dialog::{Pager, PromptYesNo},
|
||||
BindingMachine,
|
||||
},
|
||||
input::{bindings::BindingMachine, key::TerminalKey},
|
||||
widgets::{
|
||||
prelude::*,
|
||||
ui::FocusList,
|
||||
};
|
||||
|
||||
use modalkit_ratatui::{
|
||||
cmdbar::CommandBarState,
|
||||
screen::{Screen, ScreenState},
|
||||
screen::{Screen, ScreenState, TabbedLayoutDescription},
|
||||
windows::{WindowLayoutDescription, WindowLayoutState},
|
||||
TerminalCursor,
|
||||
TerminalExtOps,
|
||||
Window,
|
||||
};
|
||||
|
||||
fn config_tab_to_desc(
|
||||
layout: config::WindowLayout,
|
||||
store: &mut ProgramStore,
|
||||
) -> IambResult<WindowLayoutDescription<IambInfo>> {
|
||||
let desc = match layout {
|
||||
config::WindowLayout::Window { window } => {
|
||||
let ChatStore { names, worker, .. } = &mut store.application;
|
||||
|
||||
let window = match window {
|
||||
config::WindowPath::UserId(user_id) => {
|
||||
let name = user_id.to_string();
|
||||
let room_id = worker.join_room(name.clone())?;
|
||||
names.insert(name, room_id.clone());
|
||||
IambId::Room(room_id, None)
|
||||
},
|
||||
config::WindowPath::RoomId(room_id) => IambId::Room(room_id, None),
|
||||
config::WindowPath::AliasId(alias) => {
|
||||
let name = alias.to_string();
|
||||
let room_id = worker.join_room(name.clone())?;
|
||||
names.insert(name, room_id.clone());
|
||||
IambId::Room(room_id, None)
|
||||
},
|
||||
config::WindowPath::Window(id) => id,
|
||||
};
|
||||
|
||||
WindowLayoutDescription::Window { window, length: None }
|
||||
},
|
||||
config::WindowLayout::Split { split } => {
|
||||
let children = split
|
||||
.into_iter()
|
||||
.map(|child| config_tab_to_desc(child, store))
|
||||
.collect::<IambResult<Vec<_>>>()?;
|
||||
|
||||
WindowLayoutDescription::Split { children, length: None }
|
||||
},
|
||||
};
|
||||
|
||||
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(
|
||||
settings: ApplicationSettings,
|
||||
store: &mut ProgramStore,
|
||||
) -> IambResult<ScreenState<IambWindow, IambInfo>> {
|
||||
let cmd = CommandBarState::new(store);
|
||||
let dims = crossterm::terminal::size()?;
|
||||
let area = Rect::new(0, 0, dims.0, dims.1);
|
||||
|
||||
match settings.layout {
|
||||
config::Layout::Restore => {
|
||||
match restore_layout(area, &settings, store) {
|
||||
Ok(tabs) => {
|
||||
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::Config { tabs } => {
|
||||
let mut list = FocusList::default();
|
||||
|
||||
for tab in tabs.into_iter() {
|
||||
let tab = config_tab_to_desc(tab, store)?;
|
||||
let tab = tab.to_layout(area.into(), store)?;
|
||||
list.push(tab);
|
||||
}
|
||||
|
||||
return Ok(ScreenState::from_list(list, cmd));
|
||||
},
|
||||
}
|
||||
|
||||
let win = settings
|
||||
.tunables
|
||||
.default_room
|
||||
.and_then(|room| IambWindow::find(room, store).ok())
|
||||
.or_else(|| IambWindow::open(IambId::Welcome, store).ok())
|
||||
.unwrap();
|
||||
|
||||
return Ok(ScreenState::new(win, cmd));
|
||||
}
|
||||
|
||||
/// The main application state and event loop.
|
||||
struct Application {
|
||||
store: AsyncProgramStore,
|
||||
worker: Requester,
|
||||
/// Terminal backend.
|
||||
terminal: Terminal<CrosstermBackend<Stdout>>,
|
||||
bindings: KeyManager<TerminalKey, ProgramAction, RepeatType, ProgramContext>,
|
||||
actstack: VecDeque<(ProgramAction, ProgramContext)>,
|
||||
|
||||
/// State for the Matrix client, editing, etc.
|
||||
store: AsyncProgramStore,
|
||||
|
||||
/// UI state (open tabs, command bar, etc.) to use when rendering.
|
||||
screen: ScreenState<IambWindow, IambInfo>,
|
||||
|
||||
/// Handle to communicate synchronously with the Matrix worker task.
|
||||
worker: Requester,
|
||||
|
||||
/// Mapped keybindings.
|
||||
bindings: KeyManager<TerminalKey, ProgramAction, RepeatType>,
|
||||
|
||||
/// Pending actions to run.
|
||||
actstack: VecDeque<(ProgramAction, ProgramContext)>,
|
||||
|
||||
/// Whether or not the terminal is currently focused.
|
||||
focused: bool,
|
||||
|
||||
/// The tab layout before the last executed [TabAction].
|
||||
last_layout: Option<TabbedLayoutDescription<IambInfo>>,
|
||||
|
||||
/// Whether we need to do a full redraw (e.g., after running a subprocess).
|
||||
dirty: bool,
|
||||
}
|
||||
|
||||
impl Application {
|
||||
@@ -114,33 +270,18 @@ impl Application {
|
||||
settings: ApplicationSettings,
|
||||
store: AsyncProgramStore,
|
||||
) -> IambResult<Application> {
|
||||
let mut stdout = stdout();
|
||||
crossterm::terminal::enable_raw_mode()?;
|
||||
crossterm::execute!(stdout, EnterAlternateScreen)?;
|
||||
crossterm::execute!(stdout, EnableBracketedPaste)?;
|
||||
|
||||
let title = format!("iamb ({})", settings.profile.user_id);
|
||||
crossterm::execute!(stdout, SetTitle(title))?;
|
||||
|
||||
let backend = CrosstermBackend::new(stdout);
|
||||
let backend = CrosstermBackend::new(stdout());
|
||||
let terminal = Terminal::new(backend)?;
|
||||
|
||||
let bindings = crate::keybindings::setup_keybindings();
|
||||
let mut bindings = crate::keybindings::setup_keybindings();
|
||||
settings.setup(&mut bindings);
|
||||
let bindings = KeyManager::new(bindings);
|
||||
|
||||
let mut locked = store.lock().await;
|
||||
|
||||
let win = settings
|
||||
.tunables
|
||||
.default_room
|
||||
.and_then(|room| IambWindow::find(room, locked.deref_mut()).ok())
|
||||
.or_else(|| IambWindow::open(IambId::Welcome, locked.deref_mut()).ok())
|
||||
.unwrap();
|
||||
|
||||
let cmd = CommandBarState::new(locked.deref_mut());
|
||||
let screen = ScreenState::new(win, cmd);
|
||||
let screen = setup_screen(settings, locked.deref_mut())?;
|
||||
|
||||
let worker = locked.application.worker.clone();
|
||||
|
||||
drop(locked);
|
||||
|
||||
let actstack = VecDeque::new();
|
||||
@@ -152,25 +293,51 @@ impl Application {
|
||||
bindings,
|
||||
actstack,
|
||||
screen,
|
||||
focused: true,
|
||||
last_layout: None,
|
||||
dirty: true,
|
||||
})
|
||||
}
|
||||
|
||||
fn redraw(&mut self, full: bool, store: &mut ProgramStore) -> Result<(), std::io::Error> {
|
||||
let modestr = self.bindings.showmode();
|
||||
let cursor = self.bindings.get_cursor_indicator();
|
||||
let bindings = &mut self.bindings;
|
||||
let focused = self.focused;
|
||||
let sstate = &mut self.screen;
|
||||
let term = &mut self.terminal;
|
||||
|
||||
if store.application.ring_bell {
|
||||
store.application.ring_bell = term.backend_mut().write_all(&[7]).is_err();
|
||||
}
|
||||
|
||||
if full {
|
||||
term.clear()?;
|
||||
}
|
||||
|
||||
term.draw(|f| {
|
||||
let area = f.size();
|
||||
let area = f.area();
|
||||
|
||||
let screen = Screen::new(store).showmode(modestr).borders(true);
|
||||
let modestr = bindings.show_mode();
|
||||
let cursor = bindings.get_cursor_indicator();
|
||||
let dialogstr = bindings.show_dialog(area.height as usize, area.width as usize);
|
||||
|
||||
// Don't show terminal cursor when we show a dialog.
|
||||
let hide_cursor = !dialogstr.is_empty();
|
||||
|
||||
store.application.draw_curr = Some(Instant::now());
|
||||
let screen = Screen::new(store)
|
||||
.show_dialog(dialogstr)
|
||||
.show_mode(modestr)
|
||||
.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);
|
||||
f.render_stateful_widget(screen, area, sstate);
|
||||
|
||||
if hide_cursor {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some((cx, cy)) = sstate.get_term_cursor() {
|
||||
if let Some(c) = cursor {
|
||||
let style = Style::default().fg(Color::Green);
|
||||
@@ -179,7 +346,7 @@ impl Application {
|
||||
let inner = Rect::new(cx, cy, 1, 1);
|
||||
f.render_widget(para, inner)
|
||||
}
|
||||
f.set_cursor(cx, cy);
|
||||
f.set_cursor_position((cx, cy));
|
||||
}
|
||||
})?;
|
||||
|
||||
@@ -188,7 +355,8 @@ impl Application {
|
||||
|
||||
async fn step(&mut self) -> Result<TerminalKey, std::io::Error> {
|
||||
loop {
|
||||
self.redraw(false, self.store.clone().lock().await.deref_mut())?;
|
||||
self.redraw(self.dirty, self.store.clone().lock().await.deref_mut())?;
|
||||
self.dirty = false;
|
||||
|
||||
if !poll(Duration::from_secs(1))? {
|
||||
// Redraw in case there's new messages to show.
|
||||
@@ -196,12 +364,47 @@ impl Application {
|
||||
}
|
||||
|
||||
match read()? {
|
||||
Event::Key(ke) => return Ok(ke.into()),
|
||||
Event::Mouse(_) => {
|
||||
// Do nothing for now.
|
||||
Event::Key(ke) => {
|
||||
if ke.kind == KeyEventKind::Release {
|
||||
continue;
|
||||
}
|
||||
|
||||
return Ok(ke.into());
|
||||
},
|
||||
Event::FocusGained | Event::FocusLost => {
|
||||
// Do nothing for now.
|
||||
Event::Mouse(me) => {
|
||||
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 => {
|
||||
let mut store = self.store.lock().await;
|
||||
store.application.focused = true;
|
||||
self.focused = true;
|
||||
},
|
||||
Event::FocusLost => {
|
||||
let mut store = self.store.lock().await;
|
||||
store.application.focused = false;
|
||||
self.focused = false;
|
||||
},
|
||||
Event::Resize(_, _) => {
|
||||
// We'll redraw for the new size next time step() is called.
|
||||
@@ -215,7 +418,8 @@ impl Application {
|
||||
match self.screen.editor_command(&act, &ctx, store.deref_mut()) {
|
||||
Ok(None) => {},
|
||||
Ok(Some(info)) => {
|
||||
self.screen.push_info(info);
|
||||
drop(store);
|
||||
self.handle_info(info);
|
||||
},
|
||||
Err(e) => {
|
||||
self.screen.push_error(e);
|
||||
@@ -279,8 +483,7 @@ impl Application {
|
||||
Action::CommandBar(act) => self.screen.command_bar(&act, &ctx)?,
|
||||
Action::Macro(act) => self.bindings.macro_command(&act, &ctx, store)?,
|
||||
Action::Scroll(style) => self.screen.scroll(&style, &ctx, store)?,
|
||||
Action::Suspend => self.terminal.program_suspend()?,
|
||||
Action::Tab(cmd) => self.screen.tab_command(&cmd, &ctx, store)?,
|
||||
Action::ShowInfoMessage(info) => Some(info),
|
||||
Action::Window(cmd) => self.screen.window_command(&cmd, &ctx, store)?,
|
||||
|
||||
Action::Jump(l, dir, count) => {
|
||||
@@ -289,8 +492,20 @@ impl Application {
|
||||
|
||||
None
|
||||
},
|
||||
Action::Suspend => {
|
||||
self.terminal.program_suspend()?;
|
||||
|
||||
None
|
||||
},
|
||||
|
||||
// UI actions.
|
||||
Action::Tab(cmd) => {
|
||||
if let TabAction::Close(_, _) = &cmd {
|
||||
self.last_layout = self.screen.as_description().into();
|
||||
}
|
||||
|
||||
self.screen.tab_command(&cmd, &ctx, store)?
|
||||
},
|
||||
Action::RedrawScreen => {
|
||||
self.screen.clear_message();
|
||||
self.redraw(true, store)?;
|
||||
@@ -306,7 +521,7 @@ impl Application {
|
||||
None
|
||||
},
|
||||
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);
|
||||
|
||||
None
|
||||
@@ -318,7 +533,7 @@ impl Application {
|
||||
},
|
||||
|
||||
// Unimplemented.
|
||||
Action::KeywordLookup => {
|
||||
Action::KeywordLookup(_) => {
|
||||
// XXX: implement
|
||||
None
|
||||
},
|
||||
@@ -338,7 +553,26 @@ impl Application {
|
||||
ctx: ProgramContext,
|
||||
store: &mut ProgramStore,
|
||||
) -> IambResult<EditInfo> {
|
||||
if action.scribbles() {
|
||||
self.dirty = true;
|
||||
}
|
||||
|
||||
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 => {
|
||||
self.screen.current_window_mut()?.focus_toggle();
|
||||
|
||||
@@ -351,9 +585,13 @@ impl Application {
|
||||
|
||||
None
|
||||
},
|
||||
IambAction::Keys(act) => self.keys_command(act, ctx, store).await?,
|
||||
IambAction::Message(act) => {
|
||||
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) => {
|
||||
let acts = self.screen.current_window_mut()?.room_command(act, ctx, store).await?;
|
||||
self.action_prepend(acts);
|
||||
@@ -361,9 +599,20 @@ impl Application {
|
||||
None
|
||||
},
|
||||
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?
|
||||
},
|
||||
|
||||
IambAction::OpenLink(url) => {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
return open::that(url);
|
||||
});
|
||||
|
||||
None
|
||||
},
|
||||
|
||||
IambAction::Verify(act, user_dev) => {
|
||||
if let Some(sas) = store.application.verifications.get(&user_dev) {
|
||||
self.worker.verify(act, sas.clone())?
|
||||
@@ -392,13 +641,70 @@ impl Application {
|
||||
match action {
|
||||
HomeserverAction::CreateRoom(alias, vis, flags) => {
|
||||
let client = &store.application.worker.client;
|
||||
let room_id = create_room(client, alias.as_deref(), vis, flags).await?;
|
||||
let room = IambId::Room(room_id);
|
||||
let room_id = create_room(client, alias, vis, flags).await?;
|
||||
let room = IambId::Room(room_id, None);
|
||||
let target = OpenTarget::Application(room);
|
||||
let action = WindowAction::Switch(target);
|
||||
|
||||
Ok(vec![(action.into(), ctx)])
|
||||
},
|
||||
HomeserverAction::Logout(user, true) => {
|
||||
self.worker.logout(user)?;
|
||||
let flags = CloseFlags::QUIT | CloseFlags::FORCE;
|
||||
let act = TabAction::Close(TabTarget::All, flags);
|
||||
|
||||
Ok(vec![(act.into(), ctx)])
|
||||
},
|
||||
HomeserverAction::Logout(user, false) => {
|
||||
let msg = "Would you like to logout?";
|
||||
let act = IambAction::from(HomeserverAction::Logout(user, true));
|
||||
let prompt = PromptYesNo::new(msg, vec![Action::from(act)]);
|
||||
let prompt = Box::new(prompt);
|
||||
|
||||
Err(UIError::NeedConfirm(prompt))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async fn keys_command(
|
||||
&mut self,
|
||||
action: KeysAction,
|
||||
_: ProgramContext,
|
||||
store: &mut ProgramStore,
|
||||
) -> IambResult<EditInfo> {
|
||||
let encryption = store.application.worker.client.encryption();
|
||||
|
||||
match action {
|
||||
KeysAction::Export(path, passphrase) => {
|
||||
encryption
|
||||
.export_room_keys(path.into(), &passphrase, |_| true)
|
||||
.await
|
||||
.map_err(IambError::from)?;
|
||||
|
||||
Ok(Some("Successfully exported room keys".into()))
|
||||
},
|
||||
KeysAction::Import(path, passphrase) => {
|
||||
let res = encryption
|
||||
.import_room_keys(path.into(), &passphrase)
|
||||
.await
|
||||
.map_err(IambError::from)?;
|
||||
|
||||
let msg = format!("Imported {} of {} keys", res.imported_count, res.total_count);
|
||||
|
||||
Ok(Some(msg.into()))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_info(&mut self, info: InfoMessage) {
|
||||
match info {
|
||||
InfoMessage::Message(info) => {
|
||||
self.screen.push_info(info);
|
||||
},
|
||||
InfoMessage::Pager(text) => {
|
||||
let pager = Box::new(Pager::new(text, vec![]));
|
||||
self.bindings.run_dialog(pager);
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -422,11 +728,18 @@ impl Application {
|
||||
continue;
|
||||
},
|
||||
Ok(Some(info)) => {
|
||||
self.screen.push_info(info);
|
||||
self.handle_info(info);
|
||||
|
||||
// Continue processing; we'll redraw later.
|
||||
continue;
|
||||
},
|
||||
Err(
|
||||
UIError::NeedConfirm(dialog) |
|
||||
UIError::EditingFailure(EditError::NeedConfirm(dialog)),
|
||||
) => {
|
||||
self.bindings.run_dialog(dialog);
|
||||
continue;
|
||||
},
|
||||
Err(e) => {
|
||||
self.screen.push_error(e);
|
||||
|
||||
@@ -438,6 +751,19 @@ impl Application {
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref layout) = self.last_layout {
|
||||
let locked = self.store.lock().await;
|
||||
let path = locked.application.settings.layout_json.as_path();
|
||||
path.parent().map(create_dir_all).transpose()?;
|
||||
|
||||
let file = File::create(path)?;
|
||||
let writer = BufWriter::new(file);
|
||||
|
||||
if let Err(e) = serde_json::to_writer(writer, layout) {
|
||||
tracing::error!("Failed to save window layout while exiting: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
crossterm::terminal::disable_raw_mode()?;
|
||||
execute!(self.terminal.backend_mut(), LeaveAlternateScreen)?;
|
||||
self.terminal.show_cursor()?;
|
||||
@@ -446,23 +772,59 @@ impl Application {
|
||||
}
|
||||
}
|
||||
|
||||
async fn login(worker: Requester, settings: &ApplicationSettings) -> IambResult<()> {
|
||||
println!("Logging in for {}...", settings.profile.user_id);
|
||||
fn gen_passphrase() -> String {
|
||||
rand::thread_rng()
|
||||
.sample_iter(&Alphanumeric)
|
||||
.take(20)
|
||||
.map(char::from)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn read_response(question: &str) -> String {
|
||||
println!("{question}");
|
||||
let mut input = String::new();
|
||||
let _ = std::io::stdin().read_line(&mut input);
|
||||
input
|
||||
}
|
||||
|
||||
fn read_yesno(question: &str) -> Option<char> {
|
||||
read_response(question).chars().next().map(|c| c.to_ascii_lowercase())
|
||||
}
|
||||
|
||||
async fn login(worker: &Requester, settings: &ApplicationSettings) -> IambResult<()> {
|
||||
if settings.session_json.is_file() {
|
||||
let file = File::open(settings.session_json.as_path())?;
|
||||
let reader = BufReader::new(file);
|
||||
let session = serde_json::from_reader(reader).map_err(IambError::from)?;
|
||||
let session = settings.read_session(&settings.session_json)?;
|
||||
worker.login(LoginStyle::SessionRestore(session.into()))?;
|
||||
|
||||
worker.login(LoginStyle::SessionRestore(session))?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if settings.session_json_old.is_file() && !settings.sled_dir.is_dir() {
|
||||
let session = settings.read_session(&settings.session_json_old)?;
|
||||
worker.login(LoginStyle::SessionRestore(session.into()))?;
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
loop {
|
||||
let login_style =
|
||||
match read_response("Please select login type: [p]assword / [s]ingle sign on")
|
||||
.chars()
|
||||
.next()
|
||||
.map(|c| c.to_ascii_lowercase())
|
||||
{
|
||||
None | Some('p') => {
|
||||
let password = rpassword::prompt_password("Password: ")?;
|
||||
LoginStyle::Password(password)
|
||||
},
|
||||
Some('s') => LoginStyle::SingleSignOn,
|
||||
Some(_) => {
|
||||
println!("Failed to login. Please enter 'p' or 's'");
|
||||
continue;
|
||||
},
|
||||
};
|
||||
|
||||
match worker.login(LoginStyle::Password(password)) {
|
||||
match worker.login(login_style) {
|
||||
Ok(info) => {
|
||||
if let Some(msg) = info {
|
||||
println!("{msg}");
|
||||
@@ -481,36 +843,234 @@ async fn login(worker: Requester, settings: &ApplicationSettings) -> IambResult<
|
||||
}
|
||||
|
||||
fn print_exit<T: Display, N>(v: T) -> N {
|
||||
println!("{v}");
|
||||
eprintln!("{v}");
|
||||
process::exit(2);
|
||||
}
|
||||
|
||||
// We can't access the OlmMachine directly, so write the keys to a temporary
|
||||
// file first, and then import them later.
|
||||
async fn check_import_keys(
|
||||
settings: &ApplicationSettings,
|
||||
) -> IambResult<Option<(temp_dir::TempDir, String)>> {
|
||||
let do_import = settings.sled_dir.is_dir() && !settings.sqlite_dir.is_dir();
|
||||
|
||||
if !do_import {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let question = format!(
|
||||
"Found old sled store in {}. Would you like to export room keys from it? [y]es/[n]o",
|
||||
settings.sled_dir.display()
|
||||
);
|
||||
|
||||
loop {
|
||||
match read_yesno(&question) {
|
||||
Some('y') => {
|
||||
break;
|
||||
},
|
||||
Some('n') => {
|
||||
return Ok(None);
|
||||
},
|
||||
Some(_) | None => {
|
||||
continue;
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
let keys = sled_export::export_room_keys(&settings.sled_dir).await?;
|
||||
let passphrase = gen_passphrase();
|
||||
|
||||
println!("* Encrypting {} room keys with the passphrase {passphrase:?}...", keys.len());
|
||||
|
||||
let encrypted = match encrypt_room_key_export(&keys, &passphrase, 500000) {
|
||||
Ok(encrypted) => encrypted,
|
||||
Err(e) => {
|
||||
println!("* Failed to encrypt room keys during export: {e}");
|
||||
process::exit(2);
|
||||
},
|
||||
};
|
||||
|
||||
let tmpdir = TempDir::new()?;
|
||||
let exported = tmpdir.child("keys");
|
||||
|
||||
println!("* Writing encrypted room keys to {}...", exported.display());
|
||||
tokio::fs::write(&exported, &encrypted).await?;
|
||||
|
||||
Ok(Some((tmpdir, passphrase)))
|
||||
}
|
||||
|
||||
async fn login_upgrade(
|
||||
keydir: TempDir,
|
||||
passphrase: String,
|
||||
worker: &Requester,
|
||||
settings: &ApplicationSettings,
|
||||
store: &AsyncProgramStore,
|
||||
) -> IambResult<()> {
|
||||
println!(
|
||||
"Please log in for {} to import the room keys into a new session",
|
||||
settings.profile.user_id
|
||||
);
|
||||
|
||||
login(worker, settings).await?;
|
||||
|
||||
println!("* Importing room keys...");
|
||||
|
||||
let exported = keydir.child("keys");
|
||||
let imported = worker.client.encryption().import_room_keys(exported, &passphrase).await;
|
||||
|
||||
match imported {
|
||||
Ok(res) => {
|
||||
println!(
|
||||
"* Successfully imported {} out of {} keys",
|
||||
res.imported_count, res.total_count
|
||||
);
|
||||
let _ = keydir.cleanup();
|
||||
},
|
||||
Err(e) => {
|
||||
println!(
|
||||
"Failed to import room keys from {}/keys: {e}\n\n\
|
||||
They have been encrypted with the passphrase {passphrase:?}.\
|
||||
Please save them and try importing them manually instead\n",
|
||||
keydir.path().display()
|
||||
);
|
||||
|
||||
loop {
|
||||
match read_yesno("Would you like to continue logging in? [y]es/[n]o") {
|
||||
Some('y') => break,
|
||||
Some('n') => print_exit("* Exiting..."),
|
||||
Some(_) | None => continue,
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
println!("* Syncing...");
|
||||
worker::do_first_sync(&worker.client, store)
|
||||
.await
|
||||
.map_err(IambError::from)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn login_normal(
|
||||
worker: &Requester,
|
||||
settings: &ApplicationSettings,
|
||||
store: &AsyncProgramStore,
|
||||
) -> IambResult<()> {
|
||||
println!("* Logging in for {}...", settings.profile.user_id);
|
||||
login(worker, settings).await?;
|
||||
println!("* Syncing...");
|
||||
worker::do_first_sync(&worker.client, store)
|
||||
.await
|
||||
.map_err(IambError::from)?;
|
||||
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<()> {
|
||||
// Get old keys the first time we run w/ the upgraded SDK.
|
||||
let import_keys = check_import_keys(&settings).await?;
|
||||
|
||||
// Set up client state.
|
||||
create_dir_all(settings.sqlite_dir.as_path())?;
|
||||
let client = worker::create_client(&settings).await;
|
||||
|
||||
// Set up the async worker thread and global store.
|
||||
let worker = ClientWorker::spawn(settings.clone()).await;
|
||||
let worker = ClientWorker::spawn(client.clone(), settings.clone()).await;
|
||||
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));
|
||||
worker.init(store.clone());
|
||||
|
||||
login(worker, &settings).await.unwrap_or_else(print_exit);
|
||||
let res = if let Some((keydir, pass)) = import_keys {
|
||||
login_upgrade(keydir, pass, &worker, &settings, &store).await
|
||||
} else {
|
||||
login_normal(&worker, &settings, &store).await
|
||||
};
|
||||
|
||||
match res {
|
||||
Err(UIError::Application(IambError::Matrix(e))) => {
|
||||
if let Some(ErrorKind::UnknownToken { .. }) = e.client_api_error_kind() {
|
||||
print_exit("Server did not recognize our API token; did you log out from this session elsewhere?")
|
||||
} else {
|
||||
print_exit(e)
|
||||
}
|
||||
},
|
||||
Err(e) => print_exit(e),
|
||||
Ok(()) => (),
|
||||
}
|
||||
|
||||
// Set up the terminal for drawing, and cleanup properly on panics.
|
||||
let enable_enhanced_keys = match crossterm::terminal::supports_keyboard_enhancement() {
|
||||
Ok(supported) => supported,
|
||||
Err(e) => {
|
||||
tracing::warn!(err = %e,
|
||||
"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 enable_mouse = settings.tunables.mouse.enabled;
|
||||
std::panic::set_hook(Box::new(move |panic_info| {
|
||||
let _ = crossterm::terminal::disable_raw_mode();
|
||||
let _ = crossterm::execute!(stdout(), DisableBracketedPaste);
|
||||
let _ = crossterm::execute!(stdout(), LeaveAlternateScreen);
|
||||
let _ = crossterm::execute!(stdout(), CursorShow);
|
||||
restore_tty(enable_enhanced_keys, enable_mouse);
|
||||
orig_hook(panic_info);
|
||||
process::exit(1);
|
||||
}));
|
||||
|
||||
// And finally, start running the terminal UI.
|
||||
let mut application = Application::new(settings, store).await?;
|
||||
|
||||
// We can now run the application.
|
||||
application.run().await?;
|
||||
|
||||
// Clean up the terminal on exit.
|
||||
restore_tty(enable_enhanced_keys, enable_mouse);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -521,13 +1081,16 @@ fn main() -> IambResult<()> {
|
||||
// Load configuration and set up the Matrix SDK.
|
||||
let settings = ApplicationSettings::load(iamb).unwrap_or_else(print_exit);
|
||||
|
||||
// Set umask on Unix platforms so that tokens, keys, etc. are only readable by the user.
|
||||
#[cfg(unix)]
|
||||
unsafe {
|
||||
libc::umask(0o077);
|
||||
};
|
||||
|
||||
// Set up the tracing subscriber so we can log client messages.
|
||||
let log_prefix = format!("iamb-log-{}", settings.profile_name);
|
||||
let log_dir = settings.dirs.logs.as_path();
|
||||
|
||||
create_dir_all(settings.matrix_dir.as_path())?;
|
||||
create_dir_all(log_dir)?;
|
||||
|
||||
let appender = tracing_appender::rolling::daily(log_dir, log_prefix);
|
||||
let (appender, guard) = tracing_appender::non_blocking(appender);
|
||||
|
||||
@@ -539,6 +1102,7 @@ fn main() -> IambResult<()> {
|
||||
|
||||
let rt = tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.worker_threads(2)
|
||||
.thread_name_fn(|| {
|
||||
static ATOMIC_ID: AtomicUsize = AtomicUsize::new(0);
|
||||
let id = ATOMIC_ID.fetch_add(1, Ordering::SeqCst);
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
1021
src/message/mod.rs
1021
src/message/mod.rs
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,26 @@
|
||||
//! # Line Wrapping Logic
|
||||
//!
|
||||
//! The [TextPrinter] handles wrapping stylized text and inserting spaces for padding at the end of
|
||||
//! lines to make concatenation work right (e.g., combining table cells after wrapping their
|
||||
//! contents).
|
||||
use std::borrow::Cow;
|
||||
|
||||
use modalkit::tui::layout::Alignment;
|
||||
use modalkit::tui::style::Style;
|
||||
use modalkit::tui::text::{Span, Spans, Text};
|
||||
use ratatui::layout::Alignment;
|
||||
use ratatui::style::Style;
|
||||
use ratatui::text::{Line, Span, Text};
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use crate::util::{space_span, take_width};
|
||||
use crate::config::{ApplicationSettings, TunableValues};
|
||||
use crate::util::{
|
||||
replace_emojis_in_line,
|
||||
replace_emojis_in_span,
|
||||
replace_emojis_in_str,
|
||||
space_span,
|
||||
take_width,
|
||||
};
|
||||
|
||||
/// Wrap styled text for the current terminal width.
|
||||
pub struct TextPrinter<'a> {
|
||||
text: Text<'a>,
|
||||
width: usize,
|
||||
@@ -17,10 +30,19 @@ pub struct TextPrinter<'a> {
|
||||
alignment: Alignment,
|
||||
curr_spans: Vec<Span<'a>>,
|
||||
curr_width: usize,
|
||||
literal: bool,
|
||||
|
||||
pub(super) settings: &'a ApplicationSettings,
|
||||
}
|
||||
|
||||
impl<'a> TextPrinter<'a> {
|
||||
pub fn new(width: usize, base_style: Style, hide_reply: bool) -> Self {
|
||||
/// Create a new printer.
|
||||
pub fn new(
|
||||
width: usize,
|
||||
base_style: Style,
|
||||
hide_reply: bool,
|
||||
settings: &'a ApplicationSettings,
|
||||
) -> Self {
|
||||
TextPrinter {
|
||||
text: Text::default(),
|
||||
width,
|
||||
@@ -30,22 +52,47 @@ impl<'a> TextPrinter<'a> {
|
||||
alignment: Alignment::Left,
|
||||
curr_spans: vec![],
|
||||
curr_width: 0,
|
||||
literal: false,
|
||||
settings,
|
||||
}
|
||||
}
|
||||
|
||||
/// Configure the alignment for each line.
|
||||
pub fn align(mut self, alignment: Alignment) -> Self {
|
||||
self.alignment = alignment;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set whether newlines should be treated literally, or turned into spaces.
|
||||
pub fn literal(mut self, literal: bool) -> Self {
|
||||
self.literal = literal;
|
||||
self
|
||||
}
|
||||
|
||||
/// Indicates whether replies should be pushed to the printer.
|
||||
pub fn hide_reply(&self) -> bool {
|
||||
self.hide_reply
|
||||
}
|
||||
|
||||
/// Indicates whether emojis should be replaced by shortcodes
|
||||
pub fn emoji_shortcodes(&self) -> bool {
|
||||
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.
|
||||
pub fn width(&self) -> usize {
|
||||
self.width
|
||||
}
|
||||
|
||||
/// Create a new printer with a smaller width.
|
||||
pub fn sub(&self, indent: usize) -> Self {
|
||||
TextPrinter {
|
||||
text: Text::default(),
|
||||
@@ -56,13 +103,16 @@ impl<'a> TextPrinter<'a> {
|
||||
alignment: self.alignment,
|
||||
curr_spans: vec![],
|
||||
curr_width: 0,
|
||||
literal: self.literal,
|
||||
settings: self.settings,
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
pub fn commit(&mut self) {
|
||||
if self.curr_width > 0 {
|
||||
self.push_break();
|
||||
@@ -71,9 +121,10 @@ impl<'a> TextPrinter<'a> {
|
||||
|
||||
fn push(&mut self) {
|
||||
self.curr_width = 0;
|
||||
self.text.lines.push(Spans(std::mem::take(&mut self.curr_spans)));
|
||||
self.text.lines.push(Line::from(std::mem::take(&mut self.curr_spans)));
|
||||
}
|
||||
|
||||
/// Start a new line.
|
||||
pub fn push_break(&mut self) {
|
||||
if self.curr_width == 0 && self.text.lines.is_empty() {
|
||||
// Disallow leading breaks.
|
||||
@@ -141,7 +192,11 @@ impl<'a> TextPrinter<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push_span_nobreak(&mut self, span: Span<'a>) {
|
||||
/// Push a [Span] that isn't allowed to break across lines.
|
||||
pub fn push_span_nobreak(&mut self, mut span: Span<'a>) {
|
||||
if self.emoji_shortcodes() {
|
||||
replace_emojis_in_span(&mut span);
|
||||
}
|
||||
let sw = UnicodeWidthStr::width(span.content.as_ref());
|
||||
|
||||
if self.curr_width + sw > self.width {
|
||||
@@ -153,19 +208,47 @@ impl<'a> TextPrinter<'a> {
|
||||
self.curr_width += sw;
|
||||
}
|
||||
|
||||
/// Push text with a [Style].
|
||||
pub fn push_str(&mut self, s: &'a str, style: Style) {
|
||||
let style = self.base_style.patch(style);
|
||||
|
||||
for word in UnicodeSegmentation::split_word_bounds(s) {
|
||||
if self.width == 0 && word.chars().all(char::is_whitespace) {
|
||||
if self.width == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let tabstop = self.settings().tunables.tabstop;
|
||||
|
||||
for mut word in UnicodeSegmentation::split_word_bounds(s) {
|
||||
if let "\n" | "\r\n" = word {
|
||||
if self.literal {
|
||||
self.commit();
|
||||
continue;
|
||||
}
|
||||
|
||||
// Render embedded newlines as spaces.
|
||||
word = " ";
|
||||
}
|
||||
|
||||
if !self.literal && self.curr_width == 0 && word.chars().all(char::is_whitespace) {
|
||||
// Drop leading whitespace.
|
||||
continue;
|
||||
}
|
||||
|
||||
let sw = UnicodeWidthStr::width(word);
|
||||
let mut cow = if self.emoji_shortcodes() {
|
||||
Cow::Owned(replace_emojis_in_str(word))
|
||||
} else {
|
||||
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());
|
||||
|
||||
if sw > self.width {
|
||||
self.push_str_wrapped(word, style);
|
||||
self.push_str_wrapped(cow, style);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -173,13 +256,13 @@ impl<'a> TextPrinter<'a> {
|
||||
// Word doesn't fit on this line, so start a new one.
|
||||
self.commit();
|
||||
|
||||
if word.chars().all(char::is_whitespace) {
|
||||
if !self.literal && cow.chars().all(char::is_whitespace) {
|
||||
// Drop leading whitespace.
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let span = Span::styled(word, style);
|
||||
let span = Span::styled(cow, style);
|
||||
self.curr_spans.push(span);
|
||||
self.curr_width += sw;
|
||||
}
|
||||
@@ -190,18 +273,46 @@ impl<'a> TextPrinter<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push_line(&mut self, spans: Spans<'a>) {
|
||||
/// Push a [Line] into the printer.
|
||||
pub fn push_line(&mut self, mut line: Line<'a>) {
|
||||
self.commit();
|
||||
self.text.lines.push(spans);
|
||||
if self.emoji_shortcodes() {
|
||||
replace_emojis_in_line(&mut line);
|
||||
}
|
||||
self.text.lines.push(line);
|
||||
}
|
||||
|
||||
pub fn push_text(&mut self, text: Text<'a>) {
|
||||
/// Push multiline [Text] into the printer.
|
||||
pub fn push_text(&mut self, mut text: Text<'a>) {
|
||||
self.commit();
|
||||
if self.emoji_shortcodes() {
|
||||
for line in &mut text.lines {
|
||||
replace_emojis_in_line(line);
|
||||
}
|
||||
}
|
||||
self.text.lines.extend(text.lines);
|
||||
}
|
||||
|
||||
/// Render the contents of this printer as [Text].
|
||||
pub fn finish(mut self) -> Text<'a> {
|
||||
self.commit();
|
||||
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 }
|
||||
}
|
||||
313
src/notifications.rs
Normal file
313
src/notifications.rs
Normal file
@@ -0,0 +1,313 @@
|
||||
use std::time::SystemTime;
|
||||
|
||||
use matrix_sdk::{
|
||||
deserialized_responses::RawAnySyncOrStrippedTimelineEvent,
|
||||
notification_settings::{IsEncrypted, IsOneToOne, NotificationSettings, RoomNotificationMode},
|
||||
room::Room as MatrixRoom,
|
||||
ruma::{
|
||||
events::{room::message::MessageType, AnyMessageLikeEventContent, AnySyncTimelineEvent},
|
||||
serde::Raw,
|
||||
MilliSecondsSinceUnixEpoch,
|
||||
OwnedRoomId,
|
||||
RoomId,
|
||||
},
|
||||
Client,
|
||||
};
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
|
||||
use crate::{
|
||||
base::{AsyncProgramStore, IambError, IambResult, ProgramStore},
|
||||
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(
|
||||
client: &Client,
|
||||
settings: &ApplicationSettings,
|
||||
store: &AsyncProgramStore,
|
||||
) {
|
||||
if !settings.tunables.notifications.enabled {
|
||||
return;
|
||||
}
|
||||
let notify_via = settings.tunables.notifications.via;
|
||||
let show_message = settings.tunables.notifications.show_message;
|
||||
let server_settings = client.notification_settings().await;
|
||||
let Some(startup_ts) = MilliSecondsSinceUnixEpoch::from_system_time(SystemTime::now()) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let store = store.clone();
|
||||
client
|
||||
.register_notification_handler(move |notification, room: MatrixRoom, client: Client| {
|
||||
let store = store.clone();
|
||||
let server_settings = server_settings.clone();
|
||||
async move {
|
||||
let mode = global_or_room_mode(&server_settings, &room).await;
|
||||
if mode == RoomNotificationMode::Mute {
|
||||
return;
|
||||
}
|
||||
|
||||
if is_visible_room(&store, room.room_id()).await {
|
||||
return;
|
||||
}
|
||||
|
||||
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)) => {
|
||||
if server_ts < startup_ts {
|
||||
return;
|
||||
}
|
||||
|
||||
if is_missing_mention(&body, mode, &client) {
|
||||
return;
|
||||
}
|
||||
|
||||
send_notification(
|
||||
¬ify_via,
|
||||
&summary,
|
||||
body.as_deref(),
|
||||
room_id,
|
||||
&store,
|
||||
)
|
||||
.await;
|
||||
},
|
||||
Err(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;
|
||||
}
|
||||
|
||||
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) {
|
||||
let mut locked = store.lock().await;
|
||||
locked.application.ring_bell = true;
|
||||
}
|
||||
|
||||
#[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();
|
||||
desktop_notification
|
||||
.summary(summary)
|
||||
.appname(IAMB_XDG_NAME)
|
||||
.icon(IAMB_XDG_NAME)
|
||||
.action("default", "default");
|
||||
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
desktop_notification.urgency(notify_rust::Urgency::Normal);
|
||||
|
||||
if let Some(body) = body {
|
||||
desktop_notification.body(body);
|
||||
}
|
||||
|
||||
match desktop_notification.show() {
|
||||
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)));
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async fn global_or_room_mode(
|
||||
settings: &NotificationSettings,
|
||||
room: &MatrixRoom,
|
||||
) -> RoomNotificationMode {
|
||||
let room_mode = settings.get_user_defined_room_notification_mode(room.room_id()).await;
|
||||
if let Some(mode) = room_mode {
|
||||
return mode;
|
||||
}
|
||||
let is_one_to_one = match room.is_direct().await {
|
||||
Ok(true) => IsOneToOne::Yes,
|
||||
_ => IsOneToOne::No,
|
||||
};
|
||||
let is_encrypted = match room.is_encrypted().await {
|
||||
Ok(true) => IsEncrypted::Yes,
|
||||
_ => IsEncrypted::No,
|
||||
};
|
||||
settings
|
||||
.get_default_room_notification_mode(is_encrypted, is_one_to_one)
|
||||
.await
|
||||
}
|
||||
|
||||
fn is_missing_mention(body: &Option<String>, mode: RoomNotificationMode, client: &Client) -> bool {
|
||||
if let Some(body) = body {
|
||||
if mode == RoomNotificationMode::MentionsAndKeywordsOnly {
|
||||
let mentioned = match client.user_id() {
|
||||
Some(user_id) => body.contains(user_id.localpart()),
|
||||
_ => false,
|
||||
};
|
||||
return !mentioned;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn is_open(locked: &mut ProgramStore, room_id: &RoomId) -> bool {
|
||||
if let Some(draw_curr) = locked.application.draw_curr {
|
||||
let info = locked.application.get_room_info(room_id.to_owned());
|
||||
if let Some(draw_last) = info.draw_last {
|
||||
return draw_last == draw_curr;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn is_focused(locked: &ProgramStore) -> bool {
|
||||
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,
|
||||
show_body: bool,
|
||||
) -> IambResult<(String, Option<String>, MilliSecondsSinceUnixEpoch)> {
|
||||
let event = event.deserialize().map_err(IambError::from)?;
|
||||
|
||||
let server_ts = event.origin_server_ts();
|
||||
|
||||
let sender_id = event.sender();
|
||||
let sender = room.get_member_no_sync(sender_id).await.map_err(IambError::from)?;
|
||||
|
||||
let sender_name = sender
|
||||
.as_ref()
|
||||
.and_then(|m| m.display_name())
|
||||
.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 {
|
||||
event_notification_body(&event, sender_name).map(truncate)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
return Ok((summary, body, server_ts));
|
||||
}
|
||||
|
||||
pub fn event_notification_body(event: &AnySyncTimelineEvent, sender_name: &str) -> Option<String> {
|
||||
let AnySyncTimelineEvent::MessageLike(event) = event else {
|
||||
return None;
|
||||
};
|
||||
|
||||
match event.original_content()? {
|
||||
AnyMessageLikeEventContent::RoomMessage(message) => {
|
||||
let body = match message.msgtype {
|
||||
MessageType::Audio(_) => {
|
||||
format!("{sender_name} sent an audio file.")
|
||||
},
|
||||
MessageType::Emote(content) => content.body,
|
||||
MessageType::File(_) => {
|
||||
format!("{sender_name} sent a file.")
|
||||
},
|
||||
MessageType::Image(_) => {
|
||||
format!("{sender_name} sent an image.")
|
||||
},
|
||||
MessageType::Location(_) => {
|
||||
format!("{sender_name} sent their location.")
|
||||
},
|
||||
MessageType::Notice(content) => content.body,
|
||||
MessageType::ServerNotice(content) => content.body,
|
||||
MessageType::Text(content) => content.body,
|
||||
MessageType::Video(_) => {
|
||||
format!("{sender_name} sent a video.")
|
||||
},
|
||||
MessageType::VerificationRequest(_) => {
|
||||
format!("{sender_name} sent a verification request.")
|
||||
},
|
||||
_ => {
|
||||
format!("[Unknown message type: {:?}]", &message.msgtype)
|
||||
},
|
||||
};
|
||||
Some(body)
|
||||
},
|
||||
AnyMessageLikeEventContent::Sticker(_) => Some(format!("{sender_name} sent a sticker.")),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn truncate(s: String) -> String {
|
||||
static MAX_LENGTH: usize = 5000;
|
||||
if s.graphemes(true).count() > MAX_LENGTH {
|
||||
let truncated: String = s.graphemes(true).take(MAX_LENGTH).collect();
|
||||
truncated + "..."
|
||||
} else {
|
||||
s
|
||||
}
|
||||
}
|
||||
175
src/preview.rs
Normal file
175
src/preview.rs
Normal file
@@ -0,0 +1,175 @@
|
||||
use std::{
|
||||
fs::File,
|
||||
io::{Read, Write},
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use matrix_sdk::{
|
||||
media::{MediaFormat, MediaRequestParameters},
|
||||
ruma::{
|
||||
events::{
|
||||
room::{
|
||||
message::{MessageType, RoomMessageEventContent},
|
||||
MediaSource,
|
||||
},
|
||||
MessageLikeEvent,
|
||||
},
|
||||
OwnedEventId,
|
||||
OwnedRoomId,
|
||||
},
|
||||
Media,
|
||||
};
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui_image::Resize;
|
||||
|
||||
use crate::{
|
||||
base::{AsyncProgramStore, ChatStore, IambError},
|
||||
config::ImagePreviewSize,
|
||||
message::ImageStatus,
|
||||
};
|
||||
|
||||
pub fn source_from_event(
|
||||
ev: &MessageLikeEvent<RoomMessageEventContent>,
|
||||
) -> Option<(OwnedEventId, MediaSource)> {
|
||||
if let MessageLikeEvent::Original(ev) = &ev {
|
||||
if let MessageType::Image(c) = &ev.content.msgtype {
|
||||
return Some((ev.event_id.clone(), c.source.clone()));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
impl From<ImagePreviewSize> for Rect {
|
||||
fn from(value: ImagePreviewSize) -> Self {
|
||||
Rect::new(0, 0, value.width as _, value.height as _)
|
||||
}
|
||||
}
|
||||
impl From<Rect> for ImagePreviewSize {
|
||||
fn from(rect: Rect) -> Self {
|
||||
ImagePreviewSize { width: rect.width as _, height: rect.height as _ }
|
||||
}
|
||||
}
|
||||
|
||||
/// Download and prepare the preview, and then lock the store to insert it.
|
||||
pub fn spawn_insert_preview(
|
||||
store: AsyncProgramStore,
|
||||
room_id: OwnedRoomId,
|
||||
event_id: OwnedEventId,
|
||||
source: MediaSource,
|
||||
media: Media,
|
||||
cache_dir: PathBuf,
|
||||
) {
|
||||
tokio::spawn(async move {
|
||||
let img = download_or_load(event_id.to_owned(), source, media, cache_dir)
|
||||
.await
|
||||
.map(std::io::Cursor::new)
|
||||
.map(image::ImageReader::new)
|
||||
.map_err(IambError::Matrix)
|
||||
.and_then(|reader| reader.with_guessed_format().map_err(IambError::IOError))
|
||||
.and_then(|reader| reader.decode().map_err(IambError::Image));
|
||||
|
||||
match img {
|
||||
Err(err) => {
|
||||
try_set_msg_preview_error(
|
||||
&mut store.lock().await.application,
|
||||
room_id,
|
||||
event_id,
|
||||
err,
|
||||
);
|
||||
},
|
||||
Ok(img) => {
|
||||
let mut locked = store.lock().await;
|
||||
let ChatStore { rooms, picker, settings, .. } = &mut locked.application;
|
||||
|
||||
match picker
|
||||
.as_mut()
|
||||
.ok_or_else(|| IambError::Preview("Picker is empty".to_string()))
|
||||
.and_then(|picker| {
|
||||
Ok((
|
||||
picker,
|
||||
rooms
|
||||
.get_or_default(room_id.clone())
|
||||
.get_event_mut(&event_id)
|
||||
.ok_or_else(|| {
|
||||
IambError::Preview("Message not found".to_string())
|
||||
})?,
|
||||
settings.tunables.image_preview.clone().ok_or_else(|| {
|
||||
IambError::Preview("image_preview settings not found".to_string())
|
||||
})?,
|
||||
))
|
||||
})
|
||||
.and_then(|(picker, msg, image_preview)| {
|
||||
picker
|
||||
.new_protocol(img, image_preview.size.into(), Resize::Fit(None))
|
||||
.map_err(|err| IambError::Preview(format!("{err:?}")))
|
||||
.map(|backend| (backend, msg))
|
||||
}) {
|
||||
Err(err) => {
|
||||
try_set_msg_preview_error(&mut locked.application, room_id, event_id, err);
|
||||
},
|
||||
Ok((backend, msg)) => {
|
||||
msg.image_preview = ImageStatus::Loaded(backend);
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn try_set_msg_preview_error(
|
||||
application: &mut ChatStore,
|
||||
room_id: OwnedRoomId,
|
||||
event_id: OwnedEventId,
|
||||
err: IambError,
|
||||
) {
|
||||
let rooms = &mut application.rooms;
|
||||
|
||||
match rooms
|
||||
.get_or_default(room_id.clone())
|
||||
.get_event_mut(&event_id)
|
||||
.ok_or_else(|| IambError::Preview("Message not found".to_string()))
|
||||
{
|
||||
Ok(msg) => msg.image_preview = ImageStatus::Error(format!("{err:?}")),
|
||||
Err(err) => {
|
||||
tracing::error!(
|
||||
"Failed to set error on msg.image_backend for event {}, room {}: {}",
|
||||
event_id,
|
||||
room_id,
|
||||
err
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async fn download_or_load(
|
||||
event_id: OwnedEventId,
|
||||
source: MediaSource,
|
||||
media: Media,
|
||||
mut cache_path: PathBuf,
|
||||
) -> Result<Vec<u8>, matrix_sdk::Error> {
|
||||
cache_path.push(Path::new(event_id.localpart()));
|
||||
|
||||
match File::open(&cache_path) {
|
||||
Ok(mut f) => {
|
||||
let mut buffer = Vec::new();
|
||||
f.read_to_end(&mut buffer)?;
|
||||
Ok(buffer)
|
||||
},
|
||||
Err(_) => {
|
||||
media
|
||||
.get_media_content(
|
||||
&MediaRequestParameters { source, format: MediaFormat::File },
|
||||
true,
|
||||
)
|
||||
.await
|
||||
.and_then(|buffer| {
|
||||
if let Err(err) =
|
||||
File::create(&cache_path).and_then(|mut f| f.write_all(&buffer))
|
||||
{
|
||||
return Err(err.into());
|
||||
}
|
||||
Ok(buffer)
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
58
src/sled_export.rs
Normal file
58
src/sled_export.rs
Normal file
@@ -0,0 +1,58 @@
|
||||
//! # sled -> sqlite migration code
|
||||
//!
|
||||
//! Before the 0.0.9 release, iamb used matrix-sdk@0.6.2, which used [sled]
|
||||
//! for storing information, including room keys. In matrix-sdk@0.7.0,
|
||||
//! the SDK switched to using SQLite. This module takes care of opening
|
||||
//! sled, exporting the inbound group sessions used for decryption,
|
||||
//! and importing them into SQLite.
|
||||
//!
|
||||
//! This code will eventually be removed once people have been given enough
|
||||
//! time to upgrade off of pre-0.0.9 versions.
|
||||
//!
|
||||
//! [sled]: https://docs.rs/sled/0.34.7/sled/index.html
|
||||
use sled::{Config, IVec};
|
||||
use std::path::Path;
|
||||
|
||||
use crate::base::IambError;
|
||||
use matrix_sdk::crypto::olm::{ExportedRoomKey, InboundGroupSession, PickledInboundGroupSession};
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum SledMigrationError {
|
||||
#[error("sled failure: {0}")]
|
||||
Sled(#[from] sled::Error),
|
||||
|
||||
#[error("deserialization failure: {0}")]
|
||||
Deserialize(#[from] serde_json::Error),
|
||||
}
|
||||
|
||||
fn group_session_from_slice(
|
||||
(_, bytes): (IVec, IVec),
|
||||
) -> Result<PickledInboundGroupSession, SledMigrationError> {
|
||||
serde_json::from_slice(&bytes).map_err(SledMigrationError::from)
|
||||
}
|
||||
|
||||
async fn export_room_keys_priv(
|
||||
sled_dir: &Path,
|
||||
) -> Result<Vec<ExportedRoomKey>, SledMigrationError> {
|
||||
let path = sled_dir.join("matrix-sdk-state");
|
||||
let store = Config::new().temporary(false).path(&path).open()?;
|
||||
let inbound_groups = store.open_tree("inbound_group_sessions")?;
|
||||
|
||||
let mut exported = vec![];
|
||||
let sessions = inbound_groups
|
||||
.iter()
|
||||
.map(|p| p.map_err(SledMigrationError::from).and_then(group_session_from_slice))
|
||||
.collect::<Result<Vec<_>, _>>()?
|
||||
.into_iter()
|
||||
.filter_map(|p| InboundGroupSession::from_pickle(p).ok());
|
||||
|
||||
for session in sessions {
|
||||
exported.push(session.export().await);
|
||||
}
|
||||
|
||||
Ok(exported)
|
||||
}
|
||||
|
||||
pub async fn export_room_keys(sled_dir: &Path) -> Result<Vec<ExportedRoomKey>, IambError> {
|
||||
export_room_keys_priv(sled_dir).await.map_err(IambError::from)
|
||||
}
|
||||
81
src/tests.rs
81
src/tests.rs
@@ -1,4 +1,4 @@
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use matrix_sdk::ruma::{
|
||||
@@ -15,21 +15,25 @@ use matrix_sdk::ruma::{
|
||||
};
|
||||
|
||||
use lazy_static::lazy_static;
|
||||
use modalkit::tui::style::{Color, Style};
|
||||
use ratatui::style::{Color, Style};
|
||||
use tokio::sync::mpsc::unbounded_channel;
|
||||
use tracing::Level;
|
||||
use url::Url;
|
||||
|
||||
use crate::{
|
||||
base::{ChatStore, EventLocation, ProgramStore, RoomFetchStatus, RoomInfo},
|
||||
base::{ChatStore, EventLocation, ProgramStore, RoomInfo},
|
||||
config::{
|
||||
user_color,
|
||||
user_style_from_color,
|
||||
ApplicationSettings,
|
||||
DirectoryValues,
|
||||
Notifications,
|
||||
NotifyVia,
|
||||
ProfileConfig,
|
||||
SortOverrides,
|
||||
TunableValues,
|
||||
UserColor,
|
||||
UserDisplayStyle,
|
||||
UserDisplayTunables,
|
||||
},
|
||||
message::{
|
||||
@@ -42,6 +46,8 @@ use crate::{
|
||||
worker::Requester,
|
||||
};
|
||||
|
||||
const TEST_ROOM1_ALIAS: &str = "#room1:example.com";
|
||||
|
||||
lazy_static! {
|
||||
pub static ref TEST_ROOM1_ID: OwnedRoomId = RoomId::new(server_name!("example.com")).to_owned();
|
||||
pub static ref TEST_USER1: OwnedUserId = user_id!("@user1:example.com").to_owned();
|
||||
@@ -121,17 +127,17 @@ pub fn mock_message5() -> Message {
|
||||
pub fn mock_keys() -> HashMap<OwnedEventId, EventLocation> {
|
||||
let mut keys = HashMap::new();
|
||||
|
||||
keys.insert(MSG1_EVID.clone(), EventLocation::Message(MSG1_KEY.clone()));
|
||||
keys.insert(MSG2_EVID.clone(), EventLocation::Message(MSG2_KEY.clone()));
|
||||
keys.insert(MSG3_EVID.clone(), EventLocation::Message(MSG3_KEY.clone()));
|
||||
keys.insert(MSG4_EVID.clone(), EventLocation::Message(MSG4_KEY.clone()));
|
||||
keys.insert(MSG5_EVID.clone(), EventLocation::Message(MSG5_KEY.clone()));
|
||||
keys.insert(MSG1_EVID.clone(), EventLocation::Message(None, MSG1_KEY.clone()));
|
||||
keys.insert(MSG2_EVID.clone(), EventLocation::Message(None, MSG2_KEY.clone()));
|
||||
keys.insert(MSG3_EVID.clone(), EventLocation::Message(None, MSG3_KEY.clone()));
|
||||
keys.insert(MSG4_EVID.clone(), EventLocation::Message(None, MSG4_KEY.clone()));
|
||||
keys.insert(MSG5_EVID.clone(), EventLocation::Message(None, MSG5_KEY.clone()));
|
||||
|
||||
keys
|
||||
}
|
||||
|
||||
pub fn mock_messages() -> Messages {
|
||||
let mut messages = BTreeMap::new();
|
||||
let mut messages = Messages::main();
|
||||
|
||||
messages.insert(MSG1_KEY.clone(), mock_message1());
|
||||
messages.insert(MSG2_KEY.clone(), mock_message2());
|
||||
@@ -143,29 +149,20 @@ pub fn mock_messages() -> Messages {
|
||||
}
|
||||
|
||||
pub fn mock_room() -> RoomInfo {
|
||||
RoomInfo {
|
||||
name: Some("Watercooler Discussion".into()),
|
||||
tags: None,
|
||||
|
||||
keys: mock_keys(),
|
||||
messages: mock_messages(),
|
||||
|
||||
receipts: HashMap::new(),
|
||||
read_till: None,
|
||||
reactions: HashMap::new(),
|
||||
|
||||
fetching: false,
|
||||
fetch_id: RoomFetchStatus::NotStarted,
|
||||
fetch_last: None,
|
||||
users_typing: None,
|
||||
}
|
||||
let mut room = RoomInfo::default();
|
||||
room.name = Some("Watercooler Discussion".into());
|
||||
room.keys = mock_keys();
|
||||
*room.get_thread_mut(None) = mock_messages();
|
||||
room
|
||||
}
|
||||
|
||||
pub fn mock_dirs() -> DirectoryValues {
|
||||
DirectoryValues {
|
||||
cache: PathBuf::new(),
|
||||
data: PathBuf::new(),
|
||||
logs: PathBuf::new(),
|
||||
downloads: PathBuf::new(),
|
||||
downloads: None,
|
||||
image_previews: PathBuf::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,11 +170,15 @@ pub fn mock_tunables() -> TunableValues {
|
||||
TunableValues {
|
||||
default_room: None,
|
||||
log_level: Level::INFO,
|
||||
message_shortcode_display: false,
|
||||
normal_after_send: true,
|
||||
reaction_display: true,
|
||||
reaction_shortcode_display: false,
|
||||
read_receipt_send: true,
|
||||
read_receipt_display: true,
|
||||
request_timeout: 120,
|
||||
sort: SortOverrides::default().values(),
|
||||
state_event_display: true,
|
||||
typing_notice_send: true,
|
||||
typing_notice_display: true,
|
||||
users: vec![(TEST_USER5.clone(), UserDisplayTunables {
|
||||
@@ -186,22 +187,43 @@ pub fn mock_tunables() -> TunableValues {
|
||||
})]
|
||||
.into_iter()
|
||||
.collect::<HashMap<_, _>>(),
|
||||
open_command: None,
|
||||
external_edit_file_suffix: String::from(".md"),
|
||||
username_display: UserDisplayStyle::Username,
|
||||
message_user_color: false,
|
||||
mouse: Default::default(),
|
||||
notifications: Notifications {
|
||||
enabled: false,
|
||||
via: NotifyVia::default(),
|
||||
show_message: true,
|
||||
},
|
||||
image_preview: None,
|
||||
user_gutter_width: 30,
|
||||
tabstop: 4,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn mock_settings() -> ApplicationSettings {
|
||||
ApplicationSettings {
|
||||
matrix_dir: PathBuf::new(),
|
||||
layout_json: PathBuf::new(),
|
||||
session_json: PathBuf::new(),
|
||||
session_json_old: PathBuf::new(),
|
||||
sled_dir: PathBuf::new(),
|
||||
sqlite_dir: PathBuf::new(),
|
||||
|
||||
profile_name: "test".into(),
|
||||
profile: ProfileConfig {
|
||||
user_id: user_id!("@user:example.com").to_owned(),
|
||||
url: Url::parse("https://example.com").unwrap(),
|
||||
url: None,
|
||||
settings: None,
|
||||
dirs: None,
|
||||
layout: None,
|
||||
macros: None,
|
||||
},
|
||||
tunables: mock_tunables(),
|
||||
dirs: mock_dirs(),
|
||||
layout: Default::default(),
|
||||
macros: HashMap::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,7 +245,8 @@ pub async fn mock_store() -> ProgramStore {
|
||||
let room_id = TEST_ROOM1_ID.clone();
|
||||
let info = mock_room();
|
||||
|
||||
store.rooms.insert(room_id, info);
|
||||
store.rooms.insert(room_id.clone(), info);
|
||||
store.names.insert(TEST_ROOM1_ALIAS.to_string(), room_id);
|
||||
|
||||
ProgramStore::new(store)
|
||||
}
|
||||
|
||||
53
src/util.rs
53
src/util.rs
@@ -1,10 +1,11 @@
|
||||
//! # Utility functions
|
||||
use std::borrow::Cow;
|
||||
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use modalkit::tui::style::Style;
|
||||
use modalkit::tui::text::{Span, Spans, Text};
|
||||
use ratatui::style::Style;
|
||||
use ratatui::text::{Line, Span, Text};
|
||||
|
||||
pub fn split_cow(cow: Cow<'_, str>, idx: usize) -> (Cow<'_, str>, Cow<'_, str>) {
|
||||
match cow {
|
||||
@@ -25,19 +26,19 @@ pub fn split_cow(cow: Cow<'_, str>, idx: usize) -> (Cow<'_, str>, Cow<'_, str>)
|
||||
|
||||
pub fn take_width(s: Cow<'_, str>, width: usize) -> ((Cow<'_, str>, usize), Cow<'_, str>) {
|
||||
// Find where to split the line.
|
||||
let mut idx = 0;
|
||||
let mut w = 0;
|
||||
|
||||
for (i, g) in UnicodeSegmentation::grapheme_indices(s.as_ref(), true) {
|
||||
let idx = UnicodeSegmentation::grapheme_indices(s.as_ref(), true)
|
||||
.find_map(|(i, g)| {
|
||||
let gw = UnicodeWidthStr::width(g);
|
||||
idx = i;
|
||||
|
||||
if w + gw > width {
|
||||
break;
|
||||
}
|
||||
|
||||
Some(i)
|
||||
} else {
|
||||
w += gw;
|
||||
None
|
||||
}
|
||||
})
|
||||
.unwrap_or(s.len());
|
||||
|
||||
let (s0, s1) = split_cow(s, idx);
|
||||
|
||||
@@ -105,7 +106,7 @@ where
|
||||
|
||||
for (line, w) in wrap(s, width) {
|
||||
let space = space_span(width.saturating_sub(w), style);
|
||||
let spans = Spans(vec![Span::styled(line, style), space]);
|
||||
let spans = Line::from(vec![Span::styled(line, style), space]);
|
||||
|
||||
text.lines.push(spans);
|
||||
}
|
||||
@@ -127,23 +128,45 @@ 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> {
|
||||
let height = texts.iter().map(|t| t.0.height()).max().unwrap_or(0);
|
||||
let mut text = Text { lines: vec![Spans(vec![join.clone()]); height] };
|
||||
let mut text = Text::from(vec![Line::from(vec![join.clone()]); height]);
|
||||
|
||||
for (mut t, w) in texts.into_iter() {
|
||||
for i in 0..height {
|
||||
if let Some(spans) = t.lines.get_mut(i) {
|
||||
text.lines[i].0.append(&mut spans.0);
|
||||
if let Some(line) = t.lines.get_mut(i) {
|
||||
text.lines[i].spans.append(&mut line.spans);
|
||||
} else {
|
||||
text.lines[i].0.push(space_span(w, style));
|
||||
text.lines[i].spans.push(space_span(w, style));
|
||||
}
|
||||
|
||||
text.lines[i].0.push(join.clone());
|
||||
text.lines[i].spans.push(join.clone());
|
||||
}
|
||||
}
|
||||
|
||||
text
|
||||
}
|
||||
|
||||
fn replace_emoji_in_grapheme(grapheme: &str) -> String {
|
||||
emojis::get(grapheme)
|
||||
.and_then(|emoji| emoji.shortcode())
|
||||
.map(|shortcode| format!(":{shortcode}:"))
|
||||
.unwrap_or_else(|| grapheme.to_owned())
|
||||
}
|
||||
|
||||
pub fn replace_emojis_in_str(s: &str) -> String {
|
||||
let graphemes = s.graphemes(true);
|
||||
graphemes.map(replace_emoji_in_grapheme).collect()
|
||||
}
|
||||
|
||||
pub fn replace_emojis_in_span(span: &mut Span) {
|
||||
span.content = Cow::Owned(replace_emojis_in_str(span.content.as_ref()))
|
||||
}
|
||||
|
||||
pub fn replace_emojis_in_line(line: &mut Line) {
|
||||
for span in &mut line.spans {
|
||||
replace_emojis_in_span(span);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub mod tests {
|
||||
use super::*;
|
||||
|
||||
1171
src/windows/mod.rs
1171
src/windows/mod.rs
File diff suppressed because it is too large
Load Diff
@@ -1,63 +1,74 @@
|
||||
//! Window for Matrix rooms
|
||||
use std::borrow::Cow;
|
||||
use std::ffi::OsStr;
|
||||
use std::ffi::{OsStr, OsString};
|
||||
use std::fs;
|
||||
use std::ops::Deref;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use edit::edit_with_builder as external_edit;
|
||||
use edit::Builder;
|
||||
use modalkit::editing::store::RegisterError;
|
||||
use std::process::Command;
|
||||
use tokio;
|
||||
use url::Url;
|
||||
|
||||
use matrix_sdk::{
|
||||
attachment::AttachmentConfig,
|
||||
media::{MediaFormat, MediaRequest},
|
||||
room::{Joined, Room as MatrixRoom},
|
||||
media::{MediaFormat, MediaRequestParameters},
|
||||
room::Room as MatrixRoom,
|
||||
ruma::{
|
||||
events::reaction::{ReactionEventContent, Relation as Reaction},
|
||||
events::reaction::ReactionEventContent,
|
||||
events::relation::{Annotation, Replacement},
|
||||
events::room::message::{
|
||||
AddMentions,
|
||||
ForwardThread,
|
||||
MessageType,
|
||||
OriginalRoomMessageEvent,
|
||||
Relation,
|
||||
Replacement,
|
||||
ReplyWithinThread,
|
||||
RoomMessageEventContent,
|
||||
TextMessageEventContent,
|
||||
},
|
||||
EventId,
|
||||
OwnedEventId,
|
||||
OwnedRoomId,
|
||||
RoomId,
|
||||
},
|
||||
RoomState,
|
||||
};
|
||||
|
||||
use modalkit::{
|
||||
tui::{
|
||||
use ratatui::{
|
||||
buffer::Buffer,
|
||||
layout::Rect,
|
||||
text::{Span, Spans},
|
||||
text::{Line, Span},
|
||||
widgets::{Paragraph, StatefulWidget, Widget},
|
||||
},
|
||||
widgets::textbox::{TextBox, TextBoxState},
|
||||
widgets::TerminalCursor,
|
||||
widgets::{PromptActions, WindowOps},
|
||||
};
|
||||
|
||||
use modalkit::editing::{
|
||||
action::{
|
||||
EditError,
|
||||
EditInfo,
|
||||
EditResult,
|
||||
use modalkit::keybindings::dialog::{MultiChoice, MultiChoiceItem, PromptYesNo};
|
||||
|
||||
use modalkit_ratatui::{
|
||||
textbox::{TextBox, TextBoxState},
|
||||
PromptActions,
|
||||
TerminalCursor,
|
||||
WindowOps,
|
||||
};
|
||||
|
||||
use modalkit::actions::{
|
||||
Action,
|
||||
Editable,
|
||||
EditorAction,
|
||||
InfoMessage,
|
||||
Jumpable,
|
||||
PromptAction,
|
||||
Promptable,
|
||||
Scrollable,
|
||||
UIError,
|
||||
},
|
||||
base::{CloseFlags, Count, MoveDir1D, PositionList, ScrollStyle, WordStyle, WriteFlags},
|
||||
};
|
||||
use modalkit::editing::{
|
||||
completion::CompletionList,
|
||||
context::Resolve,
|
||||
history::{self, HistoryList},
|
||||
rope::EditRope,
|
||||
};
|
||||
use modalkit::errors::{EditError, EditResult, UIError};
|
||||
use modalkit::prelude::*;
|
||||
|
||||
use crate::base::{
|
||||
DownloadFlags,
|
||||
@@ -75,11 +86,19 @@ use crate::base::{
|
||||
SendAction,
|
||||
};
|
||||
|
||||
use crate::message::{Message, MessageEvent, MessageKey, MessageTimeStamp};
|
||||
use crate::message::{
|
||||
text_to_message,
|
||||
Message,
|
||||
MessageEvent,
|
||||
MessageKey,
|
||||
MessageTimeStamp,
|
||||
TreeGenState,
|
||||
};
|
||||
use crate::worker::Requester;
|
||||
|
||||
use super::scrollback::{Scrollback, ScrollbackState};
|
||||
|
||||
/// State needed for rendering [Chat].
|
||||
pub struct ChatState {
|
||||
room_id: OwnedRoomId,
|
||||
room: MatrixRoom,
|
||||
@@ -96,10 +115,10 @@ pub struct ChatState {
|
||||
}
|
||||
|
||||
impl ChatState {
|
||||
pub fn new(room: MatrixRoom, store: &mut ProgramStore) -> Self {
|
||||
pub fn new(room: MatrixRoom, thread: Option<OwnedEventId>, store: &mut ProgramStore) -> Self {
|
||||
let room_id = room.room_id().to_owned();
|
||||
let scrollback = ScrollbackState::new(room_id.clone());
|
||||
let id = IambBufferId::Room(room_id.clone(), RoomFocus::MessageBar);
|
||||
let scrollback = ScrollbackState::new(room_id.clone(), thread.clone());
|
||||
let id = IambBufferId::Room(room_id.clone(), thread, RoomFocus::MessageBar);
|
||||
let ebuf = store.load_buffer(id);
|
||||
let tbox = TextBoxState::new(ebuf);
|
||||
|
||||
@@ -119,13 +138,26 @@ impl ChatState {
|
||||
}
|
||||
}
|
||||
|
||||
fn get_joined(&self, worker: &Requester) -> Result<Joined, IambError> {
|
||||
worker.client.get_joined_room(self.id()).ok_or(IambError::NotJoined)
|
||||
pub fn thread(&self) -> Option<&OwnedEventId> {
|
||||
self.scrollback.thread()
|
||||
}
|
||||
|
||||
fn get_joined(&self, worker: &Requester) -> Result<MatrixRoom, IambError> {
|
||||
let Some(room) = worker.client.get_room(self.id()) else {
|
||||
return Err(IambError::NotJoined);
|
||||
};
|
||||
|
||||
if room.state() == RoomState::Joined {
|
||||
Ok(room)
|
||||
} else {
|
||||
Err(IambError::NotJoined)
|
||||
}
|
||||
}
|
||||
|
||||
fn get_reply_to<'a>(&self, info: &'a RoomInfo) -> Option<&'a OriginalRoomMessageEvent> {
|
||||
let thread = self.scrollback.get_thread(info)?;
|
||||
let key = self.reply_to.as_ref()?;
|
||||
let msg = info.messages.get(key)?;
|
||||
let msg = thread.get(key)?;
|
||||
|
||||
if let MessageEvent::Original(ev) = &msg.event {
|
||||
Some(ev)
|
||||
@@ -157,67 +189,105 @@ impl ChatState {
|
||||
let settings = &store.application.settings;
|
||||
let info = store.application.rooms.get_or_default(self.room_id.clone());
|
||||
|
||||
let msg = self
|
||||
.scrollback
|
||||
.get_mut(&mut info.messages)
|
||||
.ok_or(IambError::NoSelectedMessage)?;
|
||||
let msg = self.scrollback.get_mut(info).ok_or(IambError::NoSelectedMessage)?;
|
||||
|
||||
match act {
|
||||
MessageAction::Cancel => {
|
||||
MessageAction::Cancel(skip_confirm) => {
|
||||
if skip_confirm {
|
||||
self.reset();
|
||||
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
self.reply_to = None;
|
||||
self.editing = None;
|
||||
|
||||
Ok(None)
|
||||
let msg = "Would you like to clear the message bar?";
|
||||
let act = PromptAction::Abort(false);
|
||||
let prompt = PromptYesNo::new(msg, vec![Action::from(act)]);
|
||||
let prompt = Box::new(prompt);
|
||||
|
||||
Err(UIError::NeedConfirm(prompt))
|
||||
},
|
||||
MessageAction::Download(filename, flags) => {
|
||||
if let MessageEvent::Original(ev) = &msg.event {
|
||||
let media = client.media();
|
||||
|
||||
let mut filename = match filename {
|
||||
Some(f) => PathBuf::from(f),
|
||||
None => settings.dirs.downloads.clone(),
|
||||
let mut filename = match (filename, &settings.dirs.downloads) {
|
||||
(Some(f), _) => PathBuf::from(f),
|
||||
(None, Some(downloads)) => downloads.clone(),
|
||||
(None, None) => return Err(IambError::NoDownloadDir.into()),
|
||||
};
|
||||
|
||||
let source = match &ev.content.msgtype {
|
||||
MessageType::Audio(c) => {
|
||||
if filename.is_dir() {
|
||||
filename.push(c.body.as_str());
|
||||
}
|
||||
|
||||
c.source.clone()
|
||||
},
|
||||
let (source, msg_filename) = match &ev.content.msgtype {
|
||||
MessageType::Audio(c) => (c.source.clone(), c.body.as_str()),
|
||||
MessageType::File(c) => {
|
||||
if filename.is_dir() {
|
||||
if let Some(name) = &c.filename {
|
||||
filename.push(name);
|
||||
} else {
|
||||
filename.push(c.body.as_str());
|
||||
}
|
||||
}
|
||||
|
||||
c.source.clone()
|
||||
},
|
||||
MessageType::Image(c) => {
|
||||
if filename.is_dir() {
|
||||
filename.push(c.body.as_str());
|
||||
}
|
||||
|
||||
c.source.clone()
|
||||
},
|
||||
MessageType::Video(c) => {
|
||||
if filename.is_dir() {
|
||||
filename.push(c.body.as_str());
|
||||
}
|
||||
|
||||
c.source.clone()
|
||||
(c.source.clone(), c.filename.as_deref().unwrap_or(c.body.as_str()))
|
||||
},
|
||||
MessageType::Image(c) => (c.source.clone(), c.body.as_str()),
|
||||
MessageType::Video(c) => (c.source.clone(), c.body.as_str()),
|
||||
_ => {
|
||||
if !flags.contains(DownloadFlags::OPEN) {
|
||||
return Err(IambError::NoAttachment.into());
|
||||
}
|
||||
|
||||
let links = if let Some(html) = &msg.html {
|
||||
html.get_links()
|
||||
} else {
|
||||
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() {
|
||||
return Err(IambError::NoAttachment.into());
|
||||
}
|
||||
|
||||
let choices = links
|
||||
.into_iter()
|
||||
.map(|l| {
|
||||
let url = l.1.to_string();
|
||||
let act = IambAction::OpenLink(url.clone()).into();
|
||||
MultiChoiceItem::new(l.0, url, vec![act])
|
||||
})
|
||||
.collect();
|
||||
let dialog = MultiChoice::new(choices);
|
||||
let err = UIError::NeedConfirm(Box::new(dialog));
|
||||
|
||||
return Err(err);
|
||||
},
|
||||
};
|
||||
|
||||
if filename.is_dir() {
|
||||
filename.push(msg_filename);
|
||||
}
|
||||
|
||||
if filename.exists() && !flags.contains(DownloadFlags::FORCE) {
|
||||
// Find an incrementally suffixed filename, e.g. image-2.jpg -> image-3.jpg
|
||||
if let Some(stem) = filename.file_stem().and_then(OsStr::to_str) {
|
||||
let ext = filename.extension();
|
||||
let mut filename_incr = filename.clone();
|
||||
for n in 1..=1000 {
|
||||
if let Some(ext) = ext.and_then(OsStr::to_str) {
|
||||
filename_incr.set_file_name(format!("{}-{}.{}", stem, n, ext));
|
||||
} else {
|
||||
filename_incr.set_file_name(format!("{}-{}", stem, n));
|
||||
}
|
||||
|
||||
if !filename_incr.exists() {
|
||||
filename = filename_incr;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !filename.exists() || flags.contains(DownloadFlags::FORCE) {
|
||||
let req = MediaRequest { source, format: MediaFormat::File };
|
||||
let req = MediaRequestParameters { source, format: MediaFormat::File };
|
||||
|
||||
let bytes =
|
||||
media.get_media_content(&req, true).await.map_err(IambError::from)?;
|
||||
@@ -236,14 +306,21 @@ impl ChatState {
|
||||
}
|
||||
|
||||
let info = if flags.contains(DownloadFlags::OPEN) {
|
||||
// open::that may not return until the spawned program closes.
|
||||
let target = filename.clone().into_os_string();
|
||||
tokio::task::spawn_blocking(move || open::that(target));
|
||||
|
||||
match open_command(
|
||||
store.application.settings.tunables.open_command.as_ref(),
|
||||
target,
|
||||
) {
|
||||
Ok(_) => {
|
||||
InfoMessage::from(format!(
|
||||
"Attachment downloaded to {} and opened",
|
||||
filename.display()
|
||||
))
|
||||
},
|
||||
Err(err) => {
|
||||
return Err(err);
|
||||
},
|
||||
}
|
||||
} else {
|
||||
InfoMessage::from(format!(
|
||||
"Attachment downloaded to {}",
|
||||
@@ -286,18 +363,35 @@ impl ChatState {
|
||||
};
|
||||
|
||||
self.tbox.set_text(text);
|
||||
self.reply_to = msg.reply_to().and_then(|id| info.get_message_key(&id)).cloned();
|
||||
self.editing = self.scrollback.get_key(info);
|
||||
self.focus = RoomFocus::MessageBar;
|
||||
|
||||
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 event_id = match &msg.event {
|
||||
MessageEvent::EncryptedOriginal(ev) => ev.event_id.clone(),
|
||||
MessageEvent::EncryptedRedacted(ev) => ev.event_id.clone(),
|
||||
MessageEvent::Original(ev) => ev.event_id.clone(),
|
||||
MessageEvent::Local(event_id, _) => event_id.clone(),
|
||||
MessageEvent::State(ev) => ev.event_id().to_owned(),
|
||||
MessageEvent::Redacted(_) => {
|
||||
let msg = "Cannot react to a redacted message";
|
||||
let err = UIError::Failure(msg.into());
|
||||
@@ -306,19 +400,36 @@ impl ChatState {
|
||||
},
|
||||
};
|
||||
|
||||
let reaction = Reaction::new(event_id, emoji);
|
||||
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 msg = ReactionEventContent::new(reaction);
|
||||
let _ = room.send(msg, None).await.map_err(IambError::from)?;
|
||||
let _ = room.send(msg).await.map_err(IambError::from)?;
|
||||
|
||||
Ok(None)
|
||||
},
|
||||
MessageAction::Redact(reason) => {
|
||||
MessageAction::Redact(reason, skip_confirm) => {
|
||||
if !skip_confirm {
|
||||
let msg = "Are you sure you want to redact this message?";
|
||||
let act = IambAction::Message(MessageAction::Redact(reason, 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 event_id = match &msg.event {
|
||||
MessageEvent::EncryptedOriginal(ev) => ev.event_id.clone(),
|
||||
MessageEvent::EncryptedRedacted(ev) => ev.event_id.clone(),
|
||||
MessageEvent::Original(ev) => ev.event_id.clone(),
|
||||
MessageEvent::Local(event_id, _) => event_id.clone(),
|
||||
MessageEvent::State(ev) => ev.event_id().to_owned(),
|
||||
MessageEvent::Redacted(_) => {
|
||||
let msg = "Cannot redact already redacted message";
|
||||
let err = UIError::Failure(msg.into());
|
||||
@@ -339,13 +450,34 @@ impl ChatState {
|
||||
|
||||
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 event_id: &EventId = match &msg.event {
|
||||
MessageEvent::EncryptedOriginal(ev) => ev.event_id.as_ref(),
|
||||
MessageEvent::EncryptedRedacted(ev) => ev.event_id.as_ref(),
|
||||
MessageEvent::Original(ev) => ev.event_id.as_ref(),
|
||||
MessageEvent::Local(event_id, _) => event_id.as_ref(),
|
||||
let event_id = match &msg.event {
|
||||
MessageEvent::EncryptedOriginal(ev) => ev.event_id.clone(),
|
||||
MessageEvent::EncryptedRedacted(ev) => ev.event_id.clone(),
|
||||
MessageEvent::Original(ev) => ev.event_id.clone(),
|
||||
MessageEvent::Local(event_id, _) => event_id.clone(),
|
||||
MessageEvent::State(ev) => ev.event_id().to_owned(),
|
||||
MessageEvent::Redacted(_) => {
|
||||
let msg = "Cannot unreact to a redacted message";
|
||||
let err = UIError::Failure(msg.into());
|
||||
@@ -354,7 +486,7 @@ impl ChatState {
|
||||
},
|
||||
};
|
||||
|
||||
let reactions = match info.reactions.get(event_id) {
|
||||
let reactions = match info.reactions.get(&event_id) {
|
||||
Some(r) => r,
|
||||
None => return Ok(None),
|
||||
};
|
||||
@@ -390,43 +522,55 @@ impl ChatState {
|
||||
_: ProgramContext,
|
||||
store: &mut ProgramStore,
|
||||
) -> IambResult<EditInfo> {
|
||||
let room = store
|
||||
.application
|
||||
.worker
|
||||
.client
|
||||
.get_joined_room(self.id())
|
||||
.ok_or(IambError::NotJoined)?;
|
||||
let room = self.get_joined(&store.application.worker)?;
|
||||
let info = store.application.rooms.get_or_default(self.id().to_owned());
|
||||
let mut show_echo = true;
|
||||
|
||||
let (event_id, msg) = match act {
|
||||
SendAction::Submit => {
|
||||
SendAction::Submit | SendAction::SubmitFromEditor => {
|
||||
let msg = self.tbox.get();
|
||||
|
||||
if msg.is_blank() {
|
||||
let msg = if let SendAction::SubmitFromEditor = act {
|
||||
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() {
|
||||
return Ok(None);
|
||||
} else {
|
||||
msg.trim_end().to_string()
|
||||
};
|
||||
|
||||
let msg = TextMessageEventContent::markdown(msg.to_string());
|
||||
let msg = MessageType::Text(msg);
|
||||
|
||||
let mut msg = RoomMessageEventContent::new(msg);
|
||||
let mut msg = text_to_message(msg);
|
||||
|
||||
if let Some((_, event_id)) = &self.editing {
|
||||
msg.relates_to = Some(Relation::Replacement(Replacement::new(
|
||||
event_id.clone(),
|
||||
Box::new(msg.clone()),
|
||||
msg.msgtype.clone().into(),
|
||||
)));
|
||||
|
||||
show_echo = false;
|
||||
} else if let Some(thread_root) = self.scrollback.thread() {
|
||||
if let Some(m) = self.get_reply_to(info) {
|
||||
msg = msg.make_for_thread(m, ReplyWithinThread::Yes, AddMentions::No);
|
||||
} else if let Some(m) = info.get_thread_last(thread_root) {
|
||||
msg = msg.make_for_thread(m, ReplyWithinThread::No, AddMentions::No);
|
||||
} else {
|
||||
// Internal state is wonky?
|
||||
}
|
||||
} else if let Some(m) = self.get_reply_to(info) {
|
||||
// XXX: Switch to RoomMessageEventContent::reply() once it's stable?
|
||||
msg = msg.make_reply_to(m);
|
||||
msg = msg.make_reply_to(m, ForwardThread::Yes, AddMentions::No);
|
||||
}
|
||||
|
||||
// XXX: second parameter can be a locally unique transaction id.
|
||||
// Useful for doing retries.
|
||||
let resp = room.send(msg.clone(), None).await.map_err(IambError::from)?;
|
||||
let resp = room.send(msg.clone()).await.map_err(IambError::from)?;
|
||||
let event_id = resp.event_id;
|
||||
|
||||
// Reset message bar state now that it's been sent.
|
||||
@@ -446,7 +590,37 @@ impl ChatState {
|
||||
let config = AttachmentConfig::new();
|
||||
|
||||
let resp = room
|
||||
.send_attachment(name.as_ref(), &mime, bytes.as_ref(), config)
|
||||
.send_attachment(name.as_ref(), &mime, bytes, config)
|
||||
.await
|
||||
.map_err(IambError::from)?;
|
||||
|
||||
// Mock up the local echo message for the scrollback.
|
||||
let msg = TextMessageEventContent::plain(format!("[Attached File: {name}]"));
|
||||
let msg = MessageType::Text(msg);
|
||||
let msg = RoomMessageEventContent::new(msg);
|
||||
|
||||
(resp.event_id, msg)
|
||||
},
|
||||
SendAction::UploadImage(width, height, bytes) => {
|
||||
// Convert to png because arboard does not give us the mime type.
|
||||
let bytes =
|
||||
image::ImageBuffer::from_raw(width as _, height as _, bytes.into_owned())
|
||||
.ok_or(IambError::Clipboard)
|
||||
.and_then(|imagebuf| {
|
||||
let dynimage = image::DynamicImage::ImageRgba8(imagebuf);
|
||||
let bytes = Vec::<u8>::new();
|
||||
let mut buff = std::io::Cursor::new(bytes);
|
||||
dynimage.write_to(&mut buff, image::ImageFormat::Png)?;
|
||||
Ok(buff.into_inner())
|
||||
})
|
||||
.map_err(IambError::from)?;
|
||||
let mime = mime::IMAGE_PNG;
|
||||
|
||||
let name = "Clipboard.png";
|
||||
let config = AttachmentConfig::new();
|
||||
|
||||
let resp = room
|
||||
.send_attachment(name, &mime, bytes, config)
|
||||
.await
|
||||
.map_err(IambError::from)?;
|
||||
|
||||
@@ -464,7 +638,8 @@ impl ChatState {
|
||||
let key = (MessageTimeStamp::LocalEcho, event_id.clone());
|
||||
let msg = MessageEvent::Local(event_id, msg.into());
|
||||
let msg = Message::new(msg, user, MessageTimeStamp::LocalEcho);
|
||||
info.messages.insert(key, msg);
|
||||
let thread = self.scrollback.get_thread_mut(info);
|
||||
thread.insert(key, msg);
|
||||
}
|
||||
|
||||
// Jump to the end of the scrollback to show the message.
|
||||
@@ -474,10 +649,7 @@ impl ChatState {
|
||||
}
|
||||
|
||||
pub fn focus_toggle(&mut self) {
|
||||
self.focus = match self.focus {
|
||||
RoomFocus::Scrollback => RoomFocus::MessageBar,
|
||||
RoomFocus::MessageBar => RoomFocus::Scrollback,
|
||||
};
|
||||
self.focus.toggle();
|
||||
}
|
||||
|
||||
pub fn room(&self) -> &MatrixRoom {
|
||||
@@ -488,6 +660,14 @@ impl ChatState {
|
||||
&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(
|
||||
&self,
|
||||
act: &EditorAction,
|
||||
@@ -531,12 +711,14 @@ impl WindowOps<IambInfo> for ChatState {
|
||||
fn dup(&self, store: &mut ProgramStore) -> Self {
|
||||
// XXX: I want each WindowSlot to have its own shared buffer, instead of each Room; need to
|
||||
// find a good way to pass that info here so that it can be part of the content id.
|
||||
let id = IambBufferId::Room(self.room_id.clone(), RoomFocus::MessageBar);
|
||||
let room_id = self.room_id.clone();
|
||||
let thread = self.thread().cloned();
|
||||
let id = IambBufferId::Room(room_id.clone(), thread, RoomFocus::MessageBar);
|
||||
let ebuf = store.load_buffer(id);
|
||||
let tbox = TextBoxState::new(ebuf);
|
||||
|
||||
ChatState {
|
||||
room_id: self.room_id.clone(),
|
||||
room_id,
|
||||
room: self.room.clone(),
|
||||
|
||||
tbox,
|
||||
@@ -588,12 +770,21 @@ impl Editable<ProgramContext, ProgramStore, IambInfo> for ChatState {
|
||||
ctx: &ProgramContext,
|
||||
store: &mut ProgramStore,
|
||||
) -> 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);
|
||||
|
||||
// And now we can finally run the editor command.
|
||||
match delegate!(self, w => w.editor_command(act, ctx, store)) {
|
||||
res @ Ok(_) => res,
|
||||
Err(EditError::WrongBuffer(IambBufferId::Room(room_id, focus)))
|
||||
if room_id == self.room_id && act.is_switchable(ctx) =>
|
||||
Err(EditError::WrongBuffer(IambBufferId::Room(room_id, thread, focus)))
|
||||
if room_id == self.room_id &&
|
||||
thread.as_ref() == self.thread() &&
|
||||
act.is_switchable(ctx) =>
|
||||
{
|
||||
// Switch focus.
|
||||
self.focus = focus;
|
||||
@@ -601,6 +792,15 @@ impl Editable<ProgramContext, ProgramStore, IambInfo> for ChatState {
|
||||
// Run command again.
|
||||
delegate!(self, w => w.editor_command(act, ctx, store))
|
||||
},
|
||||
Err(EditError::Register(RegisterError::ClipboardImage(data))) => {
|
||||
let msg = "Do you really want to upload the image from your system clipboard?";
|
||||
let send =
|
||||
IambAction::Send(SendAction::UploadImage(data.width, data.height, data.bytes));
|
||||
let prompt = PromptYesNo::new(msg, vec![Action::from(send)]);
|
||||
let prompt = Box::new(prompt);
|
||||
|
||||
Err(EditError::NeedConfirm(prompt))
|
||||
},
|
||||
res @ Err(_) => res,
|
||||
}
|
||||
}
|
||||
@@ -675,6 +875,7 @@ impl PromptActions<ProgramContext, ProgramStore, IambInfo> for ChatState {
|
||||
|
||||
fn recall(
|
||||
&mut self,
|
||||
filter: &RecallFilter,
|
||||
dir: &MoveDir1D,
|
||||
count: &Count,
|
||||
ctx: &ProgramContext,
|
||||
@@ -683,7 +884,7 @@ impl PromptActions<ProgramContext, ProgramStore, IambInfo> for ChatState {
|
||||
let count = ctx.resolve(count);
|
||||
let rope = self.tbox.get();
|
||||
|
||||
let text = self.sent.recall(&rope, &mut self.sent_scrollback, *dir, count);
|
||||
let text = self.sent.recall(&rope, &mut self.sent_scrollback, filter, *dir, count);
|
||||
|
||||
if let Some(text) = text {
|
||||
self.tbox.set_text(text);
|
||||
@@ -701,18 +902,18 @@ impl Promptable<ProgramContext, ProgramStore, IambInfo> for ChatState {
|
||||
store: &mut ProgramStore,
|
||||
) -> EditResult<Vec<(ProgramAction, ProgramContext)>, IambInfo> {
|
||||
if let RoomFocus::Scrollback = self.focus {
|
||||
return Ok(vec![]);
|
||||
return self.scrollback.prompt(act, ctx, store);
|
||||
}
|
||||
|
||||
match act {
|
||||
PromptAction::Submit => self.submit(ctx, store),
|
||||
PromptAction::Abort(empty) => self.abort(*empty, ctx, store),
|
||||
PromptAction::Recall(dir, count) => self.recall(dir, count, ctx, store),
|
||||
_ => Err(EditError::Unimplemented("unknown prompt action".to_string())),
|
||||
PromptAction::Recall(filter, dir, count) => self.recall(filter, dir, count, ctx, store),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// [StatefulWidget] for Matrix rooms.
|
||||
pub struct Chat<'a> {
|
||||
store: &'a mut ProgramStore,
|
||||
focused: bool,
|
||||
@@ -729,14 +930,39 @@ impl<'a> Chat<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> StatefulWidget for Chat<'a> {
|
||||
impl StatefulWidget for Chat<'_> {
|
||||
type State = ChatState;
|
||||
|
||||
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
|
||||
// Determine whether we have a description to show for the message bar.
|
||||
let desc_spans = match (&state.editing, &state.reply_to, state.thread()) {
|
||||
(None, None, None) => None,
|
||||
(None, None, Some(_)) => Some(Line::from("Replying in thread")),
|
||||
(Some(_), None, None) => Some(Line::from("Editing message")),
|
||||
(Some(_), None, Some(_)) => Some(Line::from("Editing message in thread")),
|
||||
(editing, Some(_), thread) => {
|
||||
self.store.application.rooms.get(state.id()).and_then(|room| {
|
||||
let msg = state.get_reply_to(room)?;
|
||||
let user =
|
||||
self.store.application.settings.get_user_span(msg.sender.as_ref(), room);
|
||||
let prefix = match (editing.is_some(), thread.is_some()) {
|
||||
(true, false) => Span::from("Editing reply to "),
|
||||
(true, true) => Span::from("Editing reply in thread to "),
|
||||
(false, false) => Span::from("Replying to "),
|
||||
(false, true) => Span::from("Replying in thread to "),
|
||||
};
|
||||
let spans = Line::from(vec![prefix, user]);
|
||||
|
||||
spans.into()
|
||||
})
|
||||
},
|
||||
};
|
||||
|
||||
// Determine the region to show each UI element.
|
||||
let lines = state.tbox.has_lines(5).max(1) as u16;
|
||||
let drawh = area.height;
|
||||
let texth = lines.min(drawh).clamp(1, 5);
|
||||
let desch = if state.reply_to.is_some() {
|
||||
let desch = if desc_spans.is_some() {
|
||||
drawh.saturating_sub(texth).min(1)
|
||||
} else {
|
||||
0
|
||||
@@ -747,25 +973,7 @@ impl<'a> StatefulWidget for Chat<'a> {
|
||||
let descarea = Rect::new(area.x, scrollarea.y + scrollh, area.width, desch);
|
||||
let textarea = Rect::new(area.x, descarea.y + desch, area.width, texth);
|
||||
|
||||
let scrollback_focused = state.focus.is_scrollback() && self.focused;
|
||||
let scrollback = Scrollback::new(self.store).focus(scrollback_focused);
|
||||
scrollback.render(scrollarea, buf, &mut state.scrollback);
|
||||
|
||||
let desc_spans = match (&state.editing, &state.reply_to) {
|
||||
(None, None) => None,
|
||||
(Some(_), _) => Some(Spans::from("Editing message")),
|
||||
(_, Some(_)) => {
|
||||
state.reply_to.as_ref().and_then(|k| {
|
||||
let room = self.store.application.rooms.get(state.id())?;
|
||||
let msg = room.messages.get(k)?;
|
||||
let user = self.store.application.settings.get_user_span(msg.sender.as_ref());
|
||||
let spans = Spans(vec![Span::from("Replying to "), user]);
|
||||
|
||||
spans.into()
|
||||
})
|
||||
},
|
||||
};
|
||||
|
||||
// Render the message bar and any description for it.
|
||||
if let Some(desc_spans) = desc_spans {
|
||||
Paragraph::new(desc_spans).render(descarea, buf);
|
||||
}
|
||||
@@ -774,5 +982,190 @@ impl<'a> StatefulWidget for Chat<'a> {
|
||||
|
||||
let tbox = TextBox::new().prompt(prompt);
|
||||
tbox.render(textarea, buf, &mut state.tbox);
|
||||
|
||||
// Render the message scrollback.
|
||||
let scrollback_focused = state.focus.is_scrollback() && self.focused;
|
||||
let scrollback = Scrollback::new(self.store)
|
||||
.focus(scrollback_focused)
|
||||
.room_focus(self.focused);
|
||||
scrollback.render(scrollarea, buf, &mut state.scrollback);
|
||||
}
|
||||
}
|
||||
|
||||
fn open_command(open_command: Option<&Vec<String>>, target: OsString) -> IambResult<()> {
|
||||
if let Some(mut cmd) = open_command.and_then(cmd) {
|
||||
cmd.arg(target);
|
||||
cmd.spawn()?;
|
||||
return Ok(());
|
||||
} else {
|
||||
// open::that may not return until the spawned program closes.
|
||||
tokio::task::spawn_blocking(move || {
|
||||
return open::that(target);
|
||||
});
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd(open_command: &Vec<String>) -> Option<Command> {
|
||||
if let [program, args @ ..] = open_command.as_slice() {
|
||||
let mut cmd = Command::new(program);
|
||||
cmd.args(args);
|
||||
return Some(cmd);
|
||||
}
|
||||
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,57 +1,64 @@
|
||||
//! # Windows for Matrix rooms and spaces
|
||||
use std::collections::HashSet;
|
||||
|
||||
use matrix_sdk::{
|
||||
room::{Invited, Room as MatrixRoom},
|
||||
notification_settings::RoomNotificationMode,
|
||||
room::Room as MatrixRoom,
|
||||
ruma::{
|
||||
api::client::{
|
||||
alias::{
|
||||
create_alias::v3::Request as CreateAliasRequest,
|
||||
delete_alias::v3::Request as DeleteAliasRequest,
|
||||
},
|
||||
error::ErrorKind as ClientApiErrorKind,
|
||||
},
|
||||
events::{
|
||||
room::{name::RoomNameEventContent, topic::RoomTopicEventContent},
|
||||
room::{
|
||||
canonical_alias::RoomCanonicalAliasEventContent,
|
||||
history_visibility::{HistoryVisibility, RoomHistoryVisibilityEventContent},
|
||||
name::RoomNameEventContent,
|
||||
topic::RoomTopicEventContent,
|
||||
},
|
||||
tag::{TagInfo, Tags},
|
||||
},
|
||||
OwnedEventId,
|
||||
OwnedRoomAliasId,
|
||||
OwnedUserId,
|
||||
RoomId,
|
||||
},
|
||||
DisplayName,
|
||||
RoomDisplayName,
|
||||
RoomState as MatrixRoomState,
|
||||
};
|
||||
|
||||
use modalkit::tui::{
|
||||
use ratatui::{
|
||||
buffer::Buffer,
|
||||
layout::{Alignment, Rect},
|
||||
style::{Modifier as StyleModifier, Style},
|
||||
text::{Span, Spans, Text},
|
||||
text::{Line, Span, Text},
|
||||
widgets::{Paragraph, StatefulWidget, Widget},
|
||||
};
|
||||
|
||||
use modalkit::{
|
||||
editing::action::{
|
||||
use modalkit::actions::{
|
||||
Action,
|
||||
EditInfo,
|
||||
EditResult,
|
||||
Editable,
|
||||
EditorAction,
|
||||
Jumpable,
|
||||
PromptAction,
|
||||
Promptable,
|
||||
Scrollable,
|
||||
UIError,
|
||||
},
|
||||
editing::base::{
|
||||
Axis,
|
||||
CloseFlags,
|
||||
Count,
|
||||
MoveDir1D,
|
||||
OpenTarget,
|
||||
PositionList,
|
||||
ScrollStyle,
|
||||
WordStyle,
|
||||
WriteFlags,
|
||||
},
|
||||
editing::completion::CompletionList,
|
||||
input::InputContext,
|
||||
widgets::{TermOffset, TerminalCursor, WindowOps},
|
||||
};
|
||||
use modalkit::errors::{EditResult, UIError};
|
||||
use modalkit::prelude::*;
|
||||
use modalkit::{editing::completion::CompletionList, keybindings::dialog::PromptYesNo};
|
||||
use modalkit_ratatui::{TermOffset, TerminalCursor, WindowOps};
|
||||
|
||||
use crate::base::{
|
||||
IambAction,
|
||||
IambError,
|
||||
IambId,
|
||||
IambInfo,
|
||||
IambResult,
|
||||
MemberUpdateAction,
|
||||
MessageAction,
|
||||
ProgramAction,
|
||||
ProgramContext,
|
||||
@@ -59,11 +66,14 @@ use crate::base::{
|
||||
RoomAction,
|
||||
RoomField,
|
||||
SendAction,
|
||||
SpaceAction,
|
||||
};
|
||||
|
||||
use self::chat::ChatState;
|
||||
use self::space::{Space, SpaceState};
|
||||
|
||||
use std::convert::TryFrom;
|
||||
|
||||
mod chat;
|
||||
mod scrollback;
|
||||
mod space;
|
||||
@@ -77,6 +87,38 @@ 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.
|
||||
///
|
||||
/// Since spaces function as special rooms within Matrix, we wrap their window state together, so
|
||||
/// that operations like sending and accepting invites, opening the members window, etc., all work
|
||||
/// similarly.
|
||||
pub enum RoomState {
|
||||
Chat(ChatState),
|
||||
Space(SpaceState),
|
||||
@@ -97,7 +139,8 @@ impl From<SpaceState> for RoomState {
|
||||
impl RoomState {
|
||||
pub fn new(
|
||||
room: MatrixRoom,
|
||||
name: DisplayName,
|
||||
thread: Option<OwnedEventId>,
|
||||
name: RoomDisplayName,
|
||||
tags: Option<Tags>,
|
||||
store: &mut ProgramStore,
|
||||
) -> Self {
|
||||
@@ -109,7 +152,14 @@ impl RoomState {
|
||||
if room.is_space() {
|
||||
SpaceState::new(room).into()
|
||||
} else {
|
||||
ChatState::new(room, store).into()
|
||||
ChatState::new(room, thread, store).into()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn thread(&self) -> Option<&OwnedEventId> {
|
||||
match self {
|
||||
RoomState::Chat(chat) => chat.thread(),
|
||||
RoomState::Space(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,7 +172,7 @@ impl RoomState {
|
||||
|
||||
fn draw_invite(
|
||||
&self,
|
||||
invited: Invited,
|
||||
invited: MatrixRoom,
|
||||
area: Rect,
|
||||
buf: &mut Buffer,
|
||||
store: &mut ProgramStore,
|
||||
@@ -137,15 +187,16 @@ impl RoomState {
|
||||
let mut invited = vec![Span::from(format!("You have been invited to join {name}"))];
|
||||
|
||||
if let Ok(Some(inviter)) = &inviter {
|
||||
let info = store.application.rooms.get_or_default(self.id().to_owned());
|
||||
invited.push(Span::from(" by "));
|
||||
invited.push(store.application.settings.get_user_span(inviter.user_id()));
|
||||
invited.push(store.application.settings.get_user_span(inviter.user_id(), info));
|
||||
}
|
||||
|
||||
let l1 = Spans(invited);
|
||||
let l2 = Spans::from(
|
||||
let l1 = Line::from(invited);
|
||||
let l2 = Line::from(
|
||||
"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);
|
||||
|
||||
@@ -164,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(
|
||||
&mut self,
|
||||
act: SendAction,
|
||||
@@ -179,17 +242,17 @@ impl RoomState {
|
||||
pub async fn room_command(
|
||||
&mut self,
|
||||
act: RoomAction,
|
||||
_: ProgramContext,
|
||||
ctx: ProgramContext,
|
||||
store: &mut ProgramStore,
|
||||
) -> IambResult<Vec<(Action<IambInfo>, ProgramContext)>> {
|
||||
match act {
|
||||
RoomAction::InviteAccept => {
|
||||
if let Some(room) = store.application.worker.client.get_invited_room(self.id()) {
|
||||
if let Some(room) = store.application.worker.client.get_room(self.id()) {
|
||||
let details = room.invite_details().await.map_err(IambError::from)?;
|
||||
let details = details.invitee.event().original_content();
|
||||
let is_direct = details.and_then(|ev| ev.is_direct).unwrap_or_default();
|
||||
|
||||
room.accept_invitation().await.map_err(IambError::from)?;
|
||||
room.join().await.map_err(IambError::from)?;
|
||||
|
||||
if is_direct {
|
||||
room.set_is_direct(true).await.map_err(IambError::from)?;
|
||||
@@ -201,8 +264,8 @@ impl RoomState {
|
||||
}
|
||||
},
|
||||
RoomAction::InviteReject => {
|
||||
if let Some(room) = store.application.worker.client.get_invited_room(self.id()) {
|
||||
room.reject_invitation().await.map_err(IambError::from)?;
|
||||
if let Some(room) = store.application.worker.client.get_room(self.id()) {
|
||||
room.leave().await.map_err(IambError::from)?;
|
||||
|
||||
Ok(vec![])
|
||||
} else {
|
||||
@@ -210,7 +273,7 @@ impl RoomState {
|
||||
}
|
||||
},
|
||||
RoomAction::InviteSend(user) => {
|
||||
if let Some(room) = store.application.worker.client.get_joined_room(self.id()) {
|
||||
if let Some(room) = store.application.worker.client.get_room(self.id()) {
|
||||
room.invite_user_by_id(user.as_ref()).await.map_err(IambError::from)?;
|
||||
|
||||
Ok(vec![])
|
||||
@@ -218,6 +281,65 @@ impl RoomState {
|
||||
Err(IambError::NotJoined.into())
|
||||
}
|
||||
},
|
||||
RoomAction::Leave(skip_confirm) => {
|
||||
if let Some(room) = store.application.worker.client.get_room(self.id()) {
|
||||
if skip_confirm {
|
||||
room.leave().await.map_err(IambError::from)?;
|
||||
|
||||
Ok(vec![])
|
||||
} else {
|
||||
let msg = "Do you really want to leave this room?";
|
||||
let leave = IambAction::Room(RoomAction::Leave(true));
|
||||
let prompt = PromptYesNo::new(msg, vec![Action::from(leave)]);
|
||||
let prompt = Box::new(prompt);
|
||||
|
||||
Err(UIError::NeedConfirm(prompt))
|
||||
}
|
||||
} else {
|
||||
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) => {
|
||||
let width = Count::Exact(30);
|
||||
let act =
|
||||
@@ -226,7 +348,17 @@ impl RoomState {
|
||||
width.into(),
|
||||
);
|
||||
|
||||
Ok(vec![(act, cmd.context.take())])
|
||||
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) => {
|
||||
let room = store
|
||||
@@ -235,8 +367,13 @@ impl RoomState {
|
||||
.ok_or(UIError::Application(IambError::NotJoined))?;
|
||||
|
||||
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 => {
|
||||
let ev = RoomNameEventContent::new(value.into());
|
||||
let ev = RoomNameEventContent::new(value);
|
||||
let _ = room.send_state_event(ev).await.map_err(IambError::from)?;
|
||||
},
|
||||
RoomField::Tag(tag) => {
|
||||
@@ -249,6 +386,100 @@ impl RoomState {
|
||||
let ev = RoomTopicEventContent::new(value);
|
||||
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![])
|
||||
@@ -260,8 +491,13 @@ impl RoomState {
|
||||
.ok_or(UIError::Application(IambError::NotJoined))?;
|
||||
|
||||
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 => {
|
||||
let ev = RoomNameEventContent::new(None);
|
||||
let ev = RoomNameEventContent::new("".into());
|
||||
let _ = room.send_state_event(ev).await.map_err(IambError::from)?;
|
||||
},
|
||||
RoomField::Tag(tag) => {
|
||||
@@ -271,17 +507,169 @@ impl RoomState {
|
||||
let ev = RoomTopicEventContent::new("".into());
|
||||
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![])
|
||||
},
|
||||
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)])
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_title(&self, store: &mut ProgramStore) -> Spans {
|
||||
pub fn get_title(&self, store: &mut ProgramStore) -> Line {
|
||||
let title = store.application.get_room_title(self.id());
|
||||
let style = Style::default().add_modifier(StyleModifier::BOLD);
|
||||
let mut spans = vec![Span::styled(title, style)];
|
||||
let mut spans = vec![];
|
||||
|
||||
if let RoomState::Chat(chat) = self {
|
||||
if chat.thread().is_some() {
|
||||
spans.push("Thread in ".into());
|
||||
}
|
||||
}
|
||||
|
||||
spans.push(Span::styled(title, style));
|
||||
|
||||
match self.room().topic() {
|
||||
Some(desc) if !desc.is_empty() => {
|
||||
@@ -292,7 +680,7 @@ impl RoomState {
|
||||
_ => {},
|
||||
}
|
||||
|
||||
Spans(spans)
|
||||
Line::from(spans)
|
||||
}
|
||||
|
||||
pub fn focus_toggle(&mut self) {
|
||||
@@ -370,12 +758,12 @@ impl TerminalCursor for RoomState {
|
||||
|
||||
impl WindowOps<IambInfo> for RoomState {
|
||||
fn draw(&mut self, area: Rect, buf: &mut Buffer, focused: bool, store: &mut ProgramStore) {
|
||||
if let MatrixRoom::Invited(_) = self.room() {
|
||||
if self.room().state() == MatrixRoomState::Invited {
|
||||
self.refresh_room(store);
|
||||
}
|
||||
|
||||
if let MatrixRoom::Invited(invited) = self.room() {
|
||||
self.draw_invite(invited.clone(), area, buf, store);
|
||||
if self.room().state() == MatrixRoomState::Invited {
|
||||
self.draw_invite(self.room().clone(), area, buf, store);
|
||||
}
|
||||
|
||||
match self {
|
||||
@@ -433,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());
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,34 +1,61 @@
|
||||
//! Window for Matrix spaces
|
||||
use std::ops::{Deref, DerefMut};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use matrix_sdk::ruma::events::space::child::SpaceChildEventContent;
|
||||
use matrix_sdk::ruma::events::StateEventType;
|
||||
use matrix_sdk::{
|
||||
room::Room as MatrixRoom,
|
||||
ruma::{OwnedRoomId, RoomId},
|
||||
};
|
||||
|
||||
use modalkit::tui::{buffer::Buffer, layout::Rect, widgets::StatefulWidget};
|
||||
|
||||
use modalkit::{
|
||||
widgets::list::{List, ListState},
|
||||
widgets::{TermOffset, TerminalCursor, WindowOps},
|
||||
use modalkit::prelude::{EditInfo, InfoMessage};
|
||||
use ratatui::{
|
||||
buffer::Buffer,
|
||||
layout::Rect,
|
||||
style::{Color, Style},
|
||||
text::{Line, Span, Text},
|
||||
widgets::StatefulWidget,
|
||||
};
|
||||
|
||||
use crate::base::{IambBufferId, IambInfo, ProgramStore, RoomFocus};
|
||||
use modalkit_ratatui::{
|
||||
list::{List, ListState},
|
||||
TermOffset,
|
||||
TerminalCursor,
|
||||
WindowOps,
|
||||
};
|
||||
|
||||
use crate::windows::RoomItem;
|
||||
use crate::base::{
|
||||
IambBufferId,
|
||||
IambError,
|
||||
IambInfo,
|
||||
IambResult,
|
||||
ProgramContext,
|
||||
ProgramStore,
|
||||
RoomFocus,
|
||||
SpaceAction,
|
||||
};
|
||||
|
||||
use crate::windows::{room_fields_cmp, RoomItem, RoomLikeItem};
|
||||
|
||||
const SPACE_HIERARCHY_DEBOUNCE: Duration = Duration::from_secs(5);
|
||||
|
||||
/// State needed for rendering [Space].
|
||||
pub struct SpaceState {
|
||||
room_id: OwnedRoomId,
|
||||
room: MatrixRoom,
|
||||
list: ListState<RoomItem, IambInfo>,
|
||||
last_fetch: Option<Instant>,
|
||||
}
|
||||
|
||||
impl SpaceState {
|
||||
pub fn new(room: MatrixRoom) -> Self {
|
||||
let room_id = room.room_id().to_owned();
|
||||
let content = IambBufferId::Room(room_id.clone(), RoomFocus::Scrollback);
|
||||
let content = IambBufferId::Room(room_id.clone(), None, RoomFocus::Scrollback);
|
||||
let list = ListState::new(content, vec![]);
|
||||
let last_fetch = None;
|
||||
|
||||
SpaceState { room_id, room, list }
|
||||
SpaceState { room_id, room, list, last_fetch }
|
||||
}
|
||||
|
||||
pub fn refresh_room(&mut self, store: &mut ProgramStore) {
|
||||
@@ -50,6 +77,72 @@ impl SpaceState {
|
||||
room_id: self.room_id.clone(),
|
||||
room: self.room.clone(),
|
||||
list: self.list.dup(store),
|
||||
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())
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -74,6 +167,7 @@ impl DerefMut for SpaceState {
|
||||
}
|
||||
}
|
||||
|
||||
/// [StatefulWidget] for Matrix spaces.
|
||||
pub struct Space<'a> {
|
||||
focused: bool,
|
||||
store: &'a mut ProgramStore,
|
||||
@@ -90,34 +184,59 @@ impl<'a> Space<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> StatefulWidget for Space<'a> {
|
||||
impl StatefulWidget for Space<'_> {
|
||||
type State = SpaceState;
|
||||
|
||||
fn render(self, area: Rect, buffer: &mut Buffer, state: &mut Self::State) {
|
||||
let members =
|
||||
if let Ok(m) = self.store.application.worker.space_members(state.room_id.clone()) {
|
||||
m
|
||||
} else {
|
||||
return;
|
||||
let mut empty_message = None;
|
||||
let need_fetch = match state.last_fetch {
|
||||
Some(i) => i.elapsed() >= SPACE_HIERARCHY_DEBOUNCE,
|
||||
None => true,
|
||||
};
|
||||
|
||||
let items = members
|
||||
if need_fetch {
|
||||
let res = self.store.application.worker.space_members(state.room_id.clone());
|
||||
|
||||
match res {
|
||||
Ok(members) => {
|
||||
let mut items = members
|
||||
.into_iter()
|
||||
.filter_map(|id| {
|
||||
let (room, name, tags) = self.store.application.worker.get_room(id.clone()).ok()?;
|
||||
let (room, _, tags) =
|
||||
self.store.application.worker.get_room(id.clone()).ok()?;
|
||||
let room_info = std::sync::Arc::new((room, tags));
|
||||
|
||||
if id != state.room_id {
|
||||
Some(RoomItem::new(room, name, tags, self.store))
|
||||
Some(RoomItem::new(room_info, self.store))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
.collect::<Vec<_>>();
|
||||
let fields = &self.store.application.settings.tunables.sort.rooms;
|
||||
let collator = &mut self.store.application.collator;
|
||||
items.sort_by(|a, b| room_fields_cmp(a, b, fields, collator));
|
||||
|
||||
state.list.set(items);
|
||||
state.last_fetch = Some(Instant::now());
|
||||
},
|
||||
Err(e) => {
|
||||
let lines = vec![
|
||||
Line::from("Unable to fetch space room hierarchy:"),
|
||||
Span::styled(e.to_string(), Style::default().fg(Color::Red)).into(),
|
||||
];
|
||||
|
||||
List::new(self.store)
|
||||
.focus(self.focused)
|
||||
.render(area, buffer, &mut state.list)
|
||||
empty_message = Text::from(lines).into();
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
let mut list = List::new(self.store).focus(self.focused);
|
||||
|
||||
if let Some(text) = empty_message {
|
||||
list = list.empty_message(text);
|
||||
}
|
||||
|
||||
list.render(area, buffer, &mut state.list)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
|
||||
- `:dms` will open a list of direct messages
|
||||
- `:rooms` will open a list of joined rooms
|
||||
- `:chats` will open a list containing both direct messages and rooms
|
||||
- `:members` will open a list of members for the currently focused room or space
|
||||
- `:spaces` will open a list of joined spaces
|
||||
- `:join` can be used to switch to join a new room or start a direct message
|
||||
@@ -36,10 +37,10 @@ The different subcommands are:
|
||||
|
||||
## Additional Configuration
|
||||
|
||||
You can customize iamb in your `$CONFIG_DIR/iamb/config.json` file, where
|
||||
`$CONFIG_DIR` is your system's per-user configuration directory.
|
||||
You can customize iamb in your `$CONFIG_DIR/iamb/config.toml` file, where
|
||||
`$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:
|
||||
|
||||
- `"default_profile"`, a profile name to use when starting iamb if one wasn't specified
|
||||
- `"cache"`, a directory for cached iamb
|
||||
See the manual pages or <https://iamb.chat> for more details on how to
|
||||
further configure or use iamb.
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
//! Welcome Window
|
||||
use std::ops::{Deref, DerefMut};
|
||||
|
||||
use modalkit::tui::{buffer::Buffer, layout::Rect};
|
||||
use ratatui::{buffer::Buffer, layout::Rect};
|
||||
|
||||
use modalkit::{
|
||||
widgets::textbox::TextBoxState,
|
||||
widgets::WindowOps,
|
||||
widgets::{TermOffset, TerminalCursor},
|
||||
};
|
||||
use modalkit_ratatui::{textbox::TextBoxState, TermOffset, TerminalCursor, WindowOps};
|
||||
|
||||
use modalkit::editing::action::EditInfo;
|
||||
use modalkit::editing::base::{CloseFlags, WordStyle, WriteFlags};
|
||||
use modalkit::editing::completion::CompletionList;
|
||||
use modalkit::prelude::*;
|
||||
|
||||
use crate::base::{IambBufferId, IambInfo, IambResult, ProgramStore};
|
||||
|
||||
|
||||
932
src/worker.rs
932
src/worker.rs
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user