Compare commits
178 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93fc47d019 | ||
|
|
a32149f604 | ||
|
|
3149f79d11 | ||
|
|
7ccb1cbf2c | ||
|
|
1ec311590d | ||
|
|
0ddded3b8b | ||
|
|
a8cbc352ff | ||
|
|
dfa0937077 | ||
|
|
43485270ee | ||
|
|
28fea03625 | ||
|
|
e021d4a55d | ||
|
|
b01dbe5a5d | ||
|
|
4b2382bf93 | ||
|
|
0f2442566f | ||
|
|
8c9a2714a1 | ||
|
|
d44f861871 | ||
|
|
14aa97251c | ||
|
|
55456dbc1e | ||
|
|
d5c330ac72 | ||
|
|
7b1dc93f3a | ||
|
|
745f547904 | ||
|
|
6ebb7ac7fd | ||
|
|
1bb93c18fb | ||
|
|
e3090e537f | ||
|
|
ad10082c2f | ||
|
|
67603d0623 | ||
|
|
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 |
4
.gitattributes
vendored
4
.gitattributes
vendored
@@ -1 +1,3 @@
|
|||||||
* text eol=lf
|
*.rs text eol=lf
|
||||||
|
*.toml text eol=lf
|
||||||
|
*.md text eol=lf
|
||||||
|
|||||||
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
|
||||||
45
.github/workflows/ci.yml
vendored
45
.github/workflows/ci.yml
vendored
@@ -14,13 +14,16 @@ jobs:
|
|||||||
matrix:
|
matrix:
|
||||||
platform: [ubuntu-latest, windows-latest, macos-latest]
|
platform: [ubuntu-latest, windows-latest, macos-latest]
|
||||||
runs-on: ${{ matrix.platform }}
|
runs-on: ${{ matrix.platform }}
|
||||||
|
env:
|
||||||
|
SCCACHE_GHA_ENABLED: "true"
|
||||||
|
RUSTC_WRAPPER: "sccache"
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
with:
|
with:
|
||||||
submodules: true
|
submodules: true
|
||||||
- name: Install Rust (1.66 w/ clippy)
|
- name: Install Rust (1.83 w/ clippy)
|
||||||
uses: dtolnay/rust-toolchain@1.66
|
uses: dtolnay/rust-toolchain@1.83
|
||||||
with:
|
with:
|
||||||
components: clippy
|
components: clippy
|
||||||
- name: Install Rust (nightly w/ rustfmt)
|
- name: Install Rust (nightly w/ rustfmt)
|
||||||
@@ -30,11 +33,8 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
path: ~/.cargo/registry
|
path: ~/.cargo/registry
|
||||||
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
|
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
|
||||||
- name: Cache cargo build
|
- name: Run sccache-cache
|
||||||
uses: actions/cache@v3
|
uses: mozilla-actions/sccache-action@v0.0.9
|
||||||
with:
|
|
||||||
path: target
|
|
||||||
key: ${{ runner.os }}-cargo-build-${{ hashFiles('**/Cargo.lock') }}
|
|
||||||
- name: Check formatting
|
- name: Check formatting
|
||||||
run: cargo +nightly fmt --all -- --check
|
run: cargo +nightly fmt --all -- --check
|
||||||
- name: Check Clippy
|
- name: Check Clippy
|
||||||
@@ -44,13 +44,26 @@ jobs:
|
|||||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
reporter: 'github-check'
|
reporter: 'github-check'
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: cargo test
|
run: cargo test --locked
|
||||||
- name: Build artifacts
|
|
||||||
run: cargo build --release
|
nix-flake-test:
|
||||||
- name: Upload artifacts
|
name: Flake checks ❄️
|
||||||
uses: actions/upload-artifact@master
|
strategy:
|
||||||
|
matrix:
|
||||||
|
platform: [ubuntu-latest, macos-latest]
|
||||||
|
runs-on: ${{ matrix.platform }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout Code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- uses: cachix/install-nix-action@v31
|
||||||
with:
|
with:
|
||||||
name: iamb-${{ matrix.platform }}
|
github_access_token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
path: |
|
- uses: cachix/cachix-action@v15
|
||||||
./target/release/iamb
|
with:
|
||||||
./target/release/iamb.exe
|
name: iamb-prs
|
||||||
|
authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}'
|
||||||
|
- name: Flake check
|
||||||
|
run: |
|
||||||
|
nix flake show
|
||||||
|
nix flake check --print-build-logs
|
||||||
|
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,4 +1,4 @@
|
|||||||
/target
|
/target
|
||||||
/result
|
/result
|
||||||
/TODO
|
/TODO
|
||||||
/docs/iamb.[15]
|
.direnv
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
unstable_features = true
|
unstable_features = true
|
||||||
max_width = 100
|
max_width = 100
|
||||||
fn_call_width = 90
|
fn_call_width = 88
|
||||||
struct_lit_width = 50
|
struct_lit_width = 50
|
||||||
struct_variant_width = 50
|
struct_variant_width = 50
|
||||||
chain_width = 75
|
chain_width = 75
|
||||||
|
|||||||
6320
Cargo.lock
generated
6320
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
95
Cargo.toml
95
Cargo.toml
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "iamb"
|
name = "iamb"
|
||||||
version = "0.0.8"
|
version = "0.0.11"
|
||||||
edition = "2018"
|
edition = "2018"
|
||||||
authors = ["Ulyssa <git@ulyssa.dev>"]
|
authors = ["Ulyssa <git@ulyssa.dev>"]
|
||||||
repository = "https://github.com/ulyssa/iamb"
|
repository = "https://github.com/ulyssa/iamb"
|
||||||
@@ -11,11 +11,15 @@ license = "Apache-2.0"
|
|||||||
exclude = [".github", "CONTRIBUTING.md"]
|
exclude = [".github", "CONTRIBUTING.md"]
|
||||||
keywords = ["matrix", "chat", "tui", "vim"]
|
keywords = ["matrix", "chat", "tui", "vim"]
|
||||||
categories = ["command-line-utilities"]
|
categories = ["command-line-utilities"]
|
||||||
rust-version = "1.66"
|
rust-version = "1.88"
|
||||||
build = "build.rs"
|
build = "build.rs"
|
||||||
|
|
||||||
[build-dependencies]
|
[features]
|
||||||
mandown = "0.1.3"
|
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]
|
[build-dependencies.vergen]
|
||||||
version = "8"
|
version = "8"
|
||||||
@@ -23,42 +27,72 @@ default-features = false
|
|||||||
features = ["build", "git", "gitcl",]
|
features = ["build", "git", "gitcl",]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
arboard = "3.2.0"
|
anyhow = "1.0"
|
||||||
bitflags = "1.3.2"
|
bitflags = "^2.3"
|
||||||
chrono = "0.4"
|
chrono = "0.4"
|
||||||
clap = {version = "4.0", features = ["derive"]}
|
clap = {version = "~4.3", features = ["derive"]}
|
||||||
comrak = {version = "0.18.0", features = ["shortcodes"]}
|
|
||||||
css-color-parser = "0.1.2"
|
css-color-parser = "0.1.2"
|
||||||
dirs = "4.0.0"
|
dirs = "4.0.0"
|
||||||
emojis = "~0.5.2"
|
emojis = "0.5"
|
||||||
|
feruca = "0.10.1"
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
gethostname = "0.4.1"
|
gethostname = "0.4.1"
|
||||||
html5ever = "0.26.0"
|
html5ever = "0.26.0"
|
||||||
image = "0.24.5"
|
image = "^0.25.6"
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
markup5ever_rcdom = "0.2.0"
|
markup5ever_rcdom = "0.2.0"
|
||||||
mime = "^0.3.16"
|
mime = "^0.3.16"
|
||||||
mime_guess = "^2.0.4"
|
mime_guess = "^2.0.4"
|
||||||
|
nom = "7.0.0"
|
||||||
open = "3.2.0"
|
open = "3.2.0"
|
||||||
|
rand = "0.8.5"
|
||||||
|
ratatui = "0.29.0"
|
||||||
|
ratatui-image = { version = "~8.0.1", features = ["serde"] }
|
||||||
regex = "^1.5"
|
regex = "^1.5"
|
||||||
rpassword = "^7.2"
|
rpassword = "^7.2"
|
||||||
serde = "^1.0"
|
serde = "^1.0"
|
||||||
serde_json = "^1.0"
|
serde_json = "^1.0"
|
||||||
|
sled = "0.34.7"
|
||||||
|
temp-dir = "0.1.12"
|
||||||
thiserror = "^1.0.37"
|
thiserror = "^1.0.37"
|
||||||
|
toml = "^0.8.12"
|
||||||
tracing = "~0.1.36"
|
tracing = "~0.1.36"
|
||||||
tracing-appender = "~0.2.2"
|
tracing-appender = "~0.2.2"
|
||||||
tracing-subscriber = "0.3.16"
|
tracing-subscriber = "0.3.16"
|
||||||
unicode-segmentation = "^1.7"
|
unicode-segmentation = "^1.7"
|
||||||
unicode-width = "0.1.10"
|
unicode-width = "0.1.10"
|
||||||
url = {version = "^2.2.2", features = ["serde"]}
|
url = {version = "^2.2.2", features = ["serde"]}
|
||||||
|
edit = "0.1.4"
|
||||||
|
humansize = "2.0.0"
|
||||||
|
linkify = "0.10.0"
|
||||||
|
shellexpand = "3.1.1"
|
||||||
|
|
||||||
|
[dependencies.comrak]
|
||||||
|
version = "0.22.0"
|
||||||
|
default-features = false
|
||||||
|
features = ["shortcodes"]
|
||||||
|
|
||||||
|
[dependencies.notify-rust]
|
||||||
|
version = "~4.10.0"
|
||||||
|
default-features = false
|
||||||
|
features = ["zbus", "serde"]
|
||||||
|
optional = true
|
||||||
|
|
||||||
[dependencies.modalkit]
|
[dependencies.modalkit]
|
||||||
version = "0.0.16"
|
version = "0.0.24"
|
||||||
|
default-features = false
|
||||||
|
#git = "https://github.com/ulyssa/modalkit"
|
||||||
|
#rev = "e40dbb0bfeabe4cfd08facd2acb446080a330d75"
|
||||||
|
|
||||||
|
[dependencies.modalkit-ratatui]
|
||||||
|
version = "0.0.24"
|
||||||
|
#git = "https://github.com/ulyssa/modalkit"
|
||||||
|
#rev = "e40dbb0bfeabe4cfd08facd2acb446080a330d75"
|
||||||
|
|
||||||
[dependencies.matrix-sdk]
|
[dependencies.matrix-sdk]
|
||||||
version = "0.6"
|
version = "0.14.0"
|
||||||
default-features = false
|
default-features = false
|
||||||
features = ["e2e-encryption", "sled", "rustls-tls"]
|
features = ["e2e-encryption", "sqlite", "sso-login"]
|
||||||
|
|
||||||
[dependencies.tokio]
|
[dependencies.tokio]
|
||||||
version = "1.24.1"
|
version = "1.24.1"
|
||||||
@@ -68,6 +102,37 @@ features = ["macros", "net", "rt-multi-thread", "sync", "time"]
|
|||||||
lazy_static = "1.4.0"
|
lazy_static = "1.4.0"
|
||||||
pretty_assertions = "1.4.0"
|
pretty_assertions = "1.4.0"
|
||||||
|
|
||||||
[profile.release]
|
[profile.release-lto]
|
||||||
lto = true
|
inherits = "release"
|
||||||
incremental = false
|
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
|
same "printed page" as the copyright notice for easier
|
||||||
identification within third-party archives.
|
identification within third-party archives.
|
||||||
|
|
||||||
Copyright [yyyy] [name of copyright owner]
|
Copyright 2024 Ulyssa Mello
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
you may not use this file except in compliance with the License.
|
you may not use this file except in compliance with the License.
|
||||||
|
|||||||
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
|
||||||
179
README.md
179
README.md
@@ -1,39 +1,76 @@
|
|||||||
# 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://github.com/ulyssa/iamb/actions?query=workflow%3ACI+)
|
||||||
[](https://crates.io/crates/iamb)
|
[][crates-io-iamb]
|
||||||
[](https://matrix.to/#/#iamb:0x.badd.cafe)
|
[](https://matrix.to/#/#iamb:0x.badd.cafe)
|
||||||
[](https://crates.io/crates/iamb)
|
[][crates-io-iamb]
|
||||||
|
[](https://snapcraft.io/iamb)
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
## About
|
## 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,
|
- Threads, spaces, E2EE, and read receipts
|
||||||
but much of the basic client functionality is already present.
|
- 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
|
## Documentation
|
||||||
|
|
||||||
You can find documentation for installing, configuring, and using iamb on its
|
You can find documentation for installing, configuring, and using iamb on its
|
||||||
website, [iamb.chat].
|
website, [iamb.chat].
|
||||||
|
|
||||||
## Installation
|
## Configuration
|
||||||
|
|
||||||
Install Rust (1.66.0 or above) 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 (from source)
|
||||||
|
|
||||||
|
Install Rust and Cargo using [rustup], and then run from the directory
|
||||||
|
containing the sources (ie: from a git clone):
|
||||||
|
|
||||||
|
```
|
||||||
|
cargo install --locked --path .
|
||||||
|
```
|
||||||
|
|
||||||
|
## Installation (via `crates.io`)
|
||||||
|
|
||||||
|
Install Rust (1.83.0 or above) and Cargo, and then run:
|
||||||
|
|
||||||
```
|
```
|
||||||
cargo install --locked iamb
|
cargo install --locked iamb
|
||||||
```
|
```
|
||||||
|
|
||||||
### NetBSD
|
See [Configuration](#configuration) for getting a profile set up.
|
||||||
|
|
||||||
On NetBSD a package is available from the official repositories. To install it simply run:
|
## Installation (via package managers)
|
||||||
|
|
||||||
```
|
|
||||||
pkgin install iamb
|
|
||||||
```
|
|
||||||
|
|
||||||
### Arch Linux
|
### Arch Linux
|
||||||
|
|
||||||
@@ -44,77 +81,77 @@ Arch User Repositories (AUR). To install it simply run with your favorite AUR he
|
|||||||
paru iamb-git
|
paru iamb-git
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### FreeBSD
|
||||||
|
|
||||||
|
On FreeBSD a package is available from the official repositories. To install it simply run:
|
||||||
|
|
||||||
|
```
|
||||||
|
pkg install iamb
|
||||||
|
```
|
||||||
|
|
||||||
|
### Gentoo
|
||||||
|
|
||||||
|
On Gentoo, an ebuild is available from the community-managed
|
||||||
|
[GURU overlay](https://wiki.gentoo.org/wiki/Project:GURU).
|
||||||
|
|
||||||
|
You can enable the GURU overlay with:
|
||||||
|
|
||||||
|
```
|
||||||
|
eselect repository enable guru
|
||||||
|
emerge --sync guru
|
||||||
|
```
|
||||||
|
|
||||||
|
And then install `iamb` with:
|
||||||
|
|
||||||
|
```
|
||||||
|
emerge --ask iamb
|
||||||
|
```
|
||||||
|
|
||||||
|
### macOS
|
||||||
|
|
||||||
|
On macOS a [package](https://formulae.brew.sh/formula/iamb#default) is available in Homebrew's
|
||||||
|
repository. To install it simply run:
|
||||||
|
|
||||||
|
```
|
||||||
|
brew install iamb
|
||||||
|
```
|
||||||
|
|
||||||
|
### NetBSD
|
||||||
|
|
||||||
|
On NetBSD a package is available from the official repositories. To install it simply run:
|
||||||
|
|
||||||
|
```
|
||||||
|
pkgin install iamb
|
||||||
|
```
|
||||||
|
|
||||||
### Nix / NixOS (flake)
|
### Nix / NixOS (flake)
|
||||||
|
|
||||||
```
|
```
|
||||||
nix profile install "github:ulyssa/iamb"
|
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
|
```
|
||||||
{
|
zypper install iamb
|
||||||
"profiles": {
|
|
||||||
"example.com": {
|
|
||||||
"url": "https://example.com",
|
|
||||||
"user_id": "@user:example.com"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Comparison With Other Clients
|
### Snap
|
||||||
|
|
||||||
To get an idea of what is and isn't yet implemented, here is a subset of the
|
A snap for Linux distributions which [support](https://snapcraft.io/docs/installing-snapd) the packaging system.
|
||||||
Matrix website's [features comparison table][client-comparison-matrix], showing
|
|
||||||
two other TUI clients and Element Web:
|
|
||||||
|
|
||||||
| | iamb | [gomuks] | [weechat-matrix] | Element Web/Desktop |
|
```
|
||||||
| --------------------------------------- | :---------- | :------: | :--------------: | :-----------------: |
|
snap install iamb
|
||||||
| 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 | ❌ | ✔️ | ✔️ | ✔️ |
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
iamb is released under the [Apache License, Version 2.0].
|
iamb is released under the [Apache License, Version 2.0].
|
||||||
|
|
||||||
[Apache License, Version 2.0]: https://github.com/ulyssa/iamb/blob/master/LICENSE
|
[Apache License, Version 2.0]: https://github.com/ulyssa/iamb/blob/master/LICENSE
|
||||||
[client-comparison-matrix]: https://matrix.org/clients-matrix/
|
[crates-io-iamb]: https://crates.io/crates/iamb
|
||||||
[iamb.chat]: https://iamb.chat
|
[iamb.chat]: https://iamb.chat
|
||||||
[gomuks]: https://github.com/tulir/gomuks
|
[well_known_entry]: https://spec.matrix.org/latest/client-server-api/#getwell-knownmatrixclient
|
||||||
[weechat-matrix]: https://github.com/poljar/weechat-matrix
|
[rustup]: https://rustup.rs/
|
||||||
[#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
|
|
||||||
|
|||||||
20
build.rs
20
build.rs
@@ -1,29 +1,9 @@
|
|||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
use std::fs;
|
|
||||||
use std::iter::FromIterator;
|
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
use mandown::convert;
|
|
||||||
use vergen::EmitBuilder;
|
use vergen::EmitBuilder;
|
||||||
|
|
||||||
const IAMB_1_MD: &str = include_str!("docs/iamb.1.md");
|
|
||||||
const IAMB_5_MD: &str = include_str!("docs/iamb.5.md");
|
|
||||||
|
|
||||||
fn main() -> Result<(), Box<dyn Error>> {
|
fn main() -> Result<(), Box<dyn Error>> {
|
||||||
EmitBuilder::builder().git_sha(true).emit()?;
|
EmitBuilder::builder().git_sha(true).emit()?;
|
||||||
|
|
||||||
// Build the manual pages.
|
|
||||||
println!("cargo:rerun-if-changed=docs/iamb.1.md");
|
|
||||||
println!("cargo:rerun-if-changed=docs/iamb.5.md");
|
|
||||||
|
|
||||||
let iamb_1 = convert(IAMB_1_MD, "IAMB", 1);
|
|
||||||
let iamb_5 = convert(IAMB_5_MD, "IAMB", 5);
|
|
||||||
|
|
||||||
let out_dir = std::env::var("OUT_DIR");
|
|
||||||
let out_dir = out_dir.as_deref().unwrap_or("docs");
|
|
||||||
|
|
||||||
fs::write(PathBuf::from_iter([out_dir, "iamb.1"]), iamb_1.as_bytes())?;
|
|
||||||
fs::write(PathBuf::from_iter([out_dir, "iamb.5"]), iamb_5.as_bytes())?;
|
|
||||||
|
|
||||||
Ok(())
|
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/"
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
{
|
|
||||||
"default_profile": "default",
|
|
||||||
"profiles": {
|
|
||||||
"default": {
|
|
||||||
"user_id": "",
|
|
||||||
"url": "https://matrix.org",
|
|
||||||
"settings": {},
|
|
||||||
"dirs": {}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"settings": {
|
|
||||||
"log_level": "warn",
|
|
||||||
"reaction_display": true,
|
|
||||||
"reaction_shortcode_display": false,
|
|
||||||
"read_receipt_send": true,
|
|
||||||
"read_receipt_display": true,
|
|
||||||
"request_timeout": 10000,
|
|
||||||
"typing_notice_send": true,
|
|
||||||
"typing_notice_display": true,
|
|
||||||
"users": {
|
|
||||||
"@user:matrix.org": {
|
|
||||||
"name": "John Doe",
|
|
||||||
"color": "magenta"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"default_room": "#iamb-users:0x.badd.cafe"
|
|
||||||
},
|
|
||||||
"dirs": {
|
|
||||||
"cache": "~/.cache/iamb/",
|
|
||||||
"logs": "~/.local/share/iamb/logs/",
|
|
||||||
"downloads": "~/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 |
325
docs/iamb.1
Normal file
325
docs/iamb.1
Normal file
@@ -0,0 +1,325 @@
|
|||||||
|
.\" 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.
|
||||||
|
.It Sy ":forget"
|
||||||
|
Remove all left rooms from the internal database.
|
||||||
|
.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 ":replied"
|
||||||
|
Go to the message the current message replied to.
|
||||||
|
.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
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
# NAME
|
|
||||||
|
|
||||||
iamb – a terminal-based client for Matrix for the Vim addict
|
|
||||||
|
|
||||||
# SYNOPSIS
|
|
||||||
|
|
||||||
**iamb** [**--profile** _profile_] [**--config-directory** _directory_] [**--help** | **--version**]
|
|
||||||
|
|
||||||
# OPTIONS
|
|
||||||
|
|
||||||
These options are primitives at the top-level of the file.
|
|
||||||
|
|
||||||
**--profile**, **-P**
|
|
||||||
> The profile to start with. Overrides **default_profile** from **iamb(5)**.
|
|
||||||
|
|
||||||
**--config-directory**, **-C**
|
|
||||||
> Path to the directory the configuration file is located in.
|
|
||||||
|
|
||||||
**--help**, **-h**
|
|
||||||
> Show a short help text and quit.
|
|
||||||
|
|
||||||
**--version**, **-V**
|
|
||||||
> Show the iamb version and quit.
|
|
||||||
|
|
||||||
# SEE ALSO
|
|
||||||
|
|
||||||
**iamb(5)**
|
|
||||||
|
|
||||||
Full documentation is available online at \<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
|
||||||
134
docs/iamb.5.md
134
docs/iamb.5.md
@@ -1,134 +0,0 @@
|
|||||||
# NAME
|
|
||||||
|
|
||||||
config.json – configuration file for iamb
|
|
||||||
|
|
||||||
# SYNOPSIS
|
|
||||||
|
|
||||||
Configuration must be placed under _~/.config/iamb/_ and is named config.json.
|
|
||||||
|
|
||||||
Example configuration usually comes bundled with your installation and can
|
|
||||||
typically be found in _/usr/share/iamb_.
|
|
||||||
|
|
||||||
As implied by the filename, the configuration is formatted in JSON. It's
|
|
||||||
structure and fields are described below.
|
|
||||||
|
|
||||||
# BASIC SETTINGS
|
|
||||||
|
|
||||||
These options are primitives at the top-level of the file.
|
|
||||||
|
|
||||||
**default_profile** (type: string)
|
|
||||||
> The default profile to connect to, unless overwritten by a commandline
|
|
||||||
> switch. It has to be defined in the *PROFILES* section.
|
|
||||||
|
|
||||||
# PROFILES
|
|
||||||
|
|
||||||
These options are configured as a map under the profiles name.
|
|
||||||
|
|
||||||
**user_id** (type: string)
|
|
||||||
> The user ID to use when connecting to the server. For example "user" for
|
|
||||||
> "@user:matrix.org".
|
|
||||||
|
|
||||||
**url** (type: string)
|
|
||||||
> The URL of the users server. For example "https://matrix.org" for
|
|
||||||
> "@user:matrix.org".
|
|
||||||
|
|
||||||
**settings** (type: settings object)
|
|
||||||
> Overwrite general settings for this account. The fields are identical to
|
|
||||||
> those in *TUNABLES*.
|
|
||||||
|
|
||||||
**layout** (type: startup layout object)
|
|
||||||
> Overwrite general settings for this account. The fields are identical to
|
|
||||||
> those in *STARTUP LAYOUT*.
|
|
||||||
|
|
||||||
**dirs** (type: XDG overrides object)
|
|
||||||
> Overwrite general settings for this account. The fields are identical to
|
|
||||||
> those in *DIRECTORIES*.
|
|
||||||
|
|
||||||
# TUNABLES
|
|
||||||
|
|
||||||
These options are configured as a map under the *settings* key and can be
|
|
||||||
overridden as described in *PROFILES*.
|
|
||||||
|
|
||||||
**log_level** (type: string)
|
|
||||||
> Specifies the lowest log level that should be shown. Possible values
|
|
||||||
> are: _trace_, _debug_, _info_, _warn_, and _error_.
|
|
||||||
|
|
||||||
**reaction_display** (type: boolean)
|
|
||||||
> Defines whether or not reactions should be shown.
|
|
||||||
|
|
||||||
**reaction_shortcode_display** (type: boolean)
|
|
||||||
> Defines whether or not reactions should be shown as their respective
|
|
||||||
> shortcode.
|
|
||||||
|
|
||||||
**read_receipt_send** (type: boolean)
|
|
||||||
> Defines whether or not read confirmations are sent.
|
|
||||||
|
|
||||||
**read_receipt_display** (type: boolean)
|
|
||||||
> Defines whether or not read confirmations are displayed.
|
|
||||||
|
|
||||||
**request_timeout** (type: uint64)
|
|
||||||
> Defines the maximum time per request in seconds.
|
|
||||||
|
|
||||||
**typing_notice_send** (type: boolean)
|
|
||||||
> Defines whether or not the typing state is sent.
|
|
||||||
|
|
||||||
**typing_notice_display** (type: boolean)
|
|
||||||
> Defines whether or not the typing state is displayed.
|
|
||||||
|
|
||||||
**user** (type: map)
|
|
||||||
> Overrides values for the specified user. See *USER OVERRIDES* for
|
|
||||||
> details on the format.
|
|
||||||
|
|
||||||
**default_room** (type: string)
|
|
||||||
> The room to show by default instead of a welcome-screen.
|
|
||||||
|
|
||||||
## USER OVERRIDES
|
|
||||||
|
|
||||||
Overrides are mapped onto matrix User IDs such as _@user:matrix.org_ and are
|
|
||||||
maps containing the following key value pairs.
|
|
||||||
|
|
||||||
**name** (type: string)
|
|
||||||
> Change the display name of the user.
|
|
||||||
|
|
||||||
**color** (type: string)
|
|
||||||
> Change the color the user is shown as. Possible values are: _black_,
|
|
||||||
> _blue_, _cyan_, _dark-gray_, _gray_, _green_, _light-blue_,
|
|
||||||
> _light-cyan_, _light-green_, _light-magenta_, _light-red_,
|
|
||||||
> _light-yellow_, _magenta_, _none_, _red_, _white_, _yellow_
|
|
||||||
|
|
||||||
# STARTUP LAYOUT
|
|
||||||
|
|
||||||
Specifies what initial set of tabs and windows to show when starting the
|
|
||||||
client. Configured as an object under the key *layout*.
|
|
||||||
|
|
||||||
**style** (type: string)
|
|
||||||
> Specifies what window layout to load when starting. Valid values are
|
|
||||||
> _restore_ to restore the layout from the last time the client was exited,
|
|
||||||
> _new_ to open a single window (uses the value of _default\_room_ if set), or
|
|
||||||
> _config_ to open the layout described under _tabs_.
|
|
||||||
|
|
||||||
**tabs** (type: array of window objects)
|
|
||||||
> If **style** is set to _config_, then this value will be used to open a set
|
|
||||||
> of tabs and windows at startup. Each object can contain either a **window**
|
|
||||||
> key specifying a username, room identifier or room alias to show, or a
|
|
||||||
> **split** key specifying an array of window objects.
|
|
||||||
|
|
||||||
# DIRECTORIES
|
|
||||||
|
|
||||||
Specifies the directories to save data in. Configured as a map under the key
|
|
||||||
*dirs*.
|
|
||||||
|
|
||||||
**cache** (type: string)
|
|
||||||
> Specifies where to store assets and temporary data in.
|
|
||||||
|
|
||||||
**logs** (type: string)
|
|
||||||
> Specifies where to store log files.
|
|
||||||
|
|
||||||
**downloads** (type: string)
|
|
||||||
> Specifies where to store downloaded files.
|
|
||||||
|
|
||||||
# SEE ALSO
|
|
||||||
|
|
||||||
*iamb(1)*
|
|
||||||
|
|
||||||
Full documentation is available online at \<https://iamb.chat\>
|
|
||||||
53
docs/iamb.metainfo.xml
Normal file
53
docs/iamb.metainfo.xml
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<?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.11" date="2026-01-19"/>
|
||||||
|
<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 |
112
flake.lock
generated
112
flake.lock
generated
@@ -1,27 +1,51 @@
|
|||||||
{
|
{
|
||||||
"nodes": {
|
"nodes": {
|
||||||
"flake-utils": {
|
"crane": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1678901627,
|
"lastModified": 1759893430,
|
||||||
"narHash": "sha256-U02riOqrKKzwjsxc/400XnElV+UtPUQWpANPlyazjH0=",
|
"narHash": "sha256-yAy4otLYm9iZ+NtQwTMEbqHwswSFUbhn7x826RR6djw=",
|
||||||
"owner": "numtide",
|
"owner": "ipetkov",
|
||||||
"repo": "flake-utils",
|
"repo": "crane",
|
||||||
"rev": "93a2b84fc4b70d9e089d029deacc3583435c2ed6",
|
"rev": "1979a2524cb8c801520bd94c38bb3d5692419d93",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
"owner": "numtide",
|
"owner": "ipetkov",
|
||||||
"repo": "flake-utils",
|
"repo": "crane",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"flake-utils_2": {
|
"fenix": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": [
|
||||||
|
"nixpkgs"
|
||||||
|
],
|
||||||
|
"rust-analyzer-src": "rust-analyzer-src"
|
||||||
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1659877975,
|
"lastModified": 1760510549,
|
||||||
"narHash": "sha256-zllb8aq3YO3h8B/U0/J1WBgAL8EX5yWf5pMj3G0NAmc=",
|
"narHash": "sha256-NP+kmLMm7zSyv4Fufv+eSJXyqjLMUhUfPT6lXRlg/bU=",
|
||||||
|
"owner": "nix-community",
|
||||||
|
"repo": "fenix",
|
||||||
|
"rev": "ef7178cf086f267113b5c48fdeb6e510729c8214",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-community",
|
||||||
|
"repo": "fenix",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"flake-utils": {
|
||||||
|
"inputs": {
|
||||||
|
"systems": "systems"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1731533236,
|
||||||
|
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||||
"owner": "numtide",
|
"owner": "numtide",
|
||||||
"repo": "flake-utils",
|
"repo": "flake-utils",
|
||||||
"rev": "c0e246b9b83f637f4681389ecabcb2681b4f3af0",
|
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -32,11 +56,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1679437018,
|
"lastModified": 1760284886,
|
||||||
"narHash": "sha256-vOuiDPLHSEo/7NkiWtxpHpHgoXoNmrm+wkXZ6a072Fc=",
|
"narHash": "sha256-TK9Kr0BYBQ/1P5kAsnNQhmWWKgmZXwUQr4ZMjCzWf2c=",
|
||||||
"owner": "nixos",
|
"owner": "nixos",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "19cf008bb18e47b6e3b4e16e32a9a4bdd4b45f7e",
|
"rev": "cf3f5c4def3c7b5f1fc012b3d839575dbe552d43",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -46,45 +70,43 @@
|
|||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"nixpkgs_2": {
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1665296151,
|
|
||||||
"narHash": "sha256-uOB0oxqxN9K7XGF1hcnY+PQnlQJ+3bP2vCn/+Ru/bbc=",
|
|
||||||
"owner": "NixOS",
|
|
||||||
"repo": "nixpkgs",
|
|
||||||
"rev": "14ccaaedd95a488dd7ae142757884d8e125b3363",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "NixOS",
|
|
||||||
"ref": "nixpkgs-unstable",
|
|
||||||
"repo": "nixpkgs",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"root": {
|
"root": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
|
"crane": "crane",
|
||||||
|
"fenix": "fenix",
|
||||||
"flake-utils": "flake-utils",
|
"flake-utils": "flake-utils",
|
||||||
"nixpkgs": "nixpkgs",
|
"nixpkgs": "nixpkgs"
|
||||||
"rust-overlay": "rust-overlay"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"rust-overlay": {
|
"rust-analyzer-src": {
|
||||||
"inputs": {
|
"flake": false,
|
||||||
"flake-utils": "flake-utils_2",
|
|
||||||
"nixpkgs": "nixpkgs_2"
|
|
||||||
},
|
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1679624450,
|
"lastModified": 1760457219,
|
||||||
"narHash": "sha256-wiDqUaklmc31E1+wz5sv52sMcWvZKsL1FBeGJCxz628=",
|
"narHash": "sha256-WJOUGx42hrhmvvYcGkwea+BcJuQJLcns849OnewQqX4=",
|
||||||
"owner": "oxalica",
|
"owner": "rust-lang",
|
||||||
"repo": "rust-overlay",
|
"repo": "rust-analyzer",
|
||||||
"rev": "afbdcf305fd6f05f708fe76d52f24d37d066c251",
|
"rev": "8747cf81540bd1bbbab9ee2702f12c33aa887b46",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
"owner": "oxalica",
|
"owner": "rust-lang",
|
||||||
"repo": "rust-overlay",
|
"ref": "nightly",
|
||||||
|
"repo": "rust-analyzer",
|
||||||
|
"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"
|
"type": "github"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
121
flake.nix
121
flake.nix
@@ -5,36 +5,107 @@
|
|||||||
inputs = {
|
inputs = {
|
||||||
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
|
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
|
||||||
flake-utils.url = "github:numtide/flake-utils";
|
flake-utils.url = "github:numtide/flake-utils";
|
||||||
rust-overlay.url = "github:oxalica/rust-overlay";
|
crane.url = "github:ipetkov/crane";
|
||||||
|
fenix = {
|
||||||
|
url = "github:nix-community/fenix";
|
||||||
|
inputs.nixpkgs.follows = "nixpkgs";
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
outputs = { self, nixpkgs, flake-utils, rust-overlay, ... }:
|
outputs =
|
||||||
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."2023-03-17".default;
|
|
||||||
in
|
|
||||||
with pkgs;
|
|
||||||
{
|
{
|
||||||
packages.default = pkgs.rustPlatform.buildRustPackage {
|
self,
|
||||||
pname = "iamb";
|
nixpkgs,
|
||||||
version = "0.0.7";
|
crane,
|
||||||
src = ./.;
|
flake-utils,
|
||||||
cargoLock.lockFile = ./Cargo.lock;
|
fenix,
|
||||||
nativeBuildInputs = [ pkgs.pkgconfig ];
|
...
|
||||||
buildInputs = [ pkgs.openssl ] ++ pkgs.lib.optionals pkgs.stdenv.isDarwin
|
}:
|
||||||
(with pkgs.darwin.apple_sdk.frameworks; [ AppKit Security ]);
|
flake-utils.lib.eachDefaultSystem (
|
||||||
|
system:
|
||||||
|
let
|
||||||
|
pkgs = nixpkgs.legacyPackages.${system};
|
||||||
|
inherit (pkgs) lib;
|
||||||
|
|
||||||
|
rustToolchain = fenix.packages.${system}.fromToolchainFile {
|
||||||
|
file = ./rust-toolchain.toml;
|
||||||
|
# When the file changes, this hash must be updated.
|
||||||
|
sha256 = "sha256-Qxt8XAuaUR2OMdKbN4u8dBJOhSHxS+uS06Wl9+flVEk=";
|
||||||
};
|
};
|
||||||
devShell = mkShell {
|
|
||||||
buildInputs = [
|
# Nightly toolchain for rustfmt (pinned to current flake lock)
|
||||||
(rustNightly.override { extensions = [ "rust-src" ]; })
|
# Note that the github CI uses "current nightly" for formatting, it 's not pinned.
|
||||||
pkg-config
|
rustNightly = fenix.packages.${system}.latest;
|
||||||
cargo-tarpaulin
|
|
||||||
rust-analyzer
|
craneLib = (crane.mkLib pkgs).overrideToolchain rustToolchain;
|
||||||
rustfmt
|
craneLibNightly = (crane.mkLib pkgs).overrideToolchain rustNightly.toolchain;
|
||||||
|
|
||||||
|
src = lib.fileset.toSource {
|
||||||
|
root = ./.;
|
||||||
|
fileset = lib.fileset.unions [
|
||||||
|
(craneLib.fileset.commonCargoSources ./.)
|
||||||
|
./src/windows/welcome.md
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
commonArgs = {
|
||||||
|
inherit src;
|
||||||
|
strictDeps = true;
|
||||||
|
pname = "iamb";
|
||||||
|
version = self.shortRev or self.dirtyShortRev;
|
||||||
|
};
|
||||||
|
|
||||||
|
# Build *just* the cargo dependencies, so we can reuse
|
||||||
|
# all of that work (e.g. via cachix) when running in CI
|
||||||
|
cargoArtifacts = craneLib.buildDepsOnly commonArgs;
|
||||||
|
|
||||||
|
# Build the actual crate
|
||||||
|
iamb = craneLib.buildPackage (commonArgs // {
|
||||||
|
inherit cargoArtifacts;
|
||||||
});
|
});
|
||||||
|
in
|
||||||
|
{
|
||||||
|
checks = {
|
||||||
|
# Build the crate as part of `nix flake check`
|
||||||
|
inherit iamb;
|
||||||
|
|
||||||
|
iamb-clippy = craneLib.cargoClippy (commonArgs // {
|
||||||
|
inherit cargoArtifacts;
|
||||||
|
cargoClippyExtraArgs = "--all-targets -- --deny warnings";
|
||||||
|
});
|
||||||
|
|
||||||
|
iamb-fmt = craneLibNightly.cargoFmt {
|
||||||
|
inherit src;
|
||||||
|
};
|
||||||
|
|
||||||
|
iamb-nextest = craneLib.cargoNextest (commonArgs // {
|
||||||
|
inherit cargoArtifacts;
|
||||||
|
partitions = 1;
|
||||||
|
partitionType = "count";
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
packages.default = iamb;
|
||||||
|
|
||||||
|
apps.default = flake-utils.lib.mkApp {
|
||||||
|
drv = iamb;
|
||||||
|
};
|
||||||
|
|
||||||
|
devShells.default = craneLib.devShell {
|
||||||
|
# Inherit inputs from checks
|
||||||
|
checks = self.checks.${system};
|
||||||
|
|
||||||
|
packages = with pkgs; [
|
||||||
|
cargo-tarpaulin
|
||||||
|
cargo-watch
|
||||||
|
sqlite
|
||||||
|
];
|
||||||
|
|
||||||
|
shellHook = ''
|
||||||
|
# Prepend nightly rustfmt to PATH.
|
||||||
|
export PATH="${rustNightly.rustfmt}/bin:$PATH"
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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.88"
|
||||||
|
components = [ "clippy" ]
|
||||||
1438
src/base.rs
1438
src/base.rs
File diff suppressed because it is too large
Load Diff
667
src/commands.rs
667
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::{
|
use modalkit::{
|
||||||
editing::base::OpenTarget,
|
commands::{CommandError, CommandResult, CommandStep},
|
||||||
env::vim::command::{CommandContext, CommandDescription, OptionType},
|
env::vim::command::{CommandContext, CommandDescription, OptionType},
|
||||||
input::commands::{CommandError, CommandResult, CommandStep},
|
prelude::OpenTarget,
|
||||||
input::InputContext,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::base::{
|
use crate::base::{
|
||||||
@@ -16,22 +19,24 @@ use crate::base::{
|
|||||||
HomeserverAction,
|
HomeserverAction,
|
||||||
IambAction,
|
IambAction,
|
||||||
IambId,
|
IambId,
|
||||||
|
KeysAction,
|
||||||
|
MemberUpdateAction,
|
||||||
MessageAction,
|
MessageAction,
|
||||||
ProgramCommand,
|
ProgramCommand,
|
||||||
ProgramCommands,
|
ProgramCommands,
|
||||||
ProgramContext,
|
|
||||||
RoomAction,
|
RoomAction,
|
||||||
RoomField,
|
RoomField,
|
||||||
SendAction,
|
SendAction,
|
||||||
|
SpaceAction,
|
||||||
VerifyAction,
|
VerifyAction,
|
||||||
};
|
};
|
||||||
|
|
||||||
type ProgContext = CommandContext<ProgramContext>;
|
type ProgContext = CommandContext;
|
||||||
type ProgResult = CommandResult<ProgramCommand>;
|
type ProgResult = CommandResult<ProgramCommand>;
|
||||||
|
|
||||||
/// Convert strings the user types into a tag name.
|
/// Convert strings the user types into a tag name.
|
||||||
fn tag_name(name: String) -> Result<TagName, CommandError> {
|
fn tag_name(name: String) -> Result<TagName, CommandError> {
|
||||||
let tag = match name.as_str() {
|
let tag = match name.to_lowercase().as_str() {
|
||||||
"fav" | "favorite" | "favourite" | "m.favourite" => TagName::Favorite,
|
"fav" | "favorite" | "favourite" | "m.favourite" => TagName::Favorite,
|
||||||
"low" | "lowpriority" | "low_priority" | "low-priority" | "m.lowpriority" => {
|
"low" | "lowpriority" | "low_priority" | "low-priority" | "m.lowpriority" => {
|
||||||
TagName::LowPriority
|
TagName::LowPriority
|
||||||
@@ -95,7 +100,30 @@ fn iamb_invite(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let iact = IambAction::from(ract);
|
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);
|
return Ok(step);
|
||||||
}
|
}
|
||||||
@@ -106,7 +134,7 @@ fn iamb_verify(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
|||||||
match args.len() {
|
match args.len() {
|
||||||
0 => {
|
0 => {
|
||||||
let open = ctx.switch(OpenTarget::Application(IambId::VerifyList));
|
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);
|
return Ok(step);
|
||||||
},
|
},
|
||||||
@@ -121,7 +149,7 @@ fn iamb_verify(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
|||||||
"mismatch" => VerifyAction::Mismatch,
|
"mismatch" => VerifyAction::Mismatch,
|
||||||
"request" => {
|
"request" => {
|
||||||
let iact = IambAction::VerifyRequest(args.remove(1));
|
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);
|
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 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);
|
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 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);
|
return Ok(step);
|
||||||
}
|
}
|
||||||
@@ -156,7 +184,7 @@ fn iamb_members(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let open = IambAction::Room(RoomAction::Members(ctx.clone().into()));
|
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);
|
return Ok(step);
|
||||||
}
|
}
|
||||||
@@ -167,7 +195,18 @@ fn iamb_leave(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let leave = IambAction::Room(RoomAction::Leave(desc.bang));
|
let leave = IambAction::Room(RoomAction::Leave(desc.bang));
|
||||||
let step = CommandStep::Continue(leave.into(), ctx.context.take());
|
let step = CommandStep::Continue(leave.into(), ctx.context.clone());
|
||||||
|
|
||||||
|
return Ok(step);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn iamb_forget(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
||||||
|
if !desc.arg.text.is_empty() {
|
||||||
|
return Result::Err(CommandError::InvalidArgument);
|
||||||
|
}
|
||||||
|
|
||||||
|
let forget = IambAction::Homeserver(HomeserverAction::Forget);
|
||||||
|
let step = CommandStep::Continue(forget.into(), ctx.context.clone());
|
||||||
|
|
||||||
return Ok(step);
|
return Ok(step);
|
||||||
}
|
}
|
||||||
@@ -178,7 +217,7 @@ fn iamb_cancel(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mact = IambAction::from(MessageAction::Cancel(desc.bang));
|
let mact = IambAction::from(MessageAction::Cancel(desc.bang));
|
||||||
let step = CommandStep::Continue(mact.into(), ctx.context.take());
|
let step = CommandStep::Continue(mact.into(), ctx.context.clone());
|
||||||
|
|
||||||
return Ok(step);
|
return Ok(step);
|
||||||
}
|
}
|
||||||
@@ -189,30 +228,23 @@ fn iamb_edit(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mact = IambAction::from(MessageAction::Edit);
|
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);
|
return Ok(step);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn iamb_react(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
fn iamb_react(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
||||||
let args = desc.arg.strings()?;
|
let mut args = desc.arg.strings()?;
|
||||||
|
|
||||||
if args.len() != 1 {
|
if args.len() != 1 {
|
||||||
return Result::Err(CommandError::InvalidArgument);
|
return Result::Err(CommandError::InvalidArgument);
|
||||||
}
|
}
|
||||||
|
|
||||||
let k = args[0].as_str();
|
let react = args.remove(0);
|
||||||
|
let mact = IambAction::from(MessageAction::React(react, desc.bang));
|
||||||
if let Some(emoji) = emojis::get(k).or_else(|| emojis::get_by_shortcode(k)) {
|
let step = CommandStep::Continue(mact.into(), ctx.context.clone());
|
||||||
let mact = IambAction::from(MessageAction::React(emoji.to_string()));
|
|
||||||
let step = CommandStep::Continue(mact.into(), ctx.context.take());
|
|
||||||
|
|
||||||
return Ok(step);
|
return Ok(step);
|
||||||
} else {
|
|
||||||
let msg = format!("Invalid Emoji or shortcode: {k}");
|
|
||||||
|
|
||||||
return Result::Err(CommandError::Error(msg));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn iamb_unreact(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
fn iamb_unreact(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
||||||
@@ -222,21 +254,9 @@ fn iamb_unreact(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
|||||||
return Result::Err(CommandError::InvalidArgument);
|
return Result::Err(CommandError::InvalidArgument);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mact = if let Some(k) = args.pop() {
|
let reaction = args.pop();
|
||||||
let k = k.as_str();
|
let mact = IambAction::from(MessageAction::Unreact(reaction, desc.bang));
|
||||||
|
let step = CommandStep::Continue(mact.into(), ctx.context.clone());
|
||||||
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());
|
|
||||||
|
|
||||||
return Ok(step);
|
return Ok(step);
|
||||||
}
|
}
|
||||||
@@ -250,7 +270,7 @@ fn iamb_redact(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
|||||||
|
|
||||||
let reason = args.into_iter().next();
|
let reason = args.into_iter().next();
|
||||||
let ract = IambAction::from(MessageAction::Redact(reason, desc.bang));
|
let ract = IambAction::from(MessageAction::Redact(reason, desc.bang));
|
||||||
let step = CommandStep::Continue(ract.into(), ctx.context.take());
|
let step = CommandStep::Continue(ract.into(), ctx.context.clone());
|
||||||
|
|
||||||
return Ok(step);
|
return Ok(step);
|
||||||
}
|
}
|
||||||
@@ -261,7 +281,29 @@ fn iamb_reply(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let ract = IambAction::from(MessageAction::Reply);
|
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_replied(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
||||||
|
if !desc.arg.text.is_empty() {
|
||||||
|
return Result::Err(CommandError::InvalidArgument);
|
||||||
|
}
|
||||||
|
|
||||||
|
let ract = IambAction::from(MessageAction::Replied);
|
||||||
|
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);
|
return Ok(step);
|
||||||
}
|
}
|
||||||
@@ -272,18 +314,53 @@ fn iamb_rooms(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let open = ctx.switch(OpenTarget::Application(IambId::RoomList));
|
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);
|
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 {
|
fn iamb_spaces(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
||||||
if !desc.arg.text.is_empty() {
|
if !desc.arg.text.is_empty() {
|
||||||
return Result::Err(CommandError::InvalidArgument);
|
return Result::Err(CommandError::InvalidArgument);
|
||||||
}
|
}
|
||||||
|
|
||||||
let open = ctx.switch(OpenTarget::Application(IambId::SpaceList));
|
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);
|
return Ok(step);
|
||||||
}
|
}
|
||||||
@@ -294,7 +371,7 @@ fn iamb_welcome(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let open = ctx.switch(OpenTarget::Application(IambId::Welcome));
|
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);
|
return Ok(step);
|
||||||
}
|
}
|
||||||
@@ -307,7 +384,7 @@ fn iamb_join(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let open = ctx.switch(args.remove(0));
|
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);
|
return Ok(step);
|
||||||
}
|
}
|
||||||
@@ -354,7 +431,7 @@ fn iamb_create(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
|||||||
|
|
||||||
let hact = HomeserverAction::CreateRoom(alias, ct, flags);
|
let hact = HomeserverAction::CreateRoom(alias, ct, flags);
|
||||||
let iact = IambAction::from(hact);
|
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);
|
return Ok(step);
|
||||||
}
|
}
|
||||||
@@ -374,6 +451,37 @@ fn iamb_room(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let act: IambAction = match (field.as_str(), action.as_str(), args.pop()) {
|
let act: IambAction = match (field.as_str(), action.as_str(), args.pop()) {
|
||||||
|
// :room dm set
|
||||||
|
("dm", "set", None) => RoomAction::SetDirect(true).into(),
|
||||||
|
("dm", "set", Some(_)) => return Result::Err(CommandError::InvalidArgument),
|
||||||
|
|
||||||
|
// :room dm set
|
||||||
|
("dm", "unset", None) => RoomAction::SetDirect(false).into(),
|
||||||
|
("dm", "unset", Some(_)) => return Result::Err(CommandError::InvalidArgument),
|
||||||
|
|
||||||
|
// :room [kick|ban|unban] <user>
|
||||||
|
("kick", u, r) => {
|
||||||
|
RoomAction::MemberUpdate(MemberUpdateAction::Kick, u.into(), r, desc.bang).into()
|
||||||
|
},
|
||||||
|
("ban", u, r) => {
|
||||||
|
RoomAction::MemberUpdate(MemberUpdateAction::Ban, u.into(), r, desc.bang).into()
|
||||||
|
},
|
||||||
|
("unban", u, r) => {
|
||||||
|
RoomAction::MemberUpdate(MemberUpdateAction::Unban, u.into(), r, desc.bang).into()
|
||||||
|
},
|
||||||
|
|
||||||
|
// :room history set <visibility>
|
||||||
|
("history", "set", Some(s)) => RoomAction::Set(RoomField::History, s).into(),
|
||||||
|
("history", "set", None) => return Result::Err(CommandError::InvalidArgument),
|
||||||
|
|
||||||
|
// :room history unset
|
||||||
|
("history", "unset", None) => RoomAction::Unset(RoomField::History).into(),
|
||||||
|
("history", "unset", Some(_)) => return Result::Err(CommandError::InvalidArgument),
|
||||||
|
|
||||||
|
// :room history show
|
||||||
|
("history", "show", None) => RoomAction::Show(RoomField::History).into(),
|
||||||
|
("history", "show", Some(_)) => return Result::Err(CommandError::InvalidArgument),
|
||||||
|
|
||||||
// :room name set <room-name>
|
// :room name set <room-name>
|
||||||
("name", "set", Some(s)) => RoomAction::Set(RoomField::Name, s).into(),
|
("name", "set", Some(s)) => RoomAction::Set(RoomField::Name, s).into(),
|
||||||
("name", "set", None) => return Result::Err(CommandError::InvalidArgument),
|
("name", "set", None) => return Result::Err(CommandError::InvalidArgument),
|
||||||
@@ -390,6 +498,10 @@ fn iamb_room(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
|||||||
("topic", "unset", None) => RoomAction::Unset(RoomField::Topic).into(),
|
("topic", "unset", None) => RoomAction::Unset(RoomField::Topic).into(),
|
||||||
("topic", "unset", Some(_)) => return Result::Err(CommandError::InvalidArgument),
|
("topic", "unset", Some(_)) => return Result::Err(CommandError::InvalidArgument),
|
||||||
|
|
||||||
|
// :room topic show
|
||||||
|
("topic", "show", None) => RoomAction::Show(RoomField::Topic).into(),
|
||||||
|
("topic", "show", Some(_)) => return Result::Err(CommandError::InvalidArgument),
|
||||||
|
|
||||||
// :room tag set <tag-name>
|
// :room tag set <tag-name>
|
||||||
("tag", "set", Some(s)) => RoomAction::Set(RoomField::Tag(tag_name(s)?), "".into()).into(),
|
("tag", "set", Some(s)) => RoomAction::Set(RoomField::Tag(tag_name(s)?), "".into()).into(),
|
||||||
("tag", "set", None) => return Result::Err(CommandError::InvalidArgument),
|
("tag", "set", None) => return Result::Err(CommandError::InvalidArgument),
|
||||||
@@ -398,10 +510,143 @@ fn iamb_room(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
|||||||
("tag", "unset", Some(s)) => RoomAction::Unset(RoomField::Tag(tag_name(s)?)).into(),
|
("tag", "unset", Some(s)) => RoomAction::Unset(RoomField::Tag(tag_name(s)?)).into(),
|
||||||
("tag", "unset", None) => return Result::Err(CommandError::InvalidArgument),
|
("tag", "unset", None) => return Result::Err(CommandError::InvalidArgument),
|
||||||
|
|
||||||
|
// :room notify set <notification-level>
|
||||||
|
("notify", "set", Some(s)) => RoomAction::Set(RoomField::NotificationMode, s).into(),
|
||||||
|
("notify", "set", None) => return Result::Err(CommandError::InvalidArgument),
|
||||||
|
|
||||||
|
// :room notify unset <notification-level>
|
||||||
|
("notify", "unset", None) => RoomAction::Unset(RoomField::NotificationMode).into(),
|
||||||
|
("notify", "unset", Some(_)) => return Result::Err(CommandError::InvalidArgument),
|
||||||
|
|
||||||
|
// :room notify show
|
||||||
|
("notify", "show", None) => RoomAction::Show(RoomField::NotificationMode).into(),
|
||||||
|
("notify", "show", Some(_)) => return Result::Err(CommandError::InvalidArgument),
|
||||||
|
|
||||||
|
// :room aliases show
|
||||||
|
("alias", "show", None) => RoomAction::Show(RoomField::Aliases).into(),
|
||||||
|
("alias", "show", Some(_)) => return Result::Err(CommandError::InvalidArgument),
|
||||||
|
|
||||||
|
// :room aliases unset <alias>
|
||||||
|
("alias", "unset", Some(s)) => RoomAction::Unset(RoomField::Alias(s)).into(),
|
||||||
|
("alias", "unset", None) => return Result::Err(CommandError::InvalidArgument),
|
||||||
|
|
||||||
|
// :room aliases set <alias>
|
||||||
|
("alias", "set", Some(s)) => RoomAction::Set(RoomField::Alias(s), "".into()).into(),
|
||||||
|
("alias", "set", None) => return Result::Err(CommandError::InvalidArgument),
|
||||||
|
|
||||||
|
// :room canonicalalias show
|
||||||
|
("canonicalalias" | "canon", "show", None) => {
|
||||||
|
RoomAction::Show(RoomField::CanonicalAlias).into()
|
||||||
|
},
|
||||||
|
("canonicalalias" | "canon", "show", Some(_)) => {
|
||||||
|
return Result::Err(CommandError::InvalidArgument)
|
||||||
|
},
|
||||||
|
|
||||||
|
// :room canonicalalias set
|
||||||
|
("canonicalalias" | "canon", "set", Some(s)) => {
|
||||||
|
RoomAction::Set(RoomField::CanonicalAlias, s).into()
|
||||||
|
},
|
||||||
|
("canonicalalias" | "canon", "set", None) => {
|
||||||
|
return Result::Err(CommandError::InvalidArgument)
|
||||||
|
},
|
||||||
|
|
||||||
|
// :room canonicalalias unset
|
||||||
|
("canonicalalias" | "canon", "unset", None) => {
|
||||||
|
RoomAction::Unset(RoomField::CanonicalAlias).into()
|
||||||
|
},
|
||||||
|
("canonicalalias" | "canon", "unset", Some(_)) => {
|
||||||
|
return Result::Err(CommandError::InvalidArgument)
|
||||||
|
},
|
||||||
|
|
||||||
|
// :room id show
|
||||||
|
("id", "show", None) => RoomAction::Show(RoomField::Id).into(),
|
||||||
|
("id", "show", Some(_)) => return Result::Err(CommandError::InvalidArgument),
|
||||||
|
|
||||||
_ => return Result::Err(CommandError::InvalidArgument),
|
_ => 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);
|
return Ok(step);
|
||||||
}
|
}
|
||||||
@@ -415,7 +660,7 @@ fn iamb_upload(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
|||||||
|
|
||||||
let sact = SendAction::Upload(args.remove(0));
|
let sact = SendAction::Upload(args.remove(0));
|
||||||
let iact = IambAction::from(sact);
|
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);
|
return Ok(step);
|
||||||
}
|
}
|
||||||
@@ -433,7 +678,7 @@ fn iamb_download(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult
|
|||||||
};
|
};
|
||||||
let mact = MessageAction::Download(args.pop(), flags);
|
let mact = MessageAction::Download(args.pop(), flags);
|
||||||
let iact = IambAction::from(mact);
|
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);
|
return Ok(step);
|
||||||
}
|
}
|
||||||
@@ -451,7 +696,23 @@ fn iamb_open(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
|||||||
};
|
};
|
||||||
let mact = MessageAction::Download(args.pop(), flags);
|
let mact = MessageAction::Download(args.pop(), flags);
|
||||||
let iact = IambAction::from(mact);
|
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);
|
return Ok(step);
|
||||||
}
|
}
|
||||||
@@ -467,6 +728,11 @@ fn add_iamb_commands(cmds: &mut ProgramCommands) {
|
|||||||
aliases: vec![],
|
aliases: vec![],
|
||||||
f: iamb_create,
|
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: "dms".into(), aliases: vec![], f: iamb_dms });
|
||||||
cmds.add_command(ProgramCommand {
|
cmds.add_command(ProgramCommand {
|
||||||
name: "download".into(),
|
name: "download".into(),
|
||||||
@@ -481,11 +747,17 @@ fn add_iamb_commands(cmds: &mut ProgramCommands) {
|
|||||||
f: iamb_invite,
|
f: iamb_invite,
|
||||||
});
|
});
|
||||||
cmds.add_command(ProgramCommand { name: "join".into(), aliases: vec![], f: iamb_join });
|
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 {
|
cmds.add_command(ProgramCommand {
|
||||||
name: "leave".into(),
|
name: "leave".into(),
|
||||||
aliases: vec![],
|
aliases: vec![],
|
||||||
f: iamb_leave,
|
f: iamb_leave,
|
||||||
});
|
});
|
||||||
|
cmds.add_command(ProgramCommand {
|
||||||
|
name: "forget".into(),
|
||||||
|
aliases: vec![],
|
||||||
|
f: iamb_forget,
|
||||||
|
});
|
||||||
cmds.add_command(ProgramCommand {
|
cmds.add_command(ProgramCommand {
|
||||||
name: "members".into(),
|
name: "members".into(),
|
||||||
aliases: vec![],
|
aliases: vec![],
|
||||||
@@ -506,17 +778,32 @@ fn add_iamb_commands(cmds: &mut ProgramCommands) {
|
|||||||
aliases: vec![],
|
aliases: vec![],
|
||||||
f: iamb_reply,
|
f: iamb_reply,
|
||||||
});
|
});
|
||||||
|
cmds.add_command(ProgramCommand {
|
||||||
|
name: "replied".into(),
|
||||||
|
aliases: vec![],
|
||||||
|
f: iamb_replied,
|
||||||
|
});
|
||||||
cmds.add_command(ProgramCommand {
|
cmds.add_command(ProgramCommand {
|
||||||
name: "rooms".into(),
|
name: "rooms".into(),
|
||||||
aliases: vec![],
|
aliases: vec![],
|
||||||
f: iamb_rooms,
|
f: iamb_rooms,
|
||||||
});
|
});
|
||||||
cmds.add_command(ProgramCommand { name: "room".into(), aliases: vec![], f: iamb_room });
|
cmds.add_command(ProgramCommand { name: "room".into(), aliases: vec![], f: iamb_room });
|
||||||
|
cmds.add_command(ProgramCommand {
|
||||||
|
name: "space".into(),
|
||||||
|
aliases: vec![],
|
||||||
|
f: iamb_space,
|
||||||
|
});
|
||||||
cmds.add_command(ProgramCommand {
|
cmds.add_command(ProgramCommand {
|
||||||
name: "spaces".into(),
|
name: "spaces".into(),
|
||||||
aliases: vec![],
|
aliases: vec![],
|
||||||
f: iamb_spaces,
|
f: iamb_spaces,
|
||||||
});
|
});
|
||||||
|
cmds.add_command(ProgramCommand {
|
||||||
|
name: "unreads".into(),
|
||||||
|
aliases: vec![],
|
||||||
|
f: iamb_unreads,
|
||||||
|
});
|
||||||
cmds.add_command(ProgramCommand {
|
cmds.add_command(ProgramCommand {
|
||||||
name: "unreact".into(),
|
name: "unreact".into(),
|
||||||
aliases: vec![],
|
aliases: vec![],
|
||||||
@@ -537,8 +824,19 @@ fn add_iamb_commands(cmds: &mut ProgramCommands) {
|
|||||||
aliases: vec![],
|
aliases: vec![],
|
||||||
f: iamb_welcome,
|
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 {
|
pub fn setup_commands() -> ProgramCommands {
|
||||||
let mut cmds = ProgramCommands::default();
|
let mut cmds = ProgramCommands::default();
|
||||||
|
|
||||||
@@ -550,13 +848,14 @@ pub fn setup_commands() -> ProgramCommands {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use matrix_sdk::ruma::user_id;
|
use matrix_sdk::ruma::{room_id, user_id};
|
||||||
use modalkit::editing::action::WindowAction;
|
use modalkit::actions::WindowAction;
|
||||||
|
use modalkit::editing::context::EditContext;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_cmd_verify() {
|
fn test_cmd_verify() {
|
||||||
let mut cmds = setup_commands();
|
let mut cmds = setup_commands();
|
||||||
let ctx = ProgramContext::default();
|
let ctx = EditContext::default();
|
||||||
|
|
||||||
let res = cmds.input_cmd(":verify", ctx.clone()).unwrap();
|
let res = cmds.input_cmd(":verify", ctx.clone()).unwrap();
|
||||||
let act = WindowAction::Switch(OpenTarget::Application(IambId::VerifyList));
|
let act = WindowAction::Switch(OpenTarget::Application(IambId::VerifyList));
|
||||||
@@ -603,7 +902,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_cmd_join() {
|
fn test_cmd_join() {
|
||||||
let mut cmds = setup_commands();
|
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 res = cmds.input_cmd("join #foobar:example.com", ctx.clone()).unwrap();
|
||||||
let act = WindowAction::Switch(OpenTarget::Name("#foobar:example.com".into()));
|
let act = WindowAction::Switch(OpenTarget::Name("#foobar:example.com".into()));
|
||||||
@@ -623,7 +922,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_cmd_room_invalid() {
|
fn test_cmd_room_invalid() {
|
||||||
let mut cmds = setup_commands();
|
let mut cmds = setup_commands();
|
||||||
let ctx = ProgramContext::default();
|
let ctx = EditContext::default();
|
||||||
|
|
||||||
let res = cmds.input_cmd("room", ctx.clone());
|
let res = cmds.input_cmd("room", ctx.clone());
|
||||||
assert_eq!(res, Err(CommandError::InvalidArgument));
|
assert_eq!(res, Err(CommandError::InvalidArgument));
|
||||||
@@ -638,7 +937,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_cmd_room_topic_set() {
|
fn test_cmd_room_topic_set() {
|
||||||
let mut cmds = setup_commands();
|
let mut cmds = setup_commands();
|
||||||
let ctx = ProgramContext::default();
|
let ctx = EditContext::default();
|
||||||
|
|
||||||
let res = cmds
|
let res = cmds
|
||||||
.input_cmd("room topic set \"Lots of fun discussion!\"", ctx.clone())
|
.input_cmd("room topic set \"Lots of fun discussion!\"", ctx.clone())
|
||||||
@@ -669,7 +968,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_cmd_room_name_invalid() {
|
fn test_cmd_room_name_invalid() {
|
||||||
let mut cmds = setup_commands();
|
let mut cmds = setup_commands();
|
||||||
let ctx = ProgramContext::default();
|
let ctx = EditContext::default();
|
||||||
|
|
||||||
let res = cmds.input_cmd("room name", ctx.clone());
|
let res = cmds.input_cmd("room name", ctx.clone());
|
||||||
assert_eq!(res, Err(CommandError::InvalidArgument));
|
assert_eq!(res, Err(CommandError::InvalidArgument));
|
||||||
@@ -681,7 +980,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_cmd_room_name_set() {
|
fn test_cmd_room_name_set() {
|
||||||
let mut cmds = setup_commands();
|
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 res = cmds.input_cmd("room name set Development", ctx.clone()).unwrap();
|
||||||
let act = RoomAction::Set(RoomField::Name, "Development".into());
|
let act = RoomAction::Set(RoomField::Name, "Development".into());
|
||||||
@@ -700,7 +999,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_cmd_room_name_unset() {
|
fn test_cmd_room_name_unset() {
|
||||||
let mut cmds = setup_commands();
|
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 res = cmds.input_cmd("room name unset", ctx.clone()).unwrap();
|
||||||
let act = RoomAction::Unset(RoomField::Name);
|
let act = RoomAction::Unset(RoomField::Name);
|
||||||
@@ -710,10 +1009,36 @@ mod tests {
|
|||||||
assert_eq!(res, Err(CommandError::InvalidArgument));
|
assert_eq!(res, Err(CommandError::InvalidArgument));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cmd_room_dm_set() {
|
||||||
|
let mut cmds = setup_commands();
|
||||||
|
let ctx = EditContext::default();
|
||||||
|
|
||||||
|
let res = cmds.input_cmd("room dm set", ctx.clone()).unwrap();
|
||||||
|
let act = RoomAction::SetDirect(true);
|
||||||
|
assert_eq!(res, vec![(act.into(), ctx.clone())]);
|
||||||
|
|
||||||
|
let res = cmds.input_cmd("room dm set true", ctx.clone());
|
||||||
|
assert_eq!(res, Err(CommandError::InvalidArgument));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cmd_room_dm_unset() {
|
||||||
|
let mut cmds = setup_commands();
|
||||||
|
let ctx = EditContext::default();
|
||||||
|
|
||||||
|
let res = cmds.input_cmd("room dm unset", ctx.clone()).unwrap();
|
||||||
|
let act = RoomAction::SetDirect(false);
|
||||||
|
assert_eq!(res, vec![(act.into(), ctx.clone())]);
|
||||||
|
|
||||||
|
let res = cmds.input_cmd("room dm unset true", ctx.clone());
|
||||||
|
assert_eq!(res, Err(CommandError::InvalidArgument));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_cmd_room_tag_set() {
|
fn test_cmd_room_tag_set() {
|
||||||
let mut cmds = setup_commands();
|
let mut cmds = setup_commands();
|
||||||
let ctx = ProgramContext::default();
|
let ctx = EditContext::default();
|
||||||
|
|
||||||
let res = cmds.input_cmd("room tag set favourite", ctx.clone()).unwrap();
|
let res = cmds.input_cmd("room tag set favourite", ctx.clone()).unwrap();
|
||||||
let act = RoomAction::Set(RoomField::Tag(TagName::Favorite), "".into());
|
let act = RoomAction::Set(RoomField::Tag(TagName::Favorite), "".into());
|
||||||
@@ -782,7 +1107,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_cmd_room_tag_unset() {
|
fn test_cmd_room_tag_unset() {
|
||||||
let mut cmds = setup_commands();
|
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 res = cmds.input_cmd("room tag unset favourite", ctx.clone()).unwrap();
|
||||||
let act = RoomAction::Unset(RoomField::Tag(TagName::Favorite));
|
let act = RoomAction::Unset(RoomField::Tag(TagName::Favorite));
|
||||||
@@ -844,10 +1169,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]
|
#[test]
|
||||||
fn test_cmd_invite() {
|
fn test_cmd_invite() {
|
||||||
let mut cmds = setup_commands();
|
let mut cmds = setup_commands();
|
||||||
let ctx = ProgramContext::default();
|
let ctx = EditContext::default();
|
||||||
|
|
||||||
let res = cmds.input_cmd("invite accept", ctx.clone()).unwrap();
|
let res = cmds.input_cmd("invite accept", ctx.clone()).unwrap();
|
||||||
let act = IambAction::Room(RoomAction::InviteAccept);
|
let act = IambAction::Room(RoomAction::InviteAccept);
|
||||||
@@ -881,10 +1324,73 @@ mod tests {
|
|||||||
assert_eq!(res, Err(CommandError::InvalidArgument));
|
assert_eq!(res, Err(CommandError::InvalidArgument));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cmd_room_kick() {
|
||||||
|
let mut cmds = setup_commands();
|
||||||
|
let ctx = EditContext::default();
|
||||||
|
|
||||||
|
let res = cmds.input_cmd("room kick @user:example.com", ctx.clone()).unwrap();
|
||||||
|
let act = IambAction::Room(RoomAction::MemberUpdate(
|
||||||
|
MemberUpdateAction::Kick,
|
||||||
|
"@user:example.com".into(),
|
||||||
|
None,
|
||||||
|
false,
|
||||||
|
));
|
||||||
|
assert_eq!(res, vec![(act.into(), ctx.clone())]);
|
||||||
|
|
||||||
|
let res = cmds.input_cmd("room! kick @user:example.com", ctx.clone()).unwrap();
|
||||||
|
let act = IambAction::Room(RoomAction::MemberUpdate(
|
||||||
|
MemberUpdateAction::Kick,
|
||||||
|
"@user:example.com".into(),
|
||||||
|
None,
|
||||||
|
true,
|
||||||
|
));
|
||||||
|
assert_eq!(res, vec![(act.into(), ctx.clone())]);
|
||||||
|
|
||||||
|
let res = cmds
|
||||||
|
.input_cmd("room! kick @user:example.com \"reason here\"", ctx.clone())
|
||||||
|
.unwrap();
|
||||||
|
let act = IambAction::Room(RoomAction::MemberUpdate(
|
||||||
|
MemberUpdateAction::Kick,
|
||||||
|
"@user:example.com".into(),
|
||||||
|
Some("reason here".into()),
|
||||||
|
true,
|
||||||
|
));
|
||||||
|
assert_eq!(res, vec![(act.into(), ctx.clone())]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cmd_room_ban_unban() {
|
||||||
|
let mut cmds = setup_commands();
|
||||||
|
let ctx = EditContext::default();
|
||||||
|
|
||||||
|
let res = cmds
|
||||||
|
.input_cmd("room! ban @user:example.com \"spam\"", ctx.clone())
|
||||||
|
.unwrap();
|
||||||
|
let act = IambAction::Room(RoomAction::MemberUpdate(
|
||||||
|
MemberUpdateAction::Ban,
|
||||||
|
"@user:example.com".into(),
|
||||||
|
Some("spam".into()),
|
||||||
|
true,
|
||||||
|
));
|
||||||
|
assert_eq!(res, vec![(act.into(), ctx.clone())]);
|
||||||
|
|
||||||
|
let res = cmds
|
||||||
|
.input_cmd("room unban @user:example.com \"reconciled\"", ctx.clone())
|
||||||
|
.unwrap();
|
||||||
|
let act = IambAction::Room(RoomAction::MemberUpdate(
|
||||||
|
MemberUpdateAction::Unban,
|
||||||
|
"@user:example.com".into(),
|
||||||
|
Some("reconciled".into()),
|
||||||
|
false,
|
||||||
|
));
|
||||||
|
assert_eq!(res, vec![(act.into(), ctx.clone())]);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_cmd_redact() {
|
fn test_cmd_redact() {
|
||||||
let mut cmds = setup_commands();
|
let mut cmds = setup_commands();
|
||||||
let ctx = ProgramContext::default();
|
let ctx = EditContext::default();
|
||||||
|
|
||||||
let res = cmds.input_cmd("redact", ctx.clone()).unwrap();
|
let res = cmds.input_cmd("redact", ctx.clone()).unwrap();
|
||||||
let act = IambAction::Message(MessageAction::Redact(None, false));
|
let act = IambAction::Message(MessageAction::Redact(None, false));
|
||||||
@@ -905,4 +1411,31 @@ mod tests {
|
|||||||
let res = cmds.input_cmd("redact Removed Removed", ctx.clone());
|
let res = cmds.input_cmd("redact Removed Removed", ctx.clone());
|
||||||
assert_eq!(res, Err(CommandError::InvalidArgument));
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
740
src/config.rs
740
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::{
|
use modalkit::{
|
||||||
editing::action::WindowAction,
|
actions::{InsertTextAction, MacroAction, WindowAction},
|
||||||
env::vim::keybindings::{InputStep, VimBindings},
|
env::vim::keybindings::{InputStep, VimBindings},
|
||||||
env::vim::VimMode,
|
env::vim::VimMode,
|
||||||
input::bindings::{EdgeEvent, EdgeRepeat, InputBindings},
|
env::CommonKeyClass,
|
||||||
input::key::TerminalKey,
|
key::TerminalKey,
|
||||||
|
keybindings::{EdgeEvent, EdgeRepeat, InputBindings},
|
||||||
|
prelude::*,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::base::{IambAction, IambInfo, Keybindings, MATRIX_ID_WORD};
|
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 {
|
pub fn setup_keybindings() -> Keybindings {
|
||||||
let mut ism = Keybindings::empty();
|
let mut ism = Keybindings::empty();
|
||||||
|
|
||||||
@@ -19,20 +31,15 @@ pub fn setup_keybindings() -> Keybindings {
|
|||||||
|
|
||||||
vim.setup(&mut ism);
|
vim.setup(&mut ism);
|
||||||
|
|
||||||
let ctrl_w = EdgeEvent::Key("<C-W>".parse::<TerminalKey>().unwrap());
|
let ctrl_w = "<C-W>".parse::<TerminalKey>().unwrap();
|
||||||
let ctrl_m = EdgeEvent::Key("<C-M>".parse::<TerminalKey>().unwrap());
|
let ctrl_m = "<C-M>".parse::<TerminalKey>().unwrap();
|
||||||
let ctrl_z = EdgeEvent::Key("<C-Z>".parse::<TerminalKey>().unwrap());
|
let ctrl_z = "<C-Z>".parse::<TerminalKey>().unwrap();
|
||||||
let key_m_lc = EdgeEvent::Key("m".parse::<TerminalKey>().unwrap());
|
let key_m_lc = "m".parse::<TerminalKey>().unwrap();
|
||||||
let key_z_lc = EdgeEvent::Key("z".parse::<TerminalKey>().unwrap());
|
let key_z_lc = "z".parse::<TerminalKey>().unwrap();
|
||||||
|
let shift_enter = "<S-Enter>".parse::<TerminalKey>().unwrap();
|
||||||
|
|
||||||
let cwz = vec![
|
let cwz = vec![once(&ctrl_w), once(&key_z_lc)];
|
||||||
(EdgeRepeat::Once, ctrl_w.clone()),
|
let cwcz = vec![once(&ctrl_w), once(&ctrl_z)];
|
||||||
(EdgeRepeat::Once, key_z_lc),
|
|
||||||
];
|
|
||||||
let cwcz = vec![
|
|
||||||
(EdgeRepeat::Once, ctrl_w.clone()),
|
|
||||||
(EdgeRepeat::Once, ctrl_z),
|
|
||||||
];
|
|
||||||
let zoom = IambStep::new()
|
let zoom = IambStep::new()
|
||||||
.actions(vec![WindowAction::ZoomToggle.into()])
|
.actions(vec![WindowAction::ZoomToggle.into()])
|
||||||
.goto(VimMode::Normal);
|
.goto(VimMode::Normal);
|
||||||
@@ -42,11 +49,8 @@ pub fn setup_keybindings() -> Keybindings {
|
|||||||
ism.add_mapping(VimMode::Normal, &cwcz, &zoom);
|
ism.add_mapping(VimMode::Normal, &cwcz, &zoom);
|
||||||
ism.add_mapping(VimMode::Visual, &cwcz, &zoom);
|
ism.add_mapping(VimMode::Visual, &cwcz, &zoom);
|
||||||
|
|
||||||
let cwm = vec![
|
let cwm = vec![once(&ctrl_w), once(&key_m_lc)];
|
||||||
(EdgeRepeat::Once, ctrl_w.clone()),
|
let cwcm = vec![once(&ctrl_w), once(&ctrl_m)];
|
||||||
(EdgeRepeat::Once, key_m_lc),
|
|
||||||
];
|
|
||||||
let cwcm = vec![(EdgeRepeat::Once, ctrl_w), (EdgeRepeat::Once, ctrl_m)];
|
|
||||||
let stoggle = IambStep::new()
|
let stoggle = IambStep::new()
|
||||||
.actions(vec![IambAction::ToggleScrollbackFocus.into()])
|
.actions(vec![IambAction::ToggleScrollbackFocus.into()])
|
||||||
.goto(VimMode::Normal);
|
.goto(VimMode::Normal);
|
||||||
@@ -55,5 +59,31 @@ pub fn setup_keybindings() -> Keybindings {
|
|||||||
ism.add_mapping(VimMode::Normal, &cwcm, &stoggle);
|
ism.add_mapping(VimMode::Normal, &cwcm, &stoggle);
|
||||||
ism.add_mapping(VimMode::Visual, &cwcm, &stoggle);
|
ism.add_mapping(VimMode::Visual, &cwcm, &stoggle);
|
||||||
|
|
||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
565
src/main.rs
565
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::manual_range_contains)]
|
||||||
#![allow(clippy::needless_return)]
|
#![allow(clippy::needless_return)]
|
||||||
#![allow(clippy::result_large_err)]
|
#![allow(clippy::result_large_err)]
|
||||||
@@ -6,25 +19,23 @@ use std::collections::VecDeque;
|
|||||||
use std::convert::TryFrom;
|
use std::convert::TryFrom;
|
||||||
use std::fmt::Display;
|
use std::fmt::Display;
|
||||||
use std::fs::{create_dir_all, File};
|
use std::fs::{create_dir_all, File};
|
||||||
use std::io::{stdout, BufReader, BufWriter, Stdout};
|
use std::io::{stdout, BufWriter, Stdout, Write};
|
||||||
use std::ops::DerefMut;
|
use std::ops::DerefMut;
|
||||||
use std::process;
|
use std::process;
|
||||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use clap::Parser;
|
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 tokio::sync::Mutex as AsyncMutex;
|
||||||
use tracing_subscriber::FmtSubscriber;
|
use tracing_subscriber::FmtSubscriber;
|
||||||
|
|
||||||
use matrix_sdk::{
|
|
||||||
config::SyncSettings,
|
|
||||||
ruma::{
|
|
||||||
api::client::filter::{FilterDefinition, LazyLoadOptions, RoomEventFilter, RoomFilter},
|
|
||||||
OwnedUserId,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
use modalkit::crossterm::{
|
use modalkit::crossterm::{
|
||||||
self,
|
self,
|
||||||
cursor::Show as CursorShow,
|
cursor::Show as CursorShow,
|
||||||
@@ -33,18 +44,25 @@ use modalkit::crossterm::{
|
|||||||
read,
|
read,
|
||||||
DisableBracketedPaste,
|
DisableBracketedPaste,
|
||||||
DisableFocusChange,
|
DisableFocusChange,
|
||||||
|
DisableMouseCapture,
|
||||||
EnableBracketedPaste,
|
EnableBracketedPaste,
|
||||||
EnableFocusChange,
|
EnableFocusChange,
|
||||||
|
EnableMouseCapture,
|
||||||
Event,
|
Event,
|
||||||
|
KeyEventKind,
|
||||||
|
KeyboardEnhancementFlags,
|
||||||
|
MouseEventKind,
|
||||||
|
PopKeyboardEnhancementFlags,
|
||||||
|
PushKeyboardEnhancementFlags,
|
||||||
},
|
},
|
||||||
execute,
|
execute,
|
||||||
terminal::{EnterAlternateScreen, LeaveAlternateScreen, SetTitle},
|
terminal::{EnterAlternateScreen, LeaveAlternateScreen, SetTitle},
|
||||||
};
|
};
|
||||||
|
|
||||||
use modalkit::tui::{
|
use ratatui::{
|
||||||
backend::CrosstermBackend,
|
backend::CrosstermBackend,
|
||||||
layout::Rect,
|
layout::Rect,
|
||||||
style::{Color, Style},
|
style::{Color, Modifier, Style},
|
||||||
text::Span,
|
text::Span,
|
||||||
widgets::Paragraph,
|
widgets::Paragraph,
|
||||||
Terminal,
|
Terminal,
|
||||||
@@ -55,6 +73,9 @@ mod commands;
|
|||||||
mod config;
|
mod config;
|
||||||
mod keybindings;
|
mod keybindings;
|
||||||
mod message;
|
mod message;
|
||||||
|
mod notifications;
|
||||||
|
mod preview;
|
||||||
|
mod sled_export;
|
||||||
mod util;
|
mod util;
|
||||||
mod windows;
|
mod windows;
|
||||||
mod worker;
|
mod worker;
|
||||||
@@ -68,10 +89,12 @@ use crate::{
|
|||||||
ChatStore,
|
ChatStore,
|
||||||
HomeserverAction,
|
HomeserverAction,
|
||||||
IambAction,
|
IambAction,
|
||||||
|
IambCompleter,
|
||||||
IambError,
|
IambError,
|
||||||
IambId,
|
IambId,
|
||||||
IambInfo,
|
IambInfo,
|
||||||
IambResult,
|
IambResult,
|
||||||
|
KeysAction,
|
||||||
ProgramAction,
|
ProgramAction,
|
||||||
ProgramContext,
|
ProgramContext,
|
||||||
ProgramStore,
|
ProgramStore,
|
||||||
@@ -82,15 +105,11 @@ use crate::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use modalkit::{
|
use modalkit::{
|
||||||
editing::{
|
actions::{
|
||||||
action::{
|
|
||||||
Action,
|
Action,
|
||||||
Commandable,
|
Commandable,
|
||||||
EditError,
|
|
||||||
EditInfo,
|
|
||||||
Editable,
|
Editable,
|
||||||
EditorAction,
|
EditorAction,
|
||||||
InfoMessage,
|
|
||||||
InsertTextAction,
|
InsertTextAction,
|
||||||
Jumpable,
|
Jumpable,
|
||||||
Promptable,
|
Promptable,
|
||||||
@@ -98,24 +117,27 @@ use modalkit::{
|
|||||||
TabAction,
|
TabAction,
|
||||||
TabContainer,
|
TabContainer,
|
||||||
TabCount,
|
TabCount,
|
||||||
UIError,
|
|
||||||
WindowAction,
|
WindowAction,
|
||||||
WindowContainer,
|
WindowContainer,
|
||||||
},
|
},
|
||||||
base::{MoveDir1D, OpenTarget, RepeatType},
|
editing::{context::Resolve, key::KeyManager, store::Store},
|
||||||
context::Resolve,
|
errors::{EditError, UIError},
|
||||||
key::KeyManager,
|
key::TerminalKey,
|
||||||
store::Store,
|
keybindings::{
|
||||||
|
dialog::{Pager, PromptYesNo},
|
||||||
|
BindingMachine,
|
||||||
},
|
},
|
||||||
input::{bindings::BindingMachine, dialog::Pager, key::TerminalKey},
|
prelude::*,
|
||||||
widgets::{
|
ui::FocusList,
|
||||||
|
};
|
||||||
|
|
||||||
|
use modalkit_ratatui::{
|
||||||
cmdbar::CommandBarState,
|
cmdbar::CommandBarState,
|
||||||
screen::{FocusList, Screen, ScreenState, TabLayoutDescription},
|
screen::{Screen, ScreenState, TabbedLayoutDescription},
|
||||||
windows::WindowLayoutDescription,
|
windows::{WindowLayoutDescription, WindowLayoutState},
|
||||||
TerminalCursor,
|
TerminalCursor,
|
||||||
TerminalExtOps,
|
TerminalExtOps,
|
||||||
Window,
|
Window,
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
fn config_tab_to_desc(
|
fn config_tab_to_desc(
|
||||||
@@ -131,14 +153,14 @@ fn config_tab_to_desc(
|
|||||||
let name = user_id.to_string();
|
let name = user_id.to_string();
|
||||||
let room_id = worker.join_room(name.clone())?;
|
let room_id = worker.join_room(name.clone())?;
|
||||||
names.insert(name, room_id.clone());
|
names.insert(name, room_id.clone());
|
||||||
IambId::Room(room_id)
|
IambId::Room(room_id, None)
|
||||||
},
|
},
|
||||||
config::WindowPath::RoomId(room_id) => IambId::Room(room_id),
|
config::WindowPath::RoomId(room_id) => IambId::Room(room_id, None),
|
||||||
config::WindowPath::AliasId(alias) => {
|
config::WindowPath::AliasId(alias) => {
|
||||||
let name = alias.to_string();
|
let name = alias.to_string();
|
||||||
let room_id = worker.join_room(name.clone())?;
|
let room_id = worker.join_room(name.clone())?;
|
||||||
names.insert(name, room_id.clone());
|
names.insert(name, room_id.clone());
|
||||||
IambId::Room(room_id)
|
IambId::Room(room_id, None)
|
||||||
},
|
},
|
||||||
config::WindowPath::Window(id) => id,
|
config::WindowPath::Window(id) => id,
|
||||||
};
|
};
|
||||||
@@ -158,6 +180,17 @@ fn config_tab_to_desc(
|
|||||||
Ok(desc)
|
Ok(desc)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn restore_layout(
|
||||||
|
area: Rect,
|
||||||
|
settings: &ApplicationSettings,
|
||||||
|
store: &mut ProgramStore,
|
||||||
|
) -> IambResult<FocusList<WindowLayoutState<IambWindow, IambInfo>>> {
|
||||||
|
let layout = std::fs::read(&settings.layout_json)?;
|
||||||
|
let tabs: TabbedLayoutDescription<IambInfo> =
|
||||||
|
serde_json::from_slice(&layout).map_err(IambError::from)?;
|
||||||
|
tabs.to_layout(area.into(), store)
|
||||||
|
}
|
||||||
|
|
||||||
fn setup_screen(
|
fn setup_screen(
|
||||||
settings: ApplicationSettings,
|
settings: ApplicationSettings,
|
||||||
store: &mut ProgramStore,
|
store: &mut ProgramStore,
|
||||||
@@ -168,12 +201,14 @@ fn setup_screen(
|
|||||||
|
|
||||||
match settings.layout {
|
match settings.layout {
|
||||||
config::Layout::Restore => {
|
config::Layout::Restore => {
|
||||||
if let Ok(layout) = std::fs::read(&settings.layout_json) {
|
match restore_layout(area, &settings, store) {
|
||||||
let tabs: TabLayoutDescription<IambInfo> =
|
Ok(tabs) => {
|
||||||
serde_json::from_slice(&layout).map_err(IambError::from)?;
|
|
||||||
let tabs = tabs.to_layout(area.into(), store)?;
|
|
||||||
|
|
||||||
return Ok(ScreenState::from_list(tabs, cmd));
|
return Ok(ScreenState::from_list(tabs, cmd));
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
// Log the issue with restoring and then continue.
|
||||||
|
tracing::warn!(err = %e, "Failed to restore layout from disk");
|
||||||
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
config::Layout::New => {},
|
config::Layout::New => {},
|
||||||
@@ -200,6 +235,7 @@ fn setup_screen(
|
|||||||
return Ok(ScreenState::new(win, cmd));
|
return Ok(ScreenState::new(win, cmd));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The main application state and event loop.
|
||||||
struct Application {
|
struct Application {
|
||||||
/// Terminal backend.
|
/// Terminal backend.
|
||||||
terminal: Terminal<CrosstermBackend<Stdout>>,
|
terminal: Terminal<CrosstermBackend<Stdout>>,
|
||||||
@@ -214,7 +250,7 @@ struct Application {
|
|||||||
worker: Requester,
|
worker: Requester,
|
||||||
|
|
||||||
/// Mapped keybindings.
|
/// Mapped keybindings.
|
||||||
bindings: KeyManager<TerminalKey, ProgramAction, RepeatType, ProgramContext>,
|
bindings: KeyManager<TerminalKey, ProgramAction, RepeatType>,
|
||||||
|
|
||||||
/// Pending actions to run.
|
/// Pending actions to run.
|
||||||
actstack: VecDeque<(ProgramAction, ProgramContext)>,
|
actstack: VecDeque<(ProgramAction, ProgramContext)>,
|
||||||
@@ -223,7 +259,10 @@ struct Application {
|
|||||||
focused: bool,
|
focused: bool,
|
||||||
|
|
||||||
/// The tab layout before the last executed [TabAction].
|
/// The tab layout before the last executed [TabAction].
|
||||||
last_layout: Option<TabLayoutDescription<IambInfo>>,
|
last_layout: Option<TabbedLayoutDescription<IambInfo>>,
|
||||||
|
|
||||||
|
/// Whether we need to do a full redraw (e.g., after running a subprocess).
|
||||||
|
dirty: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Application {
|
impl Application {
|
||||||
@@ -231,25 +270,18 @@ impl Application {
|
|||||||
settings: ApplicationSettings,
|
settings: ApplicationSettings,
|
||||||
store: AsyncProgramStore,
|
store: AsyncProgramStore,
|
||||||
) -> IambResult<Application> {
|
) -> IambResult<Application> {
|
||||||
let mut stdout = stdout();
|
let backend = CrosstermBackend::new(stdout());
|
||||||
crossterm::terminal::enable_raw_mode()?;
|
|
||||||
crossterm::execute!(stdout, EnterAlternateScreen)?;
|
|
||||||
crossterm::execute!(stdout, EnableBracketedPaste)?;
|
|
||||||
crossterm::execute!(stdout, EnableFocusChange)?;
|
|
||||||
|
|
||||||
let title = format!("iamb ({})", settings.profile.user_id);
|
|
||||||
crossterm::execute!(stdout, SetTitle(title))?;
|
|
||||||
|
|
||||||
let backend = CrosstermBackend::new(stdout);
|
|
||||||
let terminal = Terminal::new(backend)?;
|
let terminal = Terminal::new(backend)?;
|
||||||
|
|
||||||
let bindings = crate::keybindings::setup_keybindings();
|
let mut bindings = crate::keybindings::setup_keybindings();
|
||||||
|
settings.setup(&mut bindings);
|
||||||
let bindings = KeyManager::new(bindings);
|
let bindings = KeyManager::new(bindings);
|
||||||
|
|
||||||
let mut locked = store.lock().await;
|
let mut locked = store.lock().await;
|
||||||
let screen = setup_screen(settings, locked.deref_mut())?;
|
let screen = setup_screen(settings, locked.deref_mut())?;
|
||||||
|
|
||||||
let worker = locked.application.worker.clone();
|
let worker = locked.application.worker.clone();
|
||||||
|
|
||||||
drop(locked);
|
drop(locked);
|
||||||
|
|
||||||
let actstack = VecDeque::new();
|
let actstack = VecDeque::new();
|
||||||
@@ -263,6 +295,7 @@ impl Application {
|
|||||||
screen,
|
screen,
|
||||||
focused: true,
|
focused: true,
|
||||||
last_layout: None,
|
last_layout: None,
|
||||||
|
dirty: true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,12 +305,16 @@ impl Application {
|
|||||||
let sstate = &mut self.screen;
|
let sstate = &mut self.screen;
|
||||||
let term = &mut self.terminal;
|
let term = &mut self.terminal;
|
||||||
|
|
||||||
|
if store.application.ring_bell {
|
||||||
|
store.application.ring_bell = term.backend_mut().write_all(&[7]).is_err();
|
||||||
|
}
|
||||||
|
|
||||||
if full {
|
if full {
|
||||||
term.clear()?;
|
term.clear()?;
|
||||||
}
|
}
|
||||||
|
|
||||||
term.draw(|f| {
|
term.draw(|f| {
|
||||||
let area = f.size();
|
let area = f.area();
|
||||||
|
|
||||||
let modestr = bindings.show_mode();
|
let modestr = bindings.show_mode();
|
||||||
let cursor = bindings.get_cursor_indicator();
|
let cursor = bindings.get_cursor_indicator();
|
||||||
@@ -286,10 +323,14 @@ impl Application {
|
|||||||
// Don't show terminal cursor when we show a dialog.
|
// Don't show terminal cursor when we show a dialog.
|
||||||
let hide_cursor = !dialogstr.is_empty();
|
let hide_cursor = !dialogstr.is_empty();
|
||||||
|
|
||||||
|
store.application.draw_curr = Some(Instant::now());
|
||||||
let screen = Screen::new(store)
|
let screen = Screen::new(store)
|
||||||
.show_dialog(dialogstr)
|
.show_dialog(dialogstr)
|
||||||
.show_mode(modestr)
|
.show_mode(modestr)
|
||||||
.borders(true)
|
.borders(true)
|
||||||
|
.border_style(Style::default().add_modifier(Modifier::DIM))
|
||||||
|
.tab_style(Style::default().add_modifier(Modifier::DIM))
|
||||||
|
.tab_style_focused(Style::default().remove_modifier(Modifier::DIM))
|
||||||
.focus(focused);
|
.focus(focused);
|
||||||
f.render_stateful_widget(screen, area, sstate);
|
f.render_stateful_widget(screen, area, sstate);
|
||||||
|
|
||||||
@@ -305,7 +346,7 @@ impl Application {
|
|||||||
let inner = Rect::new(cx, cy, 1, 1);
|
let inner = Rect::new(cx, cy, 1, 1);
|
||||||
f.render_widget(para, inner)
|
f.render_widget(para, inner)
|
||||||
}
|
}
|
||||||
f.set_cursor(cx, cy);
|
f.set_cursor_position((cx, cy));
|
||||||
}
|
}
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
@@ -314,7 +355,8 @@ impl Application {
|
|||||||
|
|
||||||
async fn step(&mut self) -> Result<TerminalKey, std::io::Error> {
|
async fn step(&mut self) -> Result<TerminalKey, std::io::Error> {
|
||||||
loop {
|
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))? {
|
if !poll(Duration::from_secs(1))? {
|
||||||
// Redraw in case there's new messages to show.
|
// Redraw in case there's new messages to show.
|
||||||
@@ -322,14 +364,46 @@ impl Application {
|
|||||||
}
|
}
|
||||||
|
|
||||||
match read()? {
|
match read()? {
|
||||||
Event::Key(ke) => return Ok(ke.into()),
|
Event::Key(ke) => {
|
||||||
Event::Mouse(_) => {
|
if ke.kind == KeyEventKind::Release {
|
||||||
// Do nothing for now.
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(ke.into());
|
||||||
|
},
|
||||||
|
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 => {
|
Event::FocusGained => {
|
||||||
|
let mut store = self.store.lock().await;
|
||||||
|
store.application.focused = true;
|
||||||
self.focused = true;
|
self.focused = true;
|
||||||
},
|
},
|
||||||
Event::FocusLost => {
|
Event::FocusLost => {
|
||||||
|
let mut store = self.store.lock().await;
|
||||||
|
store.application.focused = false;
|
||||||
self.focused = false;
|
self.focused = false;
|
||||||
},
|
},
|
||||||
Event::Resize(_, _) => {
|
Event::Resize(_, _) => {
|
||||||
@@ -447,7 +521,7 @@ impl Application {
|
|||||||
None
|
None
|
||||||
},
|
},
|
||||||
Action::Command(act) => {
|
Action::Command(act) => {
|
||||||
let acts = store.application.cmds.command(&act, &ctx)?;
|
let acts = store.application.cmds.command(&act, &ctx, &mut store.registers)?;
|
||||||
self.action_prepend(acts);
|
self.action_prepend(acts);
|
||||||
|
|
||||||
None
|
None
|
||||||
@@ -459,7 +533,7 @@ impl Application {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// Unimplemented.
|
// Unimplemented.
|
||||||
Action::KeywordLookup => {
|
Action::KeywordLookup(_) => {
|
||||||
// XXX: implement
|
// XXX: implement
|
||||||
None
|
None
|
||||||
},
|
},
|
||||||
@@ -479,7 +553,26 @@ impl Application {
|
|||||||
ctx: ProgramContext,
|
ctx: ProgramContext,
|
||||||
store: &mut ProgramStore,
|
store: &mut ProgramStore,
|
||||||
) -> IambResult<EditInfo> {
|
) -> IambResult<EditInfo> {
|
||||||
|
if action.scribbles() {
|
||||||
|
self.dirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
let info = match action {
|
let info = match action {
|
||||||
|
IambAction::ClearUnreads => {
|
||||||
|
let user_id = &store.application.settings.profile.user_id;
|
||||||
|
|
||||||
|
// Clear any notifications we displayed:
|
||||||
|
store.application.open_notifications.clear();
|
||||||
|
|
||||||
|
for room_id in store.application.sync_info.chats() {
|
||||||
|
if let Some(room) = store.application.rooms.get_mut(room_id) {
|
||||||
|
room.fully_read(user_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
},
|
||||||
|
|
||||||
IambAction::ToggleScrollbackFocus => {
|
IambAction::ToggleScrollbackFocus => {
|
||||||
self.screen.current_window_mut()?.focus_toggle();
|
self.screen.current_window_mut()?.focus_toggle();
|
||||||
|
|
||||||
@@ -492,9 +585,13 @@ impl Application {
|
|||||||
|
|
||||||
None
|
None
|
||||||
},
|
},
|
||||||
|
IambAction::Keys(act) => self.keys_command(act, ctx, store).await?,
|
||||||
IambAction::Message(act) => {
|
IambAction::Message(act) => {
|
||||||
self.screen.current_window_mut()?.message_command(act, ctx, store).await?
|
self.screen.current_window_mut()?.message_command(act, ctx, store).await?
|
||||||
},
|
},
|
||||||
|
IambAction::Space(act) => {
|
||||||
|
self.screen.current_window_mut()?.space_command(act, ctx, store).await?
|
||||||
|
},
|
||||||
IambAction::Room(act) => {
|
IambAction::Room(act) => {
|
||||||
let acts = self.screen.current_window_mut()?.room_command(act, ctx, store).await?;
|
let acts = self.screen.current_window_mut()?.room_command(act, ctx, store).await?;
|
||||||
self.action_prepend(acts);
|
self.action_prepend(acts);
|
||||||
@@ -502,9 +599,20 @@ impl Application {
|
|||||||
None
|
None
|
||||||
},
|
},
|
||||||
IambAction::Send(act) => {
|
IambAction::Send(act) => {
|
||||||
|
if store.application.settings.tunables.normal_after_send {
|
||||||
|
self.bindings.reset_mode();
|
||||||
|
}
|
||||||
self.screen.current_window_mut()?.send_command(act, ctx, store).await?
|
self.screen.current_window_mut()?.send_command(act, ctx, store).await?
|
||||||
},
|
},
|
||||||
|
|
||||||
|
IambAction::OpenLink(url) => {
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
return open::that(url);
|
||||||
|
});
|
||||||
|
|
||||||
|
None
|
||||||
|
},
|
||||||
|
|
||||||
IambAction::Verify(act, user_dev) => {
|
IambAction::Verify(act, user_dev) => {
|
||||||
if let Some(sas) = store.application.verifications.get(&user_dev) {
|
if let Some(sas) = store.application.verifications.get(&user_dev) {
|
||||||
self.worker.verify(act, sas.clone())?
|
self.worker.verify(act, sas.clone())?
|
||||||
@@ -533,13 +641,65 @@ impl Application {
|
|||||||
match action {
|
match action {
|
||||||
HomeserverAction::CreateRoom(alias, vis, flags) => {
|
HomeserverAction::CreateRoom(alias, vis, flags) => {
|
||||||
let client = &store.application.worker.client;
|
let client = &store.application.worker.client;
|
||||||
let room_id = create_room(client, alias.as_deref(), vis, flags).await?;
|
let room_id = create_room(client, alias, vis, flags).await?;
|
||||||
let room = IambId::Room(room_id);
|
let room = IambId::Room(room_id, None);
|
||||||
let target = OpenTarget::Application(room);
|
let target = OpenTarget::Application(room);
|
||||||
let action = WindowAction::Switch(target);
|
let action = WindowAction::Switch(target);
|
||||||
|
|
||||||
Ok(vec![(action.into(), ctx)])
|
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))
|
||||||
|
},
|
||||||
|
HomeserverAction::Forget => {
|
||||||
|
let client = &store.application.worker.client;
|
||||||
|
for room in client.left_rooms() {
|
||||||
|
room.forget().await.map_err(IambError::from)?;
|
||||||
|
}
|
||||||
|
Ok(vec![])
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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()))
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -619,23 +779,59 @@ impl Application {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn login(worker: Requester, settings: &ApplicationSettings) -> IambResult<()> {
|
fn gen_passphrase() -> String {
|
||||||
println!("Logging in for {}...", settings.profile.user_id);
|
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() {
|
if settings.session_json.is_file() {
|
||||||
let file = File::open(settings.session_json.as_path())?;
|
let session = settings.read_session(&settings.session_json)?;
|
||||||
let reader = BufReader::new(file);
|
worker.login(LoginStyle::SessionRestore(session.into()))?;
|
||||||
let session = serde_json::from_reader(reader).map_err(IambError::from)?;
|
|
||||||
|
|
||||||
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(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
loop {
|
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: ")?;
|
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) => {
|
Ok(info) => {
|
||||||
if let Some(msg) = info {
|
if let Some(msg) = info {
|
||||||
println!("{msg}");
|
println!("{msg}");
|
||||||
@@ -650,59 +846,237 @@ async fn login(worker: Requester, settings: &ApplicationSettings) -> IambResult<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Perform an initial, lazily-loaded sync.
|
|
||||||
let mut room = RoomEventFilter::default();
|
|
||||||
room.lazy_load_options = LazyLoadOptions::Enabled { include_redundant_members: false };
|
|
||||||
|
|
||||||
let mut room_ev = RoomFilter::default();
|
|
||||||
room_ev.state = room;
|
|
||||||
|
|
||||||
let mut filter = FilterDefinition::default();
|
|
||||||
filter.room = room_ev;
|
|
||||||
|
|
||||||
let settings = SyncSettings::new().filter(filter.into());
|
|
||||||
|
|
||||||
worker.client.sync_once(settings).await.map_err(IambError::from)?;
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn print_exit<T: Display, N>(v: T) -> N {
|
fn print_exit<T: Display, N>(v: T) -> N {
|
||||||
println!("{v}");
|
eprintln!("{v}");
|
||||||
process::exit(2);
|
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<()> {
|
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.
|
// 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 = ChatStore::new(worker.clone(), settings.clone());
|
||||||
let store = Store::new(store);
|
let mut store = Store::new(store);
|
||||||
|
store.completer = Box::new(IambCompleter);
|
||||||
|
|
||||||
let store = Arc::new(AsyncMutex::new(store));
|
let store = Arc::new(AsyncMutex::new(store));
|
||||||
worker.init(store.clone());
|
worker.init(store.clone());
|
||||||
|
|
||||||
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
|
||||||
|
};
|
||||||
|
|
||||||
fn restore_tty() {
|
match res {
|
||||||
let _ = crossterm::terminal::disable_raw_mode();
|
Err(UIError::Application(IambError::Matrix(e))) => {
|
||||||
let _ = crossterm::execute!(stdout(), DisableBracketedPaste);
|
if let Some(ErrorKind::UnknownToken { .. }) = e.client_api_error_kind() {
|
||||||
let _ = crossterm::execute!(stdout(), DisableFocusChange);
|
print_exit(format!("Server did not recognize our API token; did you log out from this session elsewhere?\nTry deleting `{}` to force a clean login.", settings.session_json.display()))
|
||||||
let _ = crossterm::execute!(stdout(), LeaveAlternateScreen);
|
} else {
|
||||||
let _ = crossterm::execute!(stdout(), CursorShow);
|
print_exit(e)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(e) => print_exit(e),
|
||||||
|
Ok(()) => (),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Make sure panics clean up the terminal properly.
|
// 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)?;
|
||||||
|
|
||||||
let orig_hook = std::panic::take_hook();
|
let orig_hook = std::panic::take_hook();
|
||||||
|
let enable_mouse = settings.tunables.mouse.enabled;
|
||||||
std::panic::set_hook(Box::new(move |panic_info| {
|
std::panic::set_hook(Box::new(move |panic_info| {
|
||||||
restore_tty();
|
restore_tty(enable_enhanced_keys, enable_mouse);
|
||||||
orig_hook(panic_info);
|
orig_hook(panic_info);
|
||||||
process::exit(1);
|
process::exit(1);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// And finally, start running the terminal UI.
|
||||||
let mut application = Application::new(settings, store).await?;
|
let mut application = Application::new(settings, store).await?;
|
||||||
|
|
||||||
// We can now run the application.
|
|
||||||
application.run().await?;
|
application.run().await?;
|
||||||
restore_tty();
|
|
||||||
|
// Clean up the terminal on exit.
|
||||||
|
restore_tty(enable_enhanced_keys, enable_mouse);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -724,9 +1098,6 @@ fn main() -> IambResult<()> {
|
|||||||
let log_prefix = format!("iamb-log-{}", settings.profile_name);
|
let log_prefix = format!("iamb-log-{}", settings.profile_name);
|
||||||
let log_dir = settings.dirs.logs.as_path();
|
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 = tracing_appender::rolling::daily(log_dir, log_prefix);
|
||||||
let (appender, guard) = tracing_appender::non_blocking(appender);
|
let (appender, guard) = tracing_appender::non_blocking(appender);
|
||||||
|
|
||||||
|
|||||||
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
1061
src/message/mod.rs
1061
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 std::borrow::Cow;
|
||||||
|
|
||||||
use modalkit::tui::layout::Alignment;
|
use ratatui::layout::Alignment;
|
||||||
use modalkit::tui::style::Style;
|
use ratatui::style::Style;
|
||||||
use modalkit::tui::text::{Span, Spans, Text};
|
use ratatui::text::{Line, Span, Text};
|
||||||
use unicode_segmentation::UnicodeSegmentation;
|
use unicode_segmentation::UnicodeSegmentation;
|
||||||
use unicode_width::UnicodeWidthStr;
|
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> {
|
pub struct TextPrinter<'a> {
|
||||||
text: Text<'a>,
|
text: Text<'a>,
|
||||||
width: usize,
|
width: usize,
|
||||||
@@ -18,10 +31,18 @@ pub struct TextPrinter<'a> {
|
|||||||
curr_spans: Vec<Span<'a>>,
|
curr_spans: Vec<Span<'a>>,
|
||||||
curr_width: usize,
|
curr_width: usize,
|
||||||
literal: bool,
|
literal: bool,
|
||||||
|
|
||||||
|
pub(super) settings: &'a ApplicationSettings,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> TextPrinter<'a> {
|
impl<'a> TextPrinter<'a> {
|
||||||
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 {
|
TextPrinter {
|
||||||
text: Text::default(),
|
text: Text::default(),
|
||||||
width,
|
width,
|
||||||
@@ -32,27 +53,46 @@ impl<'a> TextPrinter<'a> {
|
|||||||
curr_spans: vec![],
|
curr_spans: vec![],
|
||||||
curr_width: 0,
|
curr_width: 0,
|
||||||
literal: false,
|
literal: false,
|
||||||
|
settings,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Configure the alignment for each line.
|
||||||
pub fn align(mut self, alignment: Alignment) -> Self {
|
pub fn align(mut self, alignment: Alignment) -> Self {
|
||||||
self.alignment = alignment;
|
self.alignment = alignment;
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set whether newlines should be treated literally, or turned into spaces.
|
||||||
pub fn literal(mut self, literal: bool) -> Self {
|
pub fn literal(mut self, literal: bool) -> Self {
|
||||||
self.literal = literal;
|
self.literal = literal;
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Indicates whether replies should be pushed to the printer.
|
||||||
pub fn hide_reply(&self) -> bool {
|
pub fn hide_reply(&self) -> bool {
|
||||||
self.hide_reply
|
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 {
|
pub fn width(&self) -> usize {
|
||||||
self.width
|
self.width
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Create a new printer with a smaller width.
|
||||||
pub fn sub(&self, indent: usize) -> Self {
|
pub fn sub(&self, indent: usize) -> Self {
|
||||||
TextPrinter {
|
TextPrinter {
|
||||||
text: Text::default(),
|
text: Text::default(),
|
||||||
@@ -64,13 +104,15 @@ impl<'a> TextPrinter<'a> {
|
|||||||
curr_spans: vec![],
|
curr_spans: vec![],
|
||||||
curr_width: 0,
|
curr_width: 0,
|
||||||
literal: self.literal,
|
literal: self.literal,
|
||||||
|
settings: self.settings,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn remaining(&self) -> usize {
|
fn remaining(&self) -> usize {
|
||||||
self.width - self.curr_width
|
self.width.saturating_sub(self.curr_width)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// If there is any text on the current line, start a new one.
|
||||||
pub fn commit(&mut self) {
|
pub fn commit(&mut self) {
|
||||||
if self.curr_width > 0 {
|
if self.curr_width > 0 {
|
||||||
self.push_break();
|
self.push_break();
|
||||||
@@ -79,9 +121,10 @@ impl<'a> TextPrinter<'a> {
|
|||||||
|
|
||||||
fn push(&mut self) {
|
fn push(&mut self) {
|
||||||
self.curr_width = 0;
|
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) {
|
pub fn push_break(&mut self) {
|
||||||
if self.curr_width == 0 && self.text.lines.is_empty() {
|
if self.curr_width == 0 && self.text.lines.is_empty() {
|
||||||
// Disallow leading breaks.
|
// Disallow leading breaks.
|
||||||
@@ -149,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());
|
let sw = UnicodeWidthStr::width(span.content.as_ref());
|
||||||
|
|
||||||
if self.curr_width + sw > self.width {
|
if self.curr_width + sw > self.width {
|
||||||
@@ -161,6 +208,7 @@ impl<'a> TextPrinter<'a> {
|
|||||||
self.curr_width += sw;
|
self.curr_width += sw;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Push text with a [Style].
|
||||||
pub fn push_str(&mut self, s: &'a str, style: Style) {
|
pub fn push_str(&mut self, s: &'a str, style: Style) {
|
||||||
let style = self.base_style.patch(style);
|
let style = self.base_style.patch(style);
|
||||||
|
|
||||||
@@ -168,6 +216,8 @@ impl<'a> TextPrinter<'a> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let tabstop = self.settings().tunables.tabstop;
|
||||||
|
|
||||||
for mut word in UnicodeSegmentation::split_word_bounds(s) {
|
for mut word in UnicodeSegmentation::split_word_bounds(s) {
|
||||||
if let "\n" | "\r\n" = word {
|
if let "\n" | "\r\n" = word {
|
||||||
if self.literal {
|
if self.literal {
|
||||||
@@ -184,10 +234,21 @@ impl<'a> TextPrinter<'a> {
|
|||||||
continue;
|
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 {
|
if sw > self.width {
|
||||||
self.push_str_wrapped(word, style);
|
self.push_str_wrapped(cow, style);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,13 +256,13 @@ impl<'a> TextPrinter<'a> {
|
|||||||
// Word doesn't fit on this line, so start a new one.
|
// Word doesn't fit on this line, so start a new one.
|
||||||
self.commit();
|
self.commit();
|
||||||
|
|
||||||
if !self.literal && word.chars().all(char::is_whitespace) {
|
if !self.literal && cow.chars().all(char::is_whitespace) {
|
||||||
// Drop leading whitespace.
|
// Drop leading whitespace.
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let span = Span::styled(word, style);
|
let span = Span::styled(cow, style);
|
||||||
self.curr_spans.push(span);
|
self.curr_spans.push(span);
|
||||||
self.curr_width += sw;
|
self.curr_width += sw;
|
||||||
}
|
}
|
||||||
@@ -212,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.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();
|
self.commit();
|
||||||
|
if self.emoji_shortcodes() {
|
||||||
|
for line in &mut text.lines {
|
||||||
|
replace_emojis_in_line(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
self.text.lines.extend(text.lines);
|
self.text.lines.extend(text.lines);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Render the contents of this printer as [Text].
|
||||||
pub fn finish(mut self) -> Text<'a> {
|
pub fn finish(mut self) -> Text<'a> {
|
||||||
self.commit();
|
self.commit();
|
||||||
self.text
|
self.text
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::tests::mock_settings;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_push_nobreak() {
|
||||||
|
let settings = mock_settings();
|
||||||
|
let mut printer = TextPrinter::new(5, Style::default(), false, &settings);
|
||||||
|
printer.push_span_nobreak("hello world".into());
|
||||||
|
let text = printer.finish();
|
||||||
|
assert_eq!(text.lines.len(), 1);
|
||||||
|
assert_eq!(text.lines[0].spans.len(), 1);
|
||||||
|
assert_eq!(text.lines[0].spans[0].content, "hello world");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
956
src/message/state.rs
Normal file
956
src/message/state.rs
Normal file
@@ -0,0 +1,956 @@
|
|||||||
|
//! Code for displaying state events.
|
||||||
|
use std::borrow::Cow;
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
use matrix_sdk::ruma::{
|
||||||
|
events::{
|
||||||
|
room::member::MembershipChange,
|
||||||
|
AnyFullStateEventContent,
|
||||||
|
AnySyncStateEvent,
|
||||||
|
FullStateEventContent,
|
||||||
|
},
|
||||||
|
OwnedRoomId,
|
||||||
|
UserId,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::html::{StyleTree, StyleTreeNode};
|
||||||
|
use ratatui::style::{Modifier as StyleModifier, Style};
|
||||||
|
|
||||||
|
fn bold(s: impl Into<Cow<'static, str>>) -> StyleTreeNode {
|
||||||
|
let bold = Style::default().add_modifier(StyleModifier::BOLD);
|
||||||
|
let text = StyleTreeNode::Text(s.into());
|
||||||
|
StyleTreeNode::Style(Box::new(text), bold)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn body_cow_state(ev: &AnySyncStateEvent) -> Cow<'static, str> {
|
||||||
|
let event = match ev.content() {
|
||||||
|
AnyFullStateEventContent::PolicyRuleRoom(FullStateEventContent::Original {
|
||||||
|
content,
|
||||||
|
..
|
||||||
|
}) => {
|
||||||
|
let mut m = format!(
|
||||||
|
"* updated the room policy rule for {:?} to {:?}",
|
||||||
|
content.0.entity,
|
||||||
|
content.0.recommendation.as_str()
|
||||||
|
);
|
||||||
|
|
||||||
|
if !content.0.reason.is_empty() {
|
||||||
|
m.push_str(" (reason: ");
|
||||||
|
m.push_str(&content.0.reason);
|
||||||
|
m.push(')');
|
||||||
|
}
|
||||||
|
|
||||||
|
m
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::PolicyRuleServer(FullStateEventContent::Original {
|
||||||
|
content,
|
||||||
|
..
|
||||||
|
}) => {
|
||||||
|
let mut m = format!(
|
||||||
|
"* updated the server policy rule for {:?} to {:?}",
|
||||||
|
content.0.entity,
|
||||||
|
content.0.recommendation.as_str()
|
||||||
|
);
|
||||||
|
|
||||||
|
if !content.0.reason.is_empty() {
|
||||||
|
m.push_str(" (reason: ");
|
||||||
|
m.push_str(&content.0.reason);
|
||||||
|
m.push(')');
|
||||||
|
}
|
||||||
|
|
||||||
|
m
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::PolicyRuleUser(FullStateEventContent::Original {
|
||||||
|
content,
|
||||||
|
..
|
||||||
|
}) => {
|
||||||
|
let mut m = format!(
|
||||||
|
"* updated the user policy rule for {:?} to {:?}",
|
||||||
|
content.0.entity,
|
||||||
|
content.0.recommendation.as_str()
|
||||||
|
);
|
||||||
|
|
||||||
|
if !content.0.reason.is_empty() {
|
||||||
|
m.push_str(" (reason: ");
|
||||||
|
m.push_str(&content.0.reason);
|
||||||
|
m.push(')');
|
||||||
|
}
|
||||||
|
|
||||||
|
m
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomAliases(FullStateEventContent::Original {
|
||||||
|
content, ..
|
||||||
|
}) => {
|
||||||
|
let mut m = String::from("* set the room aliases to: ");
|
||||||
|
|
||||||
|
for (i, alias) in content.aliases.iter().enumerate() {
|
||||||
|
if i != 0 {
|
||||||
|
m.push_str(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
m.push_str(alias.as_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
m
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomAvatar(FullStateEventContent::Original {
|
||||||
|
content,
|
||||||
|
prev_content,
|
||||||
|
}) => {
|
||||||
|
let prev_url = prev_content.as_ref().and_then(|p| p.url.as_ref());
|
||||||
|
|
||||||
|
match (prev_url, content.url) {
|
||||||
|
(None, Some(_)) => return Cow::Borrowed("* added a room avatar"),
|
||||||
|
(Some(old), Some(new)) => {
|
||||||
|
if old != &new {
|
||||||
|
return Cow::Borrowed("* replaced the room avatar");
|
||||||
|
}
|
||||||
|
|
||||||
|
return Cow::Borrowed("* updated the room avatar state");
|
||||||
|
},
|
||||||
|
(Some(_), None) => return Cow::Borrowed("* removed the room avatar"),
|
||||||
|
(None, None) => return Cow::Borrowed("* updated the room avatar state"),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomCanonicalAlias(FullStateEventContent::Original {
|
||||||
|
content,
|
||||||
|
prev_content,
|
||||||
|
}) => {
|
||||||
|
let old_canon = prev_content.as_ref().and_then(|p| p.alias.as_ref());
|
||||||
|
let new_canon = content.alias.as_ref();
|
||||||
|
|
||||||
|
match (old_canon, new_canon) {
|
||||||
|
(None, Some(canon)) => {
|
||||||
|
format!("* updated the canonical alias for the room to: {canon}")
|
||||||
|
},
|
||||||
|
(Some(old), Some(new)) => {
|
||||||
|
if old != new {
|
||||||
|
format!("* updated the canonical alias for the room to: {new}")
|
||||||
|
} else {
|
||||||
|
return Cow::Borrowed("* removed the canonical alias for the room");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(Some(_), None) => {
|
||||||
|
return Cow::Borrowed("* removed the canonical alias for the room");
|
||||||
|
},
|
||||||
|
(None, None) => {
|
||||||
|
return Cow::Borrowed("* did not change the canonical alias");
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomCreate(FullStateEventContent::Original {
|
||||||
|
content, ..
|
||||||
|
}) => {
|
||||||
|
if content.federate {
|
||||||
|
return Cow::Borrowed("* created a federated room");
|
||||||
|
} else {
|
||||||
|
return Cow::Borrowed("* created a non-federated room");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomEncryption(FullStateEventContent::Original { .. }) => {
|
||||||
|
return Cow::Borrowed("* updated the encryption settings for the room");
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomGuestAccess(FullStateEventContent::Original {
|
||||||
|
content,
|
||||||
|
..
|
||||||
|
}) => {
|
||||||
|
format!("* set guest access for the room to {:?}", content.guest_access.as_str())
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomHistoryVisibility(FullStateEventContent::Original {
|
||||||
|
content,
|
||||||
|
..
|
||||||
|
}) => {
|
||||||
|
format!(
|
||||||
|
"* updated history visibility for the room to {:?}",
|
||||||
|
content.history_visibility.as_str()
|
||||||
|
)
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomJoinRules(FullStateEventContent::Original {
|
||||||
|
content,
|
||||||
|
..
|
||||||
|
}) => {
|
||||||
|
format!("* update the join rules for the room to {:?}", content.join_rule.as_str())
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomMember(FullStateEventContent::Original {
|
||||||
|
content,
|
||||||
|
prev_content,
|
||||||
|
}) => {
|
||||||
|
let Ok(state_key) = UserId::parse(ev.state_key()) else {
|
||||||
|
return Cow::Owned(format!(
|
||||||
|
"* failed to calculate membership change for {:?}",
|
||||||
|
ev.state_key()
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
let prev_details = prev_content.as_ref().map(|p| p.details());
|
||||||
|
let change = content.membership_change(prev_details, ev.sender(), &state_key);
|
||||||
|
|
||||||
|
match change {
|
||||||
|
MembershipChange::None => {
|
||||||
|
format!("* did nothing to {state_key}")
|
||||||
|
},
|
||||||
|
MembershipChange::Error => {
|
||||||
|
format!("* failed to calculate membership change to {state_key}")
|
||||||
|
},
|
||||||
|
MembershipChange::Joined => {
|
||||||
|
return Cow::Borrowed("* joined the room");
|
||||||
|
},
|
||||||
|
MembershipChange::Left => {
|
||||||
|
return Cow::Borrowed("* left the room");
|
||||||
|
},
|
||||||
|
MembershipChange::Banned => {
|
||||||
|
format!("* banned {state_key} from the room")
|
||||||
|
},
|
||||||
|
MembershipChange::Unbanned => {
|
||||||
|
format!("* unbanned {state_key} from the room")
|
||||||
|
},
|
||||||
|
MembershipChange::Kicked => {
|
||||||
|
format!("* kicked {state_key} from the room")
|
||||||
|
},
|
||||||
|
MembershipChange::Invited => {
|
||||||
|
format!("* invited {state_key} to the room")
|
||||||
|
},
|
||||||
|
MembershipChange::KickedAndBanned => {
|
||||||
|
format!("* kicked and banned {state_key} from the room")
|
||||||
|
},
|
||||||
|
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 {state_key} to join the room")
|
||||||
|
},
|
||||||
|
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 }
|
||||||
|
}
|
||||||
324
src/notifications.rs
Normal file
324
src/notifications.rs
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
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,
|
||||||
|
EncryptionState,
|
||||||
|
};
|
||||||
|
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 sound_hint = settings.tunables.notifications.sound_hint.clone();
|
||||||
|
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();
|
||||||
|
let sound_hint = sound_hint.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,
|
||||||
|
sound_hint.as_deref(),
|
||||||
|
)
|
||||||
|
.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,
|
||||||
|
sound_hint: Option<&str>,
|
||||||
|
) {
|
||||||
|
#[cfg(feature = "desktop")]
|
||||||
|
if via.desktop {
|
||||||
|
send_notification_desktop(summary, body, room_id, store, sound_hint).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")]
|
||||||
|
#[cfg_attr(target_os = "macos", allow(unused_variables))]
|
||||||
|
async fn send_notification_desktop(
|
||||||
|
summary: &str,
|
||||||
|
body: Option<&str>,
|
||||||
|
room_id: OwnedRoomId,
|
||||||
|
_store: &AsyncProgramStore,
|
||||||
|
sound_hint: Option<&str>,
|
||||||
|
) {
|
||||||
|
let mut desktop_notification = notify_rust::Notification::new();
|
||||||
|
desktop_notification
|
||||||
|
.summary(summary)
|
||||||
|
.appname(IAMB_XDG_NAME)
|
||||||
|
.icon(IAMB_XDG_NAME)
|
||||||
|
.action("default", "default");
|
||||||
|
|
||||||
|
if let Some(sound_hint) = sound_hint {
|
||||||
|
desktop_notification.sound_name(sound_hint);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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.latest_encryption_state().await {
|
||||||
|
Ok(EncryptionState::Encrypted) => 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)
|
||||||
|
}
|
||||||
72
src/tests.rs
72
src/tests.rs
@@ -1,4 +1,4 @@
|
|||||||
use std::collections::{BTreeMap, HashMap};
|
use std::collections::HashMap;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use matrix_sdk::ruma::{
|
use matrix_sdk::ruma::{
|
||||||
@@ -15,19 +15,22 @@ use matrix_sdk::ruma::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use lazy_static::lazy_static;
|
use lazy_static::lazy_static;
|
||||||
use modalkit::tui::style::{Color, Style};
|
use ratatui::style::{Color, Style};
|
||||||
use tokio::sync::mpsc::unbounded_channel;
|
use tokio::sync::mpsc::unbounded_channel;
|
||||||
use tracing::Level;
|
use tracing::Level;
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
base::{ChatStore, EventLocation, ProgramStore, RoomFetchStatus, RoomInfo},
|
base::{ChatStore, EventLocation, ProgramStore, RoomInfo},
|
||||||
config::{
|
config::{
|
||||||
user_color,
|
user_color,
|
||||||
user_style_from_color,
|
user_style_from_color,
|
||||||
ApplicationSettings,
|
ApplicationSettings,
|
||||||
DirectoryValues,
|
DirectoryValues,
|
||||||
|
Notifications,
|
||||||
|
NotifyVia,
|
||||||
ProfileConfig,
|
ProfileConfig,
|
||||||
|
SortOverrides,
|
||||||
TunableValues,
|
TunableValues,
|
||||||
UserColor,
|
UserColor,
|
||||||
UserDisplayStyle,
|
UserDisplayStyle,
|
||||||
@@ -46,7 +49,8 @@ use crate::{
|
|||||||
const TEST_ROOM1_ALIAS: &str = "#room1:example.com";
|
const TEST_ROOM1_ALIAS: &str = "#room1:example.com";
|
||||||
|
|
||||||
lazy_static! {
|
lazy_static! {
|
||||||
pub static ref TEST_ROOM1_ID: OwnedRoomId = RoomId::new(server_name!("example.com")).to_owned();
|
pub static ref TEST_ROOM1_ID: OwnedRoomId =
|
||||||
|
RoomId::new_v1(server_name!("example.com")).to_owned();
|
||||||
pub static ref TEST_USER1: OwnedUserId = user_id!("@user1:example.com").to_owned();
|
pub static ref TEST_USER1: OwnedUserId = user_id!("@user1:example.com").to_owned();
|
||||||
pub static ref TEST_USER2: OwnedUserId = user_id!("@user2:example.com").to_owned();
|
pub static ref TEST_USER2: OwnedUserId = user_id!("@user2:example.com").to_owned();
|
||||||
pub static ref TEST_USER3: OwnedUserId = user_id!("@user3:example.com").to_owned();
|
pub static ref TEST_USER3: OwnedUserId = user_id!("@user3:example.com").to_owned();
|
||||||
@@ -124,17 +128,17 @@ pub fn mock_message5() -> Message {
|
|||||||
pub fn mock_keys() -> HashMap<OwnedEventId, EventLocation> {
|
pub fn mock_keys() -> HashMap<OwnedEventId, EventLocation> {
|
||||||
let mut keys = HashMap::new();
|
let mut keys = HashMap::new();
|
||||||
|
|
||||||
keys.insert(MSG1_EVID.clone(), EventLocation::Message(MSG1_KEY.clone()));
|
keys.insert(MSG1_EVID.clone(), EventLocation::Message(None, MSG1_KEY.clone()));
|
||||||
keys.insert(MSG2_EVID.clone(), EventLocation::Message(MSG2_KEY.clone()));
|
keys.insert(MSG2_EVID.clone(), EventLocation::Message(None, MSG2_KEY.clone()));
|
||||||
keys.insert(MSG3_EVID.clone(), EventLocation::Message(MSG3_KEY.clone()));
|
keys.insert(MSG3_EVID.clone(), EventLocation::Message(None, MSG3_KEY.clone()));
|
||||||
keys.insert(MSG4_EVID.clone(), EventLocation::Message(MSG4_KEY.clone()));
|
keys.insert(MSG4_EVID.clone(), EventLocation::Message(None, MSG4_KEY.clone()));
|
||||||
keys.insert(MSG5_EVID.clone(), EventLocation::Message(MSG5_KEY.clone()));
|
keys.insert(MSG5_EVID.clone(), EventLocation::Message(None, MSG5_KEY.clone()));
|
||||||
|
|
||||||
keys
|
keys
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn mock_messages() -> Messages {
|
pub fn mock_messages() -> Messages {
|
||||||
let mut messages = BTreeMap::new();
|
let mut messages = Messages::main();
|
||||||
|
|
||||||
messages.insert(MSG1_KEY.clone(), mock_message1());
|
messages.insert(MSG1_KEY.clone(), mock_message1());
|
||||||
messages.insert(MSG2_KEY.clone(), mock_message2());
|
messages.insert(MSG2_KEY.clone(), mock_message2());
|
||||||
@@ -146,30 +150,20 @@ pub fn mock_messages() -> Messages {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn mock_room() -> RoomInfo {
|
pub fn mock_room() -> RoomInfo {
|
||||||
RoomInfo {
|
let mut room = RoomInfo::default();
|
||||||
name: Some("Watercooler Discussion".into()),
|
room.name = Some("Watercooler Discussion".into());
|
||||||
tags: None,
|
room.keys = mock_keys();
|
||||||
|
*room.get_thread_mut(None) = mock_messages();
|
||||||
keys: mock_keys(),
|
room
|
||||||
messages: mock_messages(),
|
|
||||||
|
|
||||||
receipts: HashMap::new(),
|
|
||||||
read_till: None,
|
|
||||||
reactions: HashMap::new(),
|
|
||||||
|
|
||||||
fetching: false,
|
|
||||||
fetch_id: RoomFetchStatus::NotStarted,
|
|
||||||
fetch_last: None,
|
|
||||||
users_typing: None,
|
|
||||||
display_names: HashMap::new(),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn mock_dirs() -> DirectoryValues {
|
pub fn mock_dirs() -> DirectoryValues {
|
||||||
DirectoryValues {
|
DirectoryValues {
|
||||||
cache: PathBuf::new(),
|
cache: PathBuf::new(),
|
||||||
|
data: PathBuf::new(),
|
||||||
logs: PathBuf::new(),
|
logs: PathBuf::new(),
|
||||||
downloads: None,
|
downloads: None,
|
||||||
|
image_previews: PathBuf::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,11 +171,15 @@ pub fn mock_tunables() -> TunableValues {
|
|||||||
TunableValues {
|
TunableValues {
|
||||||
default_room: None,
|
default_room: None,
|
||||||
log_level: Level::INFO,
|
log_level: Level::INFO,
|
||||||
|
message_shortcode_display: false,
|
||||||
|
normal_after_send: true,
|
||||||
reaction_display: true,
|
reaction_display: true,
|
||||||
reaction_shortcode_display: false,
|
reaction_shortcode_display: false,
|
||||||
read_receipt_send: true,
|
read_receipt_send: true,
|
||||||
read_receipt_display: true,
|
read_receipt_display: true,
|
||||||
request_timeout: 120,
|
request_timeout: 120,
|
||||||
|
sort: SortOverrides::default().values(),
|
||||||
|
state_event_display: true,
|
||||||
typing_notice_send: true,
|
typing_notice_send: true,
|
||||||
typing_notice_display: true,
|
typing_notice_display: true,
|
||||||
users: vec![(TEST_USER5.clone(), UserDisplayTunables {
|
users: vec![(TEST_USER5.clone(), UserDisplayTunables {
|
||||||
@@ -191,27 +189,43 @@ pub fn mock_tunables() -> TunableValues {
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.collect::<HashMap<_, _>>(),
|
.collect::<HashMap<_, _>>(),
|
||||||
open_command: None,
|
open_command: None,
|
||||||
|
external_edit_file_suffix: String::from(".md"),
|
||||||
username_display: UserDisplayStyle::Username,
|
username_display: UserDisplayStyle::Username,
|
||||||
|
message_user_color: false,
|
||||||
|
mouse: Default::default(),
|
||||||
|
notifications: Notifications {
|
||||||
|
enabled: false,
|
||||||
|
via: NotifyVia::default(),
|
||||||
|
show_message: true,
|
||||||
|
sound_hint: None,
|
||||||
|
},
|
||||||
|
image_preview: None,
|
||||||
|
user_gutter_width: 30,
|
||||||
|
tabstop: 4,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn mock_settings() -> ApplicationSettings {
|
pub fn mock_settings() -> ApplicationSettings {
|
||||||
ApplicationSettings {
|
ApplicationSettings {
|
||||||
matrix_dir: PathBuf::new(),
|
|
||||||
layout_json: PathBuf::new(),
|
layout_json: PathBuf::new(),
|
||||||
session_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_name: "test".into(),
|
||||||
profile: ProfileConfig {
|
profile: ProfileConfig {
|
||||||
user_id: user_id!("@user:example.com").to_owned(),
|
user_id: user_id!("@user:example.com").to_owned(),
|
||||||
url: Url::parse("https://example.com").unwrap(),
|
url: None,
|
||||||
settings: None,
|
settings: None,
|
||||||
dirs: None,
|
dirs: None,
|
||||||
layout: None,
|
layout: None,
|
||||||
|
macros: None,
|
||||||
},
|
},
|
||||||
tunables: mock_tunables(),
|
tunables: mock_tunables(),
|
||||||
dirs: mock_dirs(),
|
dirs: mock_dirs(),
|
||||||
layout: Default::default(),
|
layout: Default::default(),
|
||||||
|
macros: HashMap::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
53
src/util.rs
53
src/util.rs
@@ -1,10 +1,11 @@
|
|||||||
|
//! # Utility functions
|
||||||
use std::borrow::Cow;
|
use std::borrow::Cow;
|
||||||
|
|
||||||
use unicode_segmentation::UnicodeSegmentation;
|
use unicode_segmentation::UnicodeSegmentation;
|
||||||
use unicode_width::UnicodeWidthStr;
|
use unicode_width::UnicodeWidthStr;
|
||||||
|
|
||||||
use modalkit::tui::style::Style;
|
use ratatui::style::Style;
|
||||||
use modalkit::tui::text::{Span, Spans, Text};
|
use ratatui::text::{Line, Span, Text};
|
||||||
|
|
||||||
pub fn split_cow(cow: Cow<'_, str>, idx: usize) -> (Cow<'_, str>, Cow<'_, str>) {
|
pub fn split_cow(cow: Cow<'_, str>, idx: usize) -> (Cow<'_, str>, Cow<'_, str>) {
|
||||||
match cow {
|
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>) {
|
pub fn take_width(s: Cow<'_, str>, width: usize) -> ((Cow<'_, str>, usize), Cow<'_, str>) {
|
||||||
// Find where to split the line.
|
// Find where to split the line.
|
||||||
let mut idx = 0;
|
|
||||||
let mut w = 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);
|
let gw = UnicodeWidthStr::width(g);
|
||||||
idx = i;
|
|
||||||
|
|
||||||
if w + gw > width {
|
if w + gw > width {
|
||||||
break;
|
Some(i)
|
||||||
}
|
} else {
|
||||||
|
|
||||||
w += gw;
|
w += gw;
|
||||||
|
None
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
.unwrap_or(s.len());
|
||||||
|
|
||||||
let (s0, s1) = split_cow(s, idx);
|
let (s0, s1) = split_cow(s, idx);
|
||||||
|
|
||||||
@@ -105,7 +106,7 @@ where
|
|||||||
|
|
||||||
for (line, w) in wrap(s, width) {
|
for (line, w) in wrap(s, width) {
|
||||||
let space = space_span(width.saturating_sub(w), style);
|
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);
|
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> {
|
pub fn join_cell_text<'a>(texts: Vec<(Text<'a>, usize)>, join: Span<'a>, style: Style) -> Text<'a> {
|
||||||
let height = texts.iter().map(|t| t.0.height()).max().unwrap_or(0);
|
let height = texts.iter().map(|t| t.0.height()).max().unwrap_or(0);
|
||||||
let mut text = Text { 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 (mut t, w) in texts.into_iter() {
|
||||||
for i in 0..height {
|
for i in 0..height {
|
||||||
if let Some(spans) = t.lines.get_mut(i) {
|
if let Some(line) = t.lines.get_mut(i) {
|
||||||
text.lines[i].0.append(&mut spans.0);
|
text.lines[i].spans.append(&mut line.spans);
|
||||||
} else {
|
} 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
|
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)]
|
#[cfg(test)]
|
||||||
pub mod tests {
|
pub mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
1083
src/windows/mod.rs
1083
src/windows/mod.rs
File diff suppressed because it is too large
Load Diff
@@ -1,67 +1,76 @@
|
|||||||
|
//! Window for Matrix rooms
|
||||||
use std::borrow::Cow;
|
use std::borrow::Cow;
|
||||||
use std::ffi::{OsStr, OsString};
|
use std::ffi::{OsStr, OsString};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::ops::Deref;
|
use std::ops::Deref;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use edit::edit_with_builder as external_edit;
|
||||||
|
use edit::Builder;
|
||||||
|
use matrix_sdk::EncryptionState;
|
||||||
use modalkit::editing::store::RegisterError;
|
use modalkit::editing::store::RegisterError;
|
||||||
|
use ratatui::style::{Color, Style};
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
use tokio;
|
use tokio;
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
use matrix_sdk::{
|
use matrix_sdk::{
|
||||||
attachment::AttachmentConfig,
|
attachment::AttachmentConfig,
|
||||||
media::{MediaFormat, MediaRequest},
|
media::{MediaFormat, MediaRequestParameters},
|
||||||
room::{Joined, Room as MatrixRoom},
|
room::Room as MatrixRoom,
|
||||||
ruma::{
|
ruma::{
|
||||||
events::reaction::{ReactionEventContent, Relation as Reaction},
|
events::reaction::ReactionEventContent,
|
||||||
|
events::relation::{Annotation, Replacement},
|
||||||
events::room::message::{
|
events::room::message::{
|
||||||
|
AddMentions,
|
||||||
|
ForwardThread,
|
||||||
MessageType,
|
MessageType,
|
||||||
OriginalRoomMessageEvent,
|
OriginalRoomMessageEvent,
|
||||||
Relation,
|
Relation,
|
||||||
Replacement,
|
ReplyWithinThread,
|
||||||
RoomMessageEventContent,
|
RoomMessageEventContent,
|
||||||
TextMessageEventContent,
|
TextMessageEventContent,
|
||||||
},
|
},
|
||||||
EventId,
|
OwnedEventId,
|
||||||
OwnedRoomId,
|
OwnedRoomId,
|
||||||
RoomId,
|
RoomId,
|
||||||
},
|
},
|
||||||
|
RoomState,
|
||||||
};
|
};
|
||||||
|
|
||||||
use modalkit::{
|
use ratatui::{
|
||||||
input::dialog::PromptYesNo,
|
|
||||||
tui::{
|
|
||||||
buffer::Buffer,
|
buffer::Buffer,
|
||||||
layout::Rect,
|
layout::Rect,
|
||||||
text::{Span, Spans},
|
text::{Line, Span},
|
||||||
widgets::{Paragraph, StatefulWidget, Widget},
|
widgets::{Paragraph, StatefulWidget, Widget},
|
||||||
},
|
|
||||||
widgets::textbox::{TextBox, TextBoxState},
|
|
||||||
widgets::TerminalCursor,
|
|
||||||
widgets::{PromptActions, WindowOps},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
use modalkit::editing::{
|
use modalkit::keybindings::dialog::{MultiChoice, MultiChoiceItem, PromptYesNo};
|
||||||
action::{
|
|
||||||
|
use modalkit_ratatui::{
|
||||||
|
textbox::{TextBox, TextBoxState},
|
||||||
|
PromptActions,
|
||||||
|
TerminalCursor,
|
||||||
|
WindowOps,
|
||||||
|
};
|
||||||
|
|
||||||
|
use modalkit::actions::{
|
||||||
Action,
|
Action,
|
||||||
EditError,
|
|
||||||
EditInfo,
|
|
||||||
EditResult,
|
|
||||||
Editable,
|
Editable,
|
||||||
EditorAction,
|
EditorAction,
|
||||||
InfoMessage,
|
|
||||||
Jumpable,
|
Jumpable,
|
||||||
PromptAction,
|
PromptAction,
|
||||||
Promptable,
|
Promptable,
|
||||||
Scrollable,
|
Scrollable,
|
||||||
UIError,
|
};
|
||||||
},
|
use modalkit::editing::{
|
||||||
base::{CloseFlags, Count, MoveDir1D, PositionList, ScrollStyle, WordStyle, WriteFlags},
|
|
||||||
completion::CompletionList,
|
completion::CompletionList,
|
||||||
context::Resolve,
|
context::Resolve,
|
||||||
history::{self, HistoryList},
|
history::{self, HistoryList},
|
||||||
rope::EditRope,
|
rope::EditRope,
|
||||||
};
|
};
|
||||||
|
use modalkit::errors::{EditError, EditResult, UIError};
|
||||||
|
use modalkit::prelude::*;
|
||||||
|
|
||||||
use crate::base::{
|
use crate::base::{
|
||||||
DownloadFlags,
|
DownloadFlags,
|
||||||
@@ -79,11 +88,19 @@ use crate::base::{
|
|||||||
SendAction,
|
SendAction,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::message::{text_to_message, Message, MessageEvent, MessageKey, MessageTimeStamp};
|
use crate::message::{
|
||||||
|
text_to_message,
|
||||||
|
Message,
|
||||||
|
MessageEvent,
|
||||||
|
MessageKey,
|
||||||
|
MessageTimeStamp,
|
||||||
|
TreeGenState,
|
||||||
|
};
|
||||||
use crate::worker::Requester;
|
use crate::worker::Requester;
|
||||||
|
|
||||||
use super::scrollback::{Scrollback, ScrollbackState};
|
use super::scrollback::{Scrollback, ScrollbackState};
|
||||||
|
|
||||||
|
/// State needed for rendering [Chat].
|
||||||
pub struct ChatState {
|
pub struct ChatState {
|
||||||
room_id: OwnedRoomId,
|
room_id: OwnedRoomId,
|
||||||
room: MatrixRoom,
|
room: MatrixRoom,
|
||||||
@@ -100,10 +117,10 @@ pub struct ChatState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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 room_id = room.room_id().to_owned();
|
||||||
let scrollback = ScrollbackState::new(room_id.clone());
|
let scrollback = ScrollbackState::new(room_id.clone(), thread.clone());
|
||||||
let id = IambBufferId::Room(room_id.clone(), RoomFocus::MessageBar);
|
let id = IambBufferId::Room(room_id.clone(), thread, RoomFocus::MessageBar);
|
||||||
let ebuf = store.load_buffer(id);
|
let ebuf = store.load_buffer(id);
|
||||||
let tbox = TextBoxState::new(ebuf);
|
let tbox = TextBoxState::new(ebuf);
|
||||||
|
|
||||||
@@ -123,13 +140,26 @@ impl ChatState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_joined(&self, worker: &Requester) -> Result<Joined, IambError> {
|
pub fn thread(&self) -> Option<&OwnedEventId> {
|
||||||
worker.client.get_joined_room(self.id()).ok_or(IambError::NotJoined)
|
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> {
|
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 key = self.reply_to.as_ref()?;
|
||||||
let msg = info.messages.get(key)?;
|
let msg = thread.get(key)?;
|
||||||
|
|
||||||
if let MessageEvent::Original(ev) = &msg.event {
|
if let MessageEvent::Original(ev) = &msg.event {
|
||||||
Some(ev)
|
Some(ev)
|
||||||
@@ -161,20 +191,19 @@ impl ChatState {
|
|||||||
let settings = &store.application.settings;
|
let settings = &store.application.settings;
|
||||||
let info = store.application.rooms.get_or_default(self.room_id.clone());
|
let info = store.application.rooms.get_or_default(self.room_id.clone());
|
||||||
|
|
||||||
let msg = self
|
let msg = self.scrollback.get_mut(info).ok_or(IambError::NoSelectedMessage)?;
|
||||||
.scrollback
|
|
||||||
.get_mut(&mut info.messages)
|
|
||||||
.ok_or(IambError::NoSelectedMessage)?;
|
|
||||||
|
|
||||||
match act {
|
match act {
|
||||||
MessageAction::Cancel(skip_confirm) => {
|
MessageAction::Cancel(skip_confirm) => {
|
||||||
self.reply_to = None;
|
|
||||||
self.editing = None;
|
|
||||||
|
|
||||||
if skip_confirm {
|
if skip_confirm {
|
||||||
|
self.reset();
|
||||||
|
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
self.reply_to = None;
|
||||||
|
self.editing = None;
|
||||||
|
|
||||||
let msg = "Would you like to clear the message bar?";
|
let msg = "Would you like to clear the message bar?";
|
||||||
let act = PromptAction::Abort(false);
|
let act = PromptAction::Abort(false);
|
||||||
let prompt = PromptYesNo::new(msg, vec![Action::from(act)]);
|
let prompt = PromptYesNo::new(msg, vec![Action::from(act)]);
|
||||||
@@ -193,19 +222,48 @@ impl ChatState {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let (source, msg_filename) = match &ev.content.msgtype {
|
let (source, msg_filename) = match &ev.content.msgtype {
|
||||||
MessageType::Audio(c) => (c.source.clone(), c.body.as_str()),
|
MessageType::Audio(c) => (c.source.clone(), c.filename()),
|
||||||
MessageType::File(c) => {
|
MessageType::File(c) => (c.source.clone(), c.filename()),
|
||||||
(c.source.clone(), c.filename.as_deref().unwrap_or(c.body.as_str()))
|
MessageType::Image(c) => (c.source.clone(), c.filename()),
|
||||||
},
|
MessageType::Video(c) => (c.source.clone(), c.filename()),
|
||||||
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());
|
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() {
|
if filename.is_dir() {
|
||||||
filename.push(msg_filename);
|
filename.push(msg_filename.replace(std::path::MAIN_SEPARATOR_STR, "_"));
|
||||||
}
|
}
|
||||||
|
|
||||||
if filename.exists() && !flags.contains(DownloadFlags::FORCE) {
|
if filename.exists() && !flags.contains(DownloadFlags::FORCE) {
|
||||||
@@ -215,9 +273,9 @@ impl ChatState {
|
|||||||
let mut filename_incr = filename.clone();
|
let mut filename_incr = filename.clone();
|
||||||
for n in 1..=1000 {
|
for n in 1..=1000 {
|
||||||
if let Some(ext) = ext.and_then(OsStr::to_str) {
|
if let Some(ext) = ext.and_then(OsStr::to_str) {
|
||||||
filename_incr.set_file_name(format!("{}-{}.{}", stem, n, ext));
|
filename_incr.set_file_name(format!("{stem}-{n}.{ext}"));
|
||||||
} else {
|
} else {
|
||||||
filename_incr.set_file_name(format!("{}-{}", stem, n));
|
filename_incr.set_file_name(format!("{stem}-{n}"));
|
||||||
}
|
}
|
||||||
|
|
||||||
if !filename_incr.exists() {
|
if !filename_incr.exists() {
|
||||||
@@ -229,7 +287,7 @@ impl ChatState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !filename.exists() || flags.contains(DownloadFlags::FORCE) {
|
if !filename.exists() || flags.contains(DownloadFlags::FORCE) {
|
||||||
let req = MediaRequest { source, format: MediaFormat::File };
|
let req = MediaRequestParameters { source, format: MediaFormat::File };
|
||||||
|
|
||||||
let bytes =
|
let bytes =
|
||||||
media.get_media_content(&req, true).await.map_err(IambError::from)?;
|
media.get_media_content(&req, true).await.map_err(IambError::from)?;
|
||||||
@@ -311,13 +369,29 @@ impl ChatState {
|
|||||||
|
|
||||||
Ok(None)
|
Ok(None)
|
||||||
},
|
},
|
||||||
MessageAction::React(emoji) => {
|
MessageAction::React(reaction, literal) => {
|
||||||
|
let emoji = if literal {
|
||||||
|
reaction
|
||||||
|
} else if let Some(emoji) =
|
||||||
|
emojis::get(&reaction).or_else(|| emojis::get_by_shortcode(&reaction))
|
||||||
|
{
|
||||||
|
emoji.to_string()
|
||||||
|
} else {
|
||||||
|
let msg = format!("{reaction:?} is not a known Emoji shortcode; do you want to react with exactly {reaction:?}?");
|
||||||
|
let act = IambAction::Message(MessageAction::React(reaction, true));
|
||||||
|
let prompt = PromptYesNo::new(msg, vec![Action::from(act)]);
|
||||||
|
let prompt = Box::new(prompt);
|
||||||
|
|
||||||
|
return Err(UIError::NeedConfirm(prompt));
|
||||||
|
};
|
||||||
|
|
||||||
let room = self.get_joined(&store.application.worker)?;
|
let room = self.get_joined(&store.application.worker)?;
|
||||||
let event_id = match &msg.event {
|
let event_id = match &msg.event {
|
||||||
MessageEvent::EncryptedOriginal(ev) => ev.event_id.clone(),
|
MessageEvent::EncryptedOriginal(ev) => ev.event_id.clone(),
|
||||||
MessageEvent::EncryptedRedacted(ev) => ev.event_id.clone(),
|
MessageEvent::EncryptedRedacted(ev) => ev.event_id.clone(),
|
||||||
MessageEvent::Original(ev) => ev.event_id.clone(),
|
MessageEvent::Original(ev) => ev.event_id.clone(),
|
||||||
MessageEvent::Local(event_id, _) => event_id.clone(),
|
MessageEvent::Local(event_id, _) => event_id.clone(),
|
||||||
|
MessageEvent::State(ev) => ev.event_id().to_owned(),
|
||||||
MessageEvent::Redacted(_) => {
|
MessageEvent::Redacted(_) => {
|
||||||
let msg = "Cannot react to a redacted message";
|
let msg = "Cannot react to a redacted message";
|
||||||
let err = UIError::Failure(msg.into());
|
let err = UIError::Failure(msg.into());
|
||||||
@@ -326,9 +400,16 @@ 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 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)
|
Ok(None)
|
||||||
},
|
},
|
||||||
@@ -348,6 +429,7 @@ impl ChatState {
|
|||||||
MessageEvent::EncryptedRedacted(ev) => ev.event_id.clone(),
|
MessageEvent::EncryptedRedacted(ev) => ev.event_id.clone(),
|
||||||
MessageEvent::Original(ev) => ev.event_id.clone(),
|
MessageEvent::Original(ev) => ev.event_id.clone(),
|
||||||
MessageEvent::Local(event_id, _) => event_id.clone(),
|
MessageEvent::Local(event_id, _) => event_id.clone(),
|
||||||
|
MessageEvent::State(ev) => ev.event_id().to_owned(),
|
||||||
MessageEvent::Redacted(_) => {
|
MessageEvent::Redacted(_) => {
|
||||||
let msg = "Cannot redact already redacted message";
|
let msg = "Cannot redact already redacted message";
|
||||||
let err = UIError::Failure(msg.into());
|
let err = UIError::Failure(msg.into());
|
||||||
@@ -368,13 +450,49 @@ impl ChatState {
|
|||||||
|
|
||||||
Ok(None)
|
Ok(None)
|
||||||
},
|
},
|
||||||
MessageAction::Unreact(emoji) => {
|
MessageAction::Replied => {
|
||||||
|
let Some(reply) = msg.reply_to() else {
|
||||||
|
let msg = "Selected message is not a reply";
|
||||||
|
return Err(UIError::Failure(msg.into()));
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(key) = info.get_message_key(&reply) else {
|
||||||
|
store.application.need_load.need_message(self.room_id.clone(), reply);
|
||||||
|
let msg = "Replied to message will be loaded in the background";
|
||||||
|
return Err(UIError::Failure(msg.into()));
|
||||||
|
};
|
||||||
|
|
||||||
|
self.scrollback.goto_message(key.clone());
|
||||||
|
Ok(None)
|
||||||
|
},
|
||||||
|
MessageAction::Unreact(reaction, literal) => {
|
||||||
|
let emoji = match reaction {
|
||||||
|
reaction if literal => reaction,
|
||||||
|
Some(reaction) => {
|
||||||
|
if let Some(emoji) =
|
||||||
|
emojis::get(&reaction).or_else(|| emojis::get_by_shortcode(&reaction))
|
||||||
|
{
|
||||||
|
Some(emoji.to_string())
|
||||||
|
} else {
|
||||||
|
let msg = format!("{reaction:?} is not a known Emoji shortcode; do you want to remove exactly {reaction:?}?");
|
||||||
|
let act =
|
||||||
|
IambAction::Message(MessageAction::Unreact(Some(reaction), true));
|
||||||
|
let prompt = PromptYesNo::new(msg, vec![Action::from(act)]);
|
||||||
|
let prompt = Box::new(prompt);
|
||||||
|
|
||||||
|
return Err(UIError::NeedConfirm(prompt));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
let room = self.get_joined(&store.application.worker)?;
|
let room = self.get_joined(&store.application.worker)?;
|
||||||
let event_id: &EventId = match &msg.event {
|
let event_id = match &msg.event {
|
||||||
MessageEvent::EncryptedOriginal(ev) => ev.event_id.as_ref(),
|
MessageEvent::EncryptedOriginal(ev) => ev.event_id.clone(),
|
||||||
MessageEvent::EncryptedRedacted(ev) => ev.event_id.as_ref(),
|
MessageEvent::EncryptedRedacted(ev) => ev.event_id.clone(),
|
||||||
MessageEvent::Original(ev) => ev.event_id.as_ref(),
|
MessageEvent::Original(ev) => ev.event_id.clone(),
|
||||||
MessageEvent::Local(event_id, _) => event_id.as_ref(),
|
MessageEvent::Local(event_id, _) => event_id.clone(),
|
||||||
|
MessageEvent::State(ev) => ev.event_id().to_owned(),
|
||||||
MessageEvent::Redacted(_) => {
|
MessageEvent::Redacted(_) => {
|
||||||
let msg = "Cannot unreact to a redacted message";
|
let msg = "Cannot unreact to a redacted message";
|
||||||
let err = UIError::Failure(msg.into());
|
let err = UIError::Failure(msg.into());
|
||||||
@@ -383,7 +501,7 @@ impl ChatState {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
let reactions = match info.reactions.get(event_id) {
|
let reactions = match info.reactions.get(&event_id) {
|
||||||
Some(r) => r,
|
Some(r) => r,
|
||||||
None => return Ok(None),
|
None => return Ok(None),
|
||||||
};
|
};
|
||||||
@@ -419,40 +537,55 @@ impl ChatState {
|
|||||||
_: ProgramContext,
|
_: ProgramContext,
|
||||||
store: &mut ProgramStore,
|
store: &mut ProgramStore,
|
||||||
) -> IambResult<EditInfo> {
|
) -> IambResult<EditInfo> {
|
||||||
let room = store
|
let room = self.get_joined(&store.application.worker)?;
|
||||||
.application
|
|
||||||
.worker
|
|
||||||
.client
|
|
||||||
.get_joined_room(self.id())
|
|
||||||
.ok_or(IambError::NotJoined)?;
|
|
||||||
let info = store.application.rooms.get_or_default(self.id().to_owned());
|
let info = store.application.rooms.get_or_default(self.id().to_owned());
|
||||||
let mut show_echo = true;
|
let mut show_echo = true;
|
||||||
|
|
||||||
let (event_id, msg) = match act {
|
let (event_id, msg) = match act {
|
||||||
SendAction::Submit => {
|
SendAction::Submit | SendAction::SubmitFromEditor => {
|
||||||
let msg = self.tbox.get();
|
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);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
|
edited_msg
|
||||||
|
} else if msg.is_blank() {
|
||||||
|
return Ok(None);
|
||||||
|
} else {
|
||||||
|
msg.trim_end().to_string()
|
||||||
|
};
|
||||||
|
|
||||||
let mut msg = text_to_message(msg.trim_end().to_string());
|
let mut msg = text_to_message(msg);
|
||||||
|
|
||||||
if let Some((_, event_id)) = &self.editing {
|
if let Some((_, event_id)) = &self.editing {
|
||||||
msg.relates_to = Some(Relation::Replacement(Replacement::new(
|
msg.relates_to = Some(Relation::Replacement(Replacement::new(
|
||||||
event_id.clone(),
|
event_id.clone(),
|
||||||
Box::new(msg.clone()),
|
msg.msgtype.clone().into(),
|
||||||
)));
|
)));
|
||||||
|
|
||||||
show_echo = false;
|
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) {
|
} 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, ForwardThread::Yes, AddMentions::No);
|
||||||
msg = msg.make_reply_to(m);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// XXX: second parameter can be a locally unique transaction id.
|
// XXX: second parameter can be a locally unique transaction id.
|
||||||
// Useful for doing retries.
|
// 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;
|
let event_id = resp.event_id;
|
||||||
|
|
||||||
// Reset message bar state now that it's been sent.
|
// Reset message bar state now that it's been sent.
|
||||||
@@ -472,7 +605,7 @@ impl ChatState {
|
|||||||
let config = AttachmentConfig::new();
|
let config = AttachmentConfig::new();
|
||||||
|
|
||||||
let resp = room
|
let resp = room
|
||||||
.send_attachment(name.as_ref(), &mime, bytes.as_ref(), config)
|
.send_attachment(name.as_ref(), &mime, bytes, config)
|
||||||
.await
|
.await
|
||||||
.map_err(IambError::from)?;
|
.map_err(IambError::from)?;
|
||||||
|
|
||||||
@@ -492,17 +625,16 @@ impl ChatState {
|
|||||||
let dynimage = image::DynamicImage::ImageRgba8(imagebuf);
|
let dynimage = image::DynamicImage::ImageRgba8(imagebuf);
|
||||||
let bytes = Vec::<u8>::new();
|
let bytes = Vec::<u8>::new();
|
||||||
let mut buff = std::io::Cursor::new(bytes);
|
let mut buff = std::io::Cursor::new(bytes);
|
||||||
dynimage.write_to(&mut buff, image::ImageOutputFormat::Png)?;
|
dynimage.write_to(&mut buff, image::ImageFormat::Png)?;
|
||||||
Ok(buff.into_inner())
|
Ok(buff.into_inner())
|
||||||
})
|
})?;
|
||||||
.map_err(IambError::from)?;
|
|
||||||
let mime = mime::IMAGE_PNG;
|
let mime = mime::IMAGE_PNG;
|
||||||
|
|
||||||
let name = "Clipboard.png";
|
let name = "Clipboard.png";
|
||||||
let config = AttachmentConfig::new();
|
let config = AttachmentConfig::new();
|
||||||
|
|
||||||
let resp = room
|
let resp = room
|
||||||
.send_attachment(name.as_ref(), &mime, bytes.as_ref(), config)
|
.send_attachment(name, &mime, bytes, config)
|
||||||
.await
|
.await
|
||||||
.map_err(IambError::from)?;
|
.map_err(IambError::from)?;
|
||||||
|
|
||||||
@@ -520,7 +652,8 @@ impl ChatState {
|
|||||||
let key = (MessageTimeStamp::LocalEcho, event_id.clone());
|
let key = (MessageTimeStamp::LocalEcho, event_id.clone());
|
||||||
let msg = MessageEvent::Local(event_id, msg.into());
|
let msg = MessageEvent::Local(event_id, msg.into());
|
||||||
let msg = Message::new(msg, user, MessageTimeStamp::LocalEcho);
|
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.
|
// Jump to the end of the scrollback to show the message.
|
||||||
@@ -530,10 +663,7 @@ impl ChatState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn focus_toggle(&mut self) {
|
pub fn focus_toggle(&mut self) {
|
||||||
self.focus = match self.focus {
|
self.focus.toggle();
|
||||||
RoomFocus::Scrollback => RoomFocus::MessageBar,
|
|
||||||
RoomFocus::MessageBar => RoomFocus::Scrollback,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn room(&self) -> &MatrixRoom {
|
pub fn room(&self) -> &MatrixRoom {
|
||||||
@@ -544,6 +674,14 @@ impl ChatState {
|
|||||||
&self.room_id
|
&self.room_id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn auto_toggle_focus(
|
||||||
|
&mut self,
|
||||||
|
act: &EditorAction,
|
||||||
|
ctx: &ProgramContext,
|
||||||
|
) -> Option<EditorAction> {
|
||||||
|
auto_toggle_focus(&mut self.focus, act, ctx, &self.scrollback, &mut self.tbox)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn typing_notice(
|
pub fn typing_notice(
|
||||||
&self,
|
&self,
|
||||||
act: &EditorAction,
|
act: &EditorAction,
|
||||||
@@ -587,12 +725,14 @@ impl WindowOps<IambInfo> for ChatState {
|
|||||||
fn dup(&self, store: &mut ProgramStore) -> Self {
|
fn dup(&self, store: &mut ProgramStore) -> Self {
|
||||||
// XXX: I want each WindowSlot to have its own shared buffer, instead of each Room; need to
|
// 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.
|
// 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 ebuf = store.load_buffer(id);
|
||||||
let tbox = TextBoxState::new(ebuf);
|
let tbox = TextBoxState::new(ebuf);
|
||||||
|
|
||||||
ChatState {
|
ChatState {
|
||||||
room_id: self.room_id.clone(),
|
room_id,
|
||||||
room: self.room.clone(),
|
room: self.room.clone(),
|
||||||
|
|
||||||
tbox,
|
tbox,
|
||||||
@@ -644,12 +784,21 @@ impl Editable<ProgramContext, ProgramStore, IambInfo> for ChatState {
|
|||||||
ctx: &ProgramContext,
|
ctx: &ProgramContext,
|
||||||
store: &mut ProgramStore,
|
store: &mut ProgramStore,
|
||||||
) -> EditResult<EditInfo, IambInfo> {
|
) -> EditResult<EditInfo, IambInfo> {
|
||||||
|
// Check whether we should automatically switch between the message bar
|
||||||
|
// or message scrollback, and use an adjusted action if we do so.
|
||||||
|
let adjusted = self.auto_toggle_focus(act, ctx);
|
||||||
|
let act = adjusted.as_ref().unwrap_or(act);
|
||||||
|
|
||||||
|
// Send typing notice if needed.
|
||||||
self.typing_notice(act, ctx, store);
|
self.typing_notice(act, ctx, store);
|
||||||
|
|
||||||
|
// And now we can finally run the editor command.
|
||||||
match delegate!(self, w => w.editor_command(act, ctx, store)) {
|
match delegate!(self, w => w.editor_command(act, ctx, store)) {
|
||||||
res @ Ok(_) => res,
|
res @ Ok(_) => res,
|
||||||
Err(EditError::WrongBuffer(IambBufferId::Room(room_id, focus)))
|
Err(EditError::WrongBuffer(IambBufferId::Room(room_id, thread, focus)))
|
||||||
if room_id == self.room_id && act.is_switchable(ctx) =>
|
if room_id == self.room_id &&
|
||||||
|
thread.as_ref() == self.thread() &&
|
||||||
|
act.is_switchable(ctx) =>
|
||||||
{
|
{
|
||||||
// Switch focus.
|
// Switch focus.
|
||||||
self.focus = focus;
|
self.focus = focus;
|
||||||
@@ -740,16 +889,16 @@ impl PromptActions<ProgramContext, ProgramStore, IambInfo> for ChatState {
|
|||||||
|
|
||||||
fn recall(
|
fn recall(
|
||||||
&mut self,
|
&mut self,
|
||||||
|
filter: &RecallFilter,
|
||||||
dir: &MoveDir1D,
|
dir: &MoveDir1D,
|
||||||
count: &Count,
|
count: &Count,
|
||||||
prefixed: bool,
|
|
||||||
ctx: &ProgramContext,
|
ctx: &ProgramContext,
|
||||||
_: &mut ProgramStore,
|
_: &mut ProgramStore,
|
||||||
) -> EditResult<Vec<(ProgramAction, ProgramContext)>, IambInfo> {
|
) -> EditResult<Vec<(ProgramAction, ProgramContext)>, IambInfo> {
|
||||||
let count = ctx.resolve(count);
|
let count = ctx.resolve(count);
|
||||||
let rope = self.tbox.get();
|
let rope = self.tbox.get();
|
||||||
|
|
||||||
let text = self.sent.recall(&rope, &mut self.sent_scrollback, *dir, prefixed, count);
|
let text = self.sent.recall(&rope, &mut self.sent_scrollback, filter, *dir, count);
|
||||||
|
|
||||||
if let Some(text) = text {
|
if let Some(text) = text {
|
||||||
self.tbox.set_text(text);
|
self.tbox.set_text(text);
|
||||||
@@ -767,20 +916,18 @@ impl Promptable<ProgramContext, ProgramStore, IambInfo> for ChatState {
|
|||||||
store: &mut ProgramStore,
|
store: &mut ProgramStore,
|
||||||
) -> EditResult<Vec<(ProgramAction, ProgramContext)>, IambInfo> {
|
) -> EditResult<Vec<(ProgramAction, ProgramContext)>, IambInfo> {
|
||||||
if let RoomFocus::Scrollback = self.focus {
|
if let RoomFocus::Scrollback = self.focus {
|
||||||
return Ok(vec![]);
|
return self.scrollback.prompt(act, ctx, store);
|
||||||
}
|
}
|
||||||
|
|
||||||
match act {
|
match act {
|
||||||
PromptAction::Submit => self.submit(ctx, store),
|
PromptAction::Submit => self.submit(ctx, store),
|
||||||
PromptAction::Abort(empty) => self.abort(*empty, ctx, store),
|
PromptAction::Abort(empty) => self.abort(*empty, ctx, store),
|
||||||
PromptAction::Recall(dir, count, prefixed) => {
|
PromptAction::Recall(filter, dir, count) => self.recall(filter, dir, count, ctx, store),
|
||||||
self.recall(dir, count, *prefixed, ctx, store)
|
|
||||||
},
|
|
||||||
_ => Err(EditError::Unimplemented("unknown prompt action".to_string())),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// [StatefulWidget] for Matrix rooms.
|
||||||
pub struct Chat<'a> {
|
pub struct Chat<'a> {
|
||||||
store: &'a mut ProgramStore,
|
store: &'a mut ProgramStore,
|
||||||
focused: bool,
|
focused: bool,
|
||||||
@@ -797,26 +944,28 @@ impl<'a> Chat<'a> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> StatefulWidget for Chat<'a> {
|
impl StatefulWidget for Chat<'_> {
|
||||||
type State = ChatState;
|
type State = ChatState;
|
||||||
|
|
||||||
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
|
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
|
||||||
// Determine whether we have a description to show for the message bar.
|
// Determine whether we have a description to show for the message bar.
|
||||||
let desc_spans = match (&state.editing, &state.reply_to) {
|
let desc_spans = match (&state.editing, &state.reply_to, state.thread()) {
|
||||||
(None, None) => None,
|
(None, None, None) => None,
|
||||||
(Some(_), None) => Some(Spans::from("Editing message")),
|
(None, None, Some(_)) => Some(Line::from("Replying in thread")),
|
||||||
(editing, Some(_)) => {
|
(Some(_), None, None) => Some(Line::from("Editing message")),
|
||||||
state.reply_to.as_ref().and_then(|k| {
|
(Some(_), None, Some(_)) => Some(Line::from("Editing message in thread")),
|
||||||
let room = self.store.application.rooms.get(state.id())?;
|
(editing, Some(_), thread) => {
|
||||||
let msg = room.messages.get(k)?;
|
self.store.application.rooms.get(state.id()).and_then(|room| {
|
||||||
|
let msg = state.get_reply_to(room)?;
|
||||||
let user =
|
let user =
|
||||||
self.store.application.settings.get_user_span(msg.sender.as_ref(), room);
|
self.store.application.settings.get_user_span(msg.sender.as_ref(), room);
|
||||||
let prefix = if editing.is_some() {
|
let prefix = match (editing.is_some(), thread.is_some()) {
|
||||||
Span::from("Editing reply to ")
|
(true, false) => Span::from("Editing reply to "),
|
||||||
} else {
|
(true, true) => Span::from("Editing reply in thread to "),
|
||||||
Span::from("Replying to ")
|
(false, false) => Span::from("Replying to "),
|
||||||
|
(false, true) => Span::from("Replying in thread to "),
|
||||||
};
|
};
|
||||||
let spans = Spans(vec![prefix, user]);
|
let spans = Line::from(vec![prefix, user]);
|
||||||
|
|
||||||
spans.into()
|
spans.into()
|
||||||
})
|
})
|
||||||
@@ -843,7 +992,16 @@ impl<'a> StatefulWidget for Chat<'a> {
|
|||||||
Paragraph::new(desc_spans).render(descarea, buf);
|
Paragraph::new(desc_spans).render(descarea, buf);
|
||||||
}
|
}
|
||||||
|
|
||||||
let prompt = if self.focused { "> " } else { " " };
|
let prompt = match (self.focused, state.room().encryption_state()) {
|
||||||
|
(false, _) => Span::raw(" "),
|
||||||
|
(_, EncryptionState::Encrypted) => {
|
||||||
|
Span::styled("\u{1F512}\u{FE0E} ", Style::new().fg(Color::LightGreen))
|
||||||
|
},
|
||||||
|
(_, EncryptionState::NotEncrypted) => {
|
||||||
|
Span::styled("\u{1F513}\u{FE0E} ", Style::new().fg(Color::Red))
|
||||||
|
},
|
||||||
|
(_, EncryptionState::Unknown) => Span::styled("> ", Style::new().fg(Color::Red)),
|
||||||
|
};
|
||||||
|
|
||||||
let tbox = TextBox::new().prompt(prompt);
|
let tbox = TextBox::new().prompt(prompt);
|
||||||
tbox.render(textarea, buf, &mut state.tbox);
|
tbox.render(textarea, buf, &mut state.tbox);
|
||||||
@@ -879,3 +1037,158 @@ fn cmd(open_command: &Vec<String>) -> Option<Command> {
|
|||||||
}
|
}
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn auto_toggle_focus(
|
||||||
|
focus: &mut RoomFocus,
|
||||||
|
act: &EditorAction,
|
||||||
|
ctx: &ProgramContext,
|
||||||
|
scrollback: &ScrollbackState,
|
||||||
|
tbox: &mut TextBoxState<IambInfo>,
|
||||||
|
) -> Option<EditorAction> {
|
||||||
|
let is_insert = ctx.get_insert_style().is_some();
|
||||||
|
|
||||||
|
match (focus, act) {
|
||||||
|
(f @ RoomFocus::Scrollback, _) if is_insert => {
|
||||||
|
// Insert mode commands should switch focus.
|
||||||
|
f.toggle();
|
||||||
|
None
|
||||||
|
},
|
||||||
|
(f @ RoomFocus::Scrollback, EditorAction::InsertText(_)) => {
|
||||||
|
// Pasting or otherwise inserting text should switch.
|
||||||
|
f.toggle();
|
||||||
|
None
|
||||||
|
},
|
||||||
|
(
|
||||||
|
f @ RoomFocus::Scrollback,
|
||||||
|
EditorAction::Edit(
|
||||||
|
op,
|
||||||
|
EditTarget::Motion(mov @ MoveType::Line(MoveDir1D::Next), count),
|
||||||
|
),
|
||||||
|
) if ctx.resolve(op).is_motion() => {
|
||||||
|
let count = ctx.resolve(count);
|
||||||
|
|
||||||
|
if count > 0 && scrollback.is_latest() {
|
||||||
|
// Trying to move down a line when already at the end of room history should
|
||||||
|
// switch.
|
||||||
|
f.toggle();
|
||||||
|
|
||||||
|
// And decrement the count for the action.
|
||||||
|
let count = count.saturating_sub(1).into();
|
||||||
|
let target = EditTarget::Motion(mov.clone(), count);
|
||||||
|
let dec = EditorAction::Edit(op.clone(), target);
|
||||||
|
|
||||||
|
Some(dec)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(
|
||||||
|
f @ RoomFocus::MessageBar,
|
||||||
|
EditorAction::Edit(
|
||||||
|
op,
|
||||||
|
EditTarget::Motion(mov @ MoveType::Line(MoveDir1D::Previous), count),
|
||||||
|
),
|
||||||
|
) if !is_insert && ctx.resolve(op).is_motion() => {
|
||||||
|
let count = ctx.resolve(count);
|
||||||
|
|
||||||
|
if count > 0 && tbox.get_cursor().y == 0 {
|
||||||
|
// Trying to move up a line when already at the top of the msgbar should
|
||||||
|
// switch as long as we're not in Insert mode.
|
||||||
|
f.toggle();
|
||||||
|
|
||||||
|
// And decrement the count for the action.
|
||||||
|
let count = count.saturating_sub(1).into();
|
||||||
|
let target = EditTarget::Motion(mov.clone(), count);
|
||||||
|
let dec = EditorAction::Edit(op.clone(), target);
|
||||||
|
|
||||||
|
Some(dec)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(RoomFocus::Scrollback, _) | (RoomFocus::MessageBar, _) => {
|
||||||
|
// Do not switch.
|
||||||
|
None
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
use modalkit::actions::{EditAction, InsertTextAction};
|
||||||
|
|
||||||
|
use crate::tests::{mock_store, TEST_ROOM1_ID};
|
||||||
|
|
||||||
|
macro_rules! move_line {
|
||||||
|
($dir: expr, $count: expr) => {
|
||||||
|
EditorAction::Edit(
|
||||||
|
EditAction::Motion.into(),
|
||||||
|
EditTarget::Motion(MoveType::Line($dir), $count.into()),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_auto_focus() {
|
||||||
|
let mut store = mock_store().await;
|
||||||
|
let ctx = ProgramContext::default();
|
||||||
|
|
||||||
|
let room_id = TEST_ROOM1_ID.clone();
|
||||||
|
let scrollback = ScrollbackState::new(room_id.clone(), None);
|
||||||
|
|
||||||
|
let id = IambBufferId::Room(room_id, None, RoomFocus::MessageBar);
|
||||||
|
let ebuf = store.load_buffer(id);
|
||||||
|
let mut tbox = TextBoxState::new(ebuf);
|
||||||
|
|
||||||
|
// Start out focused on the scrollback.
|
||||||
|
let mut focused = RoomFocus::Scrollback;
|
||||||
|
|
||||||
|
// Inserting text toggles:
|
||||||
|
let act = EditorAction::InsertText(InsertTextAction::Type(
|
||||||
|
Char::from('a').into(),
|
||||||
|
MoveDir1D::Next,
|
||||||
|
1.into(),
|
||||||
|
));
|
||||||
|
let res = auto_toggle_focus(&mut focused, &act, &ctx, &scrollback, &mut tbox);
|
||||||
|
assert_eq!(focused, RoomFocus::MessageBar);
|
||||||
|
assert!(res.is_none());
|
||||||
|
|
||||||
|
// Going down in message bar doesn't toggle:
|
||||||
|
let act = move_line!(MoveDir1D::Next, 1);
|
||||||
|
let res = auto_toggle_focus(&mut focused, &act, &ctx, &scrollback, &mut tbox);
|
||||||
|
assert_eq!(focused, RoomFocus::MessageBar);
|
||||||
|
assert!(res.is_none());
|
||||||
|
|
||||||
|
// But going up will:
|
||||||
|
let act = move_line!(MoveDir1D::Previous, 1);
|
||||||
|
let res = auto_toggle_focus(&mut focused, &act, &ctx, &scrollback, &mut tbox);
|
||||||
|
assert_eq!(focused, RoomFocus::Scrollback);
|
||||||
|
assert_eq!(res, Some(move_line!(MoveDir1D::Previous, 0)));
|
||||||
|
|
||||||
|
// Going up in scrollback doesn't toggle:
|
||||||
|
let act = move_line!(MoveDir1D::Previous, 1);
|
||||||
|
let res = auto_toggle_focus(&mut focused, &act, &ctx, &scrollback, &mut tbox);
|
||||||
|
assert_eq!(focused, RoomFocus::Scrollback);
|
||||||
|
assert_eq!(res, None);
|
||||||
|
|
||||||
|
// And then go back down:
|
||||||
|
let act = move_line!(MoveDir1D::Next, 1);
|
||||||
|
let res = auto_toggle_focus(&mut focused, &act, &ctx, &scrollback, &mut tbox);
|
||||||
|
assert_eq!(focused, RoomFocus::MessageBar);
|
||||||
|
assert_eq!(res, Some(move_line!(MoveDir1D::Next, 0)));
|
||||||
|
|
||||||
|
// Go up 2 will go up 1 in scrollback:
|
||||||
|
let act = move_line!(MoveDir1D::Previous, 2);
|
||||||
|
let res = auto_toggle_focus(&mut focused, &act, &ctx, &scrollback, &mut tbox);
|
||||||
|
assert_eq!(focused, RoomFocus::Scrollback);
|
||||||
|
assert_eq!(res, Some(move_line!(MoveDir1D::Previous, 1)));
|
||||||
|
|
||||||
|
// Go down 3 will go down 2 in messagebar:
|
||||||
|
let act = move_line!(MoveDir1D::Next, 3);
|
||||||
|
let res = auto_toggle_focus(&mut focused, &act, &ctx, &scrollback, &mut tbox);
|
||||||
|
assert_eq!(focused, RoomFocus::MessageBar);
|
||||||
|
assert_eq!(res, Some(move_line!(MoveDir1D::Next, 2)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,52 +1,56 @@
|
|||||||
|
//! # Windows for Matrix rooms and spaces
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
use matrix_sdk::{
|
use matrix_sdk::{
|
||||||
room::{Invited, Room as MatrixRoom},
|
notification_settings::RoomNotificationMode,
|
||||||
|
room::Room as MatrixRoom,
|
||||||
ruma::{
|
ruma::{
|
||||||
|
api::client::{
|
||||||
|
alias::{
|
||||||
|
create_alias::v3::Request as CreateAliasRequest,
|
||||||
|
delete_alias::v3::Request as DeleteAliasRequest,
|
||||||
|
},
|
||||||
|
error::ErrorKind as ClientApiErrorKind,
|
||||||
|
},
|
||||||
events::{
|
events::{
|
||||||
room::{name::RoomNameEventContent, topic::RoomTopicEventContent},
|
room::{
|
||||||
|
canonical_alias::RoomCanonicalAliasEventContent,
|
||||||
|
history_visibility::{HistoryVisibility, RoomHistoryVisibilityEventContent},
|
||||||
|
name::RoomNameEventContent,
|
||||||
|
topic::RoomTopicEventContent,
|
||||||
|
},
|
||||||
tag::{TagInfo, Tags},
|
tag::{TagInfo, Tags},
|
||||||
},
|
},
|
||||||
|
OwnedEventId,
|
||||||
|
OwnedRoomAliasId,
|
||||||
|
OwnedUserId,
|
||||||
RoomId,
|
RoomId,
|
||||||
},
|
},
|
||||||
DisplayName,
|
RoomDisplayName,
|
||||||
|
RoomState as MatrixRoomState,
|
||||||
};
|
};
|
||||||
|
|
||||||
use modalkit::tui::{
|
use ratatui::{
|
||||||
buffer::Buffer,
|
buffer::Buffer,
|
||||||
layout::{Alignment, Rect},
|
layout::{Alignment, Rect},
|
||||||
style::{Modifier as StyleModifier, Style},
|
style::{Modifier as StyleModifier, Style},
|
||||||
text::{Span, Spans, Text},
|
text::{Line, Span, Text},
|
||||||
widgets::{Paragraph, StatefulWidget, Widget},
|
widgets::{Paragraph, StatefulWidget, Widget},
|
||||||
};
|
};
|
||||||
|
|
||||||
use modalkit::{
|
use modalkit::actions::{
|
||||||
editing::action::{
|
|
||||||
Action,
|
Action,
|
||||||
EditInfo,
|
|
||||||
EditResult,
|
|
||||||
Editable,
|
Editable,
|
||||||
EditorAction,
|
EditorAction,
|
||||||
Jumpable,
|
Jumpable,
|
||||||
PromptAction,
|
PromptAction,
|
||||||
Promptable,
|
Promptable,
|
||||||
Scrollable,
|
Scrollable,
|
||||||
UIError,
|
|
||||||
},
|
|
||||||
editing::base::{
|
|
||||||
Axis,
|
|
||||||
CloseFlags,
|
|
||||||
Count,
|
|
||||||
MoveDir1D,
|
|
||||||
OpenTarget,
|
|
||||||
PositionList,
|
|
||||||
ScrollStyle,
|
|
||||||
WordStyle,
|
|
||||||
WriteFlags,
|
|
||||||
},
|
|
||||||
editing::completion::CompletionList,
|
|
||||||
input::dialog::PromptYesNo,
|
|
||||||
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::{
|
use crate::base::{
|
||||||
IambAction,
|
IambAction,
|
||||||
@@ -54,6 +58,7 @@ use crate::base::{
|
|||||||
IambId,
|
IambId,
|
||||||
IambInfo,
|
IambInfo,
|
||||||
IambResult,
|
IambResult,
|
||||||
|
MemberUpdateAction,
|
||||||
MessageAction,
|
MessageAction,
|
||||||
ProgramAction,
|
ProgramAction,
|
||||||
ProgramContext,
|
ProgramContext,
|
||||||
@@ -61,11 +66,14 @@ use crate::base::{
|
|||||||
RoomAction,
|
RoomAction,
|
||||||
RoomField,
|
RoomField,
|
||||||
SendAction,
|
SendAction,
|
||||||
|
SpaceAction,
|
||||||
};
|
};
|
||||||
|
|
||||||
use self::chat::ChatState;
|
use self::chat::ChatState;
|
||||||
use self::space::{Space, SpaceState};
|
use self::space::{Space, SpaceState};
|
||||||
|
|
||||||
|
use std::convert::TryFrom;
|
||||||
|
|
||||||
mod chat;
|
mod chat;
|
||||||
mod scrollback;
|
mod scrollback;
|
||||||
mod space;
|
mod space;
|
||||||
@@ -79,27 +87,60 @@ 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 {
|
pub enum RoomState {
|
||||||
Chat(ChatState),
|
Chat(Box<ChatState>),
|
||||||
Space(SpaceState),
|
Space(Box<SpaceState>),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<ChatState> for RoomState {
|
impl From<ChatState> for RoomState {
|
||||||
fn from(chat: ChatState) -> Self {
|
fn from(chat: ChatState) -> Self {
|
||||||
RoomState::Chat(chat)
|
RoomState::Chat(Box::new(chat))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<SpaceState> for RoomState {
|
impl From<SpaceState> for RoomState {
|
||||||
fn from(space: SpaceState) -> Self {
|
fn from(space: SpaceState) -> Self {
|
||||||
RoomState::Space(space)
|
RoomState::Space(Box::new(space))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RoomState {
|
impl RoomState {
|
||||||
pub fn new(
|
pub fn new(
|
||||||
room: MatrixRoom,
|
room: MatrixRoom,
|
||||||
name: DisplayName,
|
thread: Option<OwnedEventId>,
|
||||||
|
name: RoomDisplayName,
|
||||||
tags: Option<Tags>,
|
tags: Option<Tags>,
|
||||||
store: &mut ProgramStore,
|
store: &mut ProgramStore,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
@@ -111,7 +152,14 @@ impl RoomState {
|
|||||||
if room.is_space() {
|
if room.is_space() {
|
||||||
SpaceState::new(room).into()
|
SpaceState::new(room).into()
|
||||||
} else {
|
} 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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,7 +172,7 @@ impl RoomState {
|
|||||||
|
|
||||||
fn draw_invite(
|
fn draw_invite(
|
||||||
&self,
|
&self,
|
||||||
invited: Invited,
|
invited: MatrixRoom,
|
||||||
area: Rect,
|
area: Rect,
|
||||||
buf: &mut Buffer,
|
buf: &mut Buffer,
|
||||||
store: &mut ProgramStore,
|
store: &mut ProgramStore,
|
||||||
@@ -144,11 +192,11 @@ impl RoomState {
|
|||||||
invited.push(store.application.settings.get_user_span(inviter.user_id(), info));
|
invited.push(store.application.settings.get_user_span(inviter.user_id(), info));
|
||||||
}
|
}
|
||||||
|
|
||||||
let l1 = Spans(invited);
|
let l1 = Line::from(invited);
|
||||||
let l2 = Spans::from(
|
let l2 = Line::from(
|
||||||
"You can run `:invite accept` or `:invite reject` to accept or reject this invitation.",
|
"You can run `:invite accept` or `:invite reject` to accept or reject this invitation.",
|
||||||
);
|
);
|
||||||
let text = Text { lines: vec![l1, l2] };
|
let text = Text::from(vec![l1, l2]);
|
||||||
|
|
||||||
Paragraph::new(text).alignment(Alignment::Center).render(area, buf);
|
Paragraph::new(text).alignment(Alignment::Center).render(area, buf);
|
||||||
|
|
||||||
@@ -167,6 +215,18 @@ impl RoomState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn space_command(
|
||||||
|
&mut self,
|
||||||
|
act: SpaceAction,
|
||||||
|
ctx: ProgramContext,
|
||||||
|
store: &mut ProgramStore,
|
||||||
|
) -> IambResult<EditInfo> {
|
||||||
|
match self {
|
||||||
|
RoomState::Space(space) => space.space_command(act, ctx, store).await,
|
||||||
|
RoomState::Chat(_) => Err(IambError::NoSelectedSpace.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn send_command(
|
pub async fn send_command(
|
||||||
&mut self,
|
&mut self,
|
||||||
act: SendAction,
|
act: SendAction,
|
||||||
@@ -182,17 +242,17 @@ impl RoomState {
|
|||||||
pub async fn room_command(
|
pub async fn room_command(
|
||||||
&mut self,
|
&mut self,
|
||||||
act: RoomAction,
|
act: RoomAction,
|
||||||
_: ProgramContext,
|
ctx: ProgramContext,
|
||||||
store: &mut ProgramStore,
|
store: &mut ProgramStore,
|
||||||
) -> IambResult<Vec<(Action<IambInfo>, ProgramContext)>> {
|
) -> IambResult<Vec<(Action<IambInfo>, ProgramContext)>> {
|
||||||
match act {
|
match act {
|
||||||
RoomAction::InviteAccept => {
|
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 = room.invite_details().await.map_err(IambError::from)?;
|
||||||
let details = details.invitee.event().original_content();
|
let details = details.invitee.event().original_content();
|
||||||
let is_direct = details.and_then(|ev| ev.is_direct).unwrap_or_default();
|
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 {
|
if is_direct {
|
||||||
room.set_is_direct(true).await.map_err(IambError::from)?;
|
room.set_is_direct(true).await.map_err(IambError::from)?;
|
||||||
@@ -204,8 +264,8 @@ impl RoomState {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
RoomAction::InviteReject => {
|
RoomAction::InviteReject => {
|
||||||
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()) {
|
||||||
room.reject_invitation().await.map_err(IambError::from)?;
|
room.leave().await.map_err(IambError::from)?;
|
||||||
|
|
||||||
Ok(vec![])
|
Ok(vec![])
|
||||||
} else {
|
} else {
|
||||||
@@ -213,7 +273,7 @@ impl RoomState {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
RoomAction::InviteSend(user) => {
|
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)?;
|
room.invite_user_by_id(user.as_ref()).await.map_err(IambError::from)?;
|
||||||
|
|
||||||
Ok(vec![])
|
Ok(vec![])
|
||||||
@@ -222,7 +282,7 @@ impl RoomState {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
RoomAction::Leave(skip_confirm) => {
|
RoomAction::Leave(skip_confirm) => {
|
||||||
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()) {
|
||||||
if skip_confirm {
|
if skip_confirm {
|
||||||
room.leave().await.map_err(IambError::from)?;
|
room.leave().await.map_err(IambError::from)?;
|
||||||
|
|
||||||
@@ -239,6 +299,47 @@ impl RoomState {
|
|||||||
Err(IambError::NotJoined.into())
|
Err(IambError::NotJoined.into())
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
RoomAction::MemberUpdate(mua, user, reason, skip_confirm) => {
|
||||||
|
let Some(room) = store.application.worker.client.get_room(self.id()) else {
|
||||||
|
return Err(IambError::NotJoined.into());
|
||||||
|
};
|
||||||
|
|
||||||
|
let Ok(user_id) = OwnedUserId::try_from(user.as_str()) else {
|
||||||
|
let err = IambError::InvalidUserId(user);
|
||||||
|
|
||||||
|
return Err(err.into());
|
||||||
|
};
|
||||||
|
|
||||||
|
if !skip_confirm {
|
||||||
|
let msg = format!("Do you really want to {mua} {user} from this room?");
|
||||||
|
let act = RoomAction::MemberUpdate(mua, user, reason, true);
|
||||||
|
let act = IambAction::from(act);
|
||||||
|
let prompt = PromptYesNo::new(msg, vec![Action::from(act)]);
|
||||||
|
let prompt = Box::new(prompt);
|
||||||
|
|
||||||
|
return Err(UIError::NeedConfirm(prompt));
|
||||||
|
}
|
||||||
|
|
||||||
|
match mua {
|
||||||
|
MemberUpdateAction::Ban => {
|
||||||
|
room.ban_user(&user_id, reason.as_deref())
|
||||||
|
.await
|
||||||
|
.map_err(IambError::from)?;
|
||||||
|
},
|
||||||
|
MemberUpdateAction::Unban => {
|
||||||
|
room.unban_user(&user_id, reason.as_deref())
|
||||||
|
.await
|
||||||
|
.map_err(IambError::from)?;
|
||||||
|
},
|
||||||
|
MemberUpdateAction::Kick => {
|
||||||
|
room.kick_user(&user_id, reason.as_deref())
|
||||||
|
.await
|
||||||
|
.map_err(IambError::from)?;
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(vec![])
|
||||||
|
},
|
||||||
RoomAction::Members(mut cmd) => {
|
RoomAction::Members(mut cmd) => {
|
||||||
let width = Count::Exact(30);
|
let width = Count::Exact(30);
|
||||||
let act =
|
let act =
|
||||||
@@ -247,7 +348,17 @@ impl RoomState {
|
|||||||
width.into(),
|
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) => {
|
RoomAction::Set(field, value) => {
|
||||||
let room = store
|
let room = store
|
||||||
@@ -256,8 +367,13 @@ impl RoomState {
|
|||||||
.ok_or(UIError::Application(IambError::NotJoined))?;
|
.ok_or(UIError::Application(IambError::NotJoined))?;
|
||||||
|
|
||||||
match field {
|
match field {
|
||||||
|
RoomField::History => {
|
||||||
|
let visibility = hist_visibility_mode(value)?;
|
||||||
|
let ev = RoomHistoryVisibilityEventContent::new(visibility);
|
||||||
|
let _ = room.send_state_event(ev).await.map_err(IambError::from)?;
|
||||||
|
},
|
||||||
RoomField::Name => {
|
RoomField::Name => {
|
||||||
let ev = RoomNameEventContent::new(value.into());
|
let ev = RoomNameEventContent::new(value);
|
||||||
let _ = room.send_state_event(ev).await.map_err(IambError::from)?;
|
let _ = room.send_state_event(ev).await.map_err(IambError::from)?;
|
||||||
},
|
},
|
||||||
RoomField::Tag(tag) => {
|
RoomField::Tag(tag) => {
|
||||||
@@ -270,6 +386,100 @@ impl RoomState {
|
|||||||
let ev = RoomTopicEventContent::new(value);
|
let ev = RoomTopicEventContent::new(value);
|
||||||
let _ = room.send_state_event(ev).await.map_err(IambError::from)?;
|
let _ = room.send_state_event(ev).await.map_err(IambError::from)?;
|
||||||
},
|
},
|
||||||
|
RoomField::NotificationMode => {
|
||||||
|
let mode = notification_mode(value)?;
|
||||||
|
let client = &store.application.worker.client;
|
||||||
|
let notifications = client.notification_settings().await;
|
||||||
|
|
||||||
|
notifications
|
||||||
|
.set_room_notification_mode(self.id(), mode)
|
||||||
|
.await
|
||||||
|
.map_err(IambError::from)?;
|
||||||
|
},
|
||||||
|
RoomField::CanonicalAlias => {
|
||||||
|
let client = &mut store.application.worker.client;
|
||||||
|
|
||||||
|
let Ok(orai) = OwnedRoomAliasId::try_from(value.as_str()) else {
|
||||||
|
let err = IambError::InvalidRoomAlias(value);
|
||||||
|
|
||||||
|
return Err(err.into());
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut alt_aliases =
|
||||||
|
room.alt_aliases().into_iter().collect::<HashSet<_>>();
|
||||||
|
let canonical_old = room.canonical_alias();
|
||||||
|
|
||||||
|
// If the room's alias is already that, ignore it
|
||||||
|
if canonical_old.as_ref() == Some(&orai) {
|
||||||
|
let msg = format!("The canonical room alias is already {orai}");
|
||||||
|
|
||||||
|
return Ok(vec![(Action::ShowInfoMessage(msg.into()), ctx)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try creating the room alias on the server.
|
||||||
|
let alias_create_req =
|
||||||
|
CreateAliasRequest::new(orai.clone(), room.room_id().into());
|
||||||
|
if let Err(e) = client.send(alias_create_req).await {
|
||||||
|
if let Some(ClientApiErrorKind::Unknown) = e.client_api_error_kind() {
|
||||||
|
// Ignore when it already exists.
|
||||||
|
} else {
|
||||||
|
return Err(IambError::from(e).into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Demote the previous one to an alt alias.
|
||||||
|
alt_aliases.extend(canonical_old);
|
||||||
|
|
||||||
|
// At this point the room alias definitely exists, and we can update the
|
||||||
|
// state event.
|
||||||
|
let mut ev = RoomCanonicalAliasEventContent::new();
|
||||||
|
ev.alias = Some(orai);
|
||||||
|
ev.alt_aliases = alt_aliases.into_iter().collect();
|
||||||
|
let _ = room.send_state_event(ev).await.map_err(IambError::from)?;
|
||||||
|
},
|
||||||
|
RoomField::Alias(alias) => {
|
||||||
|
let client = &mut store.application.worker.client;
|
||||||
|
|
||||||
|
let Ok(orai) = OwnedRoomAliasId::try_from(alias.as_str()) else {
|
||||||
|
let err = IambError::InvalidRoomAlias(alias);
|
||||||
|
|
||||||
|
return Err(err.into());
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut alt_aliases =
|
||||||
|
room.alt_aliases().into_iter().collect::<HashSet<_>>();
|
||||||
|
let canonical = room.canonical_alias();
|
||||||
|
|
||||||
|
if alt_aliases.contains(&orai) || canonical.as_ref() == Some(&orai) {
|
||||||
|
let msg = format!("The alias {orai} already maps to this room");
|
||||||
|
|
||||||
|
return Ok(vec![(Action::ShowInfoMessage(msg.into()), ctx)]);
|
||||||
|
} else {
|
||||||
|
alt_aliases.insert(orai.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the room alias does not exist on the server, create it
|
||||||
|
let alias_create_req = CreateAliasRequest::new(orai, room.room_id().into());
|
||||||
|
if let Err(e) = client.send(alias_create_req).await {
|
||||||
|
if let Some(ClientApiErrorKind::Unknown) = e.client_api_error_kind() {
|
||||||
|
// Ignore when it already exists.
|
||||||
|
} else {
|
||||||
|
return Err(IambError::from(e).into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// And add it to the aliases in the state event.
|
||||||
|
let mut ev = RoomCanonicalAliasEventContent::new();
|
||||||
|
ev.alias = canonical;
|
||||||
|
ev.alt_aliases = alt_aliases.into_iter().collect();
|
||||||
|
let _ = room.send_state_event(ev).await.map_err(IambError::from)?;
|
||||||
|
},
|
||||||
|
RoomField::Aliases => {
|
||||||
|
// This never happens, aliases is only used for showing
|
||||||
|
},
|
||||||
|
RoomField::Id => {
|
||||||
|
// This never happens, id is only used for showing
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(vec![])
|
Ok(vec![])
|
||||||
@@ -281,8 +491,13 @@ impl RoomState {
|
|||||||
.ok_or(UIError::Application(IambError::NotJoined))?;
|
.ok_or(UIError::Application(IambError::NotJoined))?;
|
||||||
|
|
||||||
match field {
|
match field {
|
||||||
|
RoomField::History => {
|
||||||
|
let visibility = HistoryVisibility::Joined;
|
||||||
|
let ev = RoomHistoryVisibilityEventContent::new(visibility);
|
||||||
|
let _ = room.send_state_event(ev).await.map_err(IambError::from)?;
|
||||||
|
},
|
||||||
RoomField::Name => {
|
RoomField::Name => {
|
||||||
let ev = RoomNameEventContent::new(None);
|
let ev = RoomNameEventContent::new("".into());
|
||||||
let _ = room.send_state_event(ev).await.map_err(IambError::from)?;
|
let _ = room.send_state_event(ev).await.map_err(IambError::from)?;
|
||||||
},
|
},
|
||||||
RoomField::Tag(tag) => {
|
RoomField::Tag(tag) => {
|
||||||
@@ -292,17 +507,169 @@ impl RoomState {
|
|||||||
let ev = RoomTopicEventContent::new("".into());
|
let ev = RoomTopicEventContent::new("".into());
|
||||||
let _ = room.send_state_event(ev).await.map_err(IambError::from)?;
|
let _ = room.send_state_event(ev).await.map_err(IambError::from)?;
|
||||||
},
|
},
|
||||||
|
RoomField::NotificationMode => {
|
||||||
|
let client = &store.application.worker.client;
|
||||||
|
let notifications = client.notification_settings().await;
|
||||||
|
|
||||||
|
notifications
|
||||||
|
.delete_user_defined_room_rules(self.id())
|
||||||
|
.await
|
||||||
|
.map_err(IambError::from)?;
|
||||||
|
},
|
||||||
|
RoomField::CanonicalAlias => {
|
||||||
|
let Some(alias_to_destroy) = room.canonical_alias() else {
|
||||||
|
let msg = "This room has no canonical alias to unset";
|
||||||
|
|
||||||
|
return Ok(vec![(Action::ShowInfoMessage(msg.into()), ctx)]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Remove the canonical alias from the state event.
|
||||||
|
let mut ev = RoomCanonicalAliasEventContent::new();
|
||||||
|
ev.alias = None;
|
||||||
|
ev.alt_aliases = room.alt_aliases();
|
||||||
|
let _ = room.send_state_event(ev).await.map_err(IambError::from)?;
|
||||||
|
|
||||||
|
// And then unmap it on the server.
|
||||||
|
let del_req = DeleteAliasRequest::new(alias_to_destroy);
|
||||||
|
let _ = store
|
||||||
|
.application
|
||||||
|
.worker
|
||||||
|
.client
|
||||||
|
.send(del_req)
|
||||||
|
.await
|
||||||
|
.map_err(IambError::from)?;
|
||||||
|
},
|
||||||
|
RoomField::Alias(alias) => {
|
||||||
|
let Ok(orai) = OwnedRoomAliasId::try_from(alias.as_str()) else {
|
||||||
|
let err = IambError::InvalidRoomAlias(alias);
|
||||||
|
|
||||||
|
return Err(err.into());
|
||||||
|
};
|
||||||
|
|
||||||
|
let alt_aliases = room.alt_aliases();
|
||||||
|
let canonical = room.canonical_alias();
|
||||||
|
|
||||||
|
if !alt_aliases.contains(&orai) && canonical.as_ref() != Some(&orai) {
|
||||||
|
let msg = format!("The alias {orai:?} isn't mapped to this room");
|
||||||
|
|
||||||
|
return Ok(vec![(Action::ShowInfoMessage(msg.into()), ctx)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the alias from the state event if it's in it.
|
||||||
|
let mut ev = RoomCanonicalAliasEventContent::new();
|
||||||
|
ev.alias = canonical.filter(|canon| canon != &orai);
|
||||||
|
ev.alt_aliases = alt_aliases;
|
||||||
|
ev.alt_aliases.retain(|in_orai| in_orai != &orai);
|
||||||
|
let _ = room.send_state_event(ev).await.map_err(IambError::from)?;
|
||||||
|
|
||||||
|
// And then unmap it on the server.
|
||||||
|
let del_req = DeleteAliasRequest::new(orai);
|
||||||
|
let _ = store
|
||||||
|
.application
|
||||||
|
.worker
|
||||||
|
.client
|
||||||
|
.send(del_req)
|
||||||
|
.await
|
||||||
|
.map_err(IambError::from)?;
|
||||||
|
},
|
||||||
|
RoomField::Aliases => {
|
||||||
|
// This will not happen, you cannot unset all aliases
|
||||||
|
},
|
||||||
|
RoomField::Id => {
|
||||||
|
// This never happens, id is only used for showing
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(vec![])
|
Ok(vec![])
|
||||||
},
|
},
|
||||||
|
RoomAction::Show(field) => {
|
||||||
|
let room = store
|
||||||
|
.application
|
||||||
|
.get_joined_room(self.id())
|
||||||
|
.ok_or(UIError::Application(IambError::NotJoined))?;
|
||||||
|
|
||||||
|
let msg = match field {
|
||||||
|
RoomField::History => {
|
||||||
|
let visibility = room.history_visibility();
|
||||||
|
let visibility = visibility.as_ref().map(|v| v.as_str());
|
||||||
|
format!("Room history visibility: {}", visibility.unwrap_or("<unknown>"))
|
||||||
|
},
|
||||||
|
RoomField::Id => {
|
||||||
|
let id = room.room_id();
|
||||||
|
format!("Room identifier: {id}")
|
||||||
|
},
|
||||||
|
RoomField::Name => {
|
||||||
|
match room.name() {
|
||||||
|
None => "Room has no name".into(),
|
||||||
|
Some(name) => format!("Room name: {name:?}"),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
RoomField::Topic => {
|
||||||
|
match room.topic() {
|
||||||
|
None => "Room has no topic".into(),
|
||||||
|
Some(topic) => format!("Room topic: {topic:?}"),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
RoomField::NotificationMode => {
|
||||||
|
let client = &store.application.worker.client;
|
||||||
|
let notifications = client.notification_settings().await;
|
||||||
|
let mode =
|
||||||
|
notifications.get_user_defined_room_notification_mode(self.id()).await;
|
||||||
|
|
||||||
|
let level = match mode {
|
||||||
|
Some(RoomNotificationMode::Mute) => "mute",
|
||||||
|
Some(RoomNotificationMode::MentionsAndKeywordsOnly) => "keywords",
|
||||||
|
Some(RoomNotificationMode::AllMessages) => "all",
|
||||||
|
None => "default",
|
||||||
|
};
|
||||||
|
|
||||||
|
format!("Room notification level: {level:?}")
|
||||||
|
},
|
||||||
|
RoomField::Aliases => {
|
||||||
|
let aliases = room
|
||||||
|
.alt_aliases()
|
||||||
|
.iter()
|
||||||
|
.map(OwnedRoomAliasId::to_string)
|
||||||
|
.collect::<Vec<String>>();
|
||||||
|
|
||||||
|
if aliases.is_empty() {
|
||||||
|
"No alternative aliases in room".into()
|
||||||
|
} else {
|
||||||
|
format!("Alternative aliases: {}.", aliases.join(", "))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
RoomField::CanonicalAlias => {
|
||||||
|
match room.canonical_alias() {
|
||||||
|
None => "No canonical alias for room".into(),
|
||||||
|
Some(can) => format!("Canonical alias: {can}"),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
RoomField::Tag(_) => "Cannot currently show value for a tag".into(),
|
||||||
|
RoomField::Alias(_) => {
|
||||||
|
"Cannot show a single alias; use `:room aliases show` instead.".into()
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let msg = InfoMessage::Pager(msg);
|
||||||
|
let act = Action::ShowInfoMessage(msg);
|
||||||
|
|
||||||
|
Ok(vec![(act, ctx)])
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 title = store.application.get_room_title(self.id());
|
||||||
let style = Style::default().add_modifier(StyleModifier::BOLD);
|
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() {
|
match self.room().topic() {
|
||||||
Some(desc) if !desc.is_empty() => {
|
Some(desc) if !desc.is_empty() => {
|
||||||
@@ -313,7 +680,7 @@ impl RoomState {
|
|||||||
_ => {},
|
_ => {},
|
||||||
}
|
}
|
||||||
|
|
||||||
Spans(spans)
|
Line::from(spans)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn focus_toggle(&mut self) {
|
pub fn focus_toggle(&mut self) {
|
||||||
@@ -391,12 +758,12 @@ impl TerminalCursor for RoomState {
|
|||||||
|
|
||||||
impl WindowOps<IambInfo> for RoomState {
|
impl WindowOps<IambInfo> for RoomState {
|
||||||
fn draw(&mut self, area: Rect, buf: &mut Buffer, focused: bool, store: &mut ProgramStore) {
|
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);
|
self.refresh_room(store);
|
||||||
}
|
}
|
||||||
|
|
||||||
if let MatrixRoom::Invited(invited) = self.room() {
|
if self.room().state() == MatrixRoomState::Invited {
|
||||||
self.draw_invite(invited.clone(), area, buf, store);
|
self.draw_invite(self.room().clone(), area, buf, store);
|
||||||
}
|
}
|
||||||
|
|
||||||
match self {
|
match self {
|
||||||
@@ -409,8 +776,8 @@ impl WindowOps<IambInfo> for RoomState {
|
|||||||
|
|
||||||
fn dup(&self, store: &mut ProgramStore) -> Self {
|
fn dup(&self, store: &mut ProgramStore) -> Self {
|
||||||
match self {
|
match self {
|
||||||
RoomState::Chat(chat) => RoomState::Chat(chat.dup(store)),
|
RoomState::Chat(chat) => RoomState::Chat(Box::new(chat.dup(store))),
|
||||||
RoomState::Space(space) => RoomState::Space(space.dup(store)),
|
RoomState::Space(space) => RoomState::Space(Box::new(space.dup(store))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -454,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,30 +1,48 @@
|
|||||||
|
//! Window for Matrix spaces
|
||||||
use std::ops::{Deref, DerefMut};
|
use std::ops::{Deref, DerefMut};
|
||||||
|
use std::str::FromStr;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use matrix_sdk::ruma::events::space::child::SpaceChildEventContent;
|
||||||
|
use matrix_sdk::ruma::events::StateEventType;
|
||||||
|
use matrix_sdk::ruma::OwnedSpaceChildOrder;
|
||||||
use matrix_sdk::{
|
use matrix_sdk::{
|
||||||
room::Room as MatrixRoom,
|
room::Room as MatrixRoom,
|
||||||
ruma::{OwnedRoomId, RoomId},
|
ruma::{OwnedRoomId, RoomId},
|
||||||
};
|
};
|
||||||
|
|
||||||
use modalkit::tui::{
|
use modalkit::prelude::{EditInfo, InfoMessage};
|
||||||
|
use ratatui::{
|
||||||
buffer::Buffer,
|
buffer::Buffer,
|
||||||
layout::Rect,
|
layout::Rect,
|
||||||
style::{Color, Style},
|
style::{Color, Style},
|
||||||
text::{Span, Spans, Text},
|
text::{Line, Span, Text},
|
||||||
widgets::StatefulWidget,
|
widgets::StatefulWidget,
|
||||||
};
|
};
|
||||||
|
|
||||||
use modalkit::{
|
use modalkit_ratatui::{
|
||||||
widgets::list::{List, ListState},
|
list::{List, ListState},
|
||||||
widgets::{TermOffset, TerminalCursor, WindowOps},
|
TermOffset,
|
||||||
|
TerminalCursor,
|
||||||
|
WindowOps,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::base::{IambBufferId, IambInfo, ProgramStore, RoomFocus};
|
use crate::base::{
|
||||||
|
IambBufferId,
|
||||||
|
IambError,
|
||||||
|
IambInfo,
|
||||||
|
IambResult,
|
||||||
|
ProgramContext,
|
||||||
|
ProgramStore,
|
||||||
|
RoomFocus,
|
||||||
|
SpaceAction,
|
||||||
|
};
|
||||||
|
|
||||||
use crate::windows::RoomItem;
|
use crate::windows::{room_fields_cmp, RoomItem, RoomLikeItem};
|
||||||
|
|
||||||
const SPACE_HIERARCHY_DEBOUNCE: Duration = Duration::from_secs(5);
|
const SPACE_HIERARCHY_DEBOUNCE: Duration = Duration::from_secs(5);
|
||||||
|
|
||||||
|
/// State needed for rendering [Space].
|
||||||
pub struct SpaceState {
|
pub struct SpaceState {
|
||||||
room_id: OwnedRoomId,
|
room_id: OwnedRoomId,
|
||||||
room: MatrixRoom,
|
room: MatrixRoom,
|
||||||
@@ -35,7 +53,7 @@ pub struct SpaceState {
|
|||||||
impl SpaceState {
|
impl SpaceState {
|
||||||
pub fn new(room: MatrixRoom) -> Self {
|
pub fn new(room: MatrixRoom) -> Self {
|
||||||
let room_id = room.room_id().to_owned();
|
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 list = ListState::new(content, vec![]);
|
||||||
let last_fetch = None;
|
let last_fetch = None;
|
||||||
|
|
||||||
@@ -64,6 +82,79 @@ impl SpaceState {
|
|||||||
last_fetch: self.last_fetch,
|
last_fetch: self.last_fetch,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn space_command(
|
||||||
|
&mut self,
|
||||||
|
act: SpaceAction,
|
||||||
|
_: ProgramContext,
|
||||||
|
store: &mut ProgramStore,
|
||||||
|
) -> IambResult<EditInfo> {
|
||||||
|
match act {
|
||||||
|
SpaceAction::SetChild(child_id, order, suggested) => {
|
||||||
|
if !self
|
||||||
|
.room
|
||||||
|
.power_levels()
|
||||||
|
.await
|
||||||
|
.map_err(matrix_sdk::Error::from)
|
||||||
|
.map_err(IambError::from)?
|
||||||
|
.user_can_send_state(
|
||||||
|
&store.application.settings.profile.user_id,
|
||||||
|
StateEventType::SpaceChild,
|
||||||
|
)
|
||||||
|
{
|
||||||
|
return Err(IambError::InsufficientPermission.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let via = self.room.route().await.map_err(IambError::from)?;
|
||||||
|
let mut ev = SpaceChildEventContent::new(via);
|
||||||
|
ev.order = order
|
||||||
|
.as_deref()
|
||||||
|
.map(OwnedSpaceChildOrder::from_str)
|
||||||
|
.transpose()
|
||||||
|
.map_err(IambError::InvalidSpaceChildOrder)?;
|
||||||
|
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
|
||||||
|
.power_levels()
|
||||||
|
.await
|
||||||
|
.map_err(matrix_sdk::Error::from)
|
||||||
|
.map_err(IambError::from)?
|
||||||
|
.user_can_send_state(
|
||||||
|
&store.application.settings.profile.user_id,
|
||||||
|
StateEventType::SpaceChild,
|
||||||
|
)
|
||||||
|
{
|
||||||
|
return Err(IambError::InsufficientPermission.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let ev = SpaceChildEventContent::new(vec![]);
|
||||||
|
let event_id = self
|
||||||
|
.room
|
||||||
|
.send_state_event_for_key(&space.room_id().to_owned(), ev)
|
||||||
|
.await
|
||||||
|
.map_err(IambError::from)?;
|
||||||
|
|
||||||
|
// Fix for element (see https://github.com/element-hq/element-web/issues/29606)
|
||||||
|
let _ = self
|
||||||
|
.room
|
||||||
|
.redact(&event_id.event_id, Some("workaround for element bug"), None)
|
||||||
|
.await
|
||||||
|
.map_err(IambError::from)?;
|
||||||
|
|
||||||
|
Ok(InfoMessage::from("Room removed").into())
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TerminalCursor for SpaceState {
|
impl TerminalCursor for SpaceState {
|
||||||
@@ -86,6 +177,7 @@ impl DerefMut for SpaceState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// [StatefulWidget] for Matrix spaces.
|
||||||
pub struct Space<'a> {
|
pub struct Space<'a> {
|
||||||
focused: bool,
|
focused: bool,
|
||||||
store: &'a mut ProgramStore,
|
store: &'a mut ProgramStore,
|
||||||
@@ -102,7 +194,7 @@ impl<'a> Space<'a> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> StatefulWidget for Space<'a> {
|
impl StatefulWidget for Space<'_> {
|
||||||
type State = SpaceState;
|
type State = SpaceState;
|
||||||
|
|
||||||
fn render(self, area: Rect, buffer: &mut Buffer, state: &mut Self::State) {
|
fn render(self, area: Rect, buffer: &mut Buffer, state: &mut Self::State) {
|
||||||
@@ -117,7 +209,7 @@ impl<'a> StatefulWidget for Space<'a> {
|
|||||||
|
|
||||||
match res {
|
match res {
|
||||||
Ok(members) => {
|
Ok(members) => {
|
||||||
let items = members
|
let mut items = members
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter_map(|id| {
|
.filter_map(|id| {
|
||||||
let (room, _, tags) =
|
let (room, _, tags) =
|
||||||
@@ -130,18 +222,21 @@ impl<'a> StatefulWidget for Space<'a> {
|
|||||||
None
|
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.list.set(items);
|
||||||
state.last_fetch = Some(Instant::now());
|
state.last_fetch = Some(Instant::now());
|
||||||
},
|
},
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let lines = vec![
|
let lines = vec![
|
||||||
Spans::from("Unable to fetch space room hierarchy:"),
|
Line::from("Unable to fetch space room hierarchy:"),
|
||||||
Span::styled(e.to_string(), Style::default().fg(Color::Red)).into(),
|
Span::styled(e.to_string(), Style::default().fg(Color::Red)).into(),
|
||||||
];
|
];
|
||||||
|
|
||||||
empty_message = Text { lines }.into();
|
empty_message = Text::from(lines).into();
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
|
|
||||||
- `:dms` will open a list of direct messages
|
- `:dms` will open a list of direct messages
|
||||||
- `:rooms` will open a list of joined rooms
|
- `: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
|
- `:members` will open a list of members for the currently focused room or space
|
||||||
- `:spaces` will open a list of joined spaces
|
- `:spaces` will open a list of joined spaces
|
||||||
- `:join` can be used to switch to join a new room or start a direct message
|
- `: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
|
## Additional Configuration
|
||||||
|
|
||||||
You can customize iamb in your `$CONFIG_DIR/iamb/config.json` file, where
|
You can customize iamb in your `$CONFIG_DIR/iamb/config.toml` file, where
|
||||||
`$CONFIG_DIR` is your system's per-user configuration directory.
|
`$CONFIG_DIR` is your system's per-user configuration directory. For example,
|
||||||
|
this is typically `~/.config/iamb/config.toml` on systems that use the XDG
|
||||||
|
Base Directory Specification.
|
||||||
|
|
||||||
You can edit the following values in the file:
|
See the manual pages or <https://iamb.chat> for more details on how to
|
||||||
|
further configure or use iamb.
|
||||||
- `"default_profile"`, a profile name to use when starting iamb if one wasn't specified
|
|
||||||
- `"cache"`, a directory for cached iamb
|
|
||||||
|
|||||||
@@ -1,16 +1,12 @@
|
|||||||
|
//! Welcome Window
|
||||||
use std::ops::{Deref, DerefMut};
|
use std::ops::{Deref, DerefMut};
|
||||||
|
|
||||||
use modalkit::tui::{buffer::Buffer, layout::Rect};
|
use ratatui::{buffer::Buffer, layout::Rect};
|
||||||
|
|
||||||
use modalkit::{
|
use modalkit_ratatui::{textbox::TextBoxState, TermOffset, TerminalCursor, WindowOps};
|
||||||
widgets::textbox::TextBoxState,
|
|
||||||
widgets::WindowOps,
|
|
||||||
widgets::{TermOffset, TerminalCursor},
|
|
||||||
};
|
|
||||||
|
|
||||||
use modalkit::editing::action::EditInfo;
|
|
||||||
use modalkit::editing::base::{CloseFlags, WordStyle, WriteFlags};
|
|
||||||
use modalkit::editing::completion::CompletionList;
|
use modalkit::editing::completion::CompletionList;
|
||||||
|
use modalkit::prelude::*;
|
||||||
|
|
||||||
use crate::base::{IambBufferId, IambInfo, IambResult, ProgramStore};
|
use crate::base::{IambBufferId, IambInfo, IambResult, ProgramStore};
|
||||||
|
|
||||||
|
|||||||
710
src/worker.rs
710
src/worker.rs
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user