Compare commits
243 Commits
v0.0.4
...
ulyssa/msr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5d356e741 | ||
|
|
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 | ||
|
|
89bb107c87 | ||
|
|
ca4c0034d9 | ||
|
|
bb30cecc63 | ||
|
|
7b050f82aa | ||
|
|
b1ccec6732 | ||
|
|
6e8e12b579 | ||
|
|
3da9835a17 | ||
|
|
64891ec68f | ||
|
|
61aba80be1 | ||
|
|
8d4539831f | ||
|
|
7c39e88ba2 | ||
|
|
758397eb5a | ||
|
|
1a0af6df37 | ||
|
|
885b56038f | ||
|
|
430c601ff2 | ||
|
|
0ddefcd7b3 | ||
|
|
2a573b6056 | ||
|
|
a020b860dd | ||
|
|
6c031f589e | ||
|
|
b0256d7120 | ||
|
|
0f870367b3 | ||
|
|
8d22b83d85 | ||
|
|
529073f4d4 | ||
|
|
17c87a617e | ||
|
|
2899d4f45a | ||
|
|
ad8b4a60d2 | ||
|
|
4935899aed | ||
|
|
cc1d2f3bf8 | ||
|
|
5df9fe7960 | ||
|
|
a5c25f2487 | ||
|
|
50023bad40 | ||
|
|
b6a318dfe3 | ||
|
|
ad3b40d538 | ||
|
|
953be6a195 | ||
|
|
463d46b8ab | ||
|
|
274234ce4c | ||
|
|
a2590b6bbb | ||
|
|
725ebb9fd6 | ||
|
|
ca395097e1 | ||
|
|
e98d58a8cc | ||
|
|
e6cdd02f22 | ||
|
|
0bc4ff07b0 | ||
|
|
14fe916d94 | ||
|
|
db35581d07 | ||
|
|
7c1c62897a | ||
|
|
61897ea6f2 | ||
|
|
6a0722795a | ||
|
|
f3bbc6ad9f | ||
|
|
2dd8c0fddf | ||
|
|
a786369b14 | ||
|
|
066f60ad32 | ||
|
|
10b142c071 | ||
|
|
ac6ff63d25 | ||
|
|
54a0e76823 | ||
|
|
93eff79f79 | ||
|
|
11625262f1 | ||
|
|
0ed1d53946 | ||
|
|
e3be8c16cb | ||
|
|
4c5c57e26c | ||
|
|
8eef8787cc | ||
|
|
c9c547acc1 | ||
|
|
3629f15e0d | ||
|
|
fd72cf5c4e | ||
|
|
1d93461183 | ||
|
|
a1574c6b8d | ||
|
|
e8205df21d |
4
.gitattributes
vendored
4
.gitattributes
vendored
@@ -1 +1,3 @@
|
|||||||
* text eol=lf
|
*.rs text eol=lf
|
||||||
|
*.toml text eol=lf
|
||||||
|
*.md text eol=lf
|
||||||
|
|||||||
1
.github/FUNDING.yml
vendored
Normal file
1
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ko_fi: ulyssam
|
||||||
94
.github/workflows/binaries.yml
vendored
Normal file
94
.github/workflows/binaries.yml
vendored
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
name: Binaries
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
package:
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
platform: [ubuntu-latest, windows-latest, macos-latest]
|
||||||
|
arch: [x86_64, aarch64]
|
||||||
|
exclude:
|
||||||
|
- platform: windows-latest
|
||||||
|
arch: aarch64
|
||||||
|
include:
|
||||||
|
- platform: ubuntu-latest
|
||||||
|
arch: x86_64
|
||||||
|
triple: unknown-linux-musl
|
||||||
|
- platform: ubuntu-latest
|
||||||
|
arch: aarch64
|
||||||
|
triple: unknown-linux-gnu
|
||||||
|
- platform: macos-latest
|
||||||
|
triple: apple-darwin
|
||||||
|
- platform: windows-latest
|
||||||
|
triple: pc-windows-msvc
|
||||||
|
runs-on: ${{ matrix.platform }}
|
||||||
|
env:
|
||||||
|
TARGET: ${{ matrix.arch }}-${{ matrix.triple }}
|
||||||
|
SCCACHE_GHA_ENABLED: "true"
|
||||||
|
RUSTC_WRAPPER: "sccache"
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
submodules: true
|
||||||
|
- name: Install Rust (stable)
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
targets: ${{ env.TARGET }}
|
||||||
|
- name: Install C cross-compilation toolchain
|
||||||
|
if: matrix.platform == 'ubuntu-latest'
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt install -f -y build-essential crossbuild-essential-arm64 musl-dev
|
||||||
|
# Cross-compilation env vars for x86_64-unknown-linux-musl
|
||||||
|
echo CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER=x86_64-linux-musl-gcc >> $GITHUB_ENV
|
||||||
|
echo AR_x86_64_unknown_linux_musl=x86_64-linux-gnu-ar >> $GITHUB_ENV
|
||||||
|
echo CC_x86_64_unknown_linux_musl=x86_64-linux-musl-gcc >> $GITHUB_ENV
|
||||||
|
echo CXX_x86_64_unknown_linux_musl=x86_64-linux-gnu-g++ >> $GITHUB_ENV
|
||||||
|
# Cross-compilation env vars for aarch64-unknown-linux-gnu
|
||||||
|
echo CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc >> $GITHUB_ENV
|
||||||
|
echo AR_aarch64_unknown_linux_gnu=aarch64-linux-gnu-ar >> $GITHUB_ENV
|
||||||
|
echo CC_aarch64_unknown_linux_gnu=aarch64-linux-gnu-gcc >> $GITHUB_ENV
|
||||||
|
echo CXX_aarch64_unknown_linux_gnu=aarch64-linux-gnu-g++ >> $GITHUB_ENV
|
||||||
|
- name: Cache cargo registry
|
||||||
|
uses: actions/cache@v3
|
||||||
|
with:
|
||||||
|
path: ~/.cargo/registry
|
||||||
|
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
- name: Run sccache-cache
|
||||||
|
uses: mozilla-actions/sccache-action@v0.0.9
|
||||||
|
- name: 'Build: binary'
|
||||||
|
run: cargo +stable build --release --locked --target ${{ env.TARGET }}
|
||||||
|
- name: 'Upload: binary'
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: iamb-${{ env.TARGET }}-binary
|
||||||
|
path: |
|
||||||
|
./target/${{ env.TARGET }}/release/iamb
|
||||||
|
./target/${{ env.TARGET }}/release/iamb.exe
|
||||||
|
- name: 'Package: deb'
|
||||||
|
if: matrix.platform == 'ubuntu-latest'
|
||||||
|
run: |
|
||||||
|
cargo +stable install --locked cargo-deb
|
||||||
|
cargo +stable deb --no-strip --target ${{ env.TARGET }}
|
||||||
|
- name: 'Upload: deb'
|
||||||
|
if: matrix.platform == 'ubuntu-latest'
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: iamb-${{ env.TARGET }}-deb
|
||||||
|
path: ./target/${{ env.TARGET }}/debian/iamb*.deb
|
||||||
|
- name: 'Package: rpm'
|
||||||
|
if: matrix.platform == 'ubuntu-latest'
|
||||||
|
run: |
|
||||||
|
cargo +stable install --locked cargo-generate-rpm
|
||||||
|
cargo +stable generate-rpm --target ${{ env.TARGET }}
|
||||||
|
- name: 'Upload: rpm'
|
||||||
|
if: matrix.platform == 'ubuntu-latest'
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: iamb-${{ env.TARGET }}-rpm
|
||||||
|
path: ./target/${{ env.TARGET }}/generate-rpm/iamb*.rpm
|
||||||
73
.github/workflows/ci.yml
vendored
73
.github/workflows/ci.yml
vendored
@@ -9,52 +9,61 @@ on:
|
|||||||
name: CI
|
name: CI
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
clippy_check:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v1
|
|
||||||
with:
|
|
||||||
submodules: true
|
|
||||||
- name: Install Rust
|
|
||||||
uses: actions-rs/toolchain@v1
|
|
||||||
with:
|
|
||||||
toolchain: stable
|
|
||||||
components: clippy
|
|
||||||
override: true
|
|
||||||
- name: Check Clippy
|
|
||||||
uses: actions-rs/clippy-check@v1
|
|
||||||
with:
|
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
toolchain: stable
|
|
||||||
args:
|
|
||||||
test:
|
test:
|
||||||
strategy:
|
strategy:
|
||||||
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@v2
|
uses: actions/checkout@v3
|
||||||
with:
|
with:
|
||||||
submodules: true
|
submodules: true
|
||||||
- name: Install Rust
|
- name: Install Rust (1.83 w/ clippy)
|
||||||
uses: actions-rs/toolchain@v1
|
uses: dtolnay/rust-toolchain@1.83
|
||||||
with:
|
with:
|
||||||
toolchain: nightly
|
components: clippy
|
||||||
override: true
|
- name: Install Rust (nightly w/ rustfmt)
|
||||||
components: rustfmt, clippy
|
run: rustup toolchain install nightly --component rustfmt
|
||||||
- name: Cache cargo registry
|
- name: Cache cargo registry
|
||||||
uses: actions/cache@v1
|
uses: actions/cache@v3
|
||||||
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: Run sccache-cache
|
||||||
|
uses: mozilla-actions/sccache-action@v0.0.9
|
||||||
- name: Check formatting
|
- name: Check formatting
|
||||||
uses: actions-rs/cargo@v1
|
run: cargo +nightly fmt --all -- --check
|
||||||
|
- name: Check Clippy
|
||||||
|
if: matrix.platform == 'ubuntu-latest'
|
||||||
|
uses: giraffate/clippy-action@v1
|
||||||
with:
|
with:
|
||||||
command: fmt
|
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
args: --all -- --check
|
reporter: 'github-check'
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
uses: actions-rs/cargo@v1
|
run: cargo test --locked
|
||||||
with:
|
|
||||||
command: test
|
nix-flake-test:
|
||||||
|
name: Flake checks ❄️
|
||||||
|
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:
|
||||||
|
github_access_token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
- uses: cachix/cachix-action@v15
|
||||||
|
with:
|
||||||
|
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,2 +1,4 @@
|
|||||||
/target
|
/target
|
||||||
|
/result
|
||||||
/TODO
|
/TODO
|
||||||
|
.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
|
||||||
|
|||||||
6018
Cargo.lock
generated
6018
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
99
Cargo.toml
99
Cargo.toml
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "iamb"
|
name = "iamb"
|
||||||
version = "0.0.4"
|
version = "0.0.11-alpha.1"
|
||||||
edition = "2018"
|
edition = "2018"
|
||||||
authors = ["Ulyssa <git@ulyssa.dev>"]
|
authors = ["Ulyssa <git@ulyssa.dev>"]
|
||||||
repository = "https://github.com/ulyssa/iamb"
|
repository = "https://github.com/ulyssa/iamb"
|
||||||
@@ -11,37 +11,88 @@ 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.89"
|
||||||
|
build = "build.rs"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = ["bundled", "desktop"]
|
||||||
|
bundled = ["matrix-sdk/bundled-sqlite", "rustls-tls"]
|
||||||
|
desktop = ["dep:notify-rust", "modalkit/clipboard"]
|
||||||
|
native-tls = ["matrix-sdk/native-tls"]
|
||||||
|
rustls-tls = ["matrix-sdk/rustls-tls"]
|
||||||
|
|
||||||
|
[build-dependencies.vergen]
|
||||||
|
version = "8"
|
||||||
|
default-features = false
|
||||||
|
features = ["build", "git", "gitcl",]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
anyhow = "1.0"
|
||||||
|
bitflags = "^2.3"
|
||||||
chrono = "0.4"
|
chrono = "0.4"
|
||||||
clap = {version = "4.0", features = ["derive"]}
|
clap = {version = "~4.3", features = ["derive"]}
|
||||||
css-color-parser = "0.1.2"
|
css-color-parser = "0.1.2"
|
||||||
dirs = "4.0.0"
|
dirs = "4.0.0"
|
||||||
|
emojis = "0.5"
|
||||||
|
feruca = "0.10.1"
|
||||||
|
futures = "0.3"
|
||||||
gethostname = "0.4.1"
|
gethostname = "0.4.1"
|
||||||
html5ever = "0.26.0"
|
html5ever = "0.26.0"
|
||||||
|
image = "^0.25.6"
|
||||||
|
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"
|
||||||
|
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.10"
|
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", "markdown", "sled", "rustls-tls"]
|
features = ["e2e-encryption", "sqlite", "sso-login"]
|
||||||
|
|
||||||
[dependencies.tokio]
|
[dependencies.tokio]
|
||||||
version = "1.24.1"
|
version = "1.24.1"
|
||||||
@@ -49,3 +100,39 @@ features = ["macros", "net", "rt-multi-thread", "sync", "time"]
|
|||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
lazy_static = "1.4.0"
|
lazy_static = "1.4.0"
|
||||||
|
pretty_assertions = "1.4.0"
|
||||||
|
|
||||||
|
[profile.release-lto]
|
||||||
|
inherits = "release"
|
||||||
|
incremental = false
|
||||||
|
lto = true
|
||||||
|
|
||||||
|
[package.metadata.deb]
|
||||||
|
section = "net"
|
||||||
|
license-file = ["LICENSE", "0"]
|
||||||
|
assets = [
|
||||||
|
# Binary:
|
||||||
|
["target/release/iamb", "usr/bin/iamb", "755"],
|
||||||
|
# Manual pages:
|
||||||
|
["docs/iamb.1", "usr/share/man/man1/iamb.1", "644"],
|
||||||
|
["docs/iamb.5", "usr/share/man/man5/iamb.5", "644"],
|
||||||
|
# Other assets:
|
||||||
|
["iamb.desktop", "usr/share/applications/iamb.desktop", "644"],
|
||||||
|
["config.example.toml", "usr/share/iamb/config.example.toml", "644"],
|
||||||
|
["docs/iamb.svg", "usr/share/icons/hicolor/scalable/apps/iamb.svg", "644"],
|
||||||
|
["docs/iamb.metainfo.xml", "usr/share/metainfo/iamb.metainfo.xml", "644"],
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata.generate-rpm]
|
||||||
|
assets = [
|
||||||
|
# Binary:
|
||||||
|
{ source = "target/release/iamb", dest = "/usr/bin/iamb", mode = "755" },
|
||||||
|
# Manual pages:
|
||||||
|
{ source = "docs/iamb.1", dest = "/usr/share/man/man1/iamb.1", mode = "644" },
|
||||||
|
{ source = "docs/iamb.5", dest = "/usr/share/man/man5/iamb.5", mode = "644" },
|
||||||
|
# Other assets:
|
||||||
|
{ source = "iamb.desktop", dest = "/usr/share/applications/iamb.desktop", mode = "644" },
|
||||||
|
{ source = "config.example.toml", dest = "/usr/share/iamb/config.example.toml", mode = "644"},
|
||||||
|
{ source = "docs/iamb.svg", dest = "/usr/share/icons/hicolor/scalable/apps/iamb.svg", mode = "644"},
|
||||||
|
{ source = "docs/iamb.metainfo.xml", dest = "/usr/share/metainfo/iamb.metainfo.xml", mode = "644"},
|
||||||
|
]
|
||||||
|
|||||||
2
LICENSE
2
LICENSE
@@ -186,7 +186,7 @@
|
|||||||
same "printed page" as the copyright notice for easier
|
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
|
||||||
202
README.md
202
README.md
@@ -1,105 +1,157 @@
|
|||||||
# 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://crates.io/crates/iamb)
|
[](https://matrix.to/#/#iamb:0x.badd.cafe)
|
||||||
|
[][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 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.89.0 or above) and Cargo, and then run:
|
||||||
|
|
||||||
```
|
```
|
||||||
cargo install --locked iamb
|
cargo install --locked iamb
|
||||||
```
|
```
|
||||||
|
|
||||||
## Configuration
|
See [Configuration](#configuration) for getting a profile set up.
|
||||||
|
|
||||||
You can create a basic configuration in `$CONFIG_DIR/iamb/config.json` that looks like:
|
## Installation (via package managers)
|
||||||
|
|
||||||
```json
|
### Arch Linux
|
||||||
{
|
|
||||||
"profiles": {
|
On Arch Linux a [package](https://aur.archlinux.org/packages/iamb-git) is available in the
|
||||||
"example.com": {
|
Arch User Repositories (AUR). To install it simply run with your favorite AUR helper:
|
||||||
"url": "https://example.com",
|
|
||||||
"user_id": "@user:example.com"
|
```
|
||||||
}
|
paru iamb-git
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Comparison With Other Clients
|
### FreeBSD
|
||||||
|
|
||||||
To get an idea of what is and isn't yet implemented, here is a subset of the
|
On FreeBSD a package is available from the official repositories. To install it simply run:
|
||||||
Matrix website's [features comparison table][client-comparison-matrix], showing
|
|
||||||
two other TUI clients and Element Web:
|
|
||||||
|
|
||||||
| | iamb | [gomuks] | [weechat-matrix] | Element Web/Desktop |
|
```
|
||||||
| --------------------------------------- | :---------- | :------: | :--------------: | :-----------------: |
|
pkg install iamb
|
||||||
| Room directory | ❌ ([#14]) | ❌ | ✔️ | ✔️ |
|
```
|
||||||
| Room tag showing | ✔️ | ✔️ | ❌ | ✔️ |
|
|
||||||
| Room tag editing | ✔️ | ✔️ | ❌ | ✔️ |
|
### Gentoo
|
||||||
| Search joined rooms | ❌ ([#16]) | ✔️ | ❌ | ✔️ |
|
|
||||||
| Room user list | ✔️ | ✔️ | ✔️ | ✔️ |
|
On Gentoo, an ebuild is available from the community-managed
|
||||||
| Display Room Description | ✔️ | ✔️ | ✔️ | ✔️ |
|
[GURU overlay](https://wiki.gentoo.org/wiki/Project:GURU).
|
||||||
| Edit Room Description | ✔️ | ❌ | ✔️ | ✔️ |
|
|
||||||
| Highlights | ❌ ([#8]) | ✔️ | ✔️ | ✔️ |
|
You can enable the GURU overlay with:
|
||||||
| Pushrules | ❌ | ✔️ | ❌ | ✔️ |
|
|
||||||
| Send read markers | ✔️ | ✔️ | ✔️ | ✔️ |
|
```
|
||||||
| Display read markers | ✔️ | ❌ | ❌ | ✔️ |
|
eselect repository enable guru
|
||||||
| Sending Invites | ✔️ | ✔️ | ✔️ | ✔️ |
|
emerge --sync guru
|
||||||
| Accepting Invites | ✔️ | ✔️ | ✔️ | ✔️ |
|
```
|
||||||
| Typing Notification | ✔️ | ✔️ | ✔️ | ✔️ |
|
|
||||||
| E2E | ✔️ | ✔️ | ✔️ | ✔️ |
|
And then install `iamb` with:
|
||||||
| Replies | ✔️ | ✔️ | ❌ | ✔️ |
|
|
||||||
| Attachment uploading | ✔️ | ❌ | ✔️ | ✔️ |
|
```
|
||||||
| Attachment downloading | ✔️ | ✔️ | ✔️ | ✔️ |
|
emerge --ask iamb
|
||||||
| Send stickers | ❌ | ❌ | ❌ | ✔️ |
|
```
|
||||||
| Send formatted messages (markdown) | ✔️ | ✔️ | ✔️ | ✔️ |
|
|
||||||
| Rich Text Editor for formatted messages | ❌ | ❌ | ❌ | ✔️ |
|
### macOS
|
||||||
| Display formatted messages | ✔️ | ✔️ | ✔️ | ✔️ |
|
|
||||||
| Redacting | ✔️ | ✔️ | ✔️ | ✔️ |
|
On macOS a [package](https://formulae.brew.sh/formula/iamb#default) is available in Homebrew's
|
||||||
| Multiple Matrix Accounts | ✔️ | ❌ | ✔️ | ❌ |
|
repository. To install it simply run:
|
||||||
| New user registration | ❌ | ❌ | ❌ | ✔️ |
|
|
||||||
| VOIP | ❌ | ❌ | ❌ | ✔️ |
|
```
|
||||||
| Reactions | ❌ ([#2]) | ✔️ | ❌ | ✔️ |
|
brew install iamb
|
||||||
| Message editing | ✔️ | ✔️ | ❌ | ✔️ |
|
```
|
||||||
| Room upgrades | ❌ | ✔️ | ❌ | ✔️ |
|
|
||||||
| Localisations | ❌ | 1 | ❌ | 44 |
|
### NetBSD
|
||||||
| SSO Support | ❌ | ✔️ | ✔️ | ✔️ |
|
|
||||||
|
On NetBSD a package is available from the official repositories. To install it simply run:
|
||||||
|
|
||||||
|
```
|
||||||
|
pkgin install iamb
|
||||||
|
```
|
||||||
|
|
||||||
|
### Nix / NixOS (flake)
|
||||||
|
|
||||||
|
```
|
||||||
|
nix profile install "github:ulyssa/iamb"
|
||||||
|
```
|
||||||
|
|
||||||
|
### openSUSE Tumbleweed
|
||||||
|
|
||||||
|
On openSUSE Tumbleweed a [package](https://build.opensuse.org/package/show/openSUSE:Factory/iamb) is available from the official repositories. To install it simply run:
|
||||||
|
|
||||||
|
```
|
||||||
|
zypper install iamb
|
||||||
|
```
|
||||||
|
|
||||||
|
### Snap
|
||||||
|
|
||||||
|
A snap for Linux distributions which [support](https://snapcraft.io/docs/installing-snapd) the packaging system.
|
||||||
|
|
||||||
|
```
|
||||||
|
snap install iamb
|
||||||
|
```
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
iamb is released under the [Apache License, Version 2.0].
|
iamb is released under the [Apache License, Version 2.0].
|
||||||
|
|
||||||
[Apache License, Version 2.0]: https://github.com/ulyssa/iamb/blob/master/LICENSE
|
[Apache License, Version 2.0]: https://github.com/ulyssa/iamb/blob/master/LICENSE
|
||||||
[client-comparison-matrix]: https://matrix.org/clients-matrix/
|
[crates-io-iamb]: https://crates.io/crates/iamb
|
||||||
[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/
|
||||||
[#2]: https://github.com/ulyssa/iamb/issues/2
|
|
||||||
[#3]: https://github.com/ulyssa/iamb/issues/3
|
|
||||||
[#4]: https://github.com/ulyssa/iamb/issues/4
|
|
||||||
[#5]: https://github.com/ulyssa/iamb/issues/5
|
|
||||||
[#6]: https://github.com/ulyssa/iamb/issues/6
|
|
||||||
[#7]: https://github.com/ulyssa/iamb/issues/7
|
|
||||||
[#8]: https://github.com/ulyssa/iamb/issues/8
|
|
||||||
[#9]: https://github.com/ulyssa/iamb/issues/9
|
|
||||||
[#10]: https://github.com/ulyssa/iamb/issues/10
|
|
||||||
[#11]: https://github.com/ulyssa/iamb/issues/11
|
|
||||||
[#12]: https://github.com/ulyssa/iamb/issues/12
|
|
||||||
[#13]: https://github.com/ulyssa/iamb/issues/13
|
|
||||||
[#14]: https://github.com/ulyssa/iamb/issues/14
|
|
||||||
[#15]: https://github.com/ulyssa/iamb/issues/15
|
|
||||||
[#16]: https://github.com/ulyssa/iamb/issues/16
|
|
||||||
|
|||||||
9
build.rs
Normal file
9
build.rs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
use std::error::Error;
|
||||||
|
|
||||||
|
use vergen::EmitBuilder;
|
||||||
|
|
||||||
|
fn main() -> Result<(), Box<dyn Error>> {
|
||||||
|
EmitBuilder::builder().git_sha(true).emit()?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
58
config.example.toml
Normal file
58
config.example.toml
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
default_profile = "default"
|
||||||
|
|
||||||
|
[profiles.default]
|
||||||
|
user_id = "@user:matrix.org"
|
||||||
|
url = "https://matrix.org"
|
||||||
|
|
||||||
|
[settings]
|
||||||
|
default_room = "#iamb-users:0x.badd.cafe"
|
||||||
|
external_edit_file_suffix = ".md"
|
||||||
|
log_level = "warn"
|
||||||
|
message_shortcode_display = false
|
||||||
|
open_command = ["my-open", "--file"]
|
||||||
|
reaction_display = true
|
||||||
|
reaction_shortcode_display = false
|
||||||
|
read_receipt_display = true
|
||||||
|
read_receipt_send = true
|
||||||
|
request_timeout = 10000
|
||||||
|
typing_notice_display = true
|
||||||
|
typing_notice_send = true
|
||||||
|
user_gutter_width = 30
|
||||||
|
username_display = "username"
|
||||||
|
|
||||||
|
[settings.image_preview]
|
||||||
|
protocol.type = "sixel"
|
||||||
|
size = { "width" = 66, "height" = 10 }
|
||||||
|
|
||||||
|
[settings.sort]
|
||||||
|
rooms = ["favorite", "lowpriority", "unread", "name"]
|
||||||
|
members = ["power", "id"]
|
||||||
|
|
||||||
|
[settings.users]
|
||||||
|
"@user:matrix.org" = { "name" = "John Doe", "color" = "magenta" }
|
||||||
|
|
||||||
|
[layout]
|
||||||
|
style = "config"
|
||||||
|
|
||||||
|
[[layout.tabs]]
|
||||||
|
window = "iamb://dms"
|
||||||
|
|
||||||
|
[[layout.tabs]]
|
||||||
|
window = "iamb://rooms"
|
||||||
|
|
||||||
|
[[layout.tabs]]
|
||||||
|
split = [
|
||||||
|
{ "window" = "#iamb-users:0x.badd.cafe" },
|
||||||
|
{ "window" = "#iamb-dev:0x.badd.cafe" }
|
||||||
|
]
|
||||||
|
|
||||||
|
[macros.insert]
|
||||||
|
"jj" = "<Esc>"
|
||||||
|
|
||||||
|
[macros."normal|visual"]
|
||||||
|
"V" = "<C-W>m"
|
||||||
|
|
||||||
|
[dirs]
|
||||||
|
cache = "/home/user/.cache/iamb/"
|
||||||
|
logs = "/home/user/.local/share/iamb/logs/"
|
||||||
|
downloads = "/home/user/Downloads/"
|
||||||
BIN
docs/iamb-256x256.png
Normal file
BIN
docs/iamb-256x256.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.5 KiB |
BIN
docs/iamb-512x512.png
Normal file
BIN
docs/iamb-512x512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
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
|
||||||
590
docs/iamb.5
Normal file
590
docs/iamb.5
Normal file
@@ -0,0 +1,590 @@
|
|||||||
|
.\" iamb(7) manual page
|
||||||
|
.\"
|
||||||
|
.\" This manual page is written using the mdoc(7) macros. For more
|
||||||
|
.\" information, see <https://manpages.bsd.lv/mdoc.html>.
|
||||||
|
.\"
|
||||||
|
.\" You can preview this file with:
|
||||||
|
.\" $ man ./docs/iamb.1
|
||||||
|
.Dd Mar 24, 2024
|
||||||
|
.Dt IAMB 5
|
||||||
|
.Os
|
||||||
|
.Sh NAME
|
||||||
|
.Nm config.toml
|
||||||
|
.Nd configuration file for
|
||||||
|
.Sy iamb
|
||||||
|
.Sh DESCRIPTION
|
||||||
|
Configuration must be placed under
|
||||||
|
.Pa ~/.config/iamb/
|
||||||
|
and named
|
||||||
|
.Nm .
|
||||||
|
(If
|
||||||
|
.Ev $XDG_CONFIG_HOME
|
||||||
|
is set, then
|
||||||
|
.Sy iamb
|
||||||
|
will look for a directory named
|
||||||
|
.Pa iamb
|
||||||
|
there instead.)
|
||||||
|
.Pp
|
||||||
|
Example configuration usually comes bundled with your installation and can
|
||||||
|
typically be found in
|
||||||
|
.Pa /usr/share/iamb .
|
||||||
|
.Pp
|
||||||
|
As implied by the filename, the configuration is formatted in TOML.
|
||||||
|
It's structure and fields are described below.
|
||||||
|
.Sh CONFIGURATION
|
||||||
|
These options are sections at the top-level of the file.
|
||||||
|
.Bl -tag -width Ds
|
||||||
|
.It Sy profiles
|
||||||
|
A map of profile names containing per-account information.
|
||||||
|
See
|
||||||
|
.Sx PROFILES .
|
||||||
|
.It Sy default_profile
|
||||||
|
The name of the default profile to connect to, unless overwritten by a
|
||||||
|
commandline switch.
|
||||||
|
It should be one of the names defined in the
|
||||||
|
.Sy profiles
|
||||||
|
section.
|
||||||
|
.It Sy settings
|
||||||
|
Overwrite general settings for
|
||||||
|
.Sy iamb .
|
||||||
|
See
|
||||||
|
.Sx SETTINGS
|
||||||
|
for a description of possible values.
|
||||||
|
.It Sy layout
|
||||||
|
Configure the default window layout to use when starting
|
||||||
|
.Sy iamb .
|
||||||
|
See
|
||||||
|
.Sx "STARTUP LAYOUT"
|
||||||
|
for more information on how to configure this object.
|
||||||
|
.It Sy macros
|
||||||
|
Map keybindings to other keybindings.
|
||||||
|
See
|
||||||
|
.Sx "CUSTOM KEYBINDINGS"
|
||||||
|
for how to configure this object.
|
||||||
|
.It Sy dirs
|
||||||
|
Configure the directories to use for data, logs, and more.
|
||||||
|
See
|
||||||
|
.Sx DIRECTORIES
|
||||||
|
for the possible values you can set in this object.
|
||||||
|
.El
|
||||||
|
.Sh PROFILES
|
||||||
|
These options are configured as fields in the
|
||||||
|
.Sy profiles
|
||||||
|
object.
|
||||||
|
.Bl -tag -width Ds
|
||||||
|
.It Sy user_id
|
||||||
|
The user ID to use when connecting to the server.
|
||||||
|
For example "user" in "@user:matrix.org".
|
||||||
|
.It Sy url
|
||||||
|
The URL of the user's server.
|
||||||
|
(For example "https://matrix.org" for "@user:matrix.org".)
|
||||||
|
This is only needed when the server does not have a
|
||||||
|
.Pa /.well-known/matrix/client
|
||||||
|
entry.
|
||||||
|
.El
|
||||||
|
.Pp
|
||||||
|
In addition to the above fields, you can also reuse the following fields to set
|
||||||
|
per-profile overrides of their global values:
|
||||||
|
.Bl -bullet -offset indent -width 1m
|
||||||
|
.It
|
||||||
|
.Sy dirs
|
||||||
|
.It
|
||||||
|
.Sy layout
|
||||||
|
.It
|
||||||
|
.Sy macros
|
||||||
|
.It
|
||||||
|
.Sy settings
|
||||||
|
.El
|
||||||
|
.Ss Example 1: A single profile
|
||||||
|
.Bd -literal -offset indent
|
||||||
|
[profiles.personal]
|
||||||
|
user_id = "@user:matrix.org"
|
||||||
|
.Ed
|
||||||
|
.Ss Example 2: Two profiles with a default
|
||||||
|
In the following example, there are two profiles,
|
||||||
|
.Dq personal
|
||||||
|
(set to be the default) and
|
||||||
|
.Dq work .
|
||||||
|
The
|
||||||
|
.Dq work
|
||||||
|
profile has an explicit URL set for its homeserver.
|
||||||
|
.Bd -literal -offset indent
|
||||||
|
default_profile = "personal"
|
||||||
|
|
||||||
|
[profiles.personal]
|
||||||
|
user_id = "@user:matrix.org"
|
||||||
|
|
||||||
|
[profiles.work]
|
||||||
|
user_id = "@user:example.com"
|
||||||
|
url = "https://matrix.example.com"
|
||||||
|
.Ed
|
||||||
|
.Sh SETTINGS
|
||||||
|
These options are configured as an object under the
|
||||||
|
.Sy settings
|
||||||
|
key and can be overridden as described in
|
||||||
|
.Sx PROFILES .
|
||||||
|
.Bl -tag -width Ds
|
||||||
|
|
||||||
|
.It Sy external_edit_file_suffix
|
||||||
|
Suffix to append to temporary file names when using the :editor command. Defaults to .md.
|
||||||
|
|
||||||
|
.It Sy default_room
|
||||||
|
The room to show by default instead of the
|
||||||
|
.Sy :welcome
|
||||||
|
window.
|
||||||
|
|
||||||
|
.It Sy image_preview
|
||||||
|
Enable image previews and configure it.
|
||||||
|
An empty object will enable the feature with default settings, omitting it will disable the feature.
|
||||||
|
The available fields in this object are:
|
||||||
|
.Bl -tag -width Ds
|
||||||
|
.It Sy size
|
||||||
|
An optional object with
|
||||||
|
.Sy width
|
||||||
|
and
|
||||||
|
.Sy height
|
||||||
|
fields to specify the preview size in cells.
|
||||||
|
Defaults to 66 and 10.
|
||||||
|
.It Sy protocol
|
||||||
|
An optional object to override settings that will normally be guessed automatically:
|
||||||
|
.Bl -tag -width Ds
|
||||||
|
.It Sy type
|
||||||
|
An optional string set to one of the protocol types:
|
||||||
|
.Dq Sy sixel ,
|
||||||
|
.Dq Sy kitty , and
|
||||||
|
.Dq Sy halfblocks .
|
||||||
|
.It Sy font_size
|
||||||
|
An optional list of two numbers representing font width and height in pixels.
|
||||||
|
.El
|
||||||
|
.El
|
||||||
|
.It Sy log_level
|
||||||
|
Specifies the lowest log level that should be shown.
|
||||||
|
Possible values are:
|
||||||
|
.Dq Sy trace ,
|
||||||
|
.Dq Sy debug ,
|
||||||
|
.Dq Sy info ,
|
||||||
|
.Dq Sy warn , and
|
||||||
|
.Dq Sy error .
|
||||||
|
|
||||||
|
.It Sy message_shortcode_display
|
||||||
|
Defines whether or not Emoji characters in messages should be replaced by their
|
||||||
|
respective shortcodes.
|
||||||
|
|
||||||
|
.It Sy message_user_color
|
||||||
|
Defines whether or not the message body is colored like the username.
|
||||||
|
|
||||||
|
.It Sy normal_after_send
|
||||||
|
Defines whether to reset input to Normal mode after sending a message.
|
||||||
|
|
||||||
|
.It Sy notifications
|
||||||
|
When this subsection is present, you can enable and configure push notifications.
|
||||||
|
See
|
||||||
|
.Sx NOTIFICATIONS
|
||||||
|
for more details.
|
||||||
|
|
||||||
|
.It Sy open_command
|
||||||
|
Defines a custom command and its arguments to run when opening downloads instead of the default.
|
||||||
|
(For example,
|
||||||
|
.Sy ["my-open",\ "--file"] . )
|
||||||
|
|
||||||
|
.It Sy reaction_display
|
||||||
|
Defines whether or not reactions should be shown.
|
||||||
|
|
||||||
|
.It Sy reaction_shortcode_display
|
||||||
|
Defines whether or not reactions should be shown as their respective shortcode.
|
||||||
|
|
||||||
|
.It Sy read_receipt_send
|
||||||
|
Defines whether or not read confirmations are sent.
|
||||||
|
|
||||||
|
.It Sy read_receipt_display
|
||||||
|
Defines whether or not read confirmations are displayed.
|
||||||
|
|
||||||
|
.It Sy request_timeout
|
||||||
|
Defines the maximum time per request in seconds.
|
||||||
|
|
||||||
|
.It Sy sort
|
||||||
|
Configures how to sort the lists shown in windows like
|
||||||
|
.Sy :rooms
|
||||||
|
or
|
||||||
|
.Sy :members .
|
||||||
|
See
|
||||||
|
.Sx "SORTING LISTS"
|
||||||
|
for more details.
|
||||||
|
|
||||||
|
.It Sy state_event_display
|
||||||
|
Defines whether the state events like joined or left are shown.
|
||||||
|
|
||||||
|
.It Sy typing_notice_send
|
||||||
|
Defines whether or not the typing state is sent.
|
||||||
|
|
||||||
|
.It Sy typing_notice_display
|
||||||
|
Defines whether or not the typing state is displayed.
|
||||||
|
|
||||||
|
.It Sy user
|
||||||
|
Overrides values for the specified user.
|
||||||
|
See
|
||||||
|
.Sx "USER OVERRIDES"
|
||||||
|
for details on the format.
|
||||||
|
|
||||||
|
.It Sy username_display
|
||||||
|
Defines how usernames are shown for message senders.
|
||||||
|
Possible values are
|
||||||
|
.Dq Sy username ,
|
||||||
|
.Dq Sy localpart , or
|
||||||
|
.Dq Sy displayname .
|
||||||
|
|
||||||
|
.It Sy user_gutter_width
|
||||||
|
Specify the width of the column where usernames are displayed in a room.
|
||||||
|
Usernames that are too long are truncated.
|
||||||
|
Defaults to 30.
|
||||||
|
|
||||||
|
.It Sy tabstop
|
||||||
|
Number of spaces that a <Tab> counts for.
|
||||||
|
Defaults to 4.
|
||||||
|
.El
|
||||||
|
|
||||||
|
.Ss Example 1: Avoid showing Emojis (useful for terminals w/o support)
|
||||||
|
.Bd -literal -offset indent
|
||||||
|
[settings]
|
||||||
|
username = "username"
|
||||||
|
message_shortcode_display = true
|
||||||
|
reaction_shortcode_display = true
|
||||||
|
.Ed
|
||||||
|
|
||||||
|
.Ss Example 2: Increase request timeout to 2 minutes for a slow homeserver
|
||||||
|
.Bd -literal -offset indent
|
||||||
|
[settings]
|
||||||
|
request_timeout = 120
|
||||||
|
.Ed
|
||||||
|
|
||||||
|
.Sh NOTIFICATIONS
|
||||||
|
|
||||||
|
The
|
||||||
|
.Sy settings.notifications
|
||||||
|
subsection allows configuring how notifications for new messages behave.
|
||||||
|
|
||||||
|
The available fields in this subsection are:
|
||||||
|
.Bl -tag -width Ds
|
||||||
|
.It Sy enabled
|
||||||
|
Defaults to
|
||||||
|
.Sy false .
|
||||||
|
Setting this field to
|
||||||
|
.Sy true
|
||||||
|
enables notifications.
|
||||||
|
|
||||||
|
.It Sy via
|
||||||
|
Defaults to
|
||||||
|
.Dq Sy desktop
|
||||||
|
to use the desktop mechanism (default).
|
||||||
|
Setting this field to
|
||||||
|
.Dq Sy bell
|
||||||
|
will use the terminal bell instead.
|
||||||
|
Both can be used via
|
||||||
|
.Dq Sy desktop|bell .
|
||||||
|
|
||||||
|
.It Sy show_message
|
||||||
|
controls whether to show the message in the desktop notification, and defaults to
|
||||||
|
.Sy true .
|
||||||
|
Messages are truncated beyond a small length.
|
||||||
|
The notification rules are stored server side, loaded once at startup, and are currently not configurable in iamb.
|
||||||
|
In other words, you can simply change the rules with another client.
|
||||||
|
.El
|
||||||
|
|
||||||
|
.Ss Example 1: Enable notifications with default options
|
||||||
|
.Bd -literal -offset indent
|
||||||
|
[settings]
|
||||||
|
notifications = {}
|
||||||
|
.Ed
|
||||||
|
.Ss Example 2: Enable notifications using terminal bell
|
||||||
|
.Bd -literal -offset indent
|
||||||
|
[settings.notifications]
|
||||||
|
via = "bell"
|
||||||
|
show_message = false
|
||||||
|
.Ed
|
||||||
|
|
||||||
|
.Sh "SORTING LISTS"
|
||||||
|
|
||||||
|
The
|
||||||
|
.Sy settings.sort
|
||||||
|
subsection allows configuring how different windows have their contents sorted.
|
||||||
|
|
||||||
|
Fields available within this subsection are:
|
||||||
|
.Bl -tag -width Ds
|
||||||
|
.It Sy rooms
|
||||||
|
How to sort the
|
||||||
|
.Sy :rooms
|
||||||
|
window.
|
||||||
|
Defaults to
|
||||||
|
.Sy ["favorite",\ "lowpriority",\ "unread",\ "name"] .
|
||||||
|
.It Sy chats
|
||||||
|
How to sort the
|
||||||
|
.Sy :chats
|
||||||
|
window.
|
||||||
|
Defaults to the
|
||||||
|
.Sy rooms
|
||||||
|
value.
|
||||||
|
.It Sy dms
|
||||||
|
How to sort the
|
||||||
|
.Sy :dms
|
||||||
|
window.
|
||||||
|
Defaults to the
|
||||||
|
.Sy rooms
|
||||||
|
value.
|
||||||
|
.It Sy spaces
|
||||||
|
How to sort the
|
||||||
|
.Sy :spaces
|
||||||
|
window.
|
||||||
|
Defaults to the
|
||||||
|
.Sy rooms
|
||||||
|
value.
|
||||||
|
.It Sy members
|
||||||
|
How to sort the
|
||||||
|
.Sy :members
|
||||||
|
window.
|
||||||
|
Defaults to
|
||||||
|
.Sy ["power",\ "id"] .
|
||||||
|
.El
|
||||||
|
|
||||||
|
The available values are:
|
||||||
|
.Bl -tag -width Ds
|
||||||
|
.It Sy favorite
|
||||||
|
Put favorite rooms before other rooms.
|
||||||
|
.It Sy lowpriority
|
||||||
|
Put lowpriority rooms after other rooms.
|
||||||
|
.It Sy name
|
||||||
|
Sort rooms by alphabetically ascending room name.
|
||||||
|
.It Sy alias
|
||||||
|
Sort rooms by alphabetically ascending canonical room alias.
|
||||||
|
.It Sy id
|
||||||
|
Sort rooms by alphabetically ascending Matrix room identifier.
|
||||||
|
.It Sy unread
|
||||||
|
Put unread rooms before other rooms.
|
||||||
|
.It Sy recent
|
||||||
|
Sort rooms by most recent message timestamp.
|
||||||
|
.It Sy invite
|
||||||
|
Put invites before other rooms.
|
||||||
|
.El
|
||||||
|
.El
|
||||||
|
|
||||||
|
.Ss Example 1: Group room members by their server first
|
||||||
|
.Bd -literal -offset indent
|
||||||
|
[settings.sort]
|
||||||
|
members = ["server", "localpart"]
|
||||||
|
.Ed
|
||||||
|
|
||||||
|
.Sh "USER OVERRIDES"
|
||||||
|
|
||||||
|
The
|
||||||
|
.Sy settings.users
|
||||||
|
subsections allows overriding how specific senders are displayed.
|
||||||
|
Overrides are mapped onto Matrix User IDs such as
|
||||||
|
.Sy @user:matrix.org ,
|
||||||
|
and are typically written as inline tables containing the following keys:
|
||||||
|
|
||||||
|
.Bl -tag -width Ds
|
||||||
|
.It Sy name
|
||||||
|
Change the display name of the user.
|
||||||
|
|
||||||
|
.It Sy color
|
||||||
|
Change the color the user is shown as.
|
||||||
|
Possible values are:
|
||||||
|
.Dq Sy black ,
|
||||||
|
.Dq Sy blue ,
|
||||||
|
.Dq Sy cyan ,
|
||||||
|
.Dq Sy dark-gray ,
|
||||||
|
.Dq Sy gray ,
|
||||||
|
.Dq Sy green ,
|
||||||
|
.Dq Sy light-blue ,
|
||||||
|
.Dq Sy light-cyan ,
|
||||||
|
.Dq Sy light-green ,
|
||||||
|
.Dq Sy light-magenta ,
|
||||||
|
.Dq Sy light-red ,
|
||||||
|
.Dq Sy light-yellow ,
|
||||||
|
.Dq Sy magenta ,
|
||||||
|
.Dq Sy none ,
|
||||||
|
.Dq Sy red ,
|
||||||
|
.Dq Sy white ,
|
||||||
|
and
|
||||||
|
.Dq Sy yellow .
|
||||||
|
.El
|
||||||
|
|
||||||
|
.Ss Example 1: Override how @ada:example.com appears in chat
|
||||||
|
.Bd -literal -offset indent
|
||||||
|
[settings.users]
|
||||||
|
"@ada:example.com" = { name = "Ada Lovelace", color = "light-red" }
|
||||||
|
.Ed
|
||||||
|
|
||||||
|
.Sh STARTUP LAYOUT
|
||||||
|
|
||||||
|
The
|
||||||
|
.Sy layout
|
||||||
|
section allows configuring the initial set of tabs and windows to show when
|
||||||
|
starting the client.
|
||||||
|
|
||||||
|
.Bl -tag -width Ds
|
||||||
|
.It Sy style
|
||||||
|
Specifies what window layout to load when starting.
|
||||||
|
Valid values are
|
||||||
|
.Dq Sy restore
|
||||||
|
to restore the layout from the last time the client was exited,
|
||||||
|
.Dq Sy new
|
||||||
|
to open a single window (uses the value of
|
||||||
|
.Sy default_room
|
||||||
|
if set), or
|
||||||
|
.Dq Sy config
|
||||||
|
to open the layout described under
|
||||||
|
.Sy tabs .
|
||||||
|
|
||||||
|
.It Sy tabs
|
||||||
|
If
|
||||||
|
.Sy style
|
||||||
|
is set to
|
||||||
|
.Sy config ,
|
||||||
|
then this value will be used to open a set of tabs and windows at startup.
|
||||||
|
Each object can contain either a
|
||||||
|
.Sy window
|
||||||
|
key specifying a username, room identifier or room alias to show, or a
|
||||||
|
.Sy split
|
||||||
|
key specifying an array of window objects.
|
||||||
|
.El
|
||||||
|
|
||||||
|
.Ss Example 1: Show a single room every startup
|
||||||
|
.Bd -literal -offset indent
|
||||||
|
[settings]
|
||||||
|
default_room = "#iamb-users:0x.badd.cafe"
|
||||||
|
|
||||||
|
[layout]
|
||||||
|
style = "new"
|
||||||
|
.Ed
|
||||||
|
.Ss Example 2: Show a specific layout every startup
|
||||||
|
.Bd -literal -offset indent
|
||||||
|
[layout]
|
||||||
|
style = "config"
|
||||||
|
|
||||||
|
[[layout.tabs]]
|
||||||
|
window = "iamb://dms"
|
||||||
|
|
||||||
|
[[layout.tabs]]
|
||||||
|
window = "iamb://rooms"
|
||||||
|
|
||||||
|
[[layout.tabs]]
|
||||||
|
split = [
|
||||||
|
{ "window" = "#iamb-users:0x.badd.cafe" },
|
||||||
|
{ "window" = "#iamb-dev:0x.badd.cafe" }
|
||||||
|
]
|
||||||
|
.Ed
|
||||||
|
|
||||||
|
.Sh "CUSTOM KEYBINDINGS"
|
||||||
|
|
||||||
|
The
|
||||||
|
.Sy macros
|
||||||
|
subsections allow configuring custom keybindings.
|
||||||
|
Available subsections are:
|
||||||
|
|
||||||
|
.Bl -tag -width Ds
|
||||||
|
.It Sy insert , Sy i
|
||||||
|
Map the key sequences in this section in
|
||||||
|
.Sy Insert
|
||||||
|
mode.
|
||||||
|
|
||||||
|
.It Sy normal , Sy n
|
||||||
|
Map the key sequences in this section in
|
||||||
|
.Sy Normal
|
||||||
|
mode.
|
||||||
|
|
||||||
|
.It Sy visual , Sy v
|
||||||
|
Map the key sequences in this section in
|
||||||
|
.Sy Visual
|
||||||
|
mode.
|
||||||
|
|
||||||
|
.It Sy select
|
||||||
|
Map the key sequences in this section in
|
||||||
|
.Sy Select
|
||||||
|
mode.
|
||||||
|
|
||||||
|
.It Sy command , Sy c
|
||||||
|
Map the key sequences in this section in
|
||||||
|
.Sy Visual
|
||||||
|
mode.
|
||||||
|
|
||||||
|
.It Sy operator-pending
|
||||||
|
Map the key sequences in this section in
|
||||||
|
.Sy "Operator Pending"
|
||||||
|
mode.
|
||||||
|
.El
|
||||||
|
|
||||||
|
Multiple modes can be given together by separating their names with
|
||||||
|
.Dq Sy | .
|
||||||
|
|
||||||
|
.Ss Example 1: Use "jj" to exit Insert mode
|
||||||
|
.Bd -literal -offset indent
|
||||||
|
[macros.insert]
|
||||||
|
"jj" = "<Esc>"
|
||||||
|
.Ed
|
||||||
|
|
||||||
|
.Ss Example 2: Use "V" for switching between message bar and room history
|
||||||
|
.Bd -literal -offset indent
|
||||||
|
[macros."normal|visual"]
|
||||||
|
"V" = "<C-W>m"
|
||||||
|
.Ed
|
||||||
|
|
||||||
|
.Sh DIRECTORIES
|
||||||
|
|
||||||
|
Specifies the directories to save data in.
|
||||||
|
Configured as an object under the key
|
||||||
|
.Sy dirs .
|
||||||
|
|
||||||
|
.Bl -tag -width Ds
|
||||||
|
.It Sy cache
|
||||||
|
Specifies where to store assets and temporary data in.
|
||||||
|
(For example,
|
||||||
|
.Sy image_preview
|
||||||
|
and
|
||||||
|
.Sy logs
|
||||||
|
will also go in here by default.)
|
||||||
|
Defaults to
|
||||||
|
.Ev $XDG_CACHE_HOME/iamb .
|
||||||
|
|
||||||
|
.It Sy data
|
||||||
|
Specifies where to store persistent data in, such as E2EE room keys.
|
||||||
|
Defaults to
|
||||||
|
.Ev $XDG_DATA_HOME/iamb .
|
||||||
|
|
||||||
|
.It Sy downloads
|
||||||
|
Specifies where to store downloaded files.
|
||||||
|
Defaults to
|
||||||
|
.Ev $XDG_DOWNLOAD_DIR .
|
||||||
|
|
||||||
|
.It Sy image_previews
|
||||||
|
Specifies where to store automatically downloaded image previews.
|
||||||
|
Defaults to
|
||||||
|
.Ev ${cache}/image_preview_downloads .
|
||||||
|
|
||||||
|
.It Sy logs
|
||||||
|
Specifies where to store log files.
|
||||||
|
Defaults to
|
||||||
|
.Ev ${cache}/logs .
|
||||||
|
.El
|
||||||
|
.Sh FILES
|
||||||
|
.Bl -tag -width Ds
|
||||||
|
.It Pa ~/.config/iamb/config.toml
|
||||||
|
The TOML configuration file that
|
||||||
|
.Sy iamb
|
||||||
|
loads by default.
|
||||||
|
.It Pa ~/.config/iamb/config.json
|
||||||
|
A JSON configuration file that
|
||||||
|
.Sy iamb
|
||||||
|
will load if the TOML one is not found.
|
||||||
|
.It Pa /usr/share/iamb/config.example.toml
|
||||||
|
A sample configuration file with examples of how to set different values.
|
||||||
|
.El
|
||||||
|
.Sh "REPORTING BUGS"
|
||||||
|
Please report bugs in
|
||||||
|
.Sy iamb
|
||||||
|
or its manual pages at
|
||||||
|
.Lk https://github.com/ulyssa/iamb/issues
|
||||||
|
.Sh SEE ALSO
|
||||||
|
.Xr iamb 1
|
||||||
|
.Pp
|
||||||
|
Extended documentation is available online at
|
||||||
|
.Lk https://iamb.chat
|
||||||
52
docs/iamb.metainfo.xml
Normal file
52
docs/iamb.metainfo.xml
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<component type="console-application">
|
||||||
|
<id>chat.iamb.iamb</id>
|
||||||
|
|
||||||
|
<name>iamb</name>
|
||||||
|
<summary>A terminal Matrix client for Vim addicts</summary>
|
||||||
|
<url type="homepage">https://iamb.chat</url>
|
||||||
|
|
||||||
|
<releases>
|
||||||
|
<release version="0.0.10" date="2024-08-20"/>
|
||||||
|
<release version="0.0.9" date="2024-03-28"/>
|
||||||
|
</releases>
|
||||||
|
|
||||||
|
<developer id="dev.ulyssa">
|
||||||
|
<name>Ulyssa</name>
|
||||||
|
</developer>
|
||||||
|
|
||||||
|
<developer_name>Ulyssa</developer_name>
|
||||||
|
<metadata_license>CC-BY-SA-4.0</metadata_license>
|
||||||
|
<project_license>Apache-2.0</project_license>
|
||||||
|
|
||||||
|
<content_rating type="oars-1.1">
|
||||||
|
<content_attribute id="social-chat">intense</content_attribute>
|
||||||
|
</content_rating>
|
||||||
|
|
||||||
|
<screenshots>
|
||||||
|
<screenshot type="default">
|
||||||
|
<image>https://iamb.chat/static/images/metainfo-screenshot.png</image>
|
||||||
|
<caption>Example screenshot of room and lists of rooms, spaces and members within iamb</caption>
|
||||||
|
</screenshot>
|
||||||
|
</screenshots>
|
||||||
|
|
||||||
|
<description>
|
||||||
|
<p>
|
||||||
|
iamb is a client for the Matrix communication protocol. It provides a
|
||||||
|
terminal user interface with familiar Vim keybindings, and includes
|
||||||
|
support for multiple profiles, threads, spaces, notifications,
|
||||||
|
reactions, custom keybindings, and more.
|
||||||
|
</p>
|
||||||
|
</description>
|
||||||
|
|
||||||
|
<launchable type="desktop-id">iamb.desktop</launchable>
|
||||||
|
|
||||||
|
<categories>
|
||||||
|
<category>Network</category>
|
||||||
|
<category>Chat</category>
|
||||||
|
</categories>
|
||||||
|
|
||||||
|
<provides>
|
||||||
|
<binary>iamb</binary>
|
||||||
|
</provides>
|
||||||
|
</component>
|
||||||
BIN
docs/iamb.png
Normal file
BIN
docs/iamb.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
128
docs/iamb.svg
Normal file
128
docs/iamb.svg
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
width="120"
|
||||||
|
height="120"
|
||||||
|
viewBox="0 0 120 120"
|
||||||
|
version="1.1"
|
||||||
|
id="svg5"
|
||||||
|
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
|
||||||
|
sodipodi:docname="iamb.svg"
|
||||||
|
inkscape:export-filename="iamb.png"
|
||||||
|
inkscape:export-xdpi="288"
|
||||||
|
inkscape:export-ydpi="288"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg">
|
||||||
|
<sodipodi:namedview
|
||||||
|
id="namedview7"
|
||||||
|
pagecolor="#505050"
|
||||||
|
bordercolor="#eeeeee"
|
||||||
|
borderopacity="1"
|
||||||
|
inkscape:showpageshadow="0"
|
||||||
|
inkscape:pageopacity="0"
|
||||||
|
inkscape:pagecheckerboard="0"
|
||||||
|
inkscape:deskcolor="#505050"
|
||||||
|
inkscape:document-units="px"
|
||||||
|
showgrid="false"
|
||||||
|
inkscape:zoom="4.3724198"
|
||||||
|
inkscape:cx="2.5157694"
|
||||||
|
inkscape:cy="43.11114"
|
||||||
|
inkscape:window-width="1850"
|
||||||
|
inkscape:window-height="1016"
|
||||||
|
inkscape:window-x="0"
|
||||||
|
inkscape:window-y="0"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="layer1" />
|
||||||
|
<defs
|
||||||
|
id="defs2">
|
||||||
|
<rect
|
||||||
|
x="69.359197"
|
||||||
|
y="2.6803692"
|
||||||
|
width="66.742953"
|
||||||
|
height="18.624167"
|
||||||
|
id="rect15628" />
|
||||||
|
<rect
|
||||||
|
x="2.8780095"
|
||||||
|
y="32.203989"
|
||||||
|
width="116.94288"
|
||||||
|
height="87.251209"
|
||||||
|
id="rect14838" />
|
||||||
|
</defs>
|
||||||
|
<g
|
||||||
|
inkscape:label="Layer 1"
|
||||||
|
inkscape:groupmode="layer"
|
||||||
|
id="layer1">
|
||||||
|
<rect
|
||||||
|
id="rect111"
|
||||||
|
width="119.99836"
|
||||||
|
height="119.79127"
|
||||||
|
x="0.0058150524"
|
||||||
|
y="0.21117544"
|
||||||
|
ry="18.295183"
|
||||||
|
style="fill:#201127;fill-opacity:1;stroke-width:0.997728" />
|
||||||
|
<path
|
||||||
|
id="rect111-3"
|
||||||
|
style="fill:#1b1e34;fill-opacity:1;stroke-width:0.997728"
|
||||||
|
d="m 18.321605,-0.01480561 c -10.1355215,0 -18.29492247,8.15940011 -18.29492247,18.29492161 v 4.564453 H 119.99738 V 17.733241 C 119.70734,7.8552235 111.68056,-0.01480561 101.7298,-0.01480561 Z" />
|
||||||
|
<ellipse
|
||||||
|
style="fill:#c24b6e;fill-opacity:1"
|
||||||
|
id="path4855"
|
||||||
|
cx="105.25824"
|
||||||
|
cy="12.000000"
|
||||||
|
rx="5.9108677"
|
||||||
|
ry="5.9019933" />
|
||||||
|
<ellipse
|
||||||
|
style="fill:#ffeb99;fill-opacity:1"
|
||||||
|
id="path4855-6"
|
||||||
|
cx="91.251190"
|
||||||
|
cy="12.000000"
|
||||||
|
rx="5.9108677"
|
||||||
|
ry="5.9019933" />
|
||||||
|
<ellipse
|
||||||
|
style="fill:#6aaf9d;fill-opacity:1"
|
||||||
|
id="path4855-7"
|
||||||
|
cx="77.244141"
|
||||||
|
cy="12.000000"
|
||||||
|
rx="5.9108677"
|
||||||
|
ry="5.9019933" />
|
||||||
|
<g
|
||||||
|
aria-label="◡–"
|
||||||
|
transform="translate(-0.25103084,-17.617149)"
|
||||||
|
id="text14836"
|
||||||
|
style="font-size:77.3333px;line-height:1.25;font-family:monospace;-inkscape-font-specification:'monospace, Normal';text-align:center;letter-spacing:0px;word-spacing:0px;white-space:pre;shape-inside:url(#rect14838);shape-padding:0.114105;display:inline">
|
||||||
|
<path
|
||||||
|
d="m 38.072257,96.572677 q -4.485331,0 -8.351996,-2.319999 -3.866665,-2.397332 -6.263997,-6.263997 -2.319999,-3.866665 -2.319999,-8.506662 h 3.247999 q 0,3.711998 1.855999,6.882663 1.933332,3.093332 5.026664,5.026664 3.170665,1.933333 6.80533,1.933333 3.711999,0 6.882664,-1.933333 3.170665,-1.933332 5.026664,-5.026664 1.933333,-3.170665 1.933333,-6.882663 h 3.247998 q 0,4.485331 -2.319999,8.429329 -2.319999,3.866665 -6.263997,6.263997 -3.866665,2.397332 -8.506663,2.397332 z"
|
||||||
|
style="display:inline;fill:#ec9a6d"
|
||||||
|
id="path809" />
|
||||||
|
<path
|
||||||
|
d="m 69.08294,84.895349 v -6.186663 h 30.93332 v 6.186663 z"
|
||||||
|
style="display:inline;fill:#ec9a6d"
|
||||||
|
id="path811" />
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
aria-label="iamb"
|
||||||
|
transform="translate(-55.871719,2.2068568)"
|
||||||
|
id="text15626"
|
||||||
|
style="font-size:13.3333px;line-height:1.25;letter-spacing:0px;word-spacing:0px;white-space:pre;shape-inside:url(#rect15628);display:inline">
|
||||||
|
<path
|
||||||
|
d="m 71.026037,7.5196777 h 3.066399 v 6.3606613 h 2.376296 v 0.930987 h -5.950506 v -0.930987 h 2.376296 V 8.4506649 h -1.868485 z m 1.868485,-2.8385345 h 1.197914 v 1.5169233 h -1.197914 z"
|
||||||
|
style="font-family:'Bitstream Vera Sans Mono';-inkscape-font-specification:'Bitstream Vera Sans Mono, Normal';fill:#ec9a6d"
|
||||||
|
id="path800" />
|
||||||
|
<path
|
||||||
|
d="m 81.957,11.145971 h -0.397135 q -1.048174,0 -1.582027,0.371092 -0.527342,0.364583 -0.527342,1.093748 0,0.65755 0.397134,1.022133 0.397134,0.364582 1.100258,0.364582 0.98958,0 1.555985,-0.683592 0.566405,-0.690103 0.572916,-1.901037 v -0.266926 z m 2.324213,-0.494791 v 4.160146 H 83.076789 V 13.7306 q -0.384114,0.65104 -0.97005,0.963539 -0.579426,0.305989 -1.412757,0.305989 -1.113278,0 -1.777339,-0.624999 -0.664061,-0.631509 -0.664061,-1.686194 0,-1.217444 0.8138,-1.848953 0.82031,-0.631509 2.402338,-0.631509 h 1.608069 v -0.188802 q -0.0065,-0.8723932 -0.442708,-1.2630172 -0.436197,-0.3971345 -1.393225,-0.3971345 -0.611978,0 -1.236976,0.1757808 -0.624999,0.1757809 -1.217445,0.5143217 V 7.8517081 q 0.664061,-0.2539056 1.269528,-0.3776032 0.611977,-0.130208 1.184893,-0.130208 0.904945,0 1.542964,0.2669264 0.64453,0.2669264 1.041665,0.8007792 0.247395,0.3255201 0.351561,0.8072897 0.104167,0.4752592 0.104167,1.4322878 z"
|
||||||
|
style="font-family:'Bitstream Vera Sans Mono';-inkscape-font-specification:'Bitstream Vera Sans Mono, Normal';fill:#ec9a6d"
|
||||||
|
id="path802" />
|
||||||
|
<path
|
||||||
|
d="m 89.815053,8.2618633 q 0.221354,-0.4687488 0.559894,-0.6901024 0.345052,-0.227864 0.826821,-0.227864 0.878904,0 1.236976,0.683592 0.364583,0.6770817 0.364583,2.5585871 v 4.22525 h -1.093748 v -4.173167 q 0,-1.5429644 -0.17578,-1.9140572 -0.169271,-0.3776033 -0.624999,-0.3776033 -0.520832,0 -0.716144,0.4036449 -0.188801,0.3971344 -0.188801,1.8880156 v 4.173167 h -1.093748 v -4.173167 q 0,-1.5624956 -0.188801,-1.927078 -0.182291,-0.3645825 -0.664061,-0.3645825 -0.475259,0 -0.664061,0.4036449 -0.182291,0.3971344 -0.182291,1.8880156 v 4.173167 H 86.123656 V 7.5196777 h 1.087237 v 0.6249984 q 0.214843,-0.390624 0.533853,-0.5924464 0.32552,-0.2083328 0.735675,-0.2083328 0.49479,0 0.82031,0.227864 0.332031,0.227864 0.514322,0.6901024 z"
|
||||||
|
style="font-family:'Bitstream Vera Sans Mono';-inkscape-font-specification:'Bitstream Vera Sans Mono, Normal';fill:#ec9a6d"
|
||||||
|
id="path804" />
|
||||||
|
<path
|
||||||
|
d="m 99.417893,11.172012 q 0,-1.3932254 -0.442708,-2.102859 -0.442707,-0.7096337 -1.30859,-0.7096337 -0.872394,0 -1.321611,0.7161441 -0.449218,0.7096336 -0.449218,2.0963486 0,1.380205 0.449218,2.096349 0.449217,0.716144 1.321611,0.716144 0.865883,0 1.30859,-0.709633 0.442708,-0.709634 0.442708,-2.10286 z M 95.895766,8.4506649 q 0.286458,-0.5338528 0.787759,-0.8203104 0.507811,-0.2864576 1.171872,-0.2864576 1.3151,0 2.070307,1.0156224 0.755206,1.0091121 0.755206,2.7864517 0,1.80338 -0.761717,2.832024 -0.755206,1.022133 -2.076817,1.022133 -0.65104,0 -1.152341,-0.279948 -0.49479,-0.286457 -0.794269,-0.82682 v 0.917966 H 94.697852 V 4.6811432 h 1.197914 z"
|
||||||
|
style="font-family:'Bitstream Vera Sans Mono';-inkscape-font-specification:'Bitstream Vera Sans Mono, Normal';fill:#ec9a6d"
|
||||||
|
id="path806" />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 7.1 KiB |
116
flake.lock
generated
Normal file
116
flake.lock
generated
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"crane": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1759893430,
|
||||||
|
"narHash": "sha256-yAy4otLYm9iZ+NtQwTMEbqHwswSFUbhn7x826RR6djw=",
|
||||||
|
"owner": "ipetkov",
|
||||||
|
"repo": "crane",
|
||||||
|
"rev": "1979a2524cb8c801520bd94c38bb3d5692419d93",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "ipetkov",
|
||||||
|
"repo": "crane",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fenix": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": [
|
||||||
|
"nixpkgs"
|
||||||
|
],
|
||||||
|
"rust-analyzer-src": "rust-analyzer-src"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1760510549,
|
||||||
|
"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",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1760284886,
|
||||||
|
"narHash": "sha256-TK9Kr0BYBQ/1P5kAsnNQhmWWKgmZXwUQr4ZMjCzWf2c=",
|
||||||
|
"owner": "nixos",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "cf3f5c4def3c7b5f1fc012b3d839575dbe552d43",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nixos",
|
||||||
|
"ref": "nixos-unstable",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"crane": "crane",
|
||||||
|
"fenix": "fenix",
|
||||||
|
"flake-utils": "flake-utils",
|
||||||
|
"nixpkgs": "nixpkgs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"rust-analyzer-src": {
|
||||||
|
"flake": false,
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1760457219,
|
||||||
|
"narHash": "sha256-WJOUGx42hrhmvvYcGkwea+BcJuQJLcns849OnewQqX4=",
|
||||||
|
"owner": "rust-lang",
|
||||||
|
"repo": "rust-analyzer",
|
||||||
|
"rev": "8747cf81540bd1bbbab9ee2702f12c33aa887b46",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "rust-lang",
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
||||||
108
flake.nix
Normal file
108
flake.nix
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
{
|
||||||
|
description = "iamb";
|
||||||
|
nixConfig.bash-prompt = "\[nix-develop\]$ ";
|
||||||
|
|
||||||
|
inputs = {
|
||||||
|
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
|
||||||
|
flake-utils.url = "github:numtide/flake-utils";
|
||||||
|
crane.url = "github:ipetkov/crane";
|
||||||
|
fenix = {
|
||||||
|
url = "github:nix-community/fenix";
|
||||||
|
inputs.nixpkgs.follows = "nixpkgs";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
outputs =
|
||||||
|
{
|
||||||
|
self,
|
||||||
|
nixpkgs,
|
||||||
|
crane,
|
||||||
|
flake-utils,
|
||||||
|
fenix,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
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-Hn2uaQzRLidAWpfmRwSRdImifGUCAb9HeAqTYFXWeQk=";
|
||||||
|
};
|
||||||
|
|
||||||
|
# Nightly toolchain for rustfmt (pinned to current flake lock)
|
||||||
|
# Note that the github CI uses "current nightly" for formatting, it 's not pinned.
|
||||||
|
rustNightly = fenix.packages.${system}.latest.toolchain;
|
||||||
|
rustNightlyFmt = fenix.packages.${system}.latest.rustfmt;
|
||||||
|
|
||||||
|
craneLib = (crane.mkLib pkgs).overrideToolchain rustToolchain;
|
||||||
|
craneLibNightly = (crane.mkLib pkgs).overrideToolchain rustNightly;
|
||||||
|
|
||||||
|
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; [
|
||||||
|
rustNightlyFmt
|
||||||
|
cargo-tarpaulin
|
||||||
|
cargo-watch
|
||||||
|
sqlite
|
||||||
|
];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
12
iamb.desktop
Normal file
12
iamb.desktop
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
[Desktop Entry]
|
||||||
|
Categories=Network;InstantMessaging;Chat;
|
||||||
|
Comment=A Matrix client for Vim addicts
|
||||||
|
Exec=iamb
|
||||||
|
GenericName=Matrix Client
|
||||||
|
Keywords=Matrix;matrix.org;chat;communications;talk;
|
||||||
|
Name=iamb
|
||||||
|
Icon=iamb
|
||||||
|
StartupNotify=false
|
||||||
|
Terminal=true
|
||||||
|
TryExec=iamb
|
||||||
|
Type=Application
|
||||||
3
rust-toolchain.toml
Normal file
3
rust-toolchain.toml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[toolchain]
|
||||||
|
channel = "1.89"
|
||||||
|
components = [ "clippy" ]
|
||||||
2106
src/base.rs
2106
src/base.rs
File diff suppressed because it is too large
Load Diff
858
src/commands.rs
858
src/commands.rs
File diff suppressed because it is too large
Load Diff
1032
src/config.rs
1032
src/config.rs
File diff suppressed because it is too large
Load Diff
@@ -1,62 +1,89 @@
|
|||||||
|
//! # 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},
|
||||||
editing::base::WordStyle,
|
|
||||||
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, Keybindings};
|
use crate::base::{IambAction, IambInfo, Keybindings, MATRIX_ID_WORD};
|
||||||
|
use crate::config::{ApplicationSettings, Keys};
|
||||||
|
|
||||||
/// Find the boundaries for a Matrix username, room alias, or room ID.
|
pub type IambStep = InputStep<IambInfo>;
|
||||||
///
|
|
||||||
/// Technically "[" and "]" should be here since IPv6 addresses are allowed
|
fn once(key: &TerminalKey) -> (EdgeRepeat, EdgeEvent<TerminalKey, CommonKeyClass>) {
|
||||||
/// in the server name, but in practice that should be uncommon, and people
|
(EdgeRepeat::Once, EdgeEvent::Key(*key))
|
||||||
/// can just use `gf` and friends in Visual mode instead.
|
|
||||||
fn is_mxid_char(c: char) -> bool {
|
|
||||||
return c >= 'a' && c <= 'z' ||
|
|
||||||
c >= 'A' && c <= 'Z' ||
|
|
||||||
c >= '0' && c <= '9' ||
|
|
||||||
":-./@_#!".contains(c);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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();
|
||||||
|
|
||||||
let vim = VimBindings::default()
|
let vim = VimBindings::default()
|
||||||
.submit_on_enter()
|
.submit_on_enter()
|
||||||
.cursor_open(WordStyle::CharSet(is_mxid_char));
|
.cursor_open(MATRIX_ID_WORD.clone());
|
||||||
|
|
||||||
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 zoom = IambStep::new()
|
||||||
];
|
.actions(vec![WindowAction::ZoomToggle.into()])
|
||||||
let cwcz = vec![
|
.goto(VimMode::Normal);
|
||||||
(EdgeRepeat::Once, ctrl_w.clone()),
|
|
||||||
(EdgeRepeat::Once, ctrl_z),
|
|
||||||
];
|
|
||||||
let zoom = InputStep::new().actions(vec![WindowAction::ZoomToggle.into()]);
|
|
||||||
|
|
||||||
ism.add_mapping(VimMode::Normal, &cwz, &zoom);
|
ism.add_mapping(VimMode::Normal, &cwz, &zoom);
|
||||||
|
ism.add_mapping(VimMode::Visual, &cwz, &zoom);
|
||||||
ism.add_mapping(VimMode::Normal, &cwcz, &zoom);
|
ism.add_mapping(VimMode::Normal, &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 stoggle = IambStep::new()
|
||||||
];
|
.actions(vec![IambAction::ToggleScrollbackFocus.into()])
|
||||||
let cwcm = vec![(EdgeRepeat::Once, ctrl_w), (EdgeRepeat::Once, ctrl_m)];
|
.goto(VimMode::Normal);
|
||||||
let stoggle = InputStep::new().actions(vec![IambAction::ToggleScrollbackFocus.into()]);
|
|
||||||
ism.add_mapping(VimMode::Normal, &cwm, &stoggle);
|
ism.add_mapping(VimMode::Normal, &cwm, &stoggle);
|
||||||
|
ism.add_mapping(VimMode::Visual, &cwm, &stoggle);
|
||||||
ism.add_mapping(VimMode::Normal, &cwcm, &stoggle);
|
ism.add_mapping(VimMode::Normal, &cwcm, &stoggle);
|
||||||
|
ism.add_mapping(VimMode::Visual, &cwcm, &stoggle);
|
||||||
|
|
||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
824
src/main.rs
824
src/main.rs
File diff suppressed because it is too large
Load Diff
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
1113
src/message/mod.rs
1113
src/message/mod.rs
File diff suppressed because it is too large
Load Diff
@@ -1,12 +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_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,
|
||||||
@@ -16,10 +30,19 @@ pub struct TextPrinter<'a> {
|
|||||||
alignment: Alignment,
|
alignment: Alignment,
|
||||||
curr_spans: Vec<Span<'a>>,
|
curr_spans: Vec<Span<'a>>,
|
||||||
curr_width: usize,
|
curr_width: usize,
|
||||||
|
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,
|
||||||
@@ -29,22 +52,47 @@ impl<'a> TextPrinter<'a> {
|
|||||||
alignment: Alignment::Left,
|
alignment: Alignment::Left,
|
||||||
curr_spans: vec![],
|
curr_spans: vec![],
|
||||||
curr_width: 0,
|
curr_width: 0,
|
||||||
|
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 {
|
||||||
|
self.literal = literal;
|
||||||
|
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(),
|
||||||
@@ -55,13 +103,16 @@ impl<'a> TextPrinter<'a> {
|
|||||||
alignment: self.alignment,
|
alignment: self.alignment,
|
||||||
curr_spans: vec![],
|
curr_spans: vec![],
|
||||||
curr_width: 0,
|
curr_width: 0,
|
||||||
|
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();
|
||||||
@@ -70,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.
|
||||||
@@ -107,7 +159,7 @@ impl<'a> TextPrinter<'a> {
|
|||||||
self.push();
|
self.push();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn push_str<T>(&mut self, s: T, style: Style)
|
fn push_str_wrapped<T>(&mut self, s: T, style: Style)
|
||||||
where
|
where
|
||||||
T: Into<Cow<'a, str>>,
|
T: Into<Cow<'a, str>>,
|
||||||
{
|
{
|
||||||
@@ -140,18 +192,127 @@ impl<'a> TextPrinter<'a> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn push_line(&mut self, spans: Spans<'a>) {
|
/// Push a [Span] that isn't allowed to break across lines.
|
||||||
self.commit();
|
pub fn push_span_nobreak(&mut self, mut span: Span<'a>) {
|
||||||
self.text.lines.push(spans);
|
if self.emoji_shortcodes() {
|
||||||
|
replace_emojis_in_span(&mut span);
|
||||||
|
}
|
||||||
|
let sw = UnicodeWidthStr::width(span.content.as_ref());
|
||||||
|
|
||||||
|
if self.curr_width + sw > self.width {
|
||||||
|
// Span doesn't fit on this line, so start a new one.
|
||||||
|
self.commit();
|
||||||
|
}
|
||||||
|
|
||||||
|
self.curr_spans.push(span);
|
||||||
|
self.curr_width += sw;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn push_text(&mut self, text: Text<'a>) {
|
/// Push text with a [Style].
|
||||||
|
pub fn push_str(&mut self, s: &'a str, style: Style) {
|
||||||
|
let style = self.base_style.patch(style);
|
||||||
|
|
||||||
|
if self.width == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let tabstop = self.settings().tunables.tabstop;
|
||||||
|
|
||||||
|
for mut word in UnicodeSegmentation::split_word_bounds(s) {
|
||||||
|
if let "\n" | "\r\n" = word {
|
||||||
|
if self.literal {
|
||||||
|
self.commit();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render embedded newlines as spaces.
|
||||||
|
word = " ";
|
||||||
|
}
|
||||||
|
|
||||||
|
if !self.literal && self.curr_width == 0 && word.chars().all(char::is_whitespace) {
|
||||||
|
// Drop leading whitespace.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut cow = if self.emoji_shortcodes() {
|
||||||
|
Cow::Owned(replace_emojis_in_str(word))
|
||||||
|
} else {
|
||||||
|
Cow::Borrowed(word)
|
||||||
|
};
|
||||||
|
|
||||||
|
if cow == "\t" {
|
||||||
|
let tablen = tabstop - (self.curr_width % tabstop);
|
||||||
|
cow = Cow::Owned(" ".repeat(tablen));
|
||||||
|
}
|
||||||
|
|
||||||
|
let sw = UnicodeWidthStr::width(cow.as_ref());
|
||||||
|
|
||||||
|
if sw > self.width {
|
||||||
|
self.push_str_wrapped(cow, style);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.curr_width + sw > self.width {
|
||||||
|
// Word doesn't fit on this line, so start a new one.
|
||||||
|
self.commit();
|
||||||
|
|
||||||
|
if !self.literal && cow.chars().all(char::is_whitespace) {
|
||||||
|
// Drop leading whitespace.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let span = Span::styled(cow, style);
|
||||||
|
self.curr_spans.push(span);
|
||||||
|
self.curr_width += sw;
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.curr_width == self.width {
|
||||||
|
// If the last bit fills the full line, start a new one.
|
||||||
|
self.push();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Push a [Line] into the printer.
|
||||||
|
pub fn push_line(&mut self, mut line: Line<'a>) {
|
||||||
self.commit();
|
self.commit();
|
||||||
|
if self.emoji_shortcodes() {
|
||||||
|
replace_emojis_in_line(&mut line);
|
||||||
|
}
|
||||||
|
self.text.lines.push(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Push multiline [Text] into the printer.
|
||||||
|
pub fn push_text(&mut self, mut text: Text<'a>) {
|
||||||
|
self.commit();
|
||||||
|
if self.emoji_shortcodes() {
|
||||||
|
for line in &mut text.lines {
|
||||||
|
replace_emojis_in_line(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
self.text.lines.extend(text.lines);
|
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 {} from the room", state_key)
|
||||||
|
},
|
||||||
|
MembershipChange::Unbanned => {
|
||||||
|
format!("* unbanned {} from the room", state_key)
|
||||||
|
},
|
||||||
|
MembershipChange::Kicked => {
|
||||||
|
format!("* kicked {} from the room", state_key)
|
||||||
|
},
|
||||||
|
MembershipChange::Invited => {
|
||||||
|
format!("* invited {} to the room", state_key)
|
||||||
|
},
|
||||||
|
MembershipChange::KickedAndBanned => {
|
||||||
|
format!("* kicked and banned {} from the room", state_key)
|
||||||
|
},
|
||||||
|
MembershipChange::InvitationAccepted => {
|
||||||
|
return Cow::Borrowed("* accepted an invitation to join the room");
|
||||||
|
},
|
||||||
|
MembershipChange::InvitationRejected => {
|
||||||
|
return Cow::Borrowed("* rejected an invitation to join the room");
|
||||||
|
},
|
||||||
|
MembershipChange::InvitationRevoked => {
|
||||||
|
format!("* revoked an invitation for {} to join the room", state_key)
|
||||||
|
},
|
||||||
|
MembershipChange::Knocked => {
|
||||||
|
return Cow::Borrowed("* would like to join the room");
|
||||||
|
},
|
||||||
|
MembershipChange::KnockAccepted => {
|
||||||
|
format!("* accepted the room knock from {}", state_key)
|
||||||
|
},
|
||||||
|
MembershipChange::KnockRetracted => {
|
||||||
|
return Cow::Borrowed("* retracted their room knock");
|
||||||
|
},
|
||||||
|
MembershipChange::KnockDenied => {
|
||||||
|
format!("* rejected the room knock from {}", state_key)
|
||||||
|
},
|
||||||
|
MembershipChange::ProfileChanged { displayname_change, avatar_url_change } => {
|
||||||
|
match (displayname_change, avatar_url_change) {
|
||||||
|
(Some(change), avatar_change) => {
|
||||||
|
let mut m = match (change.old, change.new) {
|
||||||
|
(None, Some(new)) => {
|
||||||
|
format!("* set their display name to {:?}", new)
|
||||||
|
},
|
||||||
|
(Some(old), Some(new)) => {
|
||||||
|
format!("* changed their display name from {old} to {new}")
|
||||||
|
},
|
||||||
|
(Some(_), None) => "* unset their display name".to_string(),
|
||||||
|
(None, None) => {
|
||||||
|
"* made an unknown change to their display name".to_string()
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if avatar_change.is_some() {
|
||||||
|
m.push_str(" and changed their user avatar");
|
||||||
|
}
|
||||||
|
|
||||||
|
m
|
||||||
|
},
|
||||||
|
(None, Some(change)) => {
|
||||||
|
match (change.old, change.new) {
|
||||||
|
(None, Some(_)) => {
|
||||||
|
return Cow::Borrowed("* added a user avatar");
|
||||||
|
},
|
||||||
|
(Some(_), Some(_)) => {
|
||||||
|
return Cow::Borrowed("* changed their user avatar");
|
||||||
|
},
|
||||||
|
(Some(_), None) => {
|
||||||
|
return Cow::Borrowed("* removed their user avatar");
|
||||||
|
},
|
||||||
|
(None, None) => {
|
||||||
|
return Cow::Borrowed(
|
||||||
|
"* made an unknown change to their user avatar",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(None, None) => {
|
||||||
|
return Cow::Borrowed("* changed their user profile");
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
ev => {
|
||||||
|
format!("* made an unknown membership change to {}: {:?}", state_key, ev)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomName(FullStateEventContent::Original { content, .. }) => {
|
||||||
|
format!("* updated the room name to {:?}", content.name)
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomPinnedEvents(FullStateEventContent::Original { .. }) => {
|
||||||
|
return Cow::Borrowed("* updated the pinned events for the room");
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomPowerLevels(FullStateEventContent::Original { .. }) => {
|
||||||
|
return Cow::Borrowed("* updated the power levels for the room");
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomServerAcl(FullStateEventContent::Original { .. }) => {
|
||||||
|
return Cow::Borrowed("* updated the room's server ACLs");
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomThirdPartyInvite(FullStateEventContent::Original {
|
||||||
|
content,
|
||||||
|
..
|
||||||
|
}) => {
|
||||||
|
format!("* sent a third-party invite to {:?}", content.display_name)
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomTombstone(FullStateEventContent::Original {
|
||||||
|
content,
|
||||||
|
..
|
||||||
|
}) => {
|
||||||
|
format!(
|
||||||
|
"* upgraded the room; replacement room is {}",
|
||||||
|
content.replacement_room.as_str()
|
||||||
|
)
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomTopic(FullStateEventContent::Original {
|
||||||
|
content, ..
|
||||||
|
}) => {
|
||||||
|
format!("* set the room topic to {:?}", content.topic)
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::SpaceChild(FullStateEventContent::Original { .. }) => {
|
||||||
|
format!("* added a space child: {}", ev.state_key())
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::SpaceParent(FullStateEventContent::Original {
|
||||||
|
content, ..
|
||||||
|
}) => {
|
||||||
|
if content.canonical {
|
||||||
|
format!("* added a canonical parent space: {}", ev.state_key())
|
||||||
|
} else {
|
||||||
|
format!("* added a parent space: {}", ev.state_key())
|
||||||
|
}
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::BeaconInfo(FullStateEventContent::Original { .. }) => {
|
||||||
|
return Cow::Borrowed("* shared beacon information");
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::CallMember(FullStateEventContent::Original { .. }) => {
|
||||||
|
return Cow::Borrowed("* updated membership for room call");
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::MemberHints(FullStateEventContent::Original {
|
||||||
|
content, ..
|
||||||
|
}) => {
|
||||||
|
let mut m = String::from("* updated the list of service members in the room hints: ");
|
||||||
|
|
||||||
|
for (i, member) in content.service_members.iter().enumerate() {
|
||||||
|
if i != 0 {
|
||||||
|
m.push_str(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
m.push_str(member.as_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
m
|
||||||
|
},
|
||||||
|
|
||||||
|
// Redacted variants of state events:
|
||||||
|
AnyFullStateEventContent::PolicyRuleRoom(FullStateEventContent::Redacted(_)) => {
|
||||||
|
return Cow::Borrowed("* updated a room policy rule (redacted)");
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::PolicyRuleServer(FullStateEventContent::Redacted(_)) => {
|
||||||
|
return Cow::Borrowed("* updated a server policy rule (redacted)");
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::PolicyRuleUser(FullStateEventContent::Redacted(_)) => {
|
||||||
|
return Cow::Borrowed("* updated a user policy rule (redacted)");
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomAliases(FullStateEventContent::Redacted(_)) => {
|
||||||
|
return Cow::Borrowed("* updated the room aliases for the room (redacted)");
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomAvatar(FullStateEventContent::Redacted(_)) => {
|
||||||
|
return Cow::Borrowed("* updated the room avatar (redacted)");
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomCanonicalAlias(FullStateEventContent::Redacted(_)) => {
|
||||||
|
return Cow::Borrowed("* updated the canonical alias for the room (redacted)");
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomCreate(FullStateEventContent::Redacted(_)) => {
|
||||||
|
return Cow::Borrowed("* created the room (redacted)");
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomEncryption(FullStateEventContent::Redacted(_)) => {
|
||||||
|
return Cow::Borrowed("* updated the encryption settings for the room (redacted)");
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomGuestAccess(FullStateEventContent::Redacted(_)) => {
|
||||||
|
return Cow::Borrowed(
|
||||||
|
"* updated the guest access configuration for the room (redacted)",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomHistoryVisibility(FullStateEventContent::Redacted(_)) => {
|
||||||
|
return Cow::Borrowed("* updated history visilibity for the room (redacted)");
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomJoinRules(FullStateEventContent::Redacted(_)) => {
|
||||||
|
return Cow::Borrowed("* updated the join rules for the room (redacted)");
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomMember(FullStateEventContent::Redacted(_)) => {
|
||||||
|
return Cow::Borrowed("* updated the room membership (redacted)");
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomName(FullStateEventContent::Redacted(_)) => {
|
||||||
|
return Cow::Borrowed("* updated the room name (redacted)");
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomPinnedEvents(FullStateEventContent::Redacted(_)) => {
|
||||||
|
return Cow::Borrowed("* updated the pinned events for the room (redacted)");
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomPowerLevels(FullStateEventContent::Redacted(_)) => {
|
||||||
|
return Cow::Borrowed("* updated the power levels for the room (redacted)");
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomServerAcl(FullStateEventContent::Redacted(_)) => {
|
||||||
|
return Cow::Borrowed("* updated the room's server ACLs (redacted)");
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomThirdPartyInvite(FullStateEventContent::Redacted(_)) => {
|
||||||
|
return Cow::Borrowed("* sent a third-party invite (redacted)");
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomTombstone(FullStateEventContent::Redacted(_)) => {
|
||||||
|
return Cow::Borrowed("* upgraded the room (redacted)");
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomTopic(FullStateEventContent::Redacted(_)) => {
|
||||||
|
return Cow::Borrowed("* updated the room topic (redacted)");
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::SpaceChild(FullStateEventContent::Redacted(_)) => {
|
||||||
|
return Cow::Borrowed("* added a space child (redacted)");
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::SpaceParent(FullStateEventContent::Redacted(_)) => {
|
||||||
|
return Cow::Borrowed("* added a parent space (redacted)");
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::BeaconInfo(FullStateEventContent::Redacted(_)) => {
|
||||||
|
return Cow::Borrowed("* shared beacon information (redacted)");
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::CallMember(FullStateEventContent::Redacted(_)) => {
|
||||||
|
return Cow::Borrowed("Call membership changed");
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::MemberHints(FullStateEventContent::Redacted(_)) => {
|
||||||
|
return Cow::Borrowed("Member hints changed");
|
||||||
|
},
|
||||||
|
|
||||||
|
// Handle unknown events:
|
||||||
|
e => {
|
||||||
|
format!("* sent an unknown state event: {:?}", e.event_type())
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return Cow::Owned(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn html_state(ev: &AnySyncStateEvent) -> StyleTree {
|
||||||
|
let children = match ev.content() {
|
||||||
|
AnyFullStateEventContent::PolicyRuleRoom(FullStateEventContent::Original {
|
||||||
|
content,
|
||||||
|
..
|
||||||
|
}) => {
|
||||||
|
let prefix = StyleTreeNode::Text("* updated the room policy rule for ".into());
|
||||||
|
let entity = bold(format!("{:?}", content.0.entity));
|
||||||
|
let middle = StyleTreeNode::Text(" to ".into());
|
||||||
|
let rec =
|
||||||
|
StyleTreeNode::Text(format!("{:?}", content.0.recommendation.as_str()).into());
|
||||||
|
let mut cs = vec![prefix, entity, middle, rec];
|
||||||
|
|
||||||
|
if !content.0.reason.is_empty() {
|
||||||
|
let reason = format!(" (reason: {})", content.0.reason);
|
||||||
|
cs.push(StyleTreeNode::Text(reason.into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
cs
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::PolicyRuleServer(FullStateEventContent::Original {
|
||||||
|
content,
|
||||||
|
..
|
||||||
|
}) => {
|
||||||
|
let prefix = StyleTreeNode::Text("* updated the server policy rule for ".into());
|
||||||
|
let entity = bold(format!("{:?}", content.0.entity));
|
||||||
|
let middle = StyleTreeNode::Text(" to ".into());
|
||||||
|
let rec =
|
||||||
|
StyleTreeNode::Text(format!("{:?}", content.0.recommendation.as_str()).into());
|
||||||
|
let mut cs = vec![prefix, entity, middle, rec];
|
||||||
|
|
||||||
|
if !content.0.reason.is_empty() {
|
||||||
|
let reason = format!(" (reason: {})", content.0.reason);
|
||||||
|
cs.push(StyleTreeNode::Text(reason.into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
cs
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::PolicyRuleUser(FullStateEventContent::Original {
|
||||||
|
content,
|
||||||
|
..
|
||||||
|
}) => {
|
||||||
|
let prefix = StyleTreeNode::Text("* updated the user policy rule for ".into());
|
||||||
|
let entity = bold(format!("{:?}", content.0.entity));
|
||||||
|
let middle = StyleTreeNode::Text(" to ".into());
|
||||||
|
let rec =
|
||||||
|
StyleTreeNode::Text(format!("{:?}", content.0.recommendation.as_str()).into());
|
||||||
|
let mut cs = vec![prefix, entity, middle, rec];
|
||||||
|
|
||||||
|
if !content.0.reason.is_empty() {
|
||||||
|
let reason = format!(" (reason: {})", content.0.reason);
|
||||||
|
cs.push(StyleTreeNode::Text(reason.into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
cs
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomAliases(FullStateEventContent::Original {
|
||||||
|
content, ..
|
||||||
|
}) => {
|
||||||
|
let prefix = StyleTreeNode::Text("* set the room aliases to: ".into());
|
||||||
|
let mut cs = vec![prefix];
|
||||||
|
|
||||||
|
for (i, alias) in content.aliases.iter().enumerate() {
|
||||||
|
if i != 0 {
|
||||||
|
cs.push(StyleTreeNode::Text(", ".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
cs.push(StyleTreeNode::RoomAlias(alias.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
cs
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomAvatar(FullStateEventContent::Original {
|
||||||
|
content,
|
||||||
|
prev_content,
|
||||||
|
}) => {
|
||||||
|
let prev_url = prev_content.as_ref().and_then(|p| p.url.as_ref());
|
||||||
|
|
||||||
|
let node = match (prev_url, content.url) {
|
||||||
|
(None, Some(_)) => StyleTreeNode::Text("* added a room avatar".into()),
|
||||||
|
(Some(old), Some(new)) => {
|
||||||
|
if old != &new {
|
||||||
|
StyleTreeNode::Text("* replaced the room avatar".into())
|
||||||
|
} else {
|
||||||
|
StyleTreeNode::Text("* updated the room avatar state".into())
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(Some(_), None) => StyleTreeNode::Text("* removed the room avatar".into()),
|
||||||
|
(None, None) => StyleTreeNode::Text("* updated the room avatar state".into()),
|
||||||
|
};
|
||||||
|
|
||||||
|
vec![node]
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomCanonicalAlias(FullStateEventContent::Original {
|
||||||
|
content,
|
||||||
|
..
|
||||||
|
}) => {
|
||||||
|
if let Some(canon) = content.alias.as_ref() {
|
||||||
|
let canon = bold(canon.to_string());
|
||||||
|
let prefix =
|
||||||
|
StyleTreeNode::Text("* updated the canonical alias for the room to: ".into());
|
||||||
|
vec![prefix, canon]
|
||||||
|
} else {
|
||||||
|
vec![StyleTreeNode::Text(
|
||||||
|
"* removed the canonical alias for the room".into(),
|
||||||
|
)]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomCreate(FullStateEventContent::Original {
|
||||||
|
content, ..
|
||||||
|
}) => {
|
||||||
|
if content.federate {
|
||||||
|
vec![StyleTreeNode::Text("* created a federated room".into())]
|
||||||
|
} else {
|
||||||
|
vec![StyleTreeNode::Text("* created a non-federated room".into())]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomEncryption(FullStateEventContent::Original { .. }) => {
|
||||||
|
vec![StyleTreeNode::Text(
|
||||||
|
"* updated the encryption settings for the room".into(),
|
||||||
|
)]
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomGuestAccess(FullStateEventContent::Original {
|
||||||
|
content,
|
||||||
|
..
|
||||||
|
}) => {
|
||||||
|
let access = bold(format!("{:?}", content.guest_access.as_str()));
|
||||||
|
let prefix = StyleTreeNode::Text("* set guest access for the room to ".into());
|
||||||
|
vec![prefix, access]
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomHistoryVisibility(FullStateEventContent::Original {
|
||||||
|
content,
|
||||||
|
..
|
||||||
|
}) => {
|
||||||
|
let prefix =
|
||||||
|
StyleTreeNode::Text("* updated history visibility for the room to ".into());
|
||||||
|
let vis = bold(format!("{:?}", content.history_visibility.as_str()));
|
||||||
|
vec![prefix, vis]
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomJoinRules(FullStateEventContent::Original {
|
||||||
|
content,
|
||||||
|
..
|
||||||
|
}) => {
|
||||||
|
let prefix = StyleTreeNode::Text("* update the join rules for the room to ".into());
|
||||||
|
let rule = bold(format!("{:?}", content.join_rule.as_str()));
|
||||||
|
vec![prefix, rule]
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomMember(FullStateEventContent::Original {
|
||||||
|
content,
|
||||||
|
prev_content,
|
||||||
|
}) => {
|
||||||
|
let Ok(state_key) = UserId::parse(ev.state_key()) else {
|
||||||
|
let prefix =
|
||||||
|
StyleTreeNode::Text("* failed to calculate membership change for ".into());
|
||||||
|
let user_id = bold(format!("{:?}", ev.state_key()));
|
||||||
|
let children = vec![prefix, user_id];
|
||||||
|
|
||||||
|
return StyleTree { children };
|
||||||
|
};
|
||||||
|
|
||||||
|
let prev_details = prev_content.as_ref().map(|p| p.details());
|
||||||
|
let change = content.membership_change(prev_details, ev.sender(), &state_key);
|
||||||
|
let user_id = StyleTreeNode::UserId(state_key.clone());
|
||||||
|
|
||||||
|
match change {
|
||||||
|
MembershipChange::None => {
|
||||||
|
let prefix = StyleTreeNode::Text("* did nothing to ".into());
|
||||||
|
vec![prefix, user_id]
|
||||||
|
},
|
||||||
|
MembershipChange::Error => {
|
||||||
|
let prefix =
|
||||||
|
StyleTreeNode::Text("* failed to calculate membership change to ".into());
|
||||||
|
vec![prefix, user_id]
|
||||||
|
},
|
||||||
|
MembershipChange::Joined => {
|
||||||
|
vec![StyleTreeNode::Text("* joined the room".into())]
|
||||||
|
},
|
||||||
|
MembershipChange::Left => {
|
||||||
|
vec![StyleTreeNode::Text("* left the room".into())]
|
||||||
|
},
|
||||||
|
MembershipChange::Banned => {
|
||||||
|
let prefix = StyleTreeNode::Text("* banned ".into());
|
||||||
|
let suffix = StyleTreeNode::Text(" from the room".into());
|
||||||
|
vec![prefix, user_id, suffix]
|
||||||
|
},
|
||||||
|
MembershipChange::Unbanned => {
|
||||||
|
let prefix = StyleTreeNode::Text("* unbanned ".into());
|
||||||
|
let suffix = StyleTreeNode::Text(" from the room".into());
|
||||||
|
vec![prefix, user_id, suffix]
|
||||||
|
},
|
||||||
|
MembershipChange::Kicked => {
|
||||||
|
let prefix = StyleTreeNode::Text("* kicked ".into());
|
||||||
|
let suffix = StyleTreeNode::Text(" from the room".into());
|
||||||
|
vec![prefix, user_id, suffix]
|
||||||
|
},
|
||||||
|
MembershipChange::Invited => {
|
||||||
|
let prefix = StyleTreeNode::Text("* invited ".into());
|
||||||
|
let suffix = StyleTreeNode::Text(" to the room".into());
|
||||||
|
vec![prefix, user_id, suffix]
|
||||||
|
},
|
||||||
|
MembershipChange::KickedAndBanned => {
|
||||||
|
let prefix = StyleTreeNode::Text("* kicked and banned ".into());
|
||||||
|
let suffix = StyleTreeNode::Text(" from the room".into());
|
||||||
|
vec![prefix, user_id, suffix]
|
||||||
|
},
|
||||||
|
MembershipChange::InvitationAccepted => {
|
||||||
|
vec![StyleTreeNode::Text(
|
||||||
|
"* accepted an invitation to join the room".into(),
|
||||||
|
)]
|
||||||
|
},
|
||||||
|
MembershipChange::InvitationRejected => {
|
||||||
|
vec![StyleTreeNode::Text(
|
||||||
|
"* rejected an invitation to join the room".into(),
|
||||||
|
)]
|
||||||
|
},
|
||||||
|
MembershipChange::InvitationRevoked => {
|
||||||
|
let prefix = StyleTreeNode::Text("* revoked an invitation for ".into());
|
||||||
|
let suffix = StyleTreeNode::Text(" to join the room".into());
|
||||||
|
vec![prefix, user_id, suffix]
|
||||||
|
},
|
||||||
|
MembershipChange::Knocked => {
|
||||||
|
vec![StyleTreeNode::Text("* would like to join the room".into())]
|
||||||
|
},
|
||||||
|
MembershipChange::KnockAccepted => {
|
||||||
|
let prefix = StyleTreeNode::Text("* accepted the room knock from ".into());
|
||||||
|
vec![prefix, user_id]
|
||||||
|
},
|
||||||
|
MembershipChange::KnockRetracted => {
|
||||||
|
vec![StyleTreeNode::Text("* retracted their room knock".into())]
|
||||||
|
},
|
||||||
|
MembershipChange::KnockDenied => {
|
||||||
|
let prefix = StyleTreeNode::Text("* rejected the room knock from ".into());
|
||||||
|
vec![prefix, user_id]
|
||||||
|
},
|
||||||
|
MembershipChange::ProfileChanged { displayname_change, avatar_url_change } => {
|
||||||
|
match (displayname_change, avatar_url_change) {
|
||||||
|
(Some(change), avatar_change) => {
|
||||||
|
let mut m = match (change.old, change.new) {
|
||||||
|
(None, Some(new)) => {
|
||||||
|
vec![
|
||||||
|
StyleTreeNode::Text("* set their display name to ".into()),
|
||||||
|
StyleTreeNode::DisplayName(new.into(), state_key),
|
||||||
|
]
|
||||||
|
},
|
||||||
|
(Some(old), Some(new)) => {
|
||||||
|
vec![
|
||||||
|
StyleTreeNode::Text(
|
||||||
|
"* changed their display name from ".into(),
|
||||||
|
),
|
||||||
|
StyleTreeNode::DisplayName(old.into(), state_key.clone()),
|
||||||
|
StyleTreeNode::Text(" to ".into()),
|
||||||
|
StyleTreeNode::DisplayName(new.into(), state_key),
|
||||||
|
]
|
||||||
|
},
|
||||||
|
(Some(_), None) => {
|
||||||
|
vec![StyleTreeNode::Text("* unset their display name".into())]
|
||||||
|
},
|
||||||
|
(None, None) => {
|
||||||
|
vec![StyleTreeNode::Text(
|
||||||
|
"* made an unknown change to their display name".into(),
|
||||||
|
)]
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if avatar_change.is_some() {
|
||||||
|
m.push(StyleTreeNode::Text(
|
||||||
|
" and changed their user avatar".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
m
|
||||||
|
},
|
||||||
|
(None, Some(change)) => {
|
||||||
|
let m = match (change.old, change.new) {
|
||||||
|
(None, Some(_)) => Cow::Borrowed("* added a user avatar"),
|
||||||
|
(Some(_), Some(_)) => Cow::Borrowed("* changed their user avatar"),
|
||||||
|
(Some(_), None) => Cow::Borrowed("* removed their user avatar"),
|
||||||
|
(None, None) => {
|
||||||
|
Cow::Borrowed("* made an unknown change to their user avatar")
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
vec![StyleTreeNode::Text(m)]
|
||||||
|
},
|
||||||
|
(None, None) => {
|
||||||
|
vec![StyleTreeNode::Text("* changed their user profile".into())]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
ev => {
|
||||||
|
let prefix =
|
||||||
|
StyleTreeNode::Text("* made an unknown membership change to ".into());
|
||||||
|
let suffix = StyleTreeNode::Text(format!(": {:?}", ev).into());
|
||||||
|
vec![prefix, user_id, suffix]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomName(FullStateEventContent::Original { content, .. }) => {
|
||||||
|
let prefix = StyleTreeNode::Text("* updated the room name to ".into());
|
||||||
|
let name = bold(format!("{:?}", content.name));
|
||||||
|
vec![prefix, name]
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomPinnedEvents(FullStateEventContent::Original { .. }) => {
|
||||||
|
vec![StyleTreeNode::Text(
|
||||||
|
"* updated the pinned events for the room".into(),
|
||||||
|
)]
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomPowerLevels(FullStateEventContent::Original { .. }) => {
|
||||||
|
vec![StyleTreeNode::Text(
|
||||||
|
"* updated the power levels for the room".into(),
|
||||||
|
)]
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomServerAcl(FullStateEventContent::Original { .. }) => {
|
||||||
|
vec![StyleTreeNode::Text(
|
||||||
|
"* updated the room's server ACLs".into(),
|
||||||
|
)]
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomThirdPartyInvite(FullStateEventContent::Original {
|
||||||
|
content,
|
||||||
|
..
|
||||||
|
}) => {
|
||||||
|
let prefix = StyleTreeNode::Text("* sent a third-party invite to ".into());
|
||||||
|
let name = bold(format!("{:?}", content.display_name));
|
||||||
|
vec![prefix, name]
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomTombstone(FullStateEventContent::Original {
|
||||||
|
content,
|
||||||
|
..
|
||||||
|
}) => {
|
||||||
|
let prefix = StyleTreeNode::Text("* upgraded the room; replacement room is ".into());
|
||||||
|
let room = StyleTreeNode::RoomId(content.replacement_room.clone());
|
||||||
|
vec![prefix, room]
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomTopic(FullStateEventContent::Original {
|
||||||
|
content, ..
|
||||||
|
}) => {
|
||||||
|
let prefix = StyleTreeNode::Text("* set the room topic to ".into());
|
||||||
|
let topic = bold(format!("{:?}", content.topic));
|
||||||
|
vec![prefix, topic]
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::SpaceChild(FullStateEventContent::Original { .. }) => {
|
||||||
|
let prefix = StyleTreeNode::Text("* added a space child: ".into());
|
||||||
|
|
||||||
|
let room_id = if let Ok(room_id) = OwnedRoomId::from_str(ev.state_key()) {
|
||||||
|
StyleTreeNode::RoomId(room_id)
|
||||||
|
} else {
|
||||||
|
bold(ev.state_key().to_string())
|
||||||
|
};
|
||||||
|
|
||||||
|
vec![prefix, room_id]
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::SpaceParent(FullStateEventContent::Original {
|
||||||
|
content, ..
|
||||||
|
}) => {
|
||||||
|
let prefix = if content.canonical {
|
||||||
|
StyleTreeNode::Text("* added a canonical parent space: ".into())
|
||||||
|
} else {
|
||||||
|
StyleTreeNode::Text("* added a parent space: ".into())
|
||||||
|
};
|
||||||
|
|
||||||
|
let room_id = if let Ok(room_id) = OwnedRoomId::from_str(ev.state_key()) {
|
||||||
|
StyleTreeNode::RoomId(room_id)
|
||||||
|
} else {
|
||||||
|
bold(ev.state_key().to_string())
|
||||||
|
};
|
||||||
|
|
||||||
|
vec![prefix, room_id]
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::BeaconInfo(FullStateEventContent::Original { .. }) => {
|
||||||
|
vec![StyleTreeNode::Text("* shared beacon information".into())]
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::CallMember(FullStateEventContent::Original { .. }) => {
|
||||||
|
vec![StyleTreeNode::Text(
|
||||||
|
"* updated membership for room call".into(),
|
||||||
|
)]
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::MemberHints(FullStateEventContent::Original {
|
||||||
|
content, ..
|
||||||
|
}) => {
|
||||||
|
let prefix = StyleTreeNode::Text(
|
||||||
|
"* updated the list of service members in the room hints: ".into(),
|
||||||
|
);
|
||||||
|
let mut cs = vec![prefix];
|
||||||
|
|
||||||
|
for (i, member) in content.service_members.iter().enumerate() {
|
||||||
|
if i != 0 {
|
||||||
|
cs.push(StyleTreeNode::Text(", ".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
cs.push(StyleTreeNode::UserId(member.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
cs
|
||||||
|
},
|
||||||
|
|
||||||
|
// Redacted variants of state events:
|
||||||
|
AnyFullStateEventContent::PolicyRuleRoom(FullStateEventContent::Redacted(_)) => {
|
||||||
|
vec![StyleTreeNode::Text(
|
||||||
|
"* updated a room policy rule (redacted)".into(),
|
||||||
|
)]
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::PolicyRuleServer(FullStateEventContent::Redacted(_)) => {
|
||||||
|
vec![StyleTreeNode::Text(
|
||||||
|
"* updated a server policy rule (redacted)".into(),
|
||||||
|
)]
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::PolicyRuleUser(FullStateEventContent::Redacted(_)) => {
|
||||||
|
vec![StyleTreeNode::Text(
|
||||||
|
"* updated a user policy rule (redacted)".into(),
|
||||||
|
)]
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomAliases(FullStateEventContent::Redacted(_)) => {
|
||||||
|
vec![StyleTreeNode::Text(
|
||||||
|
"* updated the room aliases for the room (redacted)".into(),
|
||||||
|
)]
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomAvatar(FullStateEventContent::Redacted(_)) => {
|
||||||
|
vec![StyleTreeNode::Text(
|
||||||
|
"* updated the room avatar (redacted)".into(),
|
||||||
|
)]
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomCanonicalAlias(FullStateEventContent::Redacted(_)) => {
|
||||||
|
vec![StyleTreeNode::Text(
|
||||||
|
"* updated the canonical alias for the room (redacted)".into(),
|
||||||
|
)]
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomCreate(FullStateEventContent::Redacted(_)) => {
|
||||||
|
vec![StyleTreeNode::Text("* created the room (redacted)".into())]
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomEncryption(FullStateEventContent::Redacted(_)) => {
|
||||||
|
vec![StyleTreeNode::Text(
|
||||||
|
"* updated the encryption settings for the room (redacted)".into(),
|
||||||
|
)]
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomGuestAccess(FullStateEventContent::Redacted(_)) => {
|
||||||
|
vec![StyleTreeNode::Text(
|
||||||
|
"* updated the guest access configuration for the room (redacted)".into(),
|
||||||
|
)]
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomHistoryVisibility(FullStateEventContent::Redacted(_)) => {
|
||||||
|
vec![StyleTreeNode::Text(
|
||||||
|
"* updated history visilibity for the room (redacted)".into(),
|
||||||
|
)]
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomJoinRules(FullStateEventContent::Redacted(_)) => {
|
||||||
|
vec![StyleTreeNode::Text(
|
||||||
|
"* updated the join rules for the room (redacted)".into(),
|
||||||
|
)]
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomMember(FullStateEventContent::Redacted(_)) => {
|
||||||
|
vec![StyleTreeNode::Text(
|
||||||
|
"* updated the room membership (redacted)".into(),
|
||||||
|
)]
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomName(FullStateEventContent::Redacted(_)) => {
|
||||||
|
vec![StyleTreeNode::Text(
|
||||||
|
"* updated the room name (redacted)".into(),
|
||||||
|
)]
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomPinnedEvents(FullStateEventContent::Redacted(_)) => {
|
||||||
|
vec![StyleTreeNode::Text(
|
||||||
|
"* updated the pinned events for the room (redacted)".into(),
|
||||||
|
)]
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomPowerLevels(FullStateEventContent::Redacted(_)) => {
|
||||||
|
vec![StyleTreeNode::Text(
|
||||||
|
"* updated the power levels for the room (redacted)".into(),
|
||||||
|
)]
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomServerAcl(FullStateEventContent::Redacted(_)) => {
|
||||||
|
vec![StyleTreeNode::Text(
|
||||||
|
"* updated the room's server ACLs (redacted)".into(),
|
||||||
|
)]
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomThirdPartyInvite(FullStateEventContent::Redacted(_)) => {
|
||||||
|
vec![StyleTreeNode::Text(
|
||||||
|
"* sent a third-party invite (redacted)".into(),
|
||||||
|
)]
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomTombstone(FullStateEventContent::Redacted(_)) => {
|
||||||
|
vec![StyleTreeNode::Text("* upgraded the room (redacted)".into())]
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::RoomTopic(FullStateEventContent::Redacted(_)) => {
|
||||||
|
vec![StyleTreeNode::Text(
|
||||||
|
"* updated the room topic (redacted)".into(),
|
||||||
|
)]
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::SpaceChild(FullStateEventContent::Redacted(_)) => {
|
||||||
|
vec![StyleTreeNode::Text(
|
||||||
|
"* added a space child (redacted)".into(),
|
||||||
|
)]
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::SpaceParent(FullStateEventContent::Redacted(_)) => {
|
||||||
|
vec![StyleTreeNode::Text(
|
||||||
|
"* added a parent space (redacted)".into(),
|
||||||
|
)]
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::BeaconInfo(FullStateEventContent::Redacted(_)) => {
|
||||||
|
vec![StyleTreeNode::Text(
|
||||||
|
"* shared beacon information (redacted)".into(),
|
||||||
|
)]
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::CallMember(FullStateEventContent::Redacted(_)) => {
|
||||||
|
vec![StyleTreeNode::Text("Call membership changed".into())]
|
||||||
|
},
|
||||||
|
AnyFullStateEventContent::MemberHints(FullStateEventContent::Redacted(_)) => {
|
||||||
|
vec![StyleTreeNode::Text("Member hints changed".into())]
|
||||||
|
},
|
||||||
|
|
||||||
|
// Handle unknown events:
|
||||||
|
e => {
|
||||||
|
let prefix = StyleTreeNode::Text("* sent an unknown state event: ".into());
|
||||||
|
let event = bold(format!("{:?}", e.event_type()));
|
||||||
|
vec![prefix, event]
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
StyleTree { children }
|
||||||
|
}
|
||||||
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)
|
||||||
|
}
|
||||||
98
src/tests.rs
98
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,20 +15,25 @@ 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 url::Url;
|
use url::Url;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
base::{ChatStore, 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,
|
||||||
UserDisplayTunables,
|
UserDisplayTunables,
|
||||||
},
|
},
|
||||||
message::{
|
message::{
|
||||||
@@ -41,8 +46,11 @@ use crate::{
|
|||||||
worker::Requester,
|
worker::Requester,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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();
|
||||||
@@ -117,20 +125,20 @@ pub fn mock_message5() -> Message {
|
|||||||
mock_room1_message(content, TEST_USER2.clone(), MSG4_KEY.clone())
|
mock_room1_message(content, TEST_USER2.clone(), MSG4_KEY.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn mock_keys() -> HashMap<OwnedEventId, MessageKey> {
|
pub fn mock_keys() -> HashMap<OwnedEventId, EventLocation> {
|
||||||
let mut keys = HashMap::new();
|
let mut keys = HashMap::new();
|
||||||
|
|
||||||
keys.insert(MSG1_EVID.clone(), MSG1_KEY.clone());
|
keys.insert(MSG1_EVID.clone(), EventLocation::Message(None, MSG1_KEY.clone()));
|
||||||
keys.insert(MSG2_EVID.clone(), MSG2_KEY.clone());
|
keys.insert(MSG2_EVID.clone(), EventLocation::Message(None, MSG2_KEY.clone()));
|
||||||
keys.insert(MSG3_EVID.clone(), MSG3_KEY.clone());
|
keys.insert(MSG3_EVID.clone(), EventLocation::Message(None, MSG3_KEY.clone()));
|
||||||
keys.insert(MSG4_EVID.clone(), MSG4_KEY.clone());
|
keys.insert(MSG4_EVID.clone(), EventLocation::Message(None, MSG4_KEY.clone()));
|
||||||
keys.insert(MSG5_EVID.clone(), 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());
|
||||||
@@ -142,35 +150,36 @@ 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,
|
|
||||||
|
|
||||||
fetch_id: RoomFetchStatus::NotStarted,
|
|
||||||
fetch_last: None,
|
|
||||||
users_typing: None,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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: PathBuf::new(),
|
downloads: None,
|
||||||
|
image_previews: PathBuf::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn mock_tunables() -> TunableValues {
|
pub fn mock_tunables() -> TunableValues {
|
||||||
TunableValues {
|
TunableValues {
|
||||||
default_room: None,
|
default_room: None,
|
||||||
|
log_level: Level::INFO,
|
||||||
|
message_shortcode_display: false,
|
||||||
|
normal_after_send: true,
|
||||||
|
reaction_display: true,
|
||||||
|
reaction_shortcode_display: false,
|
||||||
read_receipt_send: true,
|
read_receipt_send: true,
|
||||||
read_receipt_display: true,
|
read_receipt_display: true,
|
||||||
|
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 {
|
||||||
@@ -179,22 +188,44 @@ pub fn mock_tunables() -> TunableValues {
|
|||||||
})]
|
})]
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.collect::<HashMap<_, _>>(),
|
.collect::<HashMap<_, _>>(),
|
||||||
|
open_command: None,
|
||||||
|
external_edit_file_suffix: String::from(".md"),
|
||||||
|
username_display: UserDisplayStyle::Username,
|
||||||
|
message_user_color: false,
|
||||||
|
mouse: Default::default(),
|
||||||
|
notifications: Notifications {
|
||||||
|
enabled: false,
|
||||||
|
via: NotifyVia::default(),
|
||||||
|
show_message: true,
|
||||||
|
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(),
|
||||||
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,
|
||||||
|
macros: None,
|
||||||
},
|
},
|
||||||
tunables: mock_tunables(),
|
tunables: mock_tunables(),
|
||||||
dirs: mock_dirs(),
|
dirs: mock_dirs(),
|
||||||
|
layout: Default::default(),
|
||||||
|
macros: HashMap::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,10 +236,19 @@ pub async fn mock_store() -> ProgramStore {
|
|||||||
let worker = Requester { tx, client };
|
let worker = Requester { tx, client };
|
||||||
|
|
||||||
let mut store = ChatStore::new(worker, mock_settings());
|
let mut store = ChatStore::new(worker, mock_settings());
|
||||||
|
|
||||||
|
// Add presence information.
|
||||||
|
store.presences.get_or_default(TEST_USER1.clone());
|
||||||
|
store.presences.get_or_default(TEST_USER2.clone());
|
||||||
|
store.presences.get_or_default(TEST_USER3.clone());
|
||||||
|
store.presences.get_or_default(TEST_USER4.clone());
|
||||||
|
store.presences.get_or_default(TEST_USER5.clone());
|
||||||
|
|
||||||
let room_id = TEST_ROOM1_ID.clone();
|
let room_id = TEST_ROOM1_ID.clone();
|
||||||
let info = mock_room();
|
let info = mock_room();
|
||||||
|
|
||||||
store.rooms.insert(room_id, info);
|
store.rooms.insert(room_id.clone(), info);
|
||||||
|
store.names.insert(TEST_ROOM1_ALIAS.to_string(), room_id);
|
||||||
|
|
||||||
ProgramStore::new(store)
|
ProgramStore::new(store)
|
||||||
}
|
}
|
||||||
|
|||||||
61
src/util.rs
61
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)
|
||||||
let gw = UnicodeWidthStr::width(g);
|
.find_map(|(i, g)| {
|
||||||
idx = i;
|
let gw = UnicodeWidthStr::width(g);
|
||||||
|
if w + gw > width {
|
||||||
if w + gw > width {
|
Some(i)
|
||||||
break;
|
} else {
|
||||||
}
|
w += gw;
|
||||||
|
None
|
||||||
w += gw;
|
}
|
||||||
}
|
})
|
||||||
|
.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::*;
|
||||||
|
|||||||
1327
src/windows/mod.rs
1327
src/windows/mod.rs
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,55 +1,64 @@
|
|||||||
|
//! # 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,
|
Editable,
|
||||||
EditInfo,
|
EditorAction,
|
||||||
EditResult,
|
Jumpable,
|
||||||
Editable,
|
PromptAction,
|
||||||
EditorAction,
|
Promptable,
|
||||||
Jumpable,
|
Scrollable,
|
||||||
PromptAction,
|
|
||||||
Promptable,
|
|
||||||
Scrollable,
|
|
||||||
UIError,
|
|
||||||
},
|
|
||||||
editing::base::{
|
|
||||||
Axis,
|
|
||||||
CloseFlags,
|
|
||||||
Count,
|
|
||||||
MoveDir1D,
|
|
||||||
OpenTarget,
|
|
||||||
PositionList,
|
|
||||||
ScrollStyle,
|
|
||||||
WordStyle,
|
|
||||||
},
|
|
||||||
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,
|
||||||
IambError,
|
IambError,
|
||||||
IambId,
|
IambId,
|
||||||
IambInfo,
|
IambInfo,
|
||||||
IambResult,
|
IambResult,
|
||||||
|
MemberUpdateAction,
|
||||||
MessageAction,
|
MessageAction,
|
||||||
ProgramAction,
|
ProgramAction,
|
||||||
ProgramContext,
|
ProgramContext,
|
||||||
@@ -57,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;
|
||||||
@@ -75,6 +87,38 @@ macro_rules! delegate {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn notification_mode(name: impl Into<String>) -> IambResult<RoomNotificationMode> {
|
||||||
|
let name = name.into();
|
||||||
|
|
||||||
|
let mode = match name.to_lowercase().as_str() {
|
||||||
|
"mute" => RoomNotificationMode::Mute,
|
||||||
|
"mentions" | "keywords" => RoomNotificationMode::MentionsAndKeywordsOnly,
|
||||||
|
"all" => RoomNotificationMode::AllMessages,
|
||||||
|
_ => return Err(IambError::InvalidNotificationLevel(name).into()),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hist_visibility_mode(name: impl Into<String>) -> IambResult<HistoryVisibility> {
|
||||||
|
let name = name.into();
|
||||||
|
|
||||||
|
let mode = match name.to_lowercase().as_str() {
|
||||||
|
"invited" => HistoryVisibility::Invited,
|
||||||
|
"joined" => HistoryVisibility::Joined,
|
||||||
|
"shared" => HistoryVisibility::Shared,
|
||||||
|
"world" | "world_readable" => HistoryVisibility::WorldReadable,
|
||||||
|
_ => return Err(IambError::InvalidHistoryVisibility(name).into()),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// State for a Matrix room or space.
|
||||||
|
///
|
||||||
|
/// Since spaces function as special rooms within Matrix, we wrap their window state together, so
|
||||||
|
/// that operations like sending and accepting invites, opening the members window, etc., all work
|
||||||
|
/// similarly.
|
||||||
pub enum RoomState {
|
pub enum RoomState {
|
||||||
Chat(ChatState),
|
Chat(ChatState),
|
||||||
Space(SpaceState),
|
Space(SpaceState),
|
||||||
@@ -95,7 +139,8 @@ impl From<SpaceState> for RoomState {
|
|||||||
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 {
|
||||||
@@ -107,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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,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,
|
||||||
@@ -132,21 +184,19 @@ impl RoomState {
|
|||||||
None => format!("{:?}", store.application.get_room_title(self.id())),
|
None => format!("{:?}", store.application.get_room_title(self.id())),
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut invited = vec![Span::from(format!(
|
let mut invited = vec![Span::from(format!("You have been invited to join {name}"))];
|
||||||
"You have been invited to join {}",
|
|
||||||
name
|
|
||||||
))];
|
|
||||||
|
|
||||||
if let Ok(Some(inviter)) = &inviter {
|
if let Ok(Some(inviter)) = &inviter {
|
||||||
|
let info = store.application.rooms.get_or_default(self.id().to_owned());
|
||||||
invited.push(Span::from(" by "));
|
invited.push(Span::from(" by "));
|
||||||
invited.push(store.application.settings.get_user_span(inviter.user_id()));
|
invited.push(store.application.settings.get_user_span(inviter.user_id(), info));
|
||||||
}
|
}
|
||||||
|
|
||||||
let l1 = Spans(invited);
|
let 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);
|
||||||
|
|
||||||
@@ -165,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,
|
||||||
@@ -180,13 +242,21 @@ 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()) {
|
||||||
room.accept_invitation().await.map_err(IambError::from)?;
|
let details = room.invite_details().await.map_err(IambError::from)?;
|
||||||
|
let details = details.invitee.event().original_content();
|
||||||
|
let is_direct = details.and_then(|ev| ev.is_direct).unwrap_or_default();
|
||||||
|
|
||||||
|
room.join().await.map_err(IambError::from)?;
|
||||||
|
|
||||||
|
if is_direct {
|
||||||
|
room.set_is_direct(true).await.map_err(IambError::from)?;
|
||||||
|
}
|
||||||
|
|
||||||
Ok(vec![])
|
Ok(vec![])
|
||||||
} else {
|
} else {
|
||||||
@@ -194,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 {
|
||||||
@@ -203,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![])
|
||||||
@@ -211,6 +281,65 @@ impl RoomState {
|
|||||||
Err(IambError::NotJoined.into())
|
Err(IambError::NotJoined.into())
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
RoomAction::Leave(skip_confirm) => {
|
||||||
|
if let Some(room) = store.application.worker.client.get_room(self.id()) {
|
||||||
|
if skip_confirm {
|
||||||
|
room.leave().await.map_err(IambError::from)?;
|
||||||
|
|
||||||
|
Ok(vec![])
|
||||||
|
} else {
|
||||||
|
let msg = "Do you really want to leave this room?";
|
||||||
|
let leave = IambAction::Room(RoomAction::Leave(true));
|
||||||
|
let prompt = PromptYesNo::new(msg, vec![Action::from(leave)]);
|
||||||
|
let prompt = Box::new(prompt);
|
||||||
|
|
||||||
|
Err(UIError::NeedConfirm(prompt))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Err(IambError::NotJoined.into())
|
||||||
|
}
|
||||||
|
},
|
||||||
|
RoomAction::MemberUpdate(mua, user, reason, skip_confirm) => {
|
||||||
|
let Some(room) = store.application.worker.client.get_room(self.id()) else {
|
||||||
|
return Err(IambError::NotJoined.into());
|
||||||
|
};
|
||||||
|
|
||||||
|
let Ok(user_id) = OwnedUserId::try_from(user.as_str()) else {
|
||||||
|
let err = IambError::InvalidUserId(user);
|
||||||
|
|
||||||
|
return Err(err.into());
|
||||||
|
};
|
||||||
|
|
||||||
|
if !skip_confirm {
|
||||||
|
let msg = format!("Do you really want to {mua} {user} from this room?");
|
||||||
|
let act = RoomAction::MemberUpdate(mua, user, reason, true);
|
||||||
|
let act = IambAction::from(act);
|
||||||
|
let prompt = PromptYesNo::new(msg, vec![Action::from(act)]);
|
||||||
|
let prompt = Box::new(prompt);
|
||||||
|
|
||||||
|
return Err(UIError::NeedConfirm(prompt));
|
||||||
|
}
|
||||||
|
|
||||||
|
match mua {
|
||||||
|
MemberUpdateAction::Ban => {
|
||||||
|
room.ban_user(&user_id, reason.as_deref())
|
||||||
|
.await
|
||||||
|
.map_err(IambError::from)?;
|
||||||
|
},
|
||||||
|
MemberUpdateAction::Unban => {
|
||||||
|
room.unban_user(&user_id, reason.as_deref())
|
||||||
|
.await
|
||||||
|
.map_err(IambError::from)?;
|
||||||
|
},
|
||||||
|
MemberUpdateAction::Kick => {
|
||||||
|
room.kick_user(&user_id, reason.as_deref())
|
||||||
|
.await
|
||||||
|
.map_err(IambError::from)?;
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(vec![])
|
||||||
|
},
|
||||||
RoomAction::Members(mut cmd) => {
|
RoomAction::Members(mut cmd) => {
|
||||||
let width = Count::Exact(30);
|
let width = Count::Exact(30);
|
||||||
let act =
|
let act =
|
||||||
@@ -219,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
|
||||||
@@ -228,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) => {
|
||||||
@@ -242,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![])
|
||||||
@@ -253,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) => {
|
||||||
@@ -264,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() => {
|
||||||
@@ -285,7 +680,7 @@ impl RoomState {
|
|||||||
_ => {},
|
_ => {},
|
||||||
}
|
}
|
||||||
|
|
||||||
Spans(spans)
|
Line::from(spans)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn focus_toggle(&mut self) {
|
pub fn focus_toggle(&mut self) {
|
||||||
@@ -363,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 {
|
||||||
@@ -386,10 +781,30 @@ impl WindowOps<IambInfo> for RoomState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn close(&mut self, _: CloseFlags, _: &mut ProgramStore) -> bool {
|
fn close(&mut self, flags: CloseFlags, store: &mut ProgramStore) -> bool {
|
||||||
// XXX: what's the right closing behaviour for a room?
|
match self {
|
||||||
// Should write send a message?
|
RoomState::Chat(chat) => chat.close(flags, store),
|
||||||
true
|
RoomState::Space(space) => space.close(flags, store),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write(
|
||||||
|
&mut self,
|
||||||
|
path: Option<&str>,
|
||||||
|
flags: WriteFlags,
|
||||||
|
store: &mut ProgramStore,
|
||||||
|
) -> IambResult<EditInfo> {
|
||||||
|
match self {
|
||||||
|
RoomState::Chat(chat) => chat.write(path, flags, store),
|
||||||
|
RoomState::Space(space) => space.write(path, flags, store),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_completions(&self) -> Option<CompletionList> {
|
||||||
|
match self {
|
||||||
|
RoomState::Chat(chat) => chat.get_completions(),
|
||||||
|
RoomState::Space(space) => space.get_completions(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_cursor_word(&self, style: &WordStyle) -> Option<String> {
|
fn get_cursor_word(&self, style: &WordStyle) -> Option<String> {
|
||||||
@@ -406,3 +821,27 @@ impl WindowOps<IambInfo> for RoomState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_room_notification_level() {
|
||||||
|
let tests = vec![
|
||||||
|
("mute", RoomNotificationMode::Mute),
|
||||||
|
("mentions", RoomNotificationMode::MentionsAndKeywordsOnly),
|
||||||
|
("keywords", RoomNotificationMode::MentionsAndKeywordsOnly),
|
||||||
|
("all", RoomNotificationMode::AllMessages),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (input, expect) in tests {
|
||||||
|
let res = notification_mode(input).unwrap();
|
||||||
|
assert_eq!(expect, res);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(notification_mode("invalid").is_err());
|
||||||
|
assert!(notification_mode("not a level").is_err());
|
||||||
|
assert!(notification_mode("@user:example.com").is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,34 +1,63 @@
|
|||||||
|
//! Window for Matrix spaces
|
||||||
use std::ops::{Deref, DerefMut};
|
use std::ops::{Deref, DerefMut};
|
||||||
|
use std::str::FromStr;
|
||||||
|
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::{buffer::Buffer, layout::Rect, widgets::StatefulWidget};
|
use modalkit::prelude::{EditInfo, InfoMessage};
|
||||||
|
use ratatui::{
|
||||||
use modalkit::{
|
buffer::Buffer,
|
||||||
widgets::list::{List, ListState},
|
layout::Rect,
|
||||||
widgets::{TermOffset, TerminalCursor, WindowOps},
|
style::{Color, Style},
|
||||||
|
text::{Line, Span, Text},
|
||||||
|
widgets::StatefulWidget,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::base::{IambBufferId, IambInfo, ProgramStore, RoomFocus};
|
use modalkit_ratatui::{
|
||||||
|
list::{List, ListState},
|
||||||
|
TermOffset,
|
||||||
|
TerminalCursor,
|
||||||
|
WindowOps,
|
||||||
|
};
|
||||||
|
|
||||||
use crate::windows::RoomItem;
|
use crate::base::{
|
||||||
|
IambBufferId,
|
||||||
|
IambError,
|
||||||
|
IambInfo,
|
||||||
|
IambResult,
|
||||||
|
ProgramContext,
|
||||||
|
ProgramStore,
|
||||||
|
RoomFocus,
|
||||||
|
SpaceAction,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::windows::{room_fields_cmp, RoomItem, RoomLikeItem};
|
||||||
|
|
||||||
|
const SPACE_HIERARCHY_DEBOUNCE: Duration = Duration::from_secs(5);
|
||||||
|
|
||||||
|
/// State needed for rendering [Space].
|
||||||
pub struct SpaceState {
|
pub struct SpaceState {
|
||||||
room_id: OwnedRoomId,
|
room_id: OwnedRoomId,
|
||||||
room: MatrixRoom,
|
room: MatrixRoom,
|
||||||
list: ListState<RoomItem, IambInfo>,
|
list: ListState<RoomItem, IambInfo>,
|
||||||
|
last_fetch: Option<Instant>,
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
||||||
|
|
||||||
SpaceState { room_id, room, list }
|
SpaceState { room_id, room, list, last_fetch }
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn refresh_room(&mut self, store: &mut ProgramStore) {
|
pub fn refresh_room(&mut self, store: &mut ProgramStore) {
|
||||||
@@ -50,6 +79,80 @@ impl SpaceState {
|
|||||||
room_id: self.room_id.clone(),
|
room_id: self.room_id.clone(),
|
||||||
room: self.room.clone(),
|
room: self.room.clone(),
|
||||||
list: self.list.dup(store),
|
list: self.list.dup(store),
|
||||||
|
last_fetch: self.last_fetch,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn space_command(
|
||||||
|
&mut self,
|
||||||
|
act: SpaceAction,
|
||||||
|
_: ProgramContext,
|
||||||
|
store: &mut ProgramStore,
|
||||||
|
) -> IambResult<EditInfo> {
|
||||||
|
match act {
|
||||||
|
SpaceAction::SetChild(child_id, order, suggested) => {
|
||||||
|
if !self
|
||||||
|
.room
|
||||||
|
.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())
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -74,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,
|
||||||
@@ -90,34 +194,59 @@ 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) {
|
||||||
let members =
|
let mut empty_message = None;
|
||||||
if let Ok(m) = self.store.application.worker.space_members(state.room_id.clone()) {
|
let need_fetch = match state.last_fetch {
|
||||||
m
|
Some(i) => i.elapsed() >= SPACE_HIERARCHY_DEBOUNCE,
|
||||||
} else {
|
None => true,
|
||||||
return;
|
};
|
||||||
};
|
|
||||||
|
|
||||||
let items = members
|
if need_fetch {
|
||||||
.into_iter()
|
let res = self.store.application.worker.space_members(state.room_id.clone());
|
||||||
.filter_map(|id| {
|
|
||||||
let (room, name, tags) = self.store.application.worker.get_room(id.clone()).ok()?;
|
|
||||||
|
|
||||||
if id != state.room_id {
|
match res {
|
||||||
Some(RoomItem::new(room, name, tags, self.store))
|
Ok(members) => {
|
||||||
} else {
|
let mut items = members
|
||||||
None
|
.into_iter()
|
||||||
}
|
.filter_map(|id| {
|
||||||
})
|
let (room, _, tags) =
|
||||||
.collect();
|
self.store.application.worker.get_room(id.clone()).ok()?;
|
||||||
|
let room_info = std::sync::Arc::new((room, tags));
|
||||||
|
|
||||||
state.list.set(items);
|
if id != state.room_id {
|
||||||
|
Some(RoomItem::new(room_info, self.store))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.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));
|
||||||
|
|
||||||
List::new(self.store)
|
state.list.set(items);
|
||||||
.focus(self.focused)
|
state.last_fetch = Some(Instant::now());
|
||||||
.render(area, buffer, &mut state.list)
|
},
|
||||||
|
Err(e) => {
|
||||||
|
let lines = vec![
|
||||||
|
Line::from("Unable to fetch space room hierarchy:"),
|
||||||
|
Span::styled(e.to_string(), Style::default().fg(Color::Red)).into(),
|
||||||
|
];
|
||||||
|
|
||||||
|
empty_message = Text::from(lines).into();
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut list = List::new(self.store).focus(self.focused);
|
||||||
|
|
||||||
|
if let Some(text) = empty_message {
|
||||||
|
list = list.empty_message(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
list.render(area, buffer, &mut state.list)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
|
|
||||||
- `:dms` will open a list of direct messages
|
- `: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,14 @@
|
|||||||
|
//! 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::base::{CloseFlags, WordStyle};
|
use modalkit::editing::completion::CompletionList;
|
||||||
|
use modalkit::prelude::*;
|
||||||
|
|
||||||
use crate::base::{IambBufferId, IambInfo, ProgramStore};
|
use crate::base::{IambBufferId, IambInfo, IambResult, ProgramStore};
|
||||||
|
|
||||||
const WELCOME_TEXT: &str = include_str!("welcome.md");
|
const WELCOME_TEXT: &str = include_str!("welcome.md");
|
||||||
|
|
||||||
@@ -63,6 +61,19 @@ impl WindowOps<IambInfo> for WelcomeState {
|
|||||||
self.tbox.close(flags, store)
|
self.tbox.close(flags, store)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn write(
|
||||||
|
&mut self,
|
||||||
|
path: Option<&str>,
|
||||||
|
flags: WriteFlags,
|
||||||
|
store: &mut ProgramStore,
|
||||||
|
) -> IambResult<EditInfo> {
|
||||||
|
self.tbox.write(path, flags, store)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_completions(&self) -> Option<CompletionList> {
|
||||||
|
self.tbox.get_completions()
|
||||||
|
}
|
||||||
|
|
||||||
fn get_cursor_word(&self, style: &WordStyle) -> Option<String> {
|
fn get_cursor_word(&self, style: &WordStyle) -> Option<String> {
|
||||||
self.tbox.get_cursor_word(style)
|
self.tbox.get_cursor_word(style)
|
||||||
}
|
}
|
||||||
|
|||||||
1182
src/worker.rs
1182
src/worker.rs
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user