Compare commits
259 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93fc47d019 | ||
|
|
a32149f604 | ||
|
|
3149f79d11 | ||
|
|
7ccb1cbf2c | ||
|
|
1ec311590d | ||
|
|
0ddded3b8b | ||
|
|
a8cbc352ff | ||
|
|
dfa0937077 | ||
|
|
43485270ee | ||
|
|
28fea03625 | ||
|
|
e021d4a55d | ||
|
|
b01dbe5a5d | ||
|
|
4b2382bf93 | ||
|
|
0f2442566f | ||
|
|
8c9a2714a1 | ||
|
|
d44f861871 | ||
|
|
14aa97251c | ||
|
|
55456dbc1e | ||
|
|
d5c330ac72 | ||
|
|
7b1dc93f3a | ||
|
|
745f547904 | ||
|
|
6ebb7ac7fd | ||
|
|
1bb93c18fb | ||
|
|
e3090e537f | ||
|
|
ad10082c2f | ||
|
|
67603d0623 | ||
|
|
e9cdb3371a | ||
|
|
0ff8828a1c | ||
|
|
331a6bca89 | ||
|
|
963ce3c7c2 | ||
|
|
ec88f4441e | ||
|
|
34d3b844af | ||
|
|
52010d44d7 | ||
|
|
0ef5c39f7f | ||
|
|
fed19d7a4b | ||
|
|
ed9ee26854 | ||
|
|
2e6c711644 | ||
|
|
d1b03880f3 | ||
|
|
d961fe3f7b | ||
|
|
9e40b49e5e | ||
|
|
33d3407694 | ||
|
|
f880358a83 | ||
|
|
f0de97a049 | ||
|
|
a9cb5608f0 | ||
|
|
c420c9dd65 | ||
|
|
ba7d0392d8 | ||
|
|
9ed9400b67 | ||
|
|
84eaadc09a | ||
|
|
998e50f4a5 | ||
|
|
f39261ff84 | ||
|
|
98aa2f871d | ||
|
|
952374aab0 | ||
|
|
e99674b245 | ||
|
|
82ed796a91 | ||
|
|
3296f58859 | ||
|
|
26802bab55 | ||
|
|
fd3fef5c9e | ||
|
|
af96bfbb41 | ||
|
|
5f927ce9c3 | ||
|
|
6e923f3878 | ||
|
|
ebd89423e9 | ||
|
|
9fce71f896 | ||
|
|
93502f9993 | ||
|
|
6529e61963 | ||
|
|
a9c1e69a89 | ||
|
|
3e45ca3d2c | ||
|
|
7dd09e32a8 | ||
|
|
1dcd658928 | ||
|
|
382a72a468 | ||
|
|
591fc0af83 | ||
|
|
2b6363f529 | ||
|
|
6470e845e0 | ||
|
|
b023e38f77 | ||
|
|
e66a8c6716 | ||
|
|
9a9bdb4862 | ||
|
|
e40a8a8d2e | ||
|
|
f4492c9f77 | ||
|
|
a32915b7e9 | ||
|
|
3355eb2d26 | ||
|
|
7b6c5df268 | ||
|
|
2e6376ff86 | ||
|
|
480888a1fc | ||
|
|
4fc05c7b40 | ||
|
|
3003f0a528 | ||
|
|
df3896df9c | ||
|
|
2a66496913 | ||
|
|
b4fc574163 | ||
|
|
e63341fe32 | ||
|
|
657e61fe2e | ||
|
|
94999dc4c0 | ||
|
|
54cb7991be | ||
|
|
c94d7d0ad7 | ||
|
|
d44961c461 | ||
|
|
6d80b516f8 | ||
|
|
04480eda1b | ||
|
|
653287478e | ||
|
|
4571788678 | ||
|
|
9a1adfb287 | ||
|
|
cb4455655f | ||
|
|
4fc71c9291 | ||
|
|
d8d8e91295 | ||
|
|
497be7f099 | ||
|
|
64e4f67e43 | ||
|
|
a18d0f54eb | ||
|
|
59e1862e9c | ||
|
|
14415a30fc | ||
|
|
6c0d126f4b | ||
|
|
c6982c9737 | ||
|
|
46f6d37f76 | ||
|
|
3971801aa3 | ||
|
|
7bc34c8145 | ||
|
|
91ca50aecb | ||
|
|
949100bdc7 | ||
|
|
b995906c79 | ||
|
|
e5b284ed19 | ||
|
|
0f17bbfa17 | ||
|
|
aba72aa64d | ||
|
|
72d35431de | ||
|
|
a98bbd97be | ||
|
|
82645c8828 | ||
|
|
5a2a7b028d | ||
|
|
2327658e8c | ||
|
|
b4e9c213e6 | ||
|
|
79f6b5b75c | ||
|
|
6600685dd5 | ||
|
|
ed1b88c197 | ||
|
|
99996e275b | ||
|
|
db9cb92737 | ||
|
|
d3b717d1be | ||
|
|
2ac71da9a6 | ||
|
|
1e9b6cc271 | ||
|
|
46e081b1e4 | ||
|
|
23a729e565 | ||
|
|
0c52375e06 | ||
|
|
c63f8d98d5 | ||
|
|
013214899a | ||
|
|
8a5049fb25 | ||
|
|
9c6ff58b96 | ||
|
|
b41faff9b7 | ||
|
|
e7f158ffcd | ||
|
|
ef868175cb | ||
|
|
8ee203c9a9 | ||
|
|
95f2c7af30 | ||
|
|
c71cec1f54 | ||
|
|
ec81b72f2c | ||
|
|
dd001af365 | ||
|
|
9732971fc2 | ||
|
|
1948d80ec8 | ||
|
|
84bc6be822 | ||
|
|
c5999bffc8 | ||
|
|
aa878f7569 | ||
|
|
a2a708f1ae | ||
|
|
3ed87aae05 | ||
|
|
1325295d2b | ||
|
|
1cb280df8b | ||
|
|
5be886301b | ||
|
|
3e3b771b2e | ||
|
|
b7ae01499b | ||
|
|
88af9bfec3 | ||
|
|
999399a70f | ||
|
|
b33759cbc3 | ||
|
|
4236d9f53e | ||
|
|
1ae22086f6 | ||
|
|
221faa828d | ||
|
|
974775b29b | ||
|
|
25eef55eb7 | ||
|
|
8943909f06 | ||
|
|
443ad241b4 | ||
|
|
3b86be0545 | ||
|
|
b2b47ed7a0 | ||
|
|
df3148b9f5 | ||
|
|
95af00ba93 | ||
|
|
9197864c5c | ||
|
|
2673cfaeb9 | ||
|
|
c7864cb869 | ||
|
|
7fdb5f98e3 | ||
|
|
0565b6eb05 | ||
|
|
47e650c2be | ||
|
|
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 | ||
|
|
8c010d7e7e | ||
|
|
4337be108b | ||
|
|
b968d8c4a2 | ||
|
|
5683a2e7a8 | ||
|
|
afe892c7fe | ||
|
|
d8713141f2 | ||
|
|
a6888bbc93 | ||
|
|
4f2261e66f | ||
|
|
8966644f6e | ||
|
|
69125e3fc4 | ||
|
|
56ec90523c | ||
|
|
d13d4b9f7f | ||
|
|
54ce042384 | ||
|
|
b6f4b03c12 | ||
|
|
504b520fe1 |
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
|
||||
|
||||
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:
|
||||
strategy:
|
||||
matrix:
|
||||
platform: [ubuntu-latest, windows-latest, macos-latest]
|
||||
runs-on: ${{ matrix.platform }}
|
||||
env:
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
RUSTC_WRAPPER: "sccache"
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
submodules: true
|
||||
- name: Install Rust
|
||||
uses: actions-rs/toolchain@v1
|
||||
- name: Install Rust (1.83 w/ clippy)
|
||||
uses: dtolnay/rust-toolchain@1.83
|
||||
with:
|
||||
toolchain: nightly
|
||||
override: true
|
||||
components: rustfmt, clippy
|
||||
components: clippy
|
||||
- name: Install Rust (nightly w/ rustfmt)
|
||||
run: rustup toolchain install nightly --component rustfmt
|
||||
- name: Cache cargo registry
|
||||
uses: actions/cache@v1
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ~/.cargo/registry
|
||||
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
|
||||
- name: Run sccache-cache
|
||||
uses: mozilla-actions/sccache-action@v0.0.9
|
||||
- name: Check formatting
|
||||
uses: actions-rs/cargo@v1
|
||||
run: cargo +nightly fmt --all -- --check
|
||||
- name: Check Clippy
|
||||
if: matrix.platform == 'ubuntu-latest'
|
||||
uses: giraffate/clippy-action@v1
|
||||
with:
|
||||
command: fmt
|
||||
args: --all -- --check
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
reporter: 'github-check'
|
||||
- name: Run tests
|
||||
uses: actions-rs/cargo@v1
|
||||
with:
|
||||
command: test
|
||||
run: cargo test --locked
|
||||
|
||||
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
|
||||
/result
|
||||
/TODO
|
||||
.direnv
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
unstable_features = true
|
||||
max_width = 100
|
||||
fn_call_width = 90
|
||||
fn_call_width = 88
|
||||
struct_lit_width = 50
|
||||
struct_variant_width = 50
|
||||
chain_width = 75
|
||||
|
||||
6362
Cargo.lock
generated
6362
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
116
Cargo.toml
116
Cargo.toml
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "iamb"
|
||||
version = "0.0.2"
|
||||
version = "0.0.11"
|
||||
edition = "2018"
|
||||
authors = ["Ulyssa <git@ulyssa.dev>"]
|
||||
repository = "https://github.com/ulyssa/iamb"
|
||||
@@ -10,29 +10,129 @@ description = "A Matrix chat client that uses Vim keybindings"
|
||||
license = "Apache-2.0"
|
||||
exclude = [".github", "CONTRIBUTING.md"]
|
||||
keywords = ["matrix", "chat", "tui", "vim"]
|
||||
rust-version = "1.66"
|
||||
categories = ["command-line-utilities"]
|
||||
rust-version = "1.88"
|
||||
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]
|
||||
anyhow = "1.0"
|
||||
bitflags = "^2.3"
|
||||
chrono = "0.4"
|
||||
clap = {version = "4.0", features = ["derive"]}
|
||||
clap = {version = "~4.3", features = ["derive"]}
|
||||
css-color-parser = "0.1.2"
|
||||
dirs = "4.0.0"
|
||||
futures = "0.3.21"
|
||||
emojis = "0.5"
|
||||
feruca = "0.10.1"
|
||||
futures = "0.3"
|
||||
gethostname = "0.4.1"
|
||||
matrix-sdk = {version = "0.6", default-features = false, features = ["e2e-encryption", "sled", "rustls-tls"]}
|
||||
modalkit = "0.0.9"
|
||||
html5ever = "0.26.0"
|
||||
image = "^0.25.6"
|
||||
libc = "0.2"
|
||||
markup5ever_rcdom = "0.2.0"
|
||||
mime = "^0.3.16"
|
||||
mime_guess = "^2.0.4"
|
||||
nom = "7.0.0"
|
||||
open = "3.2.0"
|
||||
rand = "0.8.5"
|
||||
ratatui = "0.29.0"
|
||||
ratatui-image = { version = "~8.0.1", features = ["serde"] }
|
||||
regex = "^1.5"
|
||||
rpassword = "^7.2"
|
||||
serde = "^1.0"
|
||||
serde_json = "^1.0"
|
||||
sled = "0.34"
|
||||
sled = "0.34.7"
|
||||
temp-dir = "0.1.12"
|
||||
thiserror = "^1.0.37"
|
||||
tokio = {version = "1.17.0", features = ["full"]}
|
||||
toml = "^0.8.12"
|
||||
tracing = "~0.1.36"
|
||||
tracing-appender = "~0.2.2"
|
||||
tracing-subscriber = "0.3.16"
|
||||
unicode-segmentation = "^1.7"
|
||||
unicode-width = "0.1.10"
|
||||
url = {version = "^2.2.2", features = ["serde"]}
|
||||
edit = "0.1.4"
|
||||
humansize = "2.0.0"
|
||||
linkify = "0.10.0"
|
||||
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]
|
||||
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]
|
||||
version = "0.14.0"
|
||||
default-features = false
|
||||
features = ["e2e-encryption", "sqlite", "sso-login"]
|
||||
|
||||
[dependencies.tokio]
|
||||
version = "1.24.1"
|
||||
features = ["macros", "net", "rt-multi-thread", "sync", "time"]
|
||||
|
||||
[dev-dependencies]
|
||||
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
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
Copyright 2024 Ulyssa Mello
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
||||
44
PACKAGING.md
Normal file
44
PACKAGING.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# Notes For Package Maintainers
|
||||
|
||||
## Linking Against System Packages
|
||||
|
||||
The default Cargo features for __iamb__ will bundle SQLite and use [rustls] for
|
||||
TLS. Package maintainers may want to link against the system's native SQLite
|
||||
and TLS libraries instead. To do so, you'll want to build without the default
|
||||
features and specify that it should build with `native-tls`:
|
||||
|
||||
```
|
||||
% cargo build --release --no-default-features --features=native-tls
|
||||
```
|
||||
|
||||
## Enabling LTO
|
||||
|
||||
Enabling LTO can result in smaller binaries. There is a separate profile to
|
||||
enable it when building:
|
||||
|
||||
```
|
||||
% cargo build --profile release-lto
|
||||
```
|
||||
|
||||
Note that this [can fail][ring-lto] in some build environments if both Clang
|
||||
and GCC are present.
|
||||
|
||||
## Documentation
|
||||
|
||||
In addition to the compiled binary, there are other files in the repo that
|
||||
you'll want to install as part of a package:
|
||||
|
||||
<!-- Please keep in sync w/ the `deb`/`generate-rpm` sections of `Cargo.toml` -->
|
||||
| Repository Path | Installed Path (may vary per OS) |
|
||||
| ----------------------- | ----------------------------------------------- |
|
||||
| /iamb.desktop | /usr/share/applications/iamb.desktop |
|
||||
| /config.example.toml | /usr/share/iamb/config.example.toml |
|
||||
| /docs/iamb-256x256.png | /usr/share/icons/hicolor/256x256/apps/iamb.png |
|
||||
| /docs/iamb-512x512.png | /usr/share/icons/hicolor/512x512/apps/iamb.png |
|
||||
| /docs/iamb.svg | /usr/share/icons/hicolor/scalable/apps/iamb.svg |
|
||||
| /docs/iamb.1 | /usr/share/man/man1/iamb.1 |
|
||||
| /docs/iamb.5 | /usr/share/man/man5/iamb.5 |
|
||||
| /docs/iamb.metainfo.xml | /usr/share/metainfo/iamb.metainfo.xml |
|
||||
|
||||
[ring-lto]: https://github.com/briansmith/ring/issues/1444
|
||||
[rustls]: https://crates.io/crates/rustls
|
||||
212
README.md
212
README.md
@@ -1,101 +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+)
|
||||
[][crates-io-iamb]
|
||||
[](https://matrix.to/#/#iamb:0x.badd.cafe)
|
||||
[][crates-io-iamb]
|
||||
[](https://snapcraft.io/iamb)
|
||||
|
||||

|
||||
|
||||
</div>
|
||||
|
||||
## About
|
||||
|
||||
`iamb` is a Matrix client for the terminal that uses Vim keybindings.
|
||||
`iamb` is a Matrix client for the terminal that uses Vim keybindings. It includes support for:
|
||||
|
||||
This project is a work-in-progress, and there's still a lot to be implemented,
|
||||
but much of the basic client functionality is already present.
|
||||
- Threads, spaces, E2EE, and read receipts
|
||||
- Image previews in terminals that support it (sixels, Kitty, and iTerm2), or using pixelated blocks for those that don't
|
||||
- Notifications via terminal bell or desktop environment
|
||||
- Send Markdown, HTML or plaintext messages
|
||||
- Creating, joining, and leaving rooms
|
||||
- Sending and accepting room invitations
|
||||
- Editing, redacting, and reacting to messages
|
||||
- Custom keybindings
|
||||
- Multiple profiles
|
||||
|
||||
_You may want to [see this page as it was when the latest version was published][crates-io-iamb]._
|
||||
|
||||
## Documentation
|
||||
|
||||
You can find documentation for installing, configuring, and using iamb on its
|
||||
website, [iamb.chat].
|
||||
|
||||
## Installation
|
||||
|
||||
Install Rust and Cargo, and then run:
|
||||
|
||||
```
|
||||
cargo install iamb
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
You can create a basic configuration in `$CONFIG_DIR/iamb/config.json` that looks like:
|
||||
You can create a basic configuration in `$CONFIG_DIR/iamb/config.toml` that looks like:
|
||||
|
||||
```json
|
||||
{
|
||||
"profiles": {
|
||||
"example.com": {
|
||||
"url": "https://example.com",
|
||||
"user_id": "@user:example.com"
|
||||
}
|
||||
}
|
||||
}
|
||||
```toml
|
||||
[profiles."example.com"]
|
||||
user_id = "@user:example.com"
|
||||
```
|
||||
|
||||
## Comparison With Other Clients
|
||||
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:
|
||||
|
||||
To get an idea of what is and isn't yet implemented, here is a subset of the
|
||||
Matrix website's [features comparison table][client-comparison-matrix], showing
|
||||
two other TUI clients and Element Web:
|
||||
```toml
|
||||
[profiles."example.com"]
|
||||
url = "https://example.com"
|
||||
user_id = "@user:example.com"
|
||||
```
|
||||
|
||||
## Installation (from source)
|
||||
|
||||
Install Rust and Cargo using [rustup], and then run from the directory
|
||||
containing the sources (ie: from a git clone):
|
||||
|
||||
```
|
||||
cargo install --locked --path .
|
||||
```
|
||||
|
||||
## Installation (via `crates.io`)
|
||||
|
||||
Install Rust (1.83.0 or above) and Cargo, and then run:
|
||||
|
||||
```
|
||||
cargo install --locked iamb
|
||||
```
|
||||
|
||||
See [Configuration](#configuration) for getting a profile set up.
|
||||
|
||||
## Installation (via package managers)
|
||||
|
||||
### Arch Linux
|
||||
|
||||
On Arch Linux a [package](https://aur.archlinux.org/packages/iamb-git) is available in the
|
||||
Arch User Repositories (AUR). To install it simply run with your favorite AUR helper:
|
||||
|
||||
```
|
||||
paru iamb-git
|
||||
```
|
||||
|
||||
### FreeBSD
|
||||
|
||||
On FreeBSD a package is available from the official repositories. To install it simply run:
|
||||
|
||||
```
|
||||
pkg install iamb
|
||||
```
|
||||
|
||||
### Gentoo
|
||||
|
||||
On Gentoo, an ebuild is available from the community-managed
|
||||
[GURU overlay](https://wiki.gentoo.org/wiki/Project:GURU).
|
||||
|
||||
You can enable the GURU overlay with:
|
||||
|
||||
```
|
||||
eselect repository enable guru
|
||||
emerge --sync guru
|
||||
```
|
||||
|
||||
And then install `iamb` with:
|
||||
|
||||
```
|
||||
emerge --ask iamb
|
||||
```
|
||||
|
||||
### macOS
|
||||
|
||||
On macOS a [package](https://formulae.brew.sh/formula/iamb#default) is available in Homebrew's
|
||||
repository. To install it simply run:
|
||||
|
||||
```
|
||||
brew install iamb
|
||||
```
|
||||
|
||||
### NetBSD
|
||||
|
||||
On NetBSD a package is available from the official repositories. To install it simply run:
|
||||
|
||||
```
|
||||
pkgin install iamb
|
||||
```
|
||||
|
||||
### Nix / NixOS (flake)
|
||||
|
||||
```
|
||||
nix profile install "github:ulyssa/iamb"
|
||||
```
|
||||
|
||||
### openSUSE Tumbleweed
|
||||
|
||||
On openSUSE Tumbleweed a [package](https://build.opensuse.org/package/show/openSUSE:Factory/iamb) is available from the official repositories. To install it simply run:
|
||||
|
||||
```
|
||||
zypper install iamb
|
||||
```
|
||||
|
||||
### Snap
|
||||
|
||||
A snap for Linux distributions which [support](https://snapcraft.io/docs/installing-snapd) the packaging system.
|
||||
|
||||
```
|
||||
snap install iamb
|
||||
```
|
||||
|
||||
| | iamb | [gomuks] | [weechat-matrix] | Element Web/Desktop |
|
||||
| --------------------------------------- | :----------------- | :----------------: | :----------------: | :-----------------: |
|
||||
| Room directory | :x: ([#14]) | :x: | :heavy_check_mark: | :heavy_check_mark: |
|
||||
| Room tag showing | :x: ([#15]) | :heavy_check_mark: | :x: | :heavy_check_mark: |
|
||||
| Room tag editing | :x: ([#15]) | :heavy_check_mark: | :x: | :heavy_check_mark: |
|
||||
| Search joined rooms | :x: ([#16]) | :heavy_check_mark: | :x: | :heavy_check_mark: |
|
||||
| Room user list | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: |
|
||||
| Display Room Description | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: |
|
||||
| Edit Room Description | :heavy_check_mark: | :x: | :heavy_check_mark: | :heavy_check_mark: |
|
||||
| Highlights | :x: ([#8]) | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: |
|
||||
| Pushrules | :x: | :heavy_check_mark: | :x: | :heavy_check_mark: |
|
||||
| Send read markers | :x: ([#11]) | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: |
|
||||
| Display read markers | :x: ([#11]) | :x: | :x: | :heavy_check_mark: |
|
||||
| Sending Invites | :x: ([#7]) | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: |
|
||||
| Accepting Invites | :x: ([#7]) | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: |
|
||||
| Typing Notification | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: |
|
||||
| E2E | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: |
|
||||
| Replies | :x: ([#3]) | :heavy_check_mark: | :x: | :heavy_check_mark: |
|
||||
| Attachment uploading | :x: ([#13]) | :x: | :heavy_check_mark: | :heavy_check_mark: |
|
||||
| Attachment downloading | :x: ([#13]) | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: |
|
||||
| Send stickers | :x: | :x: | :x: | :heavy_check_mark: |
|
||||
| Send formatted messages (markdown) | :x: ([#10]) | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: |
|
||||
| Rich Text Editor for formatted messages | :x: | :x: | :x: | :heavy_check_mark: |
|
||||
| Display formatted messages | :x: ([#10]) | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: |
|
||||
| Redacting | :x: ([#5]) | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: |
|
||||
| Multiple Matrix Accounts | :heavy_check_mark: | :x: | :heavy_check_mark: | :x: |
|
||||
| New user registration | :x: | :x: | :x: | :heavy_check_mark: |
|
||||
| VOIP | :x: | :x: | :x: | :heavy_check_mark: |
|
||||
| Reactions | :x: ([#2]) | :heavy_check_mark: | :x: | :heavy_check_mark: |
|
||||
| Message editing | :x: ([#4]) | :heavy_check_mark: | :x: | :heavy_check_mark: |
|
||||
| Room upgrades | :x: | :heavy_check_mark: | :x: | :heavy_check_mark: |
|
||||
| Localisations | :x: | 1 | :x: | 44 |
|
||||
| SSO Support | :x: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: |
|
||||
|
||||
## License
|
||||
|
||||
iamb is released under the [Apache License, Version 2.0].
|
||||
|
||||
[Apache License, Version 2.0]: https://github.com/ulyssa/iamb/blob/master/LICENSE
|
||||
[client-comparison-matrix]: https://matrix.org/clients-matrix/
|
||||
[crates-io-iamb]: https://crates.io/crates/iamb
|
||||
[iamb.chat]: https://iamb.chat
|
||||
[gomuks]: https://github.com/tulir/gomuks
|
||||
[weechat-matrix]: https://github.com/poljar/weechat-matrix
|
||||
[#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
|
||||
[well_known_entry]: https://spec.matrix.org/latest/client-server-api/#getwell-knownmatrixclient
|
||||
[rustup]: https://rustup.rs/
|
||||
|
||||
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
|
||||
53
docs/iamb.metainfo.xml
Normal file
53
docs/iamb.metainfo.xml
Normal file
@@ -0,0 +1,53 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<component type="console-application">
|
||||
<id>chat.iamb.iamb</id>
|
||||
|
||||
<name>iamb</name>
|
||||
<summary>A terminal Matrix client for Vim addicts</summary>
|
||||
<url type="homepage">https://iamb.chat</url>
|
||||
|
||||
<releases>
|
||||
<release version="0.0.11" date="2026-01-19"/>
|
||||
<release version="0.0.10" date="2024-08-20"/>
|
||||
<release version="0.0.9" date="2024-03-28"/>
|
||||
</releases>
|
||||
|
||||
<developer id="dev.ulyssa">
|
||||
<name>Ulyssa</name>
|
||||
</developer>
|
||||
|
||||
<developer_name>Ulyssa</developer_name>
|
||||
<metadata_license>CC-BY-SA-4.0</metadata_license>
|
||||
<project_license>Apache-2.0</project_license>
|
||||
|
||||
<content_rating type="oars-1.1">
|
||||
<content_attribute id="social-chat">intense</content_attribute>
|
||||
</content_rating>
|
||||
|
||||
<screenshots>
|
||||
<screenshot type="default">
|
||||
<image>https://iamb.chat/static/images/metainfo-screenshot.png</image>
|
||||
<caption>Example screenshot of room and lists of rooms, spaces and members within iamb</caption>
|
||||
</screenshot>
|
||||
</screenshots>
|
||||
|
||||
<description>
|
||||
<p>
|
||||
iamb is a client for the Matrix communication protocol. It provides a
|
||||
terminal user interface with familiar Vim keybindings, and includes
|
||||
support for multiple profiles, threads, spaces, notifications,
|
||||
reactions, custom keybindings, and more.
|
||||
</p>
|
||||
</description>
|
||||
|
||||
<launchable type="desktop-id">iamb.desktop</launchable>
|
||||
|
||||
<categories>
|
||||
<category>Network</category>
|
||||
<category>Chat</category>
|
||||
</categories>
|
||||
|
||||
<provides>
|
||||
<binary>iamb</binary>
|
||||
</provides>
|
||||
</component>
|
||||
BIN
docs/iamb.png
Normal file
BIN
docs/iamb.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
128
docs/iamb.svg
Normal file
128
docs/iamb.svg
Normal file
@@ -0,0 +1,128 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="120"
|
||||
height="120"
|
||||
viewBox="0 0 120 120"
|
||||
version="1.1"
|
||||
id="svg5"
|
||||
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
|
||||
sodipodi:docname="iamb.svg"
|
||||
inkscape:export-filename="iamb.png"
|
||||
inkscape:export-xdpi="288"
|
||||
inkscape:export-ydpi="288"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<sodipodi:namedview
|
||||
id="namedview7"
|
||||
pagecolor="#505050"
|
||||
bordercolor="#eeeeee"
|
||||
borderopacity="1"
|
||||
inkscape:showpageshadow="0"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#505050"
|
||||
inkscape:document-units="px"
|
||||
showgrid="false"
|
||||
inkscape:zoom="4.3724198"
|
||||
inkscape:cx="2.5157694"
|
||||
inkscape:cy="43.11114"
|
||||
inkscape:window-width="1850"
|
||||
inkscape:window-height="1016"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="0"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="layer1" />
|
||||
<defs
|
||||
id="defs2">
|
||||
<rect
|
||||
x="69.359197"
|
||||
y="2.6803692"
|
||||
width="66.742953"
|
||||
height="18.624167"
|
||||
id="rect15628" />
|
||||
<rect
|
||||
x="2.8780095"
|
||||
y="32.203989"
|
||||
width="116.94288"
|
||||
height="87.251209"
|
||||
id="rect14838" />
|
||||
</defs>
|
||||
<g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1">
|
||||
<rect
|
||||
id="rect111"
|
||||
width="119.99836"
|
||||
height="119.79127"
|
||||
x="0.0058150524"
|
||||
y="0.21117544"
|
||||
ry="18.295183"
|
||||
style="fill:#201127;fill-opacity:1;stroke-width:0.997728" />
|
||||
<path
|
||||
id="rect111-3"
|
||||
style="fill:#1b1e34;fill-opacity:1;stroke-width:0.997728"
|
||||
d="m 18.321605,-0.01480561 c -10.1355215,0 -18.29492247,8.15940011 -18.29492247,18.29492161 v 4.564453 H 119.99738 V 17.733241 C 119.70734,7.8552235 111.68056,-0.01480561 101.7298,-0.01480561 Z" />
|
||||
<ellipse
|
||||
style="fill:#c24b6e;fill-opacity:1"
|
||||
id="path4855"
|
||||
cx="105.25824"
|
||||
cy="12.000000"
|
||||
rx="5.9108677"
|
||||
ry="5.9019933" />
|
||||
<ellipse
|
||||
style="fill:#ffeb99;fill-opacity:1"
|
||||
id="path4855-6"
|
||||
cx="91.251190"
|
||||
cy="12.000000"
|
||||
rx="5.9108677"
|
||||
ry="5.9019933" />
|
||||
<ellipse
|
||||
style="fill:#6aaf9d;fill-opacity:1"
|
||||
id="path4855-7"
|
||||
cx="77.244141"
|
||||
cy="12.000000"
|
||||
rx="5.9108677"
|
||||
ry="5.9019933" />
|
||||
<g
|
||||
aria-label="◡–"
|
||||
transform="translate(-0.25103084,-17.617149)"
|
||||
id="text14836"
|
||||
style="font-size:77.3333px;line-height:1.25;font-family:monospace;-inkscape-font-specification:'monospace, Normal';text-align:center;letter-spacing:0px;word-spacing:0px;white-space:pre;shape-inside:url(#rect14838);shape-padding:0.114105;display:inline">
|
||||
<path
|
||||
d="m 38.072257,96.572677 q -4.485331,0 -8.351996,-2.319999 -3.866665,-2.397332 -6.263997,-6.263997 -2.319999,-3.866665 -2.319999,-8.506662 h 3.247999 q 0,3.711998 1.855999,6.882663 1.933332,3.093332 5.026664,5.026664 3.170665,1.933333 6.80533,1.933333 3.711999,0 6.882664,-1.933333 3.170665,-1.933332 5.026664,-5.026664 1.933333,-3.170665 1.933333,-6.882663 h 3.247998 q 0,4.485331 -2.319999,8.429329 -2.319999,3.866665 -6.263997,6.263997 -3.866665,2.397332 -8.506663,2.397332 z"
|
||||
style="display:inline;fill:#ec9a6d"
|
||||
id="path809" />
|
||||
<path
|
||||
d="m 69.08294,84.895349 v -6.186663 h 30.93332 v 6.186663 z"
|
||||
style="display:inline;fill:#ec9a6d"
|
||||
id="path811" />
|
||||
</g>
|
||||
<g
|
||||
aria-label="iamb"
|
||||
transform="translate(-55.871719,2.2068568)"
|
||||
id="text15626"
|
||||
style="font-size:13.3333px;line-height:1.25;letter-spacing:0px;word-spacing:0px;white-space:pre;shape-inside:url(#rect15628);display:inline">
|
||||
<path
|
||||
d="m 71.026037,7.5196777 h 3.066399 v 6.3606613 h 2.376296 v 0.930987 h -5.950506 v -0.930987 h 2.376296 V 8.4506649 h -1.868485 z m 1.868485,-2.8385345 h 1.197914 v 1.5169233 h -1.197914 z"
|
||||
style="font-family:'Bitstream Vera Sans Mono';-inkscape-font-specification:'Bitstream Vera Sans Mono, Normal';fill:#ec9a6d"
|
||||
id="path800" />
|
||||
<path
|
||||
d="m 81.957,11.145971 h -0.397135 q -1.048174,0 -1.582027,0.371092 -0.527342,0.364583 -0.527342,1.093748 0,0.65755 0.397134,1.022133 0.397134,0.364582 1.100258,0.364582 0.98958,0 1.555985,-0.683592 0.566405,-0.690103 0.572916,-1.901037 v -0.266926 z m 2.324213,-0.494791 v 4.160146 H 83.076789 V 13.7306 q -0.384114,0.65104 -0.97005,0.963539 -0.579426,0.305989 -1.412757,0.305989 -1.113278,0 -1.777339,-0.624999 -0.664061,-0.631509 -0.664061,-1.686194 0,-1.217444 0.8138,-1.848953 0.82031,-0.631509 2.402338,-0.631509 h 1.608069 v -0.188802 q -0.0065,-0.8723932 -0.442708,-1.2630172 -0.436197,-0.3971345 -1.393225,-0.3971345 -0.611978,0 -1.236976,0.1757808 -0.624999,0.1757809 -1.217445,0.5143217 V 7.8517081 q 0.664061,-0.2539056 1.269528,-0.3776032 0.611977,-0.130208 1.184893,-0.130208 0.904945,0 1.542964,0.2669264 0.64453,0.2669264 1.041665,0.8007792 0.247395,0.3255201 0.351561,0.8072897 0.104167,0.4752592 0.104167,1.4322878 z"
|
||||
style="font-family:'Bitstream Vera Sans Mono';-inkscape-font-specification:'Bitstream Vera Sans Mono, Normal';fill:#ec9a6d"
|
||||
id="path802" />
|
||||
<path
|
||||
d="m 89.815053,8.2618633 q 0.221354,-0.4687488 0.559894,-0.6901024 0.345052,-0.227864 0.826821,-0.227864 0.878904,0 1.236976,0.683592 0.364583,0.6770817 0.364583,2.5585871 v 4.22525 h -1.093748 v -4.173167 q 0,-1.5429644 -0.17578,-1.9140572 -0.169271,-0.3776033 -0.624999,-0.3776033 -0.520832,0 -0.716144,0.4036449 -0.188801,0.3971344 -0.188801,1.8880156 v 4.173167 h -1.093748 v -4.173167 q 0,-1.5624956 -0.188801,-1.927078 -0.182291,-0.3645825 -0.664061,-0.3645825 -0.475259,0 -0.664061,0.4036449 -0.182291,0.3971344 -0.182291,1.8880156 v 4.173167 H 86.123656 V 7.5196777 h 1.087237 v 0.6249984 q 0.214843,-0.390624 0.533853,-0.5924464 0.32552,-0.2083328 0.735675,-0.2083328 0.49479,0 0.82031,0.227864 0.332031,0.227864 0.514322,0.6901024 z"
|
||||
style="font-family:'Bitstream Vera Sans Mono';-inkscape-font-specification:'Bitstream Vera Sans Mono, Normal';fill:#ec9a6d"
|
||||
id="path804" />
|
||||
<path
|
||||
d="m 99.417893,11.172012 q 0,-1.3932254 -0.442708,-2.102859 -0.442707,-0.7096337 -1.30859,-0.7096337 -0.872394,0 -1.321611,0.7161441 -0.449218,0.7096336 -0.449218,2.0963486 0,1.380205 0.449218,2.096349 0.449217,0.716144 1.321611,0.716144 0.865883,0 1.30859,-0.709633 0.442708,-0.709634 0.442708,-2.10286 z M 95.895766,8.4506649 q 0.286458,-0.5338528 0.787759,-0.8203104 0.507811,-0.2864576 1.171872,-0.2864576 1.3151,0 2.070307,1.0156224 0.755206,1.0091121 0.755206,2.7864517 0,1.80338 -0.761717,2.832024 -0.755206,1.022133 -2.076817,1.022133 -0.65104,0 -1.152341,-0.279948 -0.49479,-0.286457 -0.794269,-0.82682 v 0.917966 H 94.697852 V 4.6811432 h 1.197914 z"
|
||||
style="font-family:'Bitstream Vera Sans Mono';-inkscape-font-specification:'Bitstream Vera Sans Mono, Normal';fill:#ec9a6d"
|
||||
id="path806" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7.1 KiB |
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
|
||||
}
|
||||
111
flake.nix
Normal file
111
flake.nix
Normal file
@@ -0,0 +1,111 @@
|
||||
{
|
||||
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-Qxt8XAuaUR2OMdKbN4u8dBJOhSHxS+uS06Wl9+flVEk=";
|
||||
};
|
||||
|
||||
# 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;
|
||||
|
||||
craneLib = (crane.mkLib pkgs).overrideToolchain rustToolchain;
|
||||
craneLibNightly = (crane.mkLib pkgs).overrideToolchain rustNightly.toolchain;
|
||||
|
||||
src = lib.fileset.toSource {
|
||||
root = ./.;
|
||||
fileset = lib.fileset.unions [
|
||||
(craneLib.fileset.commonCargoSources ./.)
|
||||
./src/windows/welcome.md
|
||||
];
|
||||
};
|
||||
|
||||
commonArgs = {
|
||||
inherit src;
|
||||
strictDeps = true;
|
||||
pname = "iamb";
|
||||
version = self.shortRev or self.dirtyShortRev;
|
||||
};
|
||||
|
||||
# Build *just* the cargo dependencies, so we can reuse
|
||||
# all of that work (e.g. via cachix) when running in CI
|
||||
cargoArtifacts = craneLib.buildDepsOnly commonArgs;
|
||||
|
||||
# Build the actual crate
|
||||
iamb = craneLib.buildPackage (commonArgs // {
|
||||
inherit cargoArtifacts;
|
||||
});
|
||||
in
|
||||
{
|
||||
checks = {
|
||||
# Build the crate as part of `nix flake check`
|
||||
inherit iamb;
|
||||
|
||||
iamb-clippy = craneLib.cargoClippy (commonArgs // {
|
||||
inherit cargoArtifacts;
|
||||
cargoClippyExtraArgs = "--all-targets -- --deny warnings";
|
||||
});
|
||||
|
||||
iamb-fmt = craneLibNightly.cargoFmt {
|
||||
inherit src;
|
||||
};
|
||||
|
||||
iamb-nextest = craneLib.cargoNextest (commonArgs // {
|
||||
inherit cargoArtifacts;
|
||||
partitions = 1;
|
||||
partitionType = "count";
|
||||
});
|
||||
};
|
||||
|
||||
packages.default = iamb;
|
||||
|
||||
apps.default = flake-utils.lib.mkApp {
|
||||
drv = iamb;
|
||||
};
|
||||
|
||||
devShells.default = craneLib.devShell {
|
||||
# Inherit inputs from checks
|
||||
checks = self.checks.${system};
|
||||
|
||||
packages = with pkgs; [
|
||||
cargo-tarpaulin
|
||||
cargo-watch
|
||||
sqlite
|
||||
];
|
||||
|
||||
shellHook = ''
|
||||
# Prepend nightly rustfmt to PATH.
|
||||
export PATH="${rustNightly.rustfmt}/bin:$PATH"
|
||||
'';
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
12
iamb.desktop
Normal file
12
iamb.desktop
Normal file
@@ -0,0 +1,12 @@
|
||||
[Desktop Entry]
|
||||
Categories=Network;InstantMessaging;Chat;
|
||||
Comment=A Matrix client for Vim addicts
|
||||
Exec=iamb
|
||||
GenericName=Matrix Client
|
||||
Keywords=Matrix;matrix.org;chat;communications;talk;
|
||||
Name=iamb
|
||||
Icon=iamb
|
||||
StartupNotify=false
|
||||
Terminal=true
|
||||
TryExec=iamb
|
||||
Type=Application
|
||||
3
rust-toolchain.toml
Normal file
3
rust-toolchain.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
[toolchain]
|
||||
channel = "1.88"
|
||||
components = [ "clippy" ]
|
||||
2197
src/base.rs
2197
src/base.rs
File diff suppressed because it is too large
Load Diff
1259
src/commands.rs
1259
src/commands.rs
File diff suppressed because it is too large
Load Diff
1218
src/config.rs
1218
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::{
|
||||
editing::action::WindowAction,
|
||||
editing::base::WordStyle,
|
||||
actions::{InsertTextAction, MacroAction, WindowAction},
|
||||
env::vim::keybindings::{InputStep, VimBindings},
|
||||
env::vim::VimMode,
|
||||
input::bindings::{EdgeEvent, EdgeRepeat, InputBindings},
|
||||
input::key::TerminalKey,
|
||||
env::CommonKeyClass,
|
||||
key::TerminalKey,
|
||||
keybindings::{EdgeEvent, EdgeRepeat, InputBindings},
|
||||
prelude::*,
|
||||
};
|
||||
|
||||
use crate::base::{IambAction, 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.
|
||||
///
|
||||
/// Technically "[" and "]" should be here since IPv6 addresses are allowed
|
||||
/// in the server name, but in practice that should be uncommon, and people
|
||||
/// 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);
|
||||
pub type IambStep = InputStep<IambInfo>;
|
||||
|
||||
fn once(key: &TerminalKey) -> (EdgeRepeat, EdgeEvent<TerminalKey, CommonKeyClass>) {
|
||||
(EdgeRepeat::Once, EdgeEvent::Key(*key))
|
||||
}
|
||||
|
||||
/// Initialize the default keybinding state.
|
||||
pub fn setup_keybindings() -> Keybindings {
|
||||
let mut ism = Keybindings::empty();
|
||||
|
||||
let vim = VimBindings::default()
|
||||
.submit_on_enter()
|
||||
.cursor_open(WordStyle::CharSet(is_mxid_char));
|
||||
.cursor_open(MATRIX_ID_WORD.clone());
|
||||
|
||||
vim.setup(&mut ism);
|
||||
|
||||
let ctrl_w = EdgeEvent::Key("<C-W>".parse::<TerminalKey>().unwrap());
|
||||
let ctrl_m = EdgeEvent::Key("<C-M>".parse::<TerminalKey>().unwrap());
|
||||
let ctrl_z = EdgeEvent::Key("<C-Z>".parse::<TerminalKey>().unwrap());
|
||||
let key_m_lc = EdgeEvent::Key("m".parse::<TerminalKey>().unwrap());
|
||||
let key_z_lc = EdgeEvent::Key("z".parse::<TerminalKey>().unwrap());
|
||||
let ctrl_w = "<C-W>".parse::<TerminalKey>().unwrap();
|
||||
let ctrl_m = "<C-M>".parse::<TerminalKey>().unwrap();
|
||||
let ctrl_z = "<C-Z>".parse::<TerminalKey>().unwrap();
|
||||
let key_m_lc = "m".parse::<TerminalKey>().unwrap();
|
||||
let key_z_lc = "z".parse::<TerminalKey>().unwrap();
|
||||
let shift_enter = "<S-Enter>".parse::<TerminalKey>().unwrap();
|
||||
|
||||
let cwz = vec![
|
||||
(EdgeRepeat::Once, ctrl_w.clone()),
|
||||
(EdgeRepeat::Once, key_z_lc),
|
||||
];
|
||||
let cwcz = vec![
|
||||
(EdgeRepeat::Once, ctrl_w.clone()),
|
||||
(EdgeRepeat::Once, ctrl_z),
|
||||
];
|
||||
let zoom = InputStep::new().actions(vec![WindowAction::ZoomToggle.into()]);
|
||||
let cwz = vec![once(&ctrl_w), once(&key_z_lc)];
|
||||
let cwcz = vec![once(&ctrl_w), once(&ctrl_z)];
|
||||
let zoom = IambStep::new()
|
||||
.actions(vec![WindowAction::ZoomToggle.into()])
|
||||
.goto(VimMode::Normal);
|
||||
|
||||
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::Visual, &cwcz, &zoom);
|
||||
|
||||
let cwm = vec![
|
||||
(EdgeRepeat::Once, ctrl_w.clone()),
|
||||
(EdgeRepeat::Once, key_m_lc),
|
||||
];
|
||||
let cwcm = vec![(EdgeRepeat::Once, ctrl_w), (EdgeRepeat::Once, ctrl_m)];
|
||||
let stoggle = InputStep::new().actions(vec![IambAction::ToggleScrollbackFocus.into()]);
|
||||
let cwm = vec![once(&ctrl_w), once(&key_m_lc)];
|
||||
let cwcm = vec![once(&ctrl_w), once(&ctrl_m)];
|
||||
let stoggle = IambStep::new()
|
||||
.actions(vec![IambAction::ToggleScrollbackFocus.into()])
|
||||
.goto(VimMode::Normal);
|
||||
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::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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
894
src/main.rs
894
src/main.rs
File diff suppressed because it is too large
Load Diff
650
src/message.rs
650
src/message.rs
@@ -1,650 +0,0 @@
|
||||
use std::borrow::Cow;
|
||||
use std::cmp::{Ord, Ordering, PartialOrd};
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::collections::BTreeMap;
|
||||
use std::convert::TryFrom;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::str::Lines;
|
||||
|
||||
use chrono::{DateTime, NaiveDateTime, Utc};
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use matrix_sdk::ruma::{
|
||||
events::{
|
||||
room::message::{MessageType, RoomMessageEventContent},
|
||||
MessageLikeEvent,
|
||||
},
|
||||
MilliSecondsSinceUnixEpoch,
|
||||
OwnedEventId,
|
||||
OwnedUserId,
|
||||
UInt,
|
||||
};
|
||||
|
||||
use modalkit::tui::{
|
||||
style::{Color, Modifier as StyleModifier, Style},
|
||||
text::{Span, Spans, Text},
|
||||
};
|
||||
|
||||
use modalkit::editing::{base::ViewportContext, cursor::Cursor};
|
||||
|
||||
use crate::base::{IambResult, RoomInfo};
|
||||
|
||||
pub type MessageEvent = MessageLikeEvent<RoomMessageEventContent>;
|
||||
pub type MessageFetchResult = IambResult<(Option<String>, Vec<MessageEvent>)>;
|
||||
pub type MessageKey = (MessageTimeStamp, OwnedEventId);
|
||||
pub type Messages = BTreeMap<MessageKey, Message>;
|
||||
|
||||
const COLORS: [Color; 13] = [
|
||||
Color::Blue,
|
||||
Color::Cyan,
|
||||
Color::Green,
|
||||
Color::LightBlue,
|
||||
Color::LightGreen,
|
||||
Color::LightCyan,
|
||||
Color::LightMagenta,
|
||||
Color::LightRed,
|
||||
Color::LightYellow,
|
||||
Color::Magenta,
|
||||
Color::Red,
|
||||
Color::Reset,
|
||||
Color::Yellow,
|
||||
];
|
||||
|
||||
const USER_GUTTER: usize = 30;
|
||||
const TIME_GUTTER: usize = 12;
|
||||
const MIN_MSG_LEN: usize = 30;
|
||||
|
||||
const USER_GUTTER_EMPTY: &str = " ";
|
||||
const USER_GUTTER_EMPTY_SPAN: Span<'static> = Span {
|
||||
content: Cow::Borrowed(USER_GUTTER_EMPTY),
|
||||
style: Style {
|
||||
fg: None,
|
||||
bg: None,
|
||||
add_modifier: StyleModifier::empty(),
|
||||
sub_modifier: StyleModifier::empty(),
|
||||
},
|
||||
};
|
||||
|
||||
pub(crate) fn user_color(user: &str) -> Color {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
user.hash(&mut hasher);
|
||||
let color = hasher.finish() as usize % COLORS.len();
|
||||
|
||||
COLORS[color]
|
||||
}
|
||||
|
||||
pub(crate) fn user_style(user: &str) -> Style {
|
||||
Style::default().fg(user_color(user)).add_modifier(StyleModifier::BOLD)
|
||||
}
|
||||
|
||||
struct WrappedLinesIterator<'a> {
|
||||
iter: Lines<'a>,
|
||||
curr: Option<&'a str>,
|
||||
width: usize,
|
||||
}
|
||||
|
||||
impl<'a> WrappedLinesIterator<'a> {
|
||||
fn new(input: &'a str, width: usize) -> Self {
|
||||
WrappedLinesIterator { iter: input.lines(), curr: None, width }
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Iterator for WrappedLinesIterator<'a> {
|
||||
type Item = (&'a str, usize);
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
if self.curr.is_none() {
|
||||
self.curr = self.iter.next();
|
||||
}
|
||||
|
||||
if let Some(s) = self.curr.take() {
|
||||
let width = UnicodeWidthStr::width(s);
|
||||
|
||||
if width <= self.width {
|
||||
return Some((s, width));
|
||||
} else {
|
||||
// Find where to split the line.
|
||||
let mut width = 0;
|
||||
let mut idx = 0;
|
||||
|
||||
for (i, g) in UnicodeSegmentation::grapheme_indices(s, true) {
|
||||
let gw = UnicodeWidthStr::width(g);
|
||||
idx = i;
|
||||
|
||||
if width + gw > self.width {
|
||||
break;
|
||||
}
|
||||
|
||||
width += gw;
|
||||
}
|
||||
|
||||
self.curr = Some(&s[idx..]);
|
||||
|
||||
return Some((&s[..idx], width));
|
||||
}
|
||||
} else {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn wrap(input: &str, width: usize) -> WrappedLinesIterator<'_> {
|
||||
WrappedLinesIterator::new(input, width)
|
||||
}
|
||||
|
||||
fn space(width: usize) -> String {
|
||||
" ".repeat(width)
|
||||
}
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum TimeStampIntError {
|
||||
#[error("Integer conversion error: {0}")]
|
||||
IntError(#[from] std::num::TryFromIntError),
|
||||
|
||||
#[error("UInt conversion error: {0}")]
|
||||
UIntError(<UInt as TryFrom<u64>>::Error),
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum MessageTimeStamp {
|
||||
OriginServer(UInt),
|
||||
LocalEcho,
|
||||
}
|
||||
|
||||
impl MessageTimeStamp {
|
||||
fn show(&self) -> Option<Span> {
|
||||
match self {
|
||||
MessageTimeStamp::OriginServer(ts) => {
|
||||
let time = i64::from(*ts) / 1000;
|
||||
let time = NaiveDateTime::from_timestamp_opt(time, 0)?;
|
||||
let time = DateTime::<Utc>::from_utc(time, Utc);
|
||||
let time = time.format("%T");
|
||||
let time = format!(" [{}]", time);
|
||||
|
||||
Span::raw(time).into()
|
||||
},
|
||||
MessageTimeStamp::LocalEcho => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_local_echo(&self) -> bool {
|
||||
matches!(self, MessageTimeStamp::LocalEcho)
|
||||
}
|
||||
}
|
||||
|
||||
impl Ord for MessageTimeStamp {
|
||||
fn cmp(&self, other: &Self) -> Ordering {
|
||||
match (self, other) {
|
||||
(MessageTimeStamp::OriginServer(_), MessageTimeStamp::LocalEcho) => Ordering::Less,
|
||||
(MessageTimeStamp::OriginServer(a), MessageTimeStamp::OriginServer(b)) => a.cmp(b),
|
||||
(MessageTimeStamp::LocalEcho, MessageTimeStamp::OriginServer(_)) => Ordering::Greater,
|
||||
(MessageTimeStamp::LocalEcho, MessageTimeStamp::LocalEcho) => Ordering::Equal,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialOrd for MessageTimeStamp {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
||||
self.cmp(other).into()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<MilliSecondsSinceUnixEpoch> for MessageTimeStamp {
|
||||
fn from(millis: MilliSecondsSinceUnixEpoch) -> Self {
|
||||
MessageTimeStamp::OriginServer(millis.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<&MessageTimeStamp> for usize {
|
||||
type Error = TimeStampIntError;
|
||||
|
||||
fn try_from(ts: &MessageTimeStamp) -> Result<Self, Self::Error> {
|
||||
let n = match ts {
|
||||
MessageTimeStamp::LocalEcho => 0,
|
||||
MessageTimeStamp::OriginServer(u) => usize::try_from(u64::from(*u))?,
|
||||
};
|
||||
|
||||
Ok(n)
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<usize> for MessageTimeStamp {
|
||||
type Error = TimeStampIntError;
|
||||
|
||||
fn try_from(u: usize) -> Result<Self, Self::Error> {
|
||||
if u == 0 {
|
||||
Ok(MessageTimeStamp::LocalEcho)
|
||||
} else {
|
||||
let n = u64::try_from(u)?;
|
||||
let n = UInt::try_from(n).map_err(TimeStampIntError::UIntError)?;
|
||||
|
||||
Ok(MessageTimeStamp::OriginServer(n))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||
pub struct MessageCursor {
|
||||
/// When timestamp is None, the corner is determined by moving backwards from
|
||||
/// the most recently received message.
|
||||
pub timestamp: Option<MessageKey>,
|
||||
|
||||
/// A row within the [Text] representation of a [Message].
|
||||
pub text_row: usize,
|
||||
}
|
||||
|
||||
impl MessageCursor {
|
||||
pub fn new(timestamp: MessageKey, text_row: usize) -> Self {
|
||||
MessageCursor { timestamp: Some(timestamp), text_row }
|
||||
}
|
||||
|
||||
/// Get a cursor that refers to the most recent message.
|
||||
pub fn latest() -> Self {
|
||||
MessageCursor::default()
|
||||
}
|
||||
|
||||
pub fn to_key<'a>(&'a self, info: &'a RoomInfo) -> Option<&'a MessageKey> {
|
||||
if let Some(ref key) = self.timestamp {
|
||||
Some(key)
|
||||
} else {
|
||||
Some(info.messages.last_key_value()?.0)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_cursor(cursor: &Cursor, info: &RoomInfo) -> Option<Self> {
|
||||
let ev_hash = u64::try_from(cursor.get_x()).ok()?;
|
||||
let ev_term = OwnedEventId::try_from("$").ok()?;
|
||||
|
||||
let ts_start = MessageTimeStamp::try_from(cursor.get_y()).ok()?;
|
||||
let start = (ts_start, ev_term);
|
||||
let mut mc = None;
|
||||
|
||||
for ((ts, event_id), _) in info.messages.range(start..) {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
event_id.hash(&mut hasher);
|
||||
|
||||
if hasher.finish() == ev_hash {
|
||||
mc = Self::from((*ts, event_id.clone())).into();
|
||||
break;
|
||||
}
|
||||
|
||||
if mc.is_none() {
|
||||
mc = Self::from((*ts, event_id.clone())).into();
|
||||
}
|
||||
|
||||
if ts > &ts_start {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return mc;
|
||||
}
|
||||
|
||||
pub fn to_cursor(&self, info: &RoomInfo) -> Option<Cursor> {
|
||||
let (ts, event_id) = self.to_key(info)?;
|
||||
|
||||
let y: usize = usize::try_from(ts).ok()?;
|
||||
|
||||
let mut hasher = DefaultHasher::new();
|
||||
event_id.hash(&mut hasher);
|
||||
let x = usize::try_from(hasher.finish()).ok()?;
|
||||
|
||||
Cursor::new(y, x).into()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Option<MessageKey>> for MessageCursor {
|
||||
fn from(key: Option<MessageKey>) -> Self {
|
||||
MessageCursor { timestamp: key, text_row: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
impl From<MessageKey> for MessageCursor {
|
||||
fn from(key: MessageKey) -> Self {
|
||||
MessageCursor { timestamp: Some(key), text_row: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
impl Ord for MessageCursor {
|
||||
fn cmp(&self, other: &Self) -> Ordering {
|
||||
match (&self.timestamp, &other.timestamp) {
|
||||
(None, None) => self.text_row.cmp(&other.text_row),
|
||||
(None, Some(_)) => Ordering::Greater,
|
||||
(Some(_), None) => Ordering::Less,
|
||||
(Some(st), Some(ot)) => {
|
||||
let pcmp = st.cmp(ot);
|
||||
let tcmp = self.text_row.cmp(&other.text_row);
|
||||
|
||||
pcmp.then(tcmp)
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialOrd for MessageCursor {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
||||
self.cmp(other).into()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum MessageContent {
|
||||
Original(Box<RoomMessageEventContent>),
|
||||
Redacted,
|
||||
}
|
||||
|
||||
impl AsRef<str> for MessageContent {
|
||||
fn as_ref(&self) -> &str {
|
||||
match self {
|
||||
MessageContent::Original(ev) => {
|
||||
match &ev.msgtype {
|
||||
MessageType::Text(content) => {
|
||||
return content.body.as_ref();
|
||||
},
|
||||
MessageType::Emote(content) => {
|
||||
return content.body.as_ref();
|
||||
},
|
||||
MessageType::Notice(content) => {
|
||||
return content.body.as_str();
|
||||
},
|
||||
MessageType::ServerNotice(_) => {
|
||||
// XXX: implement
|
||||
|
||||
return "[server notice]";
|
||||
},
|
||||
MessageType::VerificationRequest(_) => {
|
||||
// XXX: implement
|
||||
|
||||
return "[verification request]";
|
||||
},
|
||||
MessageType::Audio(..) => {
|
||||
return "[audio]";
|
||||
},
|
||||
MessageType::File(..) => {
|
||||
return "[file]";
|
||||
},
|
||||
MessageType::Image(..) => {
|
||||
return "[image]";
|
||||
},
|
||||
MessageType::Video(..) => {
|
||||
return "[video]";
|
||||
},
|
||||
_ => return "[unknown message type]",
|
||||
}
|
||||
},
|
||||
MessageContent::Redacted => "[redacted]",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Message {
|
||||
pub content: MessageContent,
|
||||
pub sender: OwnedUserId,
|
||||
pub timestamp: MessageTimeStamp,
|
||||
}
|
||||
|
||||
impl Message {
|
||||
pub fn new(content: MessageContent, sender: OwnedUserId, timestamp: MessageTimeStamp) -> Self {
|
||||
Message { content, sender, timestamp }
|
||||
}
|
||||
|
||||
pub fn show(&self, selected: bool, vwctx: &ViewportContext<MessageCursor>) -> Text {
|
||||
let width = vwctx.get_width();
|
||||
let msg = self.as_ref();
|
||||
|
||||
let mut lines = vec![];
|
||||
|
||||
let mut style = Style::default();
|
||||
|
||||
if selected {
|
||||
style = style.add_modifier(StyleModifier::REVERSED)
|
||||
}
|
||||
|
||||
if self.timestamp.is_local_echo() {
|
||||
style = style.add_modifier(StyleModifier::ITALIC);
|
||||
}
|
||||
|
||||
if USER_GUTTER + TIME_GUTTER + MIN_MSG_LEN <= width {
|
||||
let lw = width - USER_GUTTER - TIME_GUTTER;
|
||||
|
||||
for (i, (line, w)) in wrap(msg, lw).enumerate() {
|
||||
let line = Span::styled(line, style);
|
||||
let trailing = Span::styled(space(lw.saturating_sub(w)), style);
|
||||
|
||||
if i == 0 {
|
||||
let user = self.show_sender(true);
|
||||
|
||||
if let Some(time) = self.timestamp.show() {
|
||||
lines.push(Spans(vec![user, line, trailing, time]))
|
||||
} else {
|
||||
lines.push(Spans(vec![user, line, trailing]))
|
||||
}
|
||||
} else {
|
||||
let space = USER_GUTTER_EMPTY_SPAN;
|
||||
|
||||
lines.push(Spans(vec![space, line, trailing]))
|
||||
}
|
||||
}
|
||||
} else if USER_GUTTER + MIN_MSG_LEN <= width {
|
||||
let lw = width - USER_GUTTER;
|
||||
|
||||
for (i, (line, w)) in wrap(msg, lw).enumerate() {
|
||||
let line = Span::styled(line, style);
|
||||
let trailing = Span::styled(space(lw.saturating_sub(w)), style);
|
||||
|
||||
let prefix = if i == 0 {
|
||||
self.show_sender(true)
|
||||
} else {
|
||||
USER_GUTTER_EMPTY_SPAN
|
||||
};
|
||||
|
||||
lines.push(Spans(vec![prefix, line, trailing]))
|
||||
}
|
||||
} else {
|
||||
lines.push(Spans::from(self.show_sender(false)));
|
||||
|
||||
for (line, _) in wrap(msg, width.saturating_sub(2)) {
|
||||
let line = format!(" {}", line);
|
||||
let line = Span::styled(line, style);
|
||||
|
||||
lines.push(Spans(vec![line]))
|
||||
}
|
||||
}
|
||||
|
||||
return Text { lines };
|
||||
}
|
||||
|
||||
fn show_sender(&self, align_right: bool) -> Span {
|
||||
let sender = self.sender.to_string();
|
||||
let style = user_style(sender.as_str());
|
||||
|
||||
let sender = if align_right {
|
||||
format!("{: >width$} ", sender, width = 28)
|
||||
} else {
|
||||
format!("{: <width$} ", sender, width = 28)
|
||||
};
|
||||
|
||||
Span::styled(sender, style)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<MessageEvent> for Message {
|
||||
fn from(event: MessageEvent) -> Self {
|
||||
match event {
|
||||
MessageLikeEvent::Original(ev) => {
|
||||
let content = MessageContent::Original(ev.content.into());
|
||||
|
||||
Message::new(content, ev.sender, ev.origin_server_ts.into())
|
||||
},
|
||||
MessageLikeEvent::Redacted(ev) => {
|
||||
Message::new(MessageContent::Redacted, ev.sender, ev.origin_server_ts.into())
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<str> for Message {
|
||||
fn as_ref(&self) -> &str {
|
||||
self.content.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
impl ToString for Message {
|
||||
fn to_string(&self) -> String {
|
||||
self.as_ref().to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub mod tests {
|
||||
use super::*;
|
||||
use crate::tests::*;
|
||||
|
||||
#[test]
|
||||
fn test_wrapped_lines_ascii() {
|
||||
let s = "hello world!\nabcdefghijklmnopqrstuvwxyz\ngoodbye";
|
||||
|
||||
let mut iter = wrap(s, 100);
|
||||
assert_eq!(iter.next(), Some(("hello world!", 12)));
|
||||
assert_eq!(iter.next(), Some(("abcdefghijklmnopqrstuvwxyz", 26)));
|
||||
assert_eq!(iter.next(), Some(("goodbye", 7)));
|
||||
assert_eq!(iter.next(), None);
|
||||
|
||||
let mut iter = wrap(s, 5);
|
||||
assert_eq!(iter.next(), Some(("hello", 5)));
|
||||
assert_eq!(iter.next(), Some((" worl", 5)));
|
||||
assert_eq!(iter.next(), Some(("d!", 2)));
|
||||
assert_eq!(iter.next(), Some(("abcde", 5)));
|
||||
assert_eq!(iter.next(), Some(("fghij", 5)));
|
||||
assert_eq!(iter.next(), Some(("klmno", 5)));
|
||||
assert_eq!(iter.next(), Some(("pqrst", 5)));
|
||||
assert_eq!(iter.next(), Some(("uvwxy", 5)));
|
||||
assert_eq!(iter.next(), Some(("z", 1)));
|
||||
assert_eq!(iter.next(), Some(("goodb", 5)));
|
||||
assert_eq!(iter.next(), Some(("ye", 2)));
|
||||
assert_eq!(iter.next(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wrapped_lines_unicode() {
|
||||
let s = "CHICKEN";
|
||||
|
||||
let mut iter = wrap(s, 14);
|
||||
assert_eq!(iter.next(), Some((s, 14)));
|
||||
assert_eq!(iter.next(), None);
|
||||
|
||||
let mut iter = wrap(s, 5);
|
||||
assert_eq!(iter.next(), Some(("CH", 4)));
|
||||
assert_eq!(iter.next(), Some(("IC", 4)));
|
||||
assert_eq!(iter.next(), Some(("KE", 4)));
|
||||
assert_eq!(iter.next(), Some(("N", 2)));
|
||||
assert_eq!(iter.next(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mc_cmp() {
|
||||
let mc1 = MessageCursor::from(MSG1_KEY.clone());
|
||||
let mc2 = MessageCursor::from(MSG2_KEY.clone());
|
||||
let mc3 = MessageCursor::from(MSG3_KEY.clone());
|
||||
let mc4 = MessageCursor::from(MSG4_KEY.clone());
|
||||
let mc5 = MessageCursor::from(MSG5_KEY.clone());
|
||||
|
||||
// Everything is equal to itself.
|
||||
assert_eq!(mc1.cmp(&mc1), Ordering::Equal);
|
||||
assert_eq!(mc2.cmp(&mc2), Ordering::Equal);
|
||||
assert_eq!(mc3.cmp(&mc3), Ordering::Equal);
|
||||
assert_eq!(mc4.cmp(&mc4), Ordering::Equal);
|
||||
assert_eq!(mc5.cmp(&mc5), Ordering::Equal);
|
||||
|
||||
// Local echo is always greater than an origin server timestamp.
|
||||
assert_eq!(mc1.cmp(&mc2), Ordering::Greater);
|
||||
assert_eq!(mc1.cmp(&mc3), Ordering::Greater);
|
||||
assert_eq!(mc1.cmp(&mc4), Ordering::Greater);
|
||||
assert_eq!(mc1.cmp(&mc5), Ordering::Greater);
|
||||
|
||||
// mc2 is the smallest timestamp.
|
||||
assert_eq!(mc2.cmp(&mc1), Ordering::Less);
|
||||
assert_eq!(mc2.cmp(&mc3), Ordering::Less);
|
||||
assert_eq!(mc2.cmp(&mc4), Ordering::Less);
|
||||
assert_eq!(mc2.cmp(&mc5), Ordering::Less);
|
||||
|
||||
// mc3 should be less than mc4 because of its event ID.
|
||||
assert_eq!(mc3.cmp(&mc1), Ordering::Less);
|
||||
assert_eq!(mc3.cmp(&mc2), Ordering::Greater);
|
||||
assert_eq!(mc3.cmp(&mc4), Ordering::Less);
|
||||
assert_eq!(mc3.cmp(&mc5), Ordering::Less);
|
||||
|
||||
// mc4 should be greater than mc3 because of its event ID.
|
||||
assert_eq!(mc4.cmp(&mc1), Ordering::Less);
|
||||
assert_eq!(mc4.cmp(&mc2), Ordering::Greater);
|
||||
assert_eq!(mc4.cmp(&mc3), Ordering::Greater);
|
||||
assert_eq!(mc4.cmp(&mc5), Ordering::Less);
|
||||
|
||||
// mc5 is the greatest OriginServer timestamp.
|
||||
assert_eq!(mc5.cmp(&mc1), Ordering::Less);
|
||||
assert_eq!(mc5.cmp(&mc2), Ordering::Greater);
|
||||
assert_eq!(mc5.cmp(&mc3), Ordering::Greater);
|
||||
assert_eq!(mc5.cmp(&mc4), Ordering::Greater);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mc_to_key() {
|
||||
let info = mock_room();
|
||||
let mc1 = MessageCursor::from(MSG1_KEY.clone());
|
||||
let mc2 = MessageCursor::from(MSG2_KEY.clone());
|
||||
let mc3 = MessageCursor::from(MSG3_KEY.clone());
|
||||
let mc4 = MessageCursor::from(MSG4_KEY.clone());
|
||||
let mc5 = MessageCursor::from(MSG5_KEY.clone());
|
||||
let mc6 = MessageCursor::latest();
|
||||
|
||||
let k1 = mc1.to_key(&info).unwrap();
|
||||
let k2 = mc2.to_key(&info).unwrap();
|
||||
let k3 = mc3.to_key(&info).unwrap();
|
||||
let k4 = mc4.to_key(&info).unwrap();
|
||||
let k5 = mc5.to_key(&info).unwrap();
|
||||
let k6 = mc6.to_key(&info).unwrap();
|
||||
|
||||
// These should all be equal to their MSGN_KEYs.
|
||||
assert_eq!(k1, &MSG1_KEY.clone());
|
||||
assert_eq!(k2, &MSG2_KEY.clone());
|
||||
assert_eq!(k3, &MSG3_KEY.clone());
|
||||
assert_eq!(k4, &MSG4_KEY.clone());
|
||||
assert_eq!(k5, &MSG5_KEY.clone());
|
||||
|
||||
// MessageCursor::latest() turns into the largest key (our local echo message).
|
||||
assert_eq!(k6, &MSG1_KEY.clone());
|
||||
|
||||
// MessageCursor::latest() fails to convert for a room w/o messages.
|
||||
let info_empty = RoomInfo::default();
|
||||
assert_eq!(mc6.to_key(&info_empty), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mc_to_from_cursor() {
|
||||
let info = mock_room();
|
||||
let mc1 = MessageCursor::from(MSG1_KEY.clone());
|
||||
let mc2 = MessageCursor::from(MSG2_KEY.clone());
|
||||
let mc3 = MessageCursor::from(MSG3_KEY.clone());
|
||||
let mc4 = MessageCursor::from(MSG4_KEY.clone());
|
||||
let mc5 = MessageCursor::from(MSG5_KEY.clone());
|
||||
let mc6 = MessageCursor::latest();
|
||||
|
||||
let identity = |mc: &MessageCursor| {
|
||||
let c = mc.to_cursor(&info).unwrap();
|
||||
|
||||
MessageCursor::from_cursor(&c, &info).unwrap()
|
||||
};
|
||||
|
||||
// These should all convert to a Cursor and back to the original value.
|
||||
assert_eq!(identity(&mc1), mc1);
|
||||
assert_eq!(identity(&mc2), mc2);
|
||||
assert_eq!(identity(&mc3), mc3);
|
||||
assert_eq!(identity(&mc4), mc4);
|
||||
assert_eq!(identity(&mc5), mc5);
|
||||
|
||||
// MessageCursor::latest() should point at the most recent message after conversion.
|
||||
assert_eq!(identity(&mc6), mc1);
|
||||
}
|
||||
}
|
||||
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");
|
||||
}
|
||||
}
|
||||
1549
src/message/html.rs
Normal file
1549
src/message/html.rs
Normal file
File diff suppressed because it is too large
Load Diff
1512
src/message/mod.rs
Normal file
1512
src/message/mod.rs
Normal file
File diff suppressed because it is too large
Load Diff
318
src/message/printer.rs
Normal file
318
src/message/printer.rs
Normal file
@@ -0,0 +1,318 @@
|
||||
//! # 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 ratatui::layout::Alignment;
|
||||
use ratatui::style::Style;
|
||||
use ratatui::text::{Line, Span, Text};
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use crate::config::{ApplicationSettings, TunableValues};
|
||||
use crate::util::{
|
||||
replace_emojis_in_line,
|
||||
replace_emojis_in_span,
|
||||
replace_emojis_in_str,
|
||||
space_span,
|
||||
take_width,
|
||||
};
|
||||
|
||||
/// Wrap styled text for the current terminal width.
|
||||
pub struct TextPrinter<'a> {
|
||||
text: Text<'a>,
|
||||
width: usize,
|
||||
base_style: Style,
|
||||
hide_reply: bool,
|
||||
|
||||
alignment: Alignment,
|
||||
curr_spans: Vec<Span<'a>>,
|
||||
curr_width: usize,
|
||||
literal: bool,
|
||||
|
||||
pub(super) settings: &'a ApplicationSettings,
|
||||
}
|
||||
|
||||
impl<'a> TextPrinter<'a> {
|
||||
/// Create a new printer.
|
||||
pub fn new(
|
||||
width: usize,
|
||||
base_style: Style,
|
||||
hide_reply: bool,
|
||||
settings: &'a ApplicationSettings,
|
||||
) -> Self {
|
||||
TextPrinter {
|
||||
text: Text::default(),
|
||||
width,
|
||||
base_style,
|
||||
hide_reply,
|
||||
|
||||
alignment: Alignment::Left,
|
||||
curr_spans: vec![],
|
||||
curr_width: 0,
|
||||
literal: false,
|
||||
settings,
|
||||
}
|
||||
}
|
||||
|
||||
/// Configure the alignment for each line.
|
||||
pub fn align(mut self, alignment: Alignment) -> Self {
|
||||
self.alignment = alignment;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set whether newlines should be treated literally, or turned into spaces.
|
||||
pub fn literal(mut self, literal: bool) -> Self {
|
||||
self.literal = literal;
|
||||
self
|
||||
}
|
||||
|
||||
/// Indicates whether replies should be pushed to the printer.
|
||||
pub fn hide_reply(&self) -> bool {
|
||||
self.hide_reply
|
||||
}
|
||||
|
||||
/// Indicates whether emojis should be replaced by shortcodes
|
||||
pub fn emoji_shortcodes(&self) -> bool {
|
||||
self.tunables().message_shortcode_display
|
||||
}
|
||||
|
||||
pub fn settings(&self) -> &ApplicationSettings {
|
||||
self.settings
|
||||
}
|
||||
|
||||
pub fn tunables(&self) -> &TunableValues {
|
||||
&self.settings.tunables
|
||||
}
|
||||
|
||||
/// Indicates the current printer's width.
|
||||
pub fn width(&self) -> usize {
|
||||
self.width
|
||||
}
|
||||
|
||||
/// Create a new printer with a smaller width.
|
||||
pub fn sub(&self, indent: usize) -> Self {
|
||||
TextPrinter {
|
||||
text: Text::default(),
|
||||
width: self.width.saturating_sub(indent),
|
||||
base_style: self.base_style,
|
||||
hide_reply: self.hide_reply,
|
||||
|
||||
alignment: self.alignment,
|
||||
curr_spans: vec![],
|
||||
curr_width: 0,
|
||||
literal: self.literal,
|
||||
settings: self.settings,
|
||||
}
|
||||
}
|
||||
|
||||
fn remaining(&self) -> usize {
|
||||
self.width.saturating_sub(self.curr_width)
|
||||
}
|
||||
|
||||
/// If there is any text on the current line, start a new one.
|
||||
pub fn commit(&mut self) {
|
||||
if self.curr_width > 0 {
|
||||
self.push_break();
|
||||
}
|
||||
}
|
||||
|
||||
fn push(&mut self) {
|
||||
self.curr_width = 0;
|
||||
self.text.lines.push(Line::from(std::mem::take(&mut self.curr_spans)));
|
||||
}
|
||||
|
||||
/// Start a new line.
|
||||
pub fn push_break(&mut self) {
|
||||
if self.curr_width == 0 && self.text.lines.is_empty() {
|
||||
// Disallow leading breaks.
|
||||
return;
|
||||
}
|
||||
|
||||
let remaining = self.remaining();
|
||||
|
||||
if remaining > 0 {
|
||||
match self.alignment {
|
||||
Alignment::Left => {
|
||||
let tspan = space_span(remaining, self.base_style);
|
||||
self.curr_spans.push(tspan);
|
||||
},
|
||||
Alignment::Center => {
|
||||
let trailing = remaining / 2;
|
||||
let leading = remaining - trailing;
|
||||
|
||||
let tspan = space_span(trailing, self.base_style);
|
||||
let lspan = space_span(leading, self.base_style);
|
||||
|
||||
self.curr_spans.push(tspan);
|
||||
self.curr_spans.insert(0, lspan);
|
||||
},
|
||||
Alignment::Right => {
|
||||
let lspan = space_span(remaining, self.base_style);
|
||||
self.curr_spans.insert(0, lspan);
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
self.push();
|
||||
}
|
||||
|
||||
fn push_str_wrapped<T>(&mut self, s: T, style: Style)
|
||||
where
|
||||
T: Into<Cow<'a, str>>,
|
||||
{
|
||||
let style = self.base_style.patch(style);
|
||||
let mut cow = s.into();
|
||||
|
||||
loop {
|
||||
let sw = UnicodeWidthStr::width(cow.as_ref());
|
||||
|
||||
if self.curr_width + sw <= self.width {
|
||||
// The text fits within the current line.
|
||||
self.curr_spans.push(Span::styled(cow, style));
|
||||
self.curr_width += sw;
|
||||
break;
|
||||
}
|
||||
|
||||
// Take a leading portion of the text that fits in the line.
|
||||
let ((s0, w), s1) = take_width(cow, self.remaining());
|
||||
cow = s1;
|
||||
|
||||
self.curr_spans.push(Span::styled(s0, style));
|
||||
self.curr_width += w;
|
||||
|
||||
self.commit();
|
||||
}
|
||||
|
||||
if self.curr_width == self.width {
|
||||
// If the last bit fills the full line, start a new one.
|
||||
self.push();
|
||||
}
|
||||
}
|
||||
|
||||
/// Push a [Span] that isn't allowed to break across lines.
|
||||
pub fn push_span_nobreak(&mut self, mut span: Span<'a>) {
|
||||
if self.emoji_shortcodes() {
|
||||
replace_emojis_in_span(&mut span);
|
||||
}
|
||||
let sw = UnicodeWidthStr::width(span.content.as_ref());
|
||||
|
||||
if self.curr_width + sw > self.width {
|
||||
// Span doesn't fit on this line, so start a new one.
|
||||
self.commit();
|
||||
}
|
||||
|
||||
self.curr_spans.push(span);
|
||||
self.curr_width += sw;
|
||||
}
|
||||
|
||||
/// Push text with a [Style].
|
||||
pub fn push_str(&mut self, s: &'a str, style: Style) {
|
||||
let style = self.base_style.patch(style);
|
||||
|
||||
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();
|
||||
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);
|
||||
}
|
||||
|
||||
/// Render the contents of this printer as [Text].
|
||||
pub fn finish(mut self) -> Text<'a> {
|
||||
self.commit();
|
||||
self.text
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub mod tests {
|
||||
use super::*;
|
||||
use crate::tests::mock_settings;
|
||||
|
||||
#[test]
|
||||
fn test_push_nobreak() {
|
||||
let settings = mock_settings();
|
||||
let mut printer = TextPrinter::new(5, Style::default(), false, &settings);
|
||||
printer.push_span_nobreak("hello world".into());
|
||||
let text = printer.finish();
|
||||
assert_eq!(text.lines.len(), 1);
|
||||
assert_eq!(text.lines[0].spans.len(), 1);
|
||||
assert_eq!(text.lines[0].spans[0].content, "hello world");
|
||||
}
|
||||
}
|
||||
956
src/message/state.rs
Normal file
956
src/message/state.rs
Normal file
@@ -0,0 +1,956 @@
|
||||
//! Code for displaying state events.
|
||||
use std::borrow::Cow;
|
||||
use std::str::FromStr;
|
||||
|
||||
use matrix_sdk::ruma::{
|
||||
events::{
|
||||
room::member::MembershipChange,
|
||||
AnyFullStateEventContent,
|
||||
AnySyncStateEvent,
|
||||
FullStateEventContent,
|
||||
},
|
||||
OwnedRoomId,
|
||||
UserId,
|
||||
};
|
||||
|
||||
use super::html::{StyleTree, StyleTreeNode};
|
||||
use ratatui::style::{Modifier as StyleModifier, Style};
|
||||
|
||||
fn bold(s: impl Into<Cow<'static, str>>) -> StyleTreeNode {
|
||||
let bold = Style::default().add_modifier(StyleModifier::BOLD);
|
||||
let text = StyleTreeNode::Text(s.into());
|
||||
StyleTreeNode::Style(Box::new(text), bold)
|
||||
}
|
||||
|
||||
pub fn body_cow_state(ev: &AnySyncStateEvent) -> Cow<'static, str> {
|
||||
let event = match ev.content() {
|
||||
AnyFullStateEventContent::PolicyRuleRoom(FullStateEventContent::Original {
|
||||
content,
|
||||
..
|
||||
}) => {
|
||||
let mut m = format!(
|
||||
"* updated the room policy rule for {:?} to {:?}",
|
||||
content.0.entity,
|
||||
content.0.recommendation.as_str()
|
||||
);
|
||||
|
||||
if !content.0.reason.is_empty() {
|
||||
m.push_str(" (reason: ");
|
||||
m.push_str(&content.0.reason);
|
||||
m.push(')');
|
||||
}
|
||||
|
||||
m
|
||||
},
|
||||
AnyFullStateEventContent::PolicyRuleServer(FullStateEventContent::Original {
|
||||
content,
|
||||
..
|
||||
}) => {
|
||||
let mut m = format!(
|
||||
"* updated the server policy rule for {:?} to {:?}",
|
||||
content.0.entity,
|
||||
content.0.recommendation.as_str()
|
||||
);
|
||||
|
||||
if !content.0.reason.is_empty() {
|
||||
m.push_str(" (reason: ");
|
||||
m.push_str(&content.0.reason);
|
||||
m.push(')');
|
||||
}
|
||||
|
||||
m
|
||||
},
|
||||
AnyFullStateEventContent::PolicyRuleUser(FullStateEventContent::Original {
|
||||
content,
|
||||
..
|
||||
}) => {
|
||||
let mut m = format!(
|
||||
"* updated the user policy rule for {:?} to {:?}",
|
||||
content.0.entity,
|
||||
content.0.recommendation.as_str()
|
||||
);
|
||||
|
||||
if !content.0.reason.is_empty() {
|
||||
m.push_str(" (reason: ");
|
||||
m.push_str(&content.0.reason);
|
||||
m.push(')');
|
||||
}
|
||||
|
||||
m
|
||||
},
|
||||
AnyFullStateEventContent::RoomAliases(FullStateEventContent::Original {
|
||||
content, ..
|
||||
}) => {
|
||||
let mut m = String::from("* set the room aliases to: ");
|
||||
|
||||
for (i, alias) in content.aliases.iter().enumerate() {
|
||||
if i != 0 {
|
||||
m.push_str(", ");
|
||||
}
|
||||
|
||||
m.push_str(alias.as_str());
|
||||
}
|
||||
|
||||
m
|
||||
},
|
||||
AnyFullStateEventContent::RoomAvatar(FullStateEventContent::Original {
|
||||
content,
|
||||
prev_content,
|
||||
}) => {
|
||||
let prev_url = prev_content.as_ref().and_then(|p| p.url.as_ref());
|
||||
|
||||
match (prev_url, content.url) {
|
||||
(None, Some(_)) => return Cow::Borrowed("* added a room avatar"),
|
||||
(Some(old), Some(new)) => {
|
||||
if old != &new {
|
||||
return Cow::Borrowed("* replaced the room avatar");
|
||||
}
|
||||
|
||||
return Cow::Borrowed("* updated the room avatar state");
|
||||
},
|
||||
(Some(_), None) => return Cow::Borrowed("* removed the room avatar"),
|
||||
(None, None) => return Cow::Borrowed("* updated the room avatar state"),
|
||||
}
|
||||
},
|
||||
AnyFullStateEventContent::RoomCanonicalAlias(FullStateEventContent::Original {
|
||||
content,
|
||||
prev_content,
|
||||
}) => {
|
||||
let old_canon = prev_content.as_ref().and_then(|p| p.alias.as_ref());
|
||||
let new_canon = content.alias.as_ref();
|
||||
|
||||
match (old_canon, new_canon) {
|
||||
(None, Some(canon)) => {
|
||||
format!("* updated the canonical alias for the room to: {canon}")
|
||||
},
|
||||
(Some(old), Some(new)) => {
|
||||
if old != new {
|
||||
format!("* updated the canonical alias for the room to: {new}")
|
||||
} else {
|
||||
return Cow::Borrowed("* removed the canonical alias for the room");
|
||||
}
|
||||
},
|
||||
(Some(_), None) => {
|
||||
return Cow::Borrowed("* removed the canonical alias for the room");
|
||||
},
|
||||
(None, None) => {
|
||||
return Cow::Borrowed("* did not change the canonical alias");
|
||||
},
|
||||
}
|
||||
},
|
||||
AnyFullStateEventContent::RoomCreate(FullStateEventContent::Original {
|
||||
content, ..
|
||||
}) => {
|
||||
if content.federate {
|
||||
return Cow::Borrowed("* created a federated room");
|
||||
} else {
|
||||
return Cow::Borrowed("* created a non-federated room");
|
||||
}
|
||||
},
|
||||
AnyFullStateEventContent::RoomEncryption(FullStateEventContent::Original { .. }) => {
|
||||
return Cow::Borrowed("* updated the encryption settings for the room");
|
||||
},
|
||||
AnyFullStateEventContent::RoomGuestAccess(FullStateEventContent::Original {
|
||||
content,
|
||||
..
|
||||
}) => {
|
||||
format!("* set guest access for the room to {:?}", content.guest_access.as_str())
|
||||
},
|
||||
AnyFullStateEventContent::RoomHistoryVisibility(FullStateEventContent::Original {
|
||||
content,
|
||||
..
|
||||
}) => {
|
||||
format!(
|
||||
"* updated history visibility for the room to {:?}",
|
||||
content.history_visibility.as_str()
|
||||
)
|
||||
},
|
||||
AnyFullStateEventContent::RoomJoinRules(FullStateEventContent::Original {
|
||||
content,
|
||||
..
|
||||
}) => {
|
||||
format!("* update the join rules for the room to {:?}", content.join_rule.as_str())
|
||||
},
|
||||
AnyFullStateEventContent::RoomMember(FullStateEventContent::Original {
|
||||
content,
|
||||
prev_content,
|
||||
}) => {
|
||||
let Ok(state_key) = UserId::parse(ev.state_key()) else {
|
||||
return Cow::Owned(format!(
|
||||
"* failed to calculate membership change for {:?}",
|
||||
ev.state_key()
|
||||
));
|
||||
};
|
||||
|
||||
let prev_details = prev_content.as_ref().map(|p| p.details());
|
||||
let change = content.membership_change(prev_details, ev.sender(), &state_key);
|
||||
|
||||
match change {
|
||||
MembershipChange::None => {
|
||||
format!("* did nothing to {state_key}")
|
||||
},
|
||||
MembershipChange::Error => {
|
||||
format!("* failed to calculate membership change to {state_key}")
|
||||
},
|
||||
MembershipChange::Joined => {
|
||||
return Cow::Borrowed("* joined the room");
|
||||
},
|
||||
MembershipChange::Left => {
|
||||
return Cow::Borrowed("* left the room");
|
||||
},
|
||||
MembershipChange::Banned => {
|
||||
format!("* banned {state_key} from the room")
|
||||
},
|
||||
MembershipChange::Unbanned => {
|
||||
format!("* unbanned {state_key} from the room")
|
||||
},
|
||||
MembershipChange::Kicked => {
|
||||
format!("* kicked {state_key} from the room")
|
||||
},
|
||||
MembershipChange::Invited => {
|
||||
format!("* invited {state_key} to the room")
|
||||
},
|
||||
MembershipChange::KickedAndBanned => {
|
||||
format!("* kicked and banned {state_key} from the room")
|
||||
},
|
||||
MembershipChange::InvitationAccepted => {
|
||||
return Cow::Borrowed("* accepted an invitation to join the room");
|
||||
},
|
||||
MembershipChange::InvitationRejected => {
|
||||
return Cow::Borrowed("* rejected an invitation to join the room");
|
||||
},
|
||||
MembershipChange::InvitationRevoked => {
|
||||
format!("* revoked an invitation for {state_key} to join the room")
|
||||
},
|
||||
MembershipChange::Knocked => {
|
||||
return Cow::Borrowed("* would like to join the room");
|
||||
},
|
||||
MembershipChange::KnockAccepted => {
|
||||
format!("* accepted the room knock from {state_key}")
|
||||
},
|
||||
MembershipChange::KnockRetracted => {
|
||||
return Cow::Borrowed("* retracted their room knock");
|
||||
},
|
||||
MembershipChange::KnockDenied => {
|
||||
format!("* rejected the room knock from {state_key}")
|
||||
},
|
||||
MembershipChange::ProfileChanged { displayname_change, avatar_url_change } => {
|
||||
match (displayname_change, avatar_url_change) {
|
||||
(Some(change), avatar_change) => {
|
||||
let mut m = match (change.old, change.new) {
|
||||
(None, Some(new)) => {
|
||||
format!("* set their display name to {new:?}")
|
||||
},
|
||||
(Some(old), Some(new)) => {
|
||||
format!("* changed their display name from {old} to {new}")
|
||||
},
|
||||
(Some(_), None) => "* unset their display name".to_string(),
|
||||
(None, None) => {
|
||||
"* made an unknown change to their display name".to_string()
|
||||
},
|
||||
};
|
||||
|
||||
if avatar_change.is_some() {
|
||||
m.push_str(" and changed their user avatar");
|
||||
}
|
||||
|
||||
m
|
||||
},
|
||||
(None, Some(change)) => {
|
||||
match (change.old, change.new) {
|
||||
(None, Some(_)) => {
|
||||
return Cow::Borrowed("* added a user avatar");
|
||||
},
|
||||
(Some(_), Some(_)) => {
|
||||
return Cow::Borrowed("* changed their user avatar");
|
||||
},
|
||||
(Some(_), None) => {
|
||||
return Cow::Borrowed("* removed their user avatar");
|
||||
},
|
||||
(None, None) => {
|
||||
return Cow::Borrowed(
|
||||
"* made an unknown change to their user avatar",
|
||||
);
|
||||
},
|
||||
}
|
||||
},
|
||||
(None, None) => {
|
||||
return Cow::Borrowed("* changed their user profile");
|
||||
},
|
||||
}
|
||||
},
|
||||
ev => {
|
||||
format!("* made an unknown membership change to {state_key}: {ev:?}")
|
||||
},
|
||||
}
|
||||
},
|
||||
AnyFullStateEventContent::RoomName(FullStateEventContent::Original { content, .. }) => {
|
||||
format!("* updated the room name to {:?}", content.name)
|
||||
},
|
||||
AnyFullStateEventContent::RoomPinnedEvents(FullStateEventContent::Original { .. }) => {
|
||||
return Cow::Borrowed("* updated the pinned events for the room");
|
||||
},
|
||||
AnyFullStateEventContent::RoomPowerLevels(FullStateEventContent::Original { .. }) => {
|
||||
return Cow::Borrowed("* updated the power levels for the room");
|
||||
},
|
||||
AnyFullStateEventContent::RoomServerAcl(FullStateEventContent::Original { .. }) => {
|
||||
return Cow::Borrowed("* updated the room's server ACLs");
|
||||
},
|
||||
AnyFullStateEventContent::RoomThirdPartyInvite(FullStateEventContent::Original {
|
||||
content,
|
||||
..
|
||||
}) => {
|
||||
format!("* sent a third-party invite to {:?}", content.display_name)
|
||||
},
|
||||
AnyFullStateEventContent::RoomTombstone(FullStateEventContent::Original {
|
||||
content,
|
||||
..
|
||||
}) => {
|
||||
format!(
|
||||
"* upgraded the room; replacement room is {}",
|
||||
content.replacement_room.as_str()
|
||||
)
|
||||
},
|
||||
AnyFullStateEventContent::RoomTopic(FullStateEventContent::Original {
|
||||
content, ..
|
||||
}) => {
|
||||
format!("* set the room topic to {:?}", content.topic)
|
||||
},
|
||||
AnyFullStateEventContent::SpaceChild(FullStateEventContent::Original { .. }) => {
|
||||
format!("* added a space child: {}", ev.state_key())
|
||||
},
|
||||
AnyFullStateEventContent::SpaceParent(FullStateEventContent::Original {
|
||||
content, ..
|
||||
}) => {
|
||||
if content.canonical {
|
||||
format!("* added a canonical parent space: {}", ev.state_key())
|
||||
} else {
|
||||
format!("* added a parent space: {}", ev.state_key())
|
||||
}
|
||||
},
|
||||
AnyFullStateEventContent::BeaconInfo(FullStateEventContent::Original { .. }) => {
|
||||
return Cow::Borrowed("* shared beacon information");
|
||||
},
|
||||
AnyFullStateEventContent::CallMember(FullStateEventContent::Original { .. }) => {
|
||||
return Cow::Borrowed("* updated membership for room call");
|
||||
},
|
||||
AnyFullStateEventContent::MemberHints(FullStateEventContent::Original {
|
||||
content, ..
|
||||
}) => {
|
||||
let mut m = String::from("* updated the list of service members in the room hints: ");
|
||||
|
||||
for (i, member) in content.service_members.iter().enumerate() {
|
||||
if i != 0 {
|
||||
m.push_str(", ");
|
||||
}
|
||||
|
||||
m.push_str(member.as_str());
|
||||
}
|
||||
|
||||
m
|
||||
},
|
||||
|
||||
// Redacted variants of state events:
|
||||
AnyFullStateEventContent::PolicyRuleRoom(FullStateEventContent::Redacted(_)) => {
|
||||
return Cow::Borrowed("* updated a room policy rule (redacted)");
|
||||
},
|
||||
AnyFullStateEventContent::PolicyRuleServer(FullStateEventContent::Redacted(_)) => {
|
||||
return Cow::Borrowed("* updated a server policy rule (redacted)");
|
||||
},
|
||||
AnyFullStateEventContent::PolicyRuleUser(FullStateEventContent::Redacted(_)) => {
|
||||
return Cow::Borrowed("* updated a user policy rule (redacted)");
|
||||
},
|
||||
AnyFullStateEventContent::RoomAliases(FullStateEventContent::Redacted(_)) => {
|
||||
return Cow::Borrowed("* updated the room aliases for the room (redacted)");
|
||||
},
|
||||
AnyFullStateEventContent::RoomAvatar(FullStateEventContent::Redacted(_)) => {
|
||||
return Cow::Borrowed("* updated the room avatar (redacted)");
|
||||
},
|
||||
AnyFullStateEventContent::RoomCanonicalAlias(FullStateEventContent::Redacted(_)) => {
|
||||
return Cow::Borrowed("* updated the canonical alias for the room (redacted)");
|
||||
},
|
||||
AnyFullStateEventContent::RoomCreate(FullStateEventContent::Redacted(_)) => {
|
||||
return Cow::Borrowed("* created the room (redacted)");
|
||||
},
|
||||
AnyFullStateEventContent::RoomEncryption(FullStateEventContent::Redacted(_)) => {
|
||||
return Cow::Borrowed("* updated the encryption settings for the room (redacted)");
|
||||
},
|
||||
AnyFullStateEventContent::RoomGuestAccess(FullStateEventContent::Redacted(_)) => {
|
||||
return Cow::Borrowed(
|
||||
"* updated the guest access configuration for the room (redacted)",
|
||||
);
|
||||
},
|
||||
AnyFullStateEventContent::RoomHistoryVisibility(FullStateEventContent::Redacted(_)) => {
|
||||
return Cow::Borrowed("* updated history visilibity for the room (redacted)");
|
||||
},
|
||||
AnyFullStateEventContent::RoomJoinRules(FullStateEventContent::Redacted(_)) => {
|
||||
return Cow::Borrowed("* updated the join rules for the room (redacted)");
|
||||
},
|
||||
AnyFullStateEventContent::RoomMember(FullStateEventContent::Redacted(_)) => {
|
||||
return Cow::Borrowed("* updated the room membership (redacted)");
|
||||
},
|
||||
AnyFullStateEventContent::RoomName(FullStateEventContent::Redacted(_)) => {
|
||||
return Cow::Borrowed("* updated the room name (redacted)");
|
||||
},
|
||||
AnyFullStateEventContent::RoomPinnedEvents(FullStateEventContent::Redacted(_)) => {
|
||||
return Cow::Borrowed("* updated the pinned events for the room (redacted)");
|
||||
},
|
||||
AnyFullStateEventContent::RoomPowerLevels(FullStateEventContent::Redacted(_)) => {
|
||||
return Cow::Borrowed("* updated the power levels for the room (redacted)");
|
||||
},
|
||||
AnyFullStateEventContent::RoomServerAcl(FullStateEventContent::Redacted(_)) => {
|
||||
return Cow::Borrowed("* updated the room's server ACLs (redacted)");
|
||||
},
|
||||
AnyFullStateEventContent::RoomThirdPartyInvite(FullStateEventContent::Redacted(_)) => {
|
||||
return Cow::Borrowed("* sent a third-party invite (redacted)");
|
||||
},
|
||||
AnyFullStateEventContent::RoomTombstone(FullStateEventContent::Redacted(_)) => {
|
||||
return Cow::Borrowed("* upgraded the room (redacted)");
|
||||
},
|
||||
AnyFullStateEventContent::RoomTopic(FullStateEventContent::Redacted(_)) => {
|
||||
return Cow::Borrowed("* updated the room topic (redacted)");
|
||||
},
|
||||
AnyFullStateEventContent::SpaceChild(FullStateEventContent::Redacted(_)) => {
|
||||
return Cow::Borrowed("* added a space child (redacted)");
|
||||
},
|
||||
AnyFullStateEventContent::SpaceParent(FullStateEventContent::Redacted(_)) => {
|
||||
return Cow::Borrowed("* added a parent space (redacted)");
|
||||
},
|
||||
AnyFullStateEventContent::BeaconInfo(FullStateEventContent::Redacted(_)) => {
|
||||
return Cow::Borrowed("* shared beacon information (redacted)");
|
||||
},
|
||||
AnyFullStateEventContent::CallMember(FullStateEventContent::Redacted(_)) => {
|
||||
return Cow::Borrowed("Call membership changed");
|
||||
},
|
||||
AnyFullStateEventContent::MemberHints(FullStateEventContent::Redacted(_)) => {
|
||||
return Cow::Borrowed("Member hints changed");
|
||||
},
|
||||
|
||||
// Handle unknown events:
|
||||
e => {
|
||||
format!("* sent an unknown state event: {:?}", e.event_type())
|
||||
},
|
||||
};
|
||||
|
||||
return Cow::Owned(event);
|
||||
}
|
||||
|
||||
pub fn html_state(ev: &AnySyncStateEvent) -> StyleTree {
|
||||
let children = match ev.content() {
|
||||
AnyFullStateEventContent::PolicyRuleRoom(FullStateEventContent::Original {
|
||||
content,
|
||||
..
|
||||
}) => {
|
||||
let prefix = StyleTreeNode::Text("* updated the room policy rule for ".into());
|
||||
let entity = bold(format!("{:?}", content.0.entity));
|
||||
let middle = StyleTreeNode::Text(" to ".into());
|
||||
let rec =
|
||||
StyleTreeNode::Text(format!("{:?}", content.0.recommendation.as_str()).into());
|
||||
let mut cs = vec![prefix, entity, middle, rec];
|
||||
|
||||
if !content.0.reason.is_empty() {
|
||||
let reason = format!(" (reason: {})", content.0.reason);
|
||||
cs.push(StyleTreeNode::Text(reason.into()));
|
||||
}
|
||||
|
||||
cs
|
||||
},
|
||||
AnyFullStateEventContent::PolicyRuleServer(FullStateEventContent::Original {
|
||||
content,
|
||||
..
|
||||
}) => {
|
||||
let prefix = StyleTreeNode::Text("* updated the server policy rule for ".into());
|
||||
let entity = bold(format!("{:?}", content.0.entity));
|
||||
let middle = StyleTreeNode::Text(" to ".into());
|
||||
let rec =
|
||||
StyleTreeNode::Text(format!("{:?}", content.0.recommendation.as_str()).into());
|
||||
let mut cs = vec![prefix, entity, middle, rec];
|
||||
|
||||
if !content.0.reason.is_empty() {
|
||||
let reason = format!(" (reason: {})", content.0.reason);
|
||||
cs.push(StyleTreeNode::Text(reason.into()));
|
||||
}
|
||||
|
||||
cs
|
||||
},
|
||||
AnyFullStateEventContent::PolicyRuleUser(FullStateEventContent::Original {
|
||||
content,
|
||||
..
|
||||
}) => {
|
||||
let prefix = StyleTreeNode::Text("* updated the user policy rule for ".into());
|
||||
let entity = bold(format!("{:?}", content.0.entity));
|
||||
let middle = StyleTreeNode::Text(" to ".into());
|
||||
let rec =
|
||||
StyleTreeNode::Text(format!("{:?}", content.0.recommendation.as_str()).into());
|
||||
let mut cs = vec![prefix, entity, middle, rec];
|
||||
|
||||
if !content.0.reason.is_empty() {
|
||||
let reason = format!(" (reason: {})", content.0.reason);
|
||||
cs.push(StyleTreeNode::Text(reason.into()));
|
||||
}
|
||||
|
||||
cs
|
||||
},
|
||||
AnyFullStateEventContent::RoomAliases(FullStateEventContent::Original {
|
||||
content, ..
|
||||
}) => {
|
||||
let prefix = StyleTreeNode::Text("* set the room aliases to: ".into());
|
||||
let mut cs = vec![prefix];
|
||||
|
||||
for (i, alias) in content.aliases.iter().enumerate() {
|
||||
if i != 0 {
|
||||
cs.push(StyleTreeNode::Text(", ".into()));
|
||||
}
|
||||
|
||||
cs.push(StyleTreeNode::RoomAlias(alias.clone()));
|
||||
}
|
||||
|
||||
cs
|
||||
},
|
||||
AnyFullStateEventContent::RoomAvatar(FullStateEventContent::Original {
|
||||
content,
|
||||
prev_content,
|
||||
}) => {
|
||||
let prev_url = prev_content.as_ref().and_then(|p| p.url.as_ref());
|
||||
|
||||
let node = match (prev_url, content.url) {
|
||||
(None, Some(_)) => StyleTreeNode::Text("* added a room avatar".into()),
|
||||
(Some(old), Some(new)) => {
|
||||
if old != &new {
|
||||
StyleTreeNode::Text("* replaced the room avatar".into())
|
||||
} else {
|
||||
StyleTreeNode::Text("* updated the room avatar state".into())
|
||||
}
|
||||
},
|
||||
(Some(_), None) => StyleTreeNode::Text("* removed the room avatar".into()),
|
||||
(None, None) => StyleTreeNode::Text("* updated the room avatar state".into()),
|
||||
};
|
||||
|
||||
vec![node]
|
||||
},
|
||||
AnyFullStateEventContent::RoomCanonicalAlias(FullStateEventContent::Original {
|
||||
content,
|
||||
..
|
||||
}) => {
|
||||
if let Some(canon) = content.alias.as_ref() {
|
||||
let canon = bold(canon.to_string());
|
||||
let prefix =
|
||||
StyleTreeNode::Text("* updated the canonical alias for the room to: ".into());
|
||||
vec![prefix, canon]
|
||||
} else {
|
||||
vec![StyleTreeNode::Text(
|
||||
"* removed the canonical alias for the room".into(),
|
||||
)]
|
||||
}
|
||||
},
|
||||
AnyFullStateEventContent::RoomCreate(FullStateEventContent::Original {
|
||||
content, ..
|
||||
}) => {
|
||||
if content.federate {
|
||||
vec![StyleTreeNode::Text("* created a federated room".into())]
|
||||
} else {
|
||||
vec![StyleTreeNode::Text("* created a non-federated room".into())]
|
||||
}
|
||||
},
|
||||
AnyFullStateEventContent::RoomEncryption(FullStateEventContent::Original { .. }) => {
|
||||
vec![StyleTreeNode::Text(
|
||||
"* updated the encryption settings for the room".into(),
|
||||
)]
|
||||
},
|
||||
AnyFullStateEventContent::RoomGuestAccess(FullStateEventContent::Original {
|
||||
content,
|
||||
..
|
||||
}) => {
|
||||
let access = bold(format!("{:?}", content.guest_access.as_str()));
|
||||
let prefix = StyleTreeNode::Text("* set guest access for the room to ".into());
|
||||
vec![prefix, access]
|
||||
},
|
||||
AnyFullStateEventContent::RoomHistoryVisibility(FullStateEventContent::Original {
|
||||
content,
|
||||
..
|
||||
}) => {
|
||||
let prefix =
|
||||
StyleTreeNode::Text("* updated history visibility for the room to ".into());
|
||||
let vis = bold(format!("{:?}", content.history_visibility.as_str()));
|
||||
vec![prefix, vis]
|
||||
},
|
||||
AnyFullStateEventContent::RoomJoinRules(FullStateEventContent::Original {
|
||||
content,
|
||||
..
|
||||
}) => {
|
||||
let prefix = StyleTreeNode::Text("* update the join rules for the room to ".into());
|
||||
let rule = bold(format!("{:?}", content.join_rule.as_str()));
|
||||
vec![prefix, rule]
|
||||
},
|
||||
AnyFullStateEventContent::RoomMember(FullStateEventContent::Original {
|
||||
content,
|
||||
prev_content,
|
||||
}) => {
|
||||
let Ok(state_key) = UserId::parse(ev.state_key()) else {
|
||||
let prefix =
|
||||
StyleTreeNode::Text("* failed to calculate membership change for ".into());
|
||||
let user_id = bold(format!("{:?}", ev.state_key()));
|
||||
let children = vec![prefix, user_id];
|
||||
|
||||
return StyleTree { children };
|
||||
};
|
||||
|
||||
let prev_details = prev_content.as_ref().map(|p| p.details());
|
||||
let change = content.membership_change(prev_details, ev.sender(), &state_key);
|
||||
let user_id = StyleTreeNode::UserId(state_key.clone());
|
||||
|
||||
match change {
|
||||
MembershipChange::None => {
|
||||
let prefix = StyleTreeNode::Text("* did nothing to ".into());
|
||||
vec![prefix, user_id]
|
||||
},
|
||||
MembershipChange::Error => {
|
||||
let prefix =
|
||||
StyleTreeNode::Text("* failed to calculate membership change to ".into());
|
||||
vec![prefix, user_id]
|
||||
},
|
||||
MembershipChange::Joined => {
|
||||
vec![StyleTreeNode::Text("* joined the room".into())]
|
||||
},
|
||||
MembershipChange::Left => {
|
||||
vec![StyleTreeNode::Text("* left the room".into())]
|
||||
},
|
||||
MembershipChange::Banned => {
|
||||
let prefix = StyleTreeNode::Text("* banned ".into());
|
||||
let suffix = StyleTreeNode::Text(" from the room".into());
|
||||
vec![prefix, user_id, suffix]
|
||||
},
|
||||
MembershipChange::Unbanned => {
|
||||
let prefix = StyleTreeNode::Text("* unbanned ".into());
|
||||
let suffix = StyleTreeNode::Text(" from the room".into());
|
||||
vec![prefix, user_id, suffix]
|
||||
},
|
||||
MembershipChange::Kicked => {
|
||||
let prefix = StyleTreeNode::Text("* kicked ".into());
|
||||
let suffix = StyleTreeNode::Text(" from the room".into());
|
||||
vec![prefix, user_id, suffix]
|
||||
},
|
||||
MembershipChange::Invited => {
|
||||
let prefix = StyleTreeNode::Text("* invited ".into());
|
||||
let suffix = StyleTreeNode::Text(" to the room".into());
|
||||
vec![prefix, user_id, suffix]
|
||||
},
|
||||
MembershipChange::KickedAndBanned => {
|
||||
let prefix = StyleTreeNode::Text("* kicked and banned ".into());
|
||||
let suffix = StyleTreeNode::Text(" from the room".into());
|
||||
vec![prefix, user_id, suffix]
|
||||
},
|
||||
MembershipChange::InvitationAccepted => {
|
||||
vec![StyleTreeNode::Text(
|
||||
"* accepted an invitation to join the room".into(),
|
||||
)]
|
||||
},
|
||||
MembershipChange::InvitationRejected => {
|
||||
vec![StyleTreeNode::Text(
|
||||
"* rejected an invitation to join the room".into(),
|
||||
)]
|
||||
},
|
||||
MembershipChange::InvitationRevoked => {
|
||||
let prefix = StyleTreeNode::Text("* revoked an invitation for ".into());
|
||||
let suffix = StyleTreeNode::Text(" to join the room".into());
|
||||
vec![prefix, user_id, suffix]
|
||||
},
|
||||
MembershipChange::Knocked => {
|
||||
vec![StyleTreeNode::Text("* would like to join the room".into())]
|
||||
},
|
||||
MembershipChange::KnockAccepted => {
|
||||
let prefix = StyleTreeNode::Text("* accepted the room knock from ".into());
|
||||
vec![prefix, user_id]
|
||||
},
|
||||
MembershipChange::KnockRetracted => {
|
||||
vec![StyleTreeNode::Text("* retracted their room knock".into())]
|
||||
},
|
||||
MembershipChange::KnockDenied => {
|
||||
let prefix = StyleTreeNode::Text("* rejected the room knock from ".into());
|
||||
vec![prefix, user_id]
|
||||
},
|
||||
MembershipChange::ProfileChanged { displayname_change, avatar_url_change } => {
|
||||
match (displayname_change, avatar_url_change) {
|
||||
(Some(change), avatar_change) => {
|
||||
let mut m = match (change.old, change.new) {
|
||||
(None, Some(new)) => {
|
||||
vec![
|
||||
StyleTreeNode::Text("* set their display name to ".into()),
|
||||
StyleTreeNode::DisplayName(new.into(), state_key),
|
||||
]
|
||||
},
|
||||
(Some(old), Some(new)) => {
|
||||
vec![
|
||||
StyleTreeNode::Text(
|
||||
"* changed their display name from ".into(),
|
||||
),
|
||||
StyleTreeNode::DisplayName(old.into(), state_key.clone()),
|
||||
StyleTreeNode::Text(" to ".into()),
|
||||
StyleTreeNode::DisplayName(new.into(), state_key),
|
||||
]
|
||||
},
|
||||
(Some(_), None) => {
|
||||
vec![StyleTreeNode::Text("* unset their display name".into())]
|
||||
},
|
||||
(None, None) => {
|
||||
vec![StyleTreeNode::Text(
|
||||
"* made an unknown change to their display name".into(),
|
||||
)]
|
||||
},
|
||||
};
|
||||
|
||||
if avatar_change.is_some() {
|
||||
m.push(StyleTreeNode::Text(
|
||||
" and changed their user avatar".into(),
|
||||
));
|
||||
}
|
||||
|
||||
m
|
||||
},
|
||||
(None, Some(change)) => {
|
||||
let m = match (change.old, change.new) {
|
||||
(None, Some(_)) => Cow::Borrowed("* added a user avatar"),
|
||||
(Some(_), Some(_)) => Cow::Borrowed("* changed their user avatar"),
|
||||
(Some(_), None) => Cow::Borrowed("* removed their user avatar"),
|
||||
(None, None) => {
|
||||
Cow::Borrowed("* made an unknown change to their user avatar")
|
||||
},
|
||||
};
|
||||
|
||||
vec![StyleTreeNode::Text(m)]
|
||||
},
|
||||
(None, None) => {
|
||||
vec![StyleTreeNode::Text("* changed their user profile".into())]
|
||||
},
|
||||
}
|
||||
},
|
||||
ev => {
|
||||
let prefix =
|
||||
StyleTreeNode::Text("* made an unknown membership change to ".into());
|
||||
let suffix = StyleTreeNode::Text(format!(": {ev:?}").into());
|
||||
vec![prefix, user_id, suffix]
|
||||
},
|
||||
}
|
||||
},
|
||||
AnyFullStateEventContent::RoomName(FullStateEventContent::Original { content, .. }) => {
|
||||
let prefix = StyleTreeNode::Text("* updated the room name to ".into());
|
||||
let name = bold(format!("{:?}", content.name));
|
||||
vec![prefix, name]
|
||||
},
|
||||
AnyFullStateEventContent::RoomPinnedEvents(FullStateEventContent::Original { .. }) => {
|
||||
vec![StyleTreeNode::Text(
|
||||
"* updated the pinned events for the room".into(),
|
||||
)]
|
||||
},
|
||||
AnyFullStateEventContent::RoomPowerLevels(FullStateEventContent::Original { .. }) => {
|
||||
vec![StyleTreeNode::Text(
|
||||
"* updated the power levels for the room".into(),
|
||||
)]
|
||||
},
|
||||
AnyFullStateEventContent::RoomServerAcl(FullStateEventContent::Original { .. }) => {
|
||||
vec![StyleTreeNode::Text(
|
||||
"* updated the room's server ACLs".into(),
|
||||
)]
|
||||
},
|
||||
AnyFullStateEventContent::RoomThirdPartyInvite(FullStateEventContent::Original {
|
||||
content,
|
||||
..
|
||||
}) => {
|
||||
let prefix = StyleTreeNode::Text("* sent a third-party invite to ".into());
|
||||
let name = bold(format!("{:?}", content.display_name));
|
||||
vec![prefix, name]
|
||||
},
|
||||
AnyFullStateEventContent::RoomTombstone(FullStateEventContent::Original {
|
||||
content,
|
||||
..
|
||||
}) => {
|
||||
let prefix = StyleTreeNode::Text("* upgraded the room; replacement room is ".into());
|
||||
let room = StyleTreeNode::RoomId(content.replacement_room.clone());
|
||||
vec![prefix, room]
|
||||
},
|
||||
AnyFullStateEventContent::RoomTopic(FullStateEventContent::Original {
|
||||
content, ..
|
||||
}) => {
|
||||
let prefix = StyleTreeNode::Text("* set the room topic to ".into());
|
||||
let topic = bold(format!("{:?}", content.topic));
|
||||
vec![prefix, topic]
|
||||
},
|
||||
AnyFullStateEventContent::SpaceChild(FullStateEventContent::Original { .. }) => {
|
||||
let prefix = StyleTreeNode::Text("* added a space child: ".into());
|
||||
|
||||
let room_id = if let Ok(room_id) = OwnedRoomId::from_str(ev.state_key()) {
|
||||
StyleTreeNode::RoomId(room_id)
|
||||
} else {
|
||||
bold(ev.state_key().to_string())
|
||||
};
|
||||
|
||||
vec![prefix, room_id]
|
||||
},
|
||||
AnyFullStateEventContent::SpaceParent(FullStateEventContent::Original {
|
||||
content, ..
|
||||
}) => {
|
||||
let prefix = if content.canonical {
|
||||
StyleTreeNode::Text("* added a canonical parent space: ".into())
|
||||
} else {
|
||||
StyleTreeNode::Text("* added a parent space: ".into())
|
||||
};
|
||||
|
||||
let room_id = if let Ok(room_id) = OwnedRoomId::from_str(ev.state_key()) {
|
||||
StyleTreeNode::RoomId(room_id)
|
||||
} else {
|
||||
bold(ev.state_key().to_string())
|
||||
};
|
||||
|
||||
vec![prefix, room_id]
|
||||
},
|
||||
AnyFullStateEventContent::BeaconInfo(FullStateEventContent::Original { .. }) => {
|
||||
vec![StyleTreeNode::Text("* shared beacon information".into())]
|
||||
},
|
||||
AnyFullStateEventContent::CallMember(FullStateEventContent::Original { .. }) => {
|
||||
vec![StyleTreeNode::Text(
|
||||
"* updated membership for room call".into(),
|
||||
)]
|
||||
},
|
||||
AnyFullStateEventContent::MemberHints(FullStateEventContent::Original {
|
||||
content, ..
|
||||
}) => {
|
||||
let prefix = StyleTreeNode::Text(
|
||||
"* updated the list of service members in the room hints: ".into(),
|
||||
);
|
||||
let mut cs = vec![prefix];
|
||||
|
||||
for (i, member) in content.service_members.iter().enumerate() {
|
||||
if i != 0 {
|
||||
cs.push(StyleTreeNode::Text(", ".into()));
|
||||
}
|
||||
|
||||
cs.push(StyleTreeNode::UserId(member.clone()));
|
||||
}
|
||||
|
||||
cs
|
||||
},
|
||||
|
||||
// Redacted variants of state events:
|
||||
AnyFullStateEventContent::PolicyRuleRoom(FullStateEventContent::Redacted(_)) => {
|
||||
vec![StyleTreeNode::Text(
|
||||
"* updated a room policy rule (redacted)".into(),
|
||||
)]
|
||||
},
|
||||
AnyFullStateEventContent::PolicyRuleServer(FullStateEventContent::Redacted(_)) => {
|
||||
vec![StyleTreeNode::Text(
|
||||
"* updated a server policy rule (redacted)".into(),
|
||||
)]
|
||||
},
|
||||
AnyFullStateEventContent::PolicyRuleUser(FullStateEventContent::Redacted(_)) => {
|
||||
vec![StyleTreeNode::Text(
|
||||
"* updated a user policy rule (redacted)".into(),
|
||||
)]
|
||||
},
|
||||
AnyFullStateEventContent::RoomAliases(FullStateEventContent::Redacted(_)) => {
|
||||
vec![StyleTreeNode::Text(
|
||||
"* updated the room aliases for the room (redacted)".into(),
|
||||
)]
|
||||
},
|
||||
AnyFullStateEventContent::RoomAvatar(FullStateEventContent::Redacted(_)) => {
|
||||
vec![StyleTreeNode::Text(
|
||||
"* updated the room avatar (redacted)".into(),
|
||||
)]
|
||||
},
|
||||
AnyFullStateEventContent::RoomCanonicalAlias(FullStateEventContent::Redacted(_)) => {
|
||||
vec![StyleTreeNode::Text(
|
||||
"* updated the canonical alias for the room (redacted)".into(),
|
||||
)]
|
||||
},
|
||||
AnyFullStateEventContent::RoomCreate(FullStateEventContent::Redacted(_)) => {
|
||||
vec![StyleTreeNode::Text("* created the room (redacted)".into())]
|
||||
},
|
||||
AnyFullStateEventContent::RoomEncryption(FullStateEventContent::Redacted(_)) => {
|
||||
vec![StyleTreeNode::Text(
|
||||
"* updated the encryption settings for the room (redacted)".into(),
|
||||
)]
|
||||
},
|
||||
AnyFullStateEventContent::RoomGuestAccess(FullStateEventContent::Redacted(_)) => {
|
||||
vec![StyleTreeNode::Text(
|
||||
"* updated the guest access configuration for the room (redacted)".into(),
|
||||
)]
|
||||
},
|
||||
AnyFullStateEventContent::RoomHistoryVisibility(FullStateEventContent::Redacted(_)) => {
|
||||
vec![StyleTreeNode::Text(
|
||||
"* updated history visilibity for the room (redacted)".into(),
|
||||
)]
|
||||
},
|
||||
AnyFullStateEventContent::RoomJoinRules(FullStateEventContent::Redacted(_)) => {
|
||||
vec![StyleTreeNode::Text(
|
||||
"* updated the join rules for the room (redacted)".into(),
|
||||
)]
|
||||
},
|
||||
AnyFullStateEventContent::RoomMember(FullStateEventContent::Redacted(_)) => {
|
||||
vec![StyleTreeNode::Text(
|
||||
"* updated the room membership (redacted)".into(),
|
||||
)]
|
||||
},
|
||||
AnyFullStateEventContent::RoomName(FullStateEventContent::Redacted(_)) => {
|
||||
vec![StyleTreeNode::Text(
|
||||
"* updated the room name (redacted)".into(),
|
||||
)]
|
||||
},
|
||||
AnyFullStateEventContent::RoomPinnedEvents(FullStateEventContent::Redacted(_)) => {
|
||||
vec![StyleTreeNode::Text(
|
||||
"* updated the pinned events for the room (redacted)".into(),
|
||||
)]
|
||||
},
|
||||
AnyFullStateEventContent::RoomPowerLevels(FullStateEventContent::Redacted(_)) => {
|
||||
vec![StyleTreeNode::Text(
|
||||
"* updated the power levels for the room (redacted)".into(),
|
||||
)]
|
||||
},
|
||||
AnyFullStateEventContent::RoomServerAcl(FullStateEventContent::Redacted(_)) => {
|
||||
vec![StyleTreeNode::Text(
|
||||
"* updated the room's server ACLs (redacted)".into(),
|
||||
)]
|
||||
},
|
||||
AnyFullStateEventContent::RoomThirdPartyInvite(FullStateEventContent::Redacted(_)) => {
|
||||
vec![StyleTreeNode::Text(
|
||||
"* sent a third-party invite (redacted)".into(),
|
||||
)]
|
||||
},
|
||||
AnyFullStateEventContent::RoomTombstone(FullStateEventContent::Redacted(_)) => {
|
||||
vec![StyleTreeNode::Text("* upgraded the room (redacted)".into())]
|
||||
},
|
||||
AnyFullStateEventContent::RoomTopic(FullStateEventContent::Redacted(_)) => {
|
||||
vec![StyleTreeNode::Text(
|
||||
"* updated the room topic (redacted)".into(),
|
||||
)]
|
||||
},
|
||||
AnyFullStateEventContent::SpaceChild(FullStateEventContent::Redacted(_)) => {
|
||||
vec![StyleTreeNode::Text(
|
||||
"* added a space child (redacted)".into(),
|
||||
)]
|
||||
},
|
||||
AnyFullStateEventContent::SpaceParent(FullStateEventContent::Redacted(_)) => {
|
||||
vec![StyleTreeNode::Text(
|
||||
"* added a parent space (redacted)".into(),
|
||||
)]
|
||||
},
|
||||
AnyFullStateEventContent::BeaconInfo(FullStateEventContent::Redacted(_)) => {
|
||||
vec![StyleTreeNode::Text(
|
||||
"* shared beacon information (redacted)".into(),
|
||||
)]
|
||||
},
|
||||
AnyFullStateEventContent::CallMember(FullStateEventContent::Redacted(_)) => {
|
||||
vec![StyleTreeNode::Text("Call membership changed".into())]
|
||||
},
|
||||
AnyFullStateEventContent::MemberHints(FullStateEventContent::Redacted(_)) => {
|
||||
vec![StyleTreeNode::Text("Member hints changed".into())]
|
||||
},
|
||||
|
||||
// Handle unknown events:
|
||||
e => {
|
||||
let prefix = StyleTreeNode::Text("* sent an unknown state event: ".into());
|
||||
let event = bold(format!("{:?}", e.event_type()));
|
||||
vec![prefix, event]
|
||||
},
|
||||
};
|
||||
|
||||
StyleTree { children }
|
||||
}
|
||||
324
src/notifications.rs
Normal file
324
src/notifications.rs
Normal file
@@ -0,0 +1,324 @@
|
||||
use std::time::SystemTime;
|
||||
|
||||
use matrix_sdk::{
|
||||
deserialized_responses::RawAnySyncOrStrippedTimelineEvent,
|
||||
notification_settings::{IsEncrypted, IsOneToOne, NotificationSettings, RoomNotificationMode},
|
||||
room::Room as MatrixRoom,
|
||||
ruma::{
|
||||
events::{room::message::MessageType, AnyMessageLikeEventContent, AnySyncTimelineEvent},
|
||||
serde::Raw,
|
||||
MilliSecondsSinceUnixEpoch,
|
||||
OwnedRoomId,
|
||||
RoomId,
|
||||
},
|
||||
Client,
|
||||
EncryptionState,
|
||||
};
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
|
||||
use crate::{
|
||||
base::{AsyncProgramStore, IambError, IambResult, ProgramStore},
|
||||
config::{ApplicationSettings, NotifyVia},
|
||||
};
|
||||
|
||||
const IAMB_XDG_NAME: &str = match option_env!("IAMB_XDG_NAME") {
|
||||
None => "iamb",
|
||||
Some(iamb) => iamb,
|
||||
};
|
||||
|
||||
/// Handle for an open notification that should be closed when the user views it.
|
||||
pub struct NotificationHandle(
|
||||
#[cfg(all(feature = "desktop", unix, not(target_os = "macos")))]
|
||||
Option<notify_rust::NotificationHandle>,
|
||||
);
|
||||
|
||||
impl Drop for NotificationHandle {
|
||||
fn drop(&mut self) {
|
||||
#[cfg(all(feature = "desktop", unix, not(target_os = "macos")))]
|
||||
if let Some(handle) = self.0.take() {
|
||||
handle.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn register_notifications(
|
||||
client: &Client,
|
||||
settings: &ApplicationSettings,
|
||||
store: &AsyncProgramStore,
|
||||
) {
|
||||
if !settings.tunables.notifications.enabled {
|
||||
return;
|
||||
}
|
||||
let notify_via = settings.tunables.notifications.via;
|
||||
let show_message = settings.tunables.notifications.show_message;
|
||||
let sound_hint = settings.tunables.notifications.sound_hint.clone();
|
||||
let server_settings = client.notification_settings().await;
|
||||
let Some(startup_ts) = MilliSecondsSinceUnixEpoch::from_system_time(SystemTime::now()) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let store = store.clone();
|
||||
client
|
||||
.register_notification_handler(move |notification, room: MatrixRoom, client: Client| {
|
||||
let store = store.clone();
|
||||
let server_settings = server_settings.clone();
|
||||
let sound_hint = sound_hint.clone();
|
||||
async move {
|
||||
let mode = global_or_room_mode(&server_settings, &room).await;
|
||||
if mode == RoomNotificationMode::Mute {
|
||||
return;
|
||||
}
|
||||
|
||||
if is_visible_room(&store, room.room_id()).await {
|
||||
return;
|
||||
}
|
||||
|
||||
let room_id = room.room_id().to_owned();
|
||||
match notification.event {
|
||||
RawAnySyncOrStrippedTimelineEvent::Sync(e) => {
|
||||
match parse_full_notification(e, room, show_message).await {
|
||||
Ok((summary, body, server_ts)) => {
|
||||
if server_ts < startup_ts {
|
||||
return;
|
||||
}
|
||||
|
||||
if is_missing_mention(&body, mode, &client) {
|
||||
return;
|
||||
}
|
||||
|
||||
send_notification(
|
||||
¬ify_via,
|
||||
&summary,
|
||||
body.as_deref(),
|
||||
room_id,
|
||||
&store,
|
||||
sound_hint.as_deref(),
|
||||
)
|
||||
.await;
|
||||
},
|
||||
Err(err) => {
|
||||
tracing::error!("Failed to extract notification data: {err}")
|
||||
},
|
||||
}
|
||||
},
|
||||
// Stripped events may be dropped silently because they're
|
||||
// only relevant if we're not in a room, and we presumably
|
||||
// don't want notifications for rooms we're not in.
|
||||
RawAnySyncOrStrippedTimelineEvent::Stripped(_) => (),
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn send_notification(
|
||||
via: &NotifyVia,
|
||||
summary: &str,
|
||||
body: Option<&str>,
|
||||
room_id: OwnedRoomId,
|
||||
store: &AsyncProgramStore,
|
||||
sound_hint: Option<&str>,
|
||||
) {
|
||||
#[cfg(feature = "desktop")]
|
||||
if via.desktop {
|
||||
send_notification_desktop(summary, body, room_id, store, sound_hint).await;
|
||||
}
|
||||
#[cfg(not(feature = "desktop"))]
|
||||
{
|
||||
let _ = (summary, body, IAMB_XDG_NAME);
|
||||
}
|
||||
|
||||
if via.bell {
|
||||
send_notification_bell(store).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_notification_bell(store: &AsyncProgramStore) {
|
||||
let mut locked = store.lock().await;
|
||||
locked.application.ring_bell = true;
|
||||
}
|
||||
|
||||
#[cfg(feature = "desktop")]
|
||||
#[cfg_attr(target_os = "macos", allow(unused_variables))]
|
||||
async fn send_notification_desktop(
|
||||
summary: &str,
|
||||
body: Option<&str>,
|
||||
room_id: OwnedRoomId,
|
||||
_store: &AsyncProgramStore,
|
||||
sound_hint: Option<&str>,
|
||||
) {
|
||||
let mut desktop_notification = notify_rust::Notification::new();
|
||||
desktop_notification
|
||||
.summary(summary)
|
||||
.appname(IAMB_XDG_NAME)
|
||||
.icon(IAMB_XDG_NAME)
|
||||
.action("default", "default");
|
||||
|
||||
if let Some(sound_hint) = sound_hint {
|
||||
desktop_notification.sound_name(sound_hint);
|
||||
}
|
||||
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
desktop_notification.urgency(notify_rust::Urgency::Normal);
|
||||
|
||||
if let Some(body) = body {
|
||||
desktop_notification.body(body);
|
||||
}
|
||||
|
||||
match desktop_notification.show() {
|
||||
Err(err) => tracing::error!("Failed to send notification: {err}"),
|
||||
Ok(handle) => {
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
_store
|
||||
.lock()
|
||||
.await
|
||||
.application
|
||||
.open_notifications
|
||||
.entry(room_id)
|
||||
.or_default()
|
||||
.push(NotificationHandle(Some(handle)));
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async fn global_or_room_mode(
|
||||
settings: &NotificationSettings,
|
||||
room: &MatrixRoom,
|
||||
) -> RoomNotificationMode {
|
||||
let room_mode = settings.get_user_defined_room_notification_mode(room.room_id()).await;
|
||||
if let Some(mode) = room_mode {
|
||||
return mode;
|
||||
}
|
||||
let is_one_to_one = match room.is_direct().await {
|
||||
Ok(true) => IsOneToOne::Yes,
|
||||
_ => IsOneToOne::No,
|
||||
};
|
||||
let is_encrypted = match room.latest_encryption_state().await {
|
||||
Ok(EncryptionState::Encrypted) => IsEncrypted::Yes,
|
||||
_ => IsEncrypted::No,
|
||||
};
|
||||
settings
|
||||
.get_default_room_notification_mode(is_encrypted, is_one_to_one)
|
||||
.await
|
||||
}
|
||||
|
||||
fn is_missing_mention(body: &Option<String>, mode: RoomNotificationMode, client: &Client) -> bool {
|
||||
if let Some(body) = body {
|
||||
if mode == RoomNotificationMode::MentionsAndKeywordsOnly {
|
||||
let mentioned = match client.user_id() {
|
||||
Some(user_id) => body.contains(user_id.localpart()),
|
||||
_ => false,
|
||||
};
|
||||
return !mentioned;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn is_open(locked: &mut ProgramStore, room_id: &RoomId) -> bool {
|
||||
if let Some(draw_curr) = locked.application.draw_curr {
|
||||
let info = locked.application.get_room_info(room_id.to_owned());
|
||||
if let Some(draw_last) = info.draw_last {
|
||||
return draw_last == draw_curr;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn is_focused(locked: &ProgramStore) -> bool {
|
||||
locked.application.focused
|
||||
}
|
||||
|
||||
async fn is_visible_room(store: &AsyncProgramStore, room_id: &RoomId) -> bool {
|
||||
let mut locked = store.lock().await;
|
||||
|
||||
is_focused(&locked) && is_open(&mut locked, room_id)
|
||||
}
|
||||
|
||||
pub async fn parse_full_notification(
|
||||
event: Raw<AnySyncTimelineEvent>,
|
||||
room: MatrixRoom,
|
||||
show_body: bool,
|
||||
) -> IambResult<(String, Option<String>, MilliSecondsSinceUnixEpoch)> {
|
||||
let event = event.deserialize().map_err(IambError::from)?;
|
||||
|
||||
let server_ts = event.origin_server_ts();
|
||||
|
||||
let sender_id = event.sender();
|
||||
let sender = room.get_member_no_sync(sender_id).await.map_err(IambError::from)?;
|
||||
|
||||
let sender_name = sender
|
||||
.as_ref()
|
||||
.and_then(|m| m.display_name())
|
||||
.unwrap_or_else(|| sender_id.localpart());
|
||||
|
||||
let summary = if let Some(room_name) = room.cached_display_name() {
|
||||
if room.is_direct().await.map_err(IambError::from)? && sender_name == room_name.to_string()
|
||||
{
|
||||
sender_name.to_string()
|
||||
} else {
|
||||
format!("{sender_name} in {room_name}")
|
||||
}
|
||||
} else {
|
||||
sender_name.to_string()
|
||||
};
|
||||
|
||||
let body = if show_body {
|
||||
event_notification_body(&event, sender_name).map(truncate)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
return Ok((summary, body, server_ts));
|
||||
}
|
||||
|
||||
pub fn event_notification_body(event: &AnySyncTimelineEvent, sender_name: &str) -> Option<String> {
|
||||
let AnySyncTimelineEvent::MessageLike(event) = event else {
|
||||
return None;
|
||||
};
|
||||
|
||||
match event.original_content()? {
|
||||
AnyMessageLikeEventContent::RoomMessage(message) => {
|
||||
let body = match message.msgtype {
|
||||
MessageType::Audio(_) => {
|
||||
format!("{sender_name} sent an audio file.")
|
||||
},
|
||||
MessageType::Emote(content) => content.body,
|
||||
MessageType::File(_) => {
|
||||
format!("{sender_name} sent a file.")
|
||||
},
|
||||
MessageType::Image(_) => {
|
||||
format!("{sender_name} sent an image.")
|
||||
},
|
||||
MessageType::Location(_) => {
|
||||
format!("{sender_name} sent their location.")
|
||||
},
|
||||
MessageType::Notice(content) => content.body,
|
||||
MessageType::ServerNotice(content) => content.body,
|
||||
MessageType::Text(content) => content.body,
|
||||
MessageType::Video(_) => {
|
||||
format!("{sender_name} sent a video.")
|
||||
},
|
||||
MessageType::VerificationRequest(_) => {
|
||||
format!("{sender_name} sent a verification request.")
|
||||
},
|
||||
_ => {
|
||||
format!("[Unknown message type: {:?}]", &message.msgtype)
|
||||
},
|
||||
};
|
||||
Some(body)
|
||||
},
|
||||
AnyMessageLikeEventContent::Sticker(_) => Some(format!("{sender_name} sent a sticker.")),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn truncate(s: String) -> String {
|
||||
static MAX_LENGTH: usize = 5000;
|
||||
if s.graphemes(true).count() > MAX_LENGTH {
|
||||
let truncated: String = s.graphemes(true).take(MAX_LENGTH).collect();
|
||||
truncated + "..."
|
||||
} else {
|
||||
s
|
||||
}
|
||||
}
|
||||
175
src/preview.rs
Normal file
175
src/preview.rs
Normal file
@@ -0,0 +1,175 @@
|
||||
use std::{
|
||||
fs::File,
|
||||
io::{Read, Write},
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use matrix_sdk::{
|
||||
media::{MediaFormat, MediaRequestParameters},
|
||||
ruma::{
|
||||
events::{
|
||||
room::{
|
||||
message::{MessageType, RoomMessageEventContent},
|
||||
MediaSource,
|
||||
},
|
||||
MessageLikeEvent,
|
||||
},
|
||||
OwnedEventId,
|
||||
OwnedRoomId,
|
||||
},
|
||||
Media,
|
||||
};
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui_image::Resize;
|
||||
|
||||
use crate::{
|
||||
base::{AsyncProgramStore, ChatStore, IambError},
|
||||
config::ImagePreviewSize,
|
||||
message::ImageStatus,
|
||||
};
|
||||
|
||||
pub fn source_from_event(
|
||||
ev: &MessageLikeEvent<RoomMessageEventContent>,
|
||||
) -> Option<(OwnedEventId, MediaSource)> {
|
||||
if let MessageLikeEvent::Original(ev) = &ev {
|
||||
if let MessageType::Image(c) = &ev.content.msgtype {
|
||||
return Some((ev.event_id.clone(), c.source.clone()));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
impl From<ImagePreviewSize> for Rect {
|
||||
fn from(value: ImagePreviewSize) -> Self {
|
||||
Rect::new(0, 0, value.width as _, value.height as _)
|
||||
}
|
||||
}
|
||||
impl From<Rect> for ImagePreviewSize {
|
||||
fn from(rect: Rect) -> Self {
|
||||
ImagePreviewSize { width: rect.width as _, height: rect.height as _ }
|
||||
}
|
||||
}
|
||||
|
||||
/// Download and prepare the preview, and then lock the store to insert it.
|
||||
pub fn spawn_insert_preview(
|
||||
store: AsyncProgramStore,
|
||||
room_id: OwnedRoomId,
|
||||
event_id: OwnedEventId,
|
||||
source: MediaSource,
|
||||
media: Media,
|
||||
cache_dir: PathBuf,
|
||||
) {
|
||||
tokio::spawn(async move {
|
||||
let img = download_or_load(event_id.to_owned(), source, media, cache_dir)
|
||||
.await
|
||||
.map(std::io::Cursor::new)
|
||||
.map(image::ImageReader::new)
|
||||
.map_err(IambError::Matrix)
|
||||
.and_then(|reader| reader.with_guessed_format().map_err(IambError::IOError))
|
||||
.and_then(|reader| reader.decode().map_err(IambError::Image));
|
||||
|
||||
match img {
|
||||
Err(err) => {
|
||||
try_set_msg_preview_error(
|
||||
&mut store.lock().await.application,
|
||||
room_id,
|
||||
event_id,
|
||||
err,
|
||||
);
|
||||
},
|
||||
Ok(img) => {
|
||||
let mut locked = store.lock().await;
|
||||
let ChatStore { rooms, picker, settings, .. } = &mut locked.application;
|
||||
|
||||
match picker
|
||||
.as_mut()
|
||||
.ok_or_else(|| IambError::Preview("Picker is empty".to_string()))
|
||||
.and_then(|picker| {
|
||||
Ok((
|
||||
picker,
|
||||
rooms
|
||||
.get_or_default(room_id.clone())
|
||||
.get_event_mut(&event_id)
|
||||
.ok_or_else(|| {
|
||||
IambError::Preview("Message not found".to_string())
|
||||
})?,
|
||||
settings.tunables.image_preview.clone().ok_or_else(|| {
|
||||
IambError::Preview("image_preview settings not found".to_string())
|
||||
})?,
|
||||
))
|
||||
})
|
||||
.and_then(|(picker, msg, image_preview)| {
|
||||
picker
|
||||
.new_protocol(img, image_preview.size.into(), Resize::Fit(None))
|
||||
.map_err(|err| IambError::Preview(format!("{err:?}")))
|
||||
.map(|backend| (backend, msg))
|
||||
}) {
|
||||
Err(err) => {
|
||||
try_set_msg_preview_error(&mut locked.application, room_id, event_id, err);
|
||||
},
|
||||
Ok((backend, msg)) => {
|
||||
msg.image_preview = ImageStatus::Loaded(backend);
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn try_set_msg_preview_error(
|
||||
application: &mut ChatStore,
|
||||
room_id: OwnedRoomId,
|
||||
event_id: OwnedEventId,
|
||||
err: IambError,
|
||||
) {
|
||||
let rooms = &mut application.rooms;
|
||||
|
||||
match rooms
|
||||
.get_or_default(room_id.clone())
|
||||
.get_event_mut(&event_id)
|
||||
.ok_or_else(|| IambError::Preview("Message not found".to_string()))
|
||||
{
|
||||
Ok(msg) => msg.image_preview = ImageStatus::Error(format!("{err:?}")),
|
||||
Err(err) => {
|
||||
tracing::error!(
|
||||
"Failed to set error on msg.image_backend for event {}, room {}: {}",
|
||||
event_id,
|
||||
room_id,
|
||||
err
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async fn download_or_load(
|
||||
event_id: OwnedEventId,
|
||||
source: MediaSource,
|
||||
media: Media,
|
||||
mut cache_path: PathBuf,
|
||||
) -> Result<Vec<u8>, matrix_sdk::Error> {
|
||||
cache_path.push(Path::new(event_id.localpart()));
|
||||
|
||||
match File::open(&cache_path) {
|
||||
Ok(mut f) => {
|
||||
let mut buffer = Vec::new();
|
||||
f.read_to_end(&mut buffer)?;
|
||||
Ok(buffer)
|
||||
},
|
||||
Err(_) => {
|
||||
media
|
||||
.get_media_content(
|
||||
&MediaRequestParameters { source, format: MediaFormat::File },
|
||||
true,
|
||||
)
|
||||
.await
|
||||
.and_then(|buffer| {
|
||||
if let Err(err) =
|
||||
File::create(&cache_path).and_then(|mut f| f.write_all(&buffer))
|
||||
{
|
||||
return Err(err.into());
|
||||
}
|
||||
Ok(buffer)
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
58
src/sled_export.rs
Normal file
58
src/sled_export.rs
Normal file
@@ -0,0 +1,58 @@
|
||||
//! # sled -> sqlite migration code
|
||||
//!
|
||||
//! Before the 0.0.9 release, iamb used matrix-sdk@0.6.2, which used [sled]
|
||||
//! for storing information, including room keys. In matrix-sdk@0.7.0,
|
||||
//! the SDK switched to using SQLite. This module takes care of opening
|
||||
//! sled, exporting the inbound group sessions used for decryption,
|
||||
//! and importing them into SQLite.
|
||||
//!
|
||||
//! This code will eventually be removed once people have been given enough
|
||||
//! time to upgrade off of pre-0.0.9 versions.
|
||||
//!
|
||||
//! [sled]: https://docs.rs/sled/0.34.7/sled/index.html
|
||||
use sled::{Config, IVec};
|
||||
use std::path::Path;
|
||||
|
||||
use crate::base::IambError;
|
||||
use matrix_sdk::crypto::olm::{ExportedRoomKey, InboundGroupSession, PickledInboundGroupSession};
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum SledMigrationError {
|
||||
#[error("sled failure: {0}")]
|
||||
Sled(#[from] sled::Error),
|
||||
|
||||
#[error("deserialization failure: {0}")]
|
||||
Deserialize(#[from] serde_json::Error),
|
||||
}
|
||||
|
||||
fn group_session_from_slice(
|
||||
(_, bytes): (IVec, IVec),
|
||||
) -> Result<PickledInboundGroupSession, SledMigrationError> {
|
||||
serde_json::from_slice(&bytes).map_err(SledMigrationError::from)
|
||||
}
|
||||
|
||||
async fn export_room_keys_priv(
|
||||
sled_dir: &Path,
|
||||
) -> Result<Vec<ExportedRoomKey>, SledMigrationError> {
|
||||
let path = sled_dir.join("matrix-sdk-state");
|
||||
let store = Config::new().temporary(false).path(&path).open()?;
|
||||
let inbound_groups = store.open_tree("inbound_group_sessions")?;
|
||||
|
||||
let mut exported = vec![];
|
||||
let sessions = inbound_groups
|
||||
.iter()
|
||||
.map(|p| p.map_err(SledMigrationError::from).and_then(group_session_from_slice))
|
||||
.collect::<Result<Vec<_>, _>>()?
|
||||
.into_iter()
|
||||
.filter_map(|p| InboundGroupSession::from_pickle(p).ok());
|
||||
|
||||
for session in sessions {
|
||||
exported.push(session.export().await);
|
||||
}
|
||||
|
||||
Ok(exported)
|
||||
}
|
||||
|
||||
pub async fn export_room_keys(sled_dir: &Path) -> Result<Vec<ExportedRoomKey>, IambError> {
|
||||
export_room_keys_priv(sled_dir).await.map_err(IambError::from)
|
||||
}
|
||||
208
src/tests.rs
208
src/tests.rs
@@ -1,29 +1,44 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use matrix_sdk::ruma::{
|
||||
event_id,
|
||||
events::room::message::RoomMessageEventContent,
|
||||
events::room::message::{OriginalRoomMessageEvent, RoomMessageEventContent},
|
||||
server_name,
|
||||
user_id,
|
||||
EventId,
|
||||
OwnedEventId,
|
||||
OwnedRoomId,
|
||||
OwnedUserId,
|
||||
RoomId,
|
||||
UInt,
|
||||
};
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::mpsc::sync_channel;
|
||||
use lazy_static::lazy_static;
|
||||
use ratatui::style::{Color, Style};
|
||||
use tokio::sync::mpsc::unbounded_channel;
|
||||
use tracing::Level;
|
||||
use url::Url;
|
||||
|
||||
use lazy_static::lazy_static;
|
||||
|
||||
use crate::{
|
||||
base::{ChatStore, ProgramStore, RoomFetchStatus, RoomInfo},
|
||||
config::{ApplicationSettings, DirectoryValues, ProfileConfig, TunableValues},
|
||||
base::{ChatStore, EventLocation, ProgramStore, RoomInfo},
|
||||
config::{
|
||||
user_color,
|
||||
user_style_from_color,
|
||||
ApplicationSettings,
|
||||
DirectoryValues,
|
||||
Notifications,
|
||||
NotifyVia,
|
||||
ProfileConfig,
|
||||
SortOverrides,
|
||||
TunableValues,
|
||||
UserColor,
|
||||
UserDisplayStyle,
|
||||
UserDisplayTunables,
|
||||
},
|
||||
message::{
|
||||
Message,
|
||||
MessageContent,
|
||||
MessageEvent,
|
||||
MessageKey,
|
||||
MessageTimeStamp::{LocalEcho, OriginServer},
|
||||
Messages,
|
||||
@@ -31,65 +46,99 @@ use crate::{
|
||||
worker::Requester,
|
||||
};
|
||||
|
||||
const TEST_ROOM1_ALIAS: &str = "#room1:example.com";
|
||||
|
||||
lazy_static! {
|
||||
pub static ref TEST_ROOM1_ID: OwnedRoomId = RoomId::new(server_name!("example.com")).to_owned();
|
||||
pub static ref TEST_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_USER2: OwnedUserId = user_id!("@user2:example.com").to_owned();
|
||||
pub static ref TEST_USER3: OwnedUserId = user_id!("@user2:example.com").to_owned();
|
||||
pub static ref TEST_USER4: OwnedUserId = user_id!("@user2:example.com").to_owned();
|
||||
pub static ref TEST_USER5: OwnedUserId = user_id!("@user2:example.com").to_owned();
|
||||
pub static ref MSG1_KEY: MessageKey = (LocalEcho, EventId::new(server_name!("example.com")));
|
||||
pub static ref MSG2_KEY: MessageKey =
|
||||
(OriginServer(UInt::new(1).unwrap()), EventId::new(server_name!("example.com")));
|
||||
pub static ref MSG3_KEY: MessageKey = (
|
||||
OriginServer(UInt::new(2).unwrap()),
|
||||
event_id!("$5jRz3KfVhaUzXtVj7k:example.com").to_owned()
|
||||
);
|
||||
pub static ref MSG4_KEY: MessageKey = (
|
||||
OriginServer(UInt::new(2).unwrap()),
|
||||
event_id!("$JP6qFV7WyXk5ZnexM3:example.com").to_owned()
|
||||
);
|
||||
pub static ref MSG5_KEY: MessageKey =
|
||||
(OriginServer(UInt::new(8).unwrap()), EventId::new(server_name!("example.com")));
|
||||
pub static ref TEST_USER3: OwnedUserId = user_id!("@user3:example.com").to_owned();
|
||||
pub static ref TEST_USER4: OwnedUserId = user_id!("@user4:example.com").to_owned();
|
||||
pub static ref TEST_USER5: OwnedUserId = user_id!("@user5:example.com").to_owned();
|
||||
pub static ref MSG1_EVID: OwnedEventId = EventId::new(server_name!("example.com"));
|
||||
pub static ref MSG2_EVID: OwnedEventId = EventId::new(server_name!("example.com"));
|
||||
pub static ref MSG3_EVID: OwnedEventId =
|
||||
event_id!("$5jRz3KfVhaUzXtVj7k:example.com").to_owned();
|
||||
pub static ref MSG4_EVID: OwnedEventId =
|
||||
event_id!("$JP6qFV7WyXk5ZnexM3:example.com").to_owned();
|
||||
pub static ref MSG5_EVID: OwnedEventId = EventId::new(server_name!("example.com"));
|
||||
pub static ref MSG1_KEY: MessageKey = (LocalEcho, MSG1_EVID.clone());
|
||||
pub static ref MSG2_KEY: MessageKey = (OriginServer(UInt::new(1).unwrap()), MSG2_EVID.clone());
|
||||
pub static ref MSG3_KEY: MessageKey = (OriginServer(UInt::new(2).unwrap()), MSG3_EVID.clone());
|
||||
pub static ref MSG4_KEY: MessageKey = (OriginServer(UInt::new(2).unwrap()), MSG4_EVID.clone());
|
||||
pub static ref MSG5_KEY: MessageKey = (OriginServer(UInt::new(8).unwrap()), MSG5_EVID.clone());
|
||||
}
|
||||
|
||||
pub fn user_style(user: &str) -> Style {
|
||||
user_style_from_color(user_color(user))
|
||||
}
|
||||
|
||||
pub fn mock_room1_message(
|
||||
content: RoomMessageEventContent,
|
||||
sender: OwnedUserId,
|
||||
key: MessageKey,
|
||||
) -> Message {
|
||||
let origin_server_ts = key.0.as_millis().unwrap();
|
||||
let event_id = key.1;
|
||||
|
||||
let event = OriginalRoomMessageEvent {
|
||||
content,
|
||||
event_id,
|
||||
sender,
|
||||
origin_server_ts,
|
||||
room_id: TEST_ROOM1_ID.clone(),
|
||||
unsigned: Default::default(),
|
||||
};
|
||||
|
||||
event.into()
|
||||
}
|
||||
|
||||
pub fn mock_message1() -> Message {
|
||||
let content = RoomMessageEventContent::text_plain("writhe");
|
||||
let content = MessageContent::Original(content.into());
|
||||
let content = MessageEvent::Local(MSG1_EVID.clone(), content.into());
|
||||
|
||||
Message::new(content, TEST_USER1.clone(), MSG1_KEY.0)
|
||||
}
|
||||
|
||||
pub fn mock_message2() -> Message {
|
||||
let content = RoomMessageEventContent::text_plain("helium");
|
||||
let content = MessageContent::Original(content.into());
|
||||
|
||||
Message::new(content, TEST_USER2.clone(), MSG2_KEY.0)
|
||||
mock_room1_message(content, TEST_USER2.clone(), MSG2_KEY.clone())
|
||||
}
|
||||
|
||||
pub fn mock_message3() -> Message {
|
||||
let content = RoomMessageEventContent::text_plain("this\nis\na\nmultiline\nmessage");
|
||||
let content = MessageContent::Original(content.into());
|
||||
|
||||
Message::new(content, TEST_USER2.clone(), MSG3_KEY.0)
|
||||
mock_room1_message(content, TEST_USER2.clone(), MSG3_KEY.clone())
|
||||
}
|
||||
|
||||
pub fn mock_message4() -> Message {
|
||||
let content = RoomMessageEventContent::text_plain("help");
|
||||
let content = MessageContent::Original(content.into());
|
||||
|
||||
Message::new(content, TEST_USER1.clone(), MSG4_KEY.0)
|
||||
mock_room1_message(content, TEST_USER1.clone(), MSG4_KEY.clone())
|
||||
}
|
||||
|
||||
pub fn mock_message5() -> Message {
|
||||
let content = RoomMessageEventContent::text_plain("character");
|
||||
let content = MessageContent::Original(content.into());
|
||||
|
||||
Message::new(content, TEST_USER2.clone(), MSG5_KEY.0)
|
||||
mock_room1_message(content, TEST_USER2.clone(), MSG4_KEY.clone())
|
||||
}
|
||||
|
||||
pub fn mock_keys() -> HashMap<OwnedEventId, EventLocation> {
|
||||
let mut keys = HashMap::new();
|
||||
|
||||
keys.insert(MSG1_EVID.clone(), EventLocation::Message(None, MSG1_KEY.clone()));
|
||||
keys.insert(MSG2_EVID.clone(), EventLocation::Message(None, MSG2_KEY.clone()));
|
||||
keys.insert(MSG3_EVID.clone(), EventLocation::Message(None, MSG3_KEY.clone()));
|
||||
keys.insert(MSG4_EVID.clone(), EventLocation::Message(None, MSG4_KEY.clone()));
|
||||
keys.insert(MSG5_EVID.clone(), EventLocation::Message(None, MSG5_KEY.clone()));
|
||||
|
||||
keys
|
||||
}
|
||||
|
||||
pub fn mock_messages() -> Messages {
|
||||
let mut messages = BTreeMap::new();
|
||||
let mut messages = Messages::main();
|
||||
|
||||
messages.insert(MSG1_KEY.clone(), mock_message1());
|
||||
messages.insert(MSG2_KEY.clone(), mock_message2());
|
||||
@@ -101,48 +150,105 @@ pub fn mock_messages() -> Messages {
|
||||
}
|
||||
|
||||
pub fn mock_room() -> RoomInfo {
|
||||
RoomInfo {
|
||||
name: Some("Watercooler Discussion".into()),
|
||||
messages: mock_messages(),
|
||||
fetch_id: RoomFetchStatus::NotStarted,
|
||||
fetch_last: None,
|
||||
users_typing: None,
|
||||
}
|
||||
let mut room = RoomInfo::default();
|
||||
room.name = Some("Watercooler Discussion".into());
|
||||
room.keys = mock_keys();
|
||||
*room.get_thread_mut(None) = mock_messages();
|
||||
room
|
||||
}
|
||||
|
||||
pub fn mock_dirs() -> DirectoryValues {
|
||||
DirectoryValues {
|
||||
cache: PathBuf::new(),
|
||||
data: PathBuf::new(),
|
||||
logs: PathBuf::new(),
|
||||
downloads: PathBuf::new(),
|
||||
downloads: None,
|
||||
image_previews: PathBuf::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn mock_tunables() -> TunableValues {
|
||||
TunableValues {
|
||||
default_room: None,
|
||||
log_level: Level::INFO,
|
||||
message_shortcode_display: false,
|
||||
normal_after_send: true,
|
||||
reaction_display: true,
|
||||
reaction_shortcode_display: false,
|
||||
read_receipt_send: true,
|
||||
read_receipt_display: true,
|
||||
request_timeout: 120,
|
||||
sort: SortOverrides::default().values(),
|
||||
state_event_display: true,
|
||||
typing_notice_send: true,
|
||||
typing_notice_display: true,
|
||||
users: vec![(TEST_USER5.clone(), UserDisplayTunables {
|
||||
color: Some(UserColor(Color::Black)),
|
||||
name: Some("USER 5".into()),
|
||||
})]
|
||||
.into_iter()
|
||||
.collect::<HashMap<_, _>>(),
|
||||
open_command: None,
|
||||
external_edit_file_suffix: String::from(".md"),
|
||||
username_display: UserDisplayStyle::Username,
|
||||
message_user_color: false,
|
||||
mouse: Default::default(),
|
||||
notifications: Notifications {
|
||||
enabled: false,
|
||||
via: NotifyVia::default(),
|
||||
show_message: true,
|
||||
sound_hint: None,
|
||||
},
|
||||
image_preview: None,
|
||||
user_gutter_width: 30,
|
||||
tabstop: 4,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn mock_settings() -> ApplicationSettings {
|
||||
ApplicationSettings {
|
||||
matrix_dir: PathBuf::new(),
|
||||
layout_json: PathBuf::new(),
|
||||
session_json: PathBuf::new(),
|
||||
session_json_old: PathBuf::new(),
|
||||
sled_dir: PathBuf::new(),
|
||||
sqlite_dir: PathBuf::new(),
|
||||
|
||||
profile_name: "test".into(),
|
||||
profile: ProfileConfig {
|
||||
user_id: user_id!("@user:example.com").to_owned(),
|
||||
url: Url::parse("https://example.com").unwrap(),
|
||||
url: None,
|
||||
settings: None,
|
||||
dirs: None,
|
||||
layout: None,
|
||||
macros: None,
|
||||
},
|
||||
tunables: TunableValues { typing_notice: true, typing_notice_display: true },
|
||||
tunables: mock_tunables(),
|
||||
dirs: mock_dirs(),
|
||||
layout: Default::default(),
|
||||
macros: HashMap::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn mock_store() -> ProgramStore {
|
||||
let (tx, _) = sync_channel(5);
|
||||
let worker = Requester { tx };
|
||||
pub async fn mock_store() -> ProgramStore {
|
||||
let (tx, _) = unbounded_channel();
|
||||
let homeserver = Url::parse("https://localhost").unwrap();
|
||||
let client = matrix_sdk::Client::new(homeserver).await.unwrap();
|
||||
let worker = Requester { tx, client };
|
||||
|
||||
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 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)
|
||||
}
|
||||
|
||||
214
src/util.rs
Normal file
214
src/util.rs
Normal file
@@ -0,0 +1,214 @@
|
||||
//! # Utility functions
|
||||
use std::borrow::Cow;
|
||||
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use ratatui::style::Style;
|
||||
use ratatui::text::{Line, Span, Text};
|
||||
|
||||
pub fn split_cow(cow: Cow<'_, str>, idx: usize) -> (Cow<'_, str>, Cow<'_, str>) {
|
||||
match cow {
|
||||
Cow::Borrowed(s) => {
|
||||
let s1 = Cow::Borrowed(&s[idx..]);
|
||||
let s0 = Cow::Borrowed(&s[..idx]);
|
||||
|
||||
(s0, s1)
|
||||
},
|
||||
Cow::Owned(mut s) => {
|
||||
let s1 = Cow::Owned(s.split_off(idx));
|
||||
let s0 = Cow::Owned(s);
|
||||
|
||||
(s0, s1)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn take_width(s: Cow<'_, str>, width: usize) -> ((Cow<'_, str>, usize), Cow<'_, str>) {
|
||||
// Find where to split the line.
|
||||
let mut w = 0;
|
||||
|
||||
let idx = UnicodeSegmentation::grapheme_indices(s.as_ref(), true)
|
||||
.find_map(|(i, g)| {
|
||||
let gw = UnicodeWidthStr::width(g);
|
||||
if w + gw > width {
|
||||
Some(i)
|
||||
} else {
|
||||
w += gw;
|
||||
None
|
||||
}
|
||||
})
|
||||
.unwrap_or(s.len());
|
||||
|
||||
let (s0, s1) = split_cow(s, idx);
|
||||
|
||||
((s0, w), s1)
|
||||
}
|
||||
|
||||
pub struct WrappedLinesIterator<'a> {
|
||||
iter: std::vec::IntoIter<Cow<'a, str>>,
|
||||
curr: Option<Cow<'a, str>>,
|
||||
width: usize,
|
||||
}
|
||||
|
||||
impl<'a> WrappedLinesIterator<'a> {
|
||||
fn new<T>(input: T, width: usize) -> Self
|
||||
where
|
||||
T: Into<Cow<'a, str>>,
|
||||
{
|
||||
let width = width.max(2);
|
||||
|
||||
let cows: Vec<Cow<'a, str>> = match input.into() {
|
||||
Cow::Borrowed(s) => s.lines().map(Cow::Borrowed).collect(),
|
||||
Cow::Owned(s) => s.lines().map(ToOwned::to_owned).map(Cow::Owned).collect(),
|
||||
};
|
||||
|
||||
WrappedLinesIterator { iter: cows.into_iter(), curr: None, width }
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Iterator for WrappedLinesIterator<'a> {
|
||||
type Item = (Cow<'a, str>, usize);
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
if self.curr.is_none() {
|
||||
self.curr = self.iter.next();
|
||||
}
|
||||
|
||||
if let Some(s) = self.curr.take() {
|
||||
let width = UnicodeWidthStr::width(s.as_ref());
|
||||
|
||||
if width <= self.width {
|
||||
return Some((s, width));
|
||||
} else {
|
||||
let (prefix, s1) = take_width(s, self.width);
|
||||
self.curr = Some(s1);
|
||||
return Some(prefix);
|
||||
}
|
||||
} else {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn wrap<'a, T>(input: T, width: usize) -> WrappedLinesIterator<'a>
|
||||
where
|
||||
T: Into<Cow<'a, str>>,
|
||||
{
|
||||
WrappedLinesIterator::new(input, width)
|
||||
}
|
||||
|
||||
pub fn wrapped_text<'a, T>(s: T, width: usize, style: Style) -> Text<'a>
|
||||
where
|
||||
T: Into<Cow<'a, str>>,
|
||||
{
|
||||
let mut text = Text::default();
|
||||
|
||||
for (line, w) in wrap(s, width) {
|
||||
let space = space_span(width.saturating_sub(w), style);
|
||||
let spans = Line::from(vec![Span::styled(line, style), space]);
|
||||
|
||||
text.lines.push(spans);
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
pub fn space(width: usize) -> String {
|
||||
" ".repeat(width)
|
||||
}
|
||||
|
||||
pub fn space_span(width: usize, style: Style) -> Span<'static> {
|
||||
Span::styled(space(width), style)
|
||||
}
|
||||
|
||||
pub fn space_text(width: usize, style: Style) -> Text<'static> {
|
||||
space_span(width, style).into()
|
||||
}
|
||||
|
||||
pub fn join_cell_text<'a>(texts: Vec<(Text<'a>, usize)>, join: Span<'a>, style: Style) -> Text<'a> {
|
||||
let height = texts.iter().map(|t| t.0.height()).max().unwrap_or(0);
|
||||
let mut text = Text::from(vec![Line::from(vec![join.clone()]); height]);
|
||||
|
||||
for (mut t, w) in texts.into_iter() {
|
||||
for i in 0..height {
|
||||
if let Some(line) = t.lines.get_mut(i) {
|
||||
text.lines[i].spans.append(&mut line.spans);
|
||||
} else {
|
||||
text.lines[i].spans.push(space_span(w, style));
|
||||
}
|
||||
|
||||
text.lines[i].spans.push(join.clone());
|
||||
}
|
||||
}
|
||||
|
||||
text
|
||||
}
|
||||
|
||||
fn replace_emoji_in_grapheme(grapheme: &str) -> String {
|
||||
emojis::get(grapheme)
|
||||
.and_then(|emoji| emoji.shortcode())
|
||||
.map(|shortcode| format!(":{shortcode}:"))
|
||||
.unwrap_or_else(|| grapheme.to_owned())
|
||||
}
|
||||
|
||||
pub fn replace_emojis_in_str(s: &str) -> String {
|
||||
let graphemes = s.graphemes(true);
|
||||
graphemes.map(replace_emoji_in_grapheme).collect()
|
||||
}
|
||||
|
||||
pub fn replace_emojis_in_span(span: &mut Span) {
|
||||
span.content = Cow::Owned(replace_emojis_in_str(span.content.as_ref()))
|
||||
}
|
||||
|
||||
pub fn replace_emojis_in_line(line: &mut Line) {
|
||||
for span in &mut line.spans {
|
||||
replace_emojis_in_span(span);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_wrapped_lines_ascii() {
|
||||
let s = "hello world!\nabcdefghijklmnopqrstuvwxyz\ngoodbye";
|
||||
|
||||
let mut iter = wrap(s, 100);
|
||||
assert_eq!(iter.next(), Some((Cow::Borrowed("hello world!"), 12)));
|
||||
assert_eq!(iter.next(), Some((Cow::Borrowed("abcdefghijklmnopqrstuvwxyz"), 26)));
|
||||
assert_eq!(iter.next(), Some((Cow::Borrowed("goodbye"), 7)));
|
||||
assert_eq!(iter.next(), None);
|
||||
|
||||
let mut iter = wrap(s, 5);
|
||||
assert_eq!(iter.next(), Some((Cow::Borrowed("hello"), 5)));
|
||||
assert_eq!(iter.next(), Some((Cow::Borrowed(" worl"), 5)));
|
||||
assert_eq!(iter.next(), Some((Cow::Borrowed("d!"), 2)));
|
||||
assert_eq!(iter.next(), Some((Cow::Borrowed("abcde"), 5)));
|
||||
assert_eq!(iter.next(), Some((Cow::Borrowed("fghij"), 5)));
|
||||
assert_eq!(iter.next(), Some((Cow::Borrowed("klmno"), 5)));
|
||||
assert_eq!(iter.next(), Some((Cow::Borrowed("pqrst"), 5)));
|
||||
assert_eq!(iter.next(), Some((Cow::Borrowed("uvwxy"), 5)));
|
||||
assert_eq!(iter.next(), Some((Cow::Borrowed("z"), 1)));
|
||||
assert_eq!(iter.next(), Some((Cow::Borrowed("goodb"), 5)));
|
||||
assert_eq!(iter.next(), Some((Cow::Borrowed("ye"), 2)));
|
||||
assert_eq!(iter.next(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wrapped_lines_unicode() {
|
||||
let s = "CHICKEN";
|
||||
|
||||
let mut iter = wrap(s, 14);
|
||||
assert_eq!(iter.next(), Some((Cow::Borrowed(s), 14)));
|
||||
assert_eq!(iter.next(), None);
|
||||
|
||||
let mut iter = wrap(s, 5);
|
||||
assert_eq!(iter.next(), Some((Cow::Borrowed("CH"), 4)));
|
||||
assert_eq!(iter.next(), Some((Cow::Borrowed("IC"), 4)));
|
||||
assert_eq!(iter.next(), Some((Cow::Borrowed("KE"), 4)));
|
||||
assert_eq!(iter.next(), Some((Cow::Borrowed("N"), 2)));
|
||||
assert_eq!(iter.next(), None);
|
||||
}
|
||||
}
|
||||
1317
src/windows/mod.rs
1317
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,54 +1,79 @@
|
||||
use matrix_sdk::room::Room as MatrixRoom;
|
||||
use matrix_sdk::ruma::RoomId;
|
||||
use matrix_sdk::DisplayName;
|
||||
//! # Windows for Matrix rooms and spaces
|
||||
use std::collections::HashSet;
|
||||
|
||||
use modalkit::tui::{
|
||||
use matrix_sdk::{
|
||||
notification_settings::RoomNotificationMode,
|
||||
room::Room as MatrixRoom,
|
||||
ruma::{
|
||||
api::client::{
|
||||
alias::{
|
||||
create_alias::v3::Request as CreateAliasRequest,
|
||||
delete_alias::v3::Request as DeleteAliasRequest,
|
||||
},
|
||||
error::ErrorKind as ClientApiErrorKind,
|
||||
},
|
||||
events::{
|
||||
room::{
|
||||
canonical_alias::RoomCanonicalAliasEventContent,
|
||||
history_visibility::{HistoryVisibility, RoomHistoryVisibilityEventContent},
|
||||
name::RoomNameEventContent,
|
||||
topic::RoomTopicEventContent,
|
||||
},
|
||||
tag::{TagInfo, Tags},
|
||||
},
|
||||
OwnedEventId,
|
||||
OwnedRoomAliasId,
|
||||
OwnedUserId,
|
||||
RoomId,
|
||||
},
|
||||
RoomDisplayName,
|
||||
RoomState as MatrixRoomState,
|
||||
};
|
||||
|
||||
use ratatui::{
|
||||
buffer::Buffer,
|
||||
layout::Rect,
|
||||
layout::{Alignment, Rect},
|
||||
style::{Modifier as StyleModifier, Style},
|
||||
text::{Span, Spans},
|
||||
widgets::StatefulWidget,
|
||||
text::{Line, Span, Text},
|
||||
widgets::{Paragraph, StatefulWidget, Widget},
|
||||
};
|
||||
|
||||
use modalkit::{
|
||||
editing::action::{
|
||||
Action,
|
||||
EditInfo,
|
||||
EditResult,
|
||||
Editable,
|
||||
EditorAction,
|
||||
Jumpable,
|
||||
PromptAction,
|
||||
Promptable,
|
||||
Scrollable,
|
||||
},
|
||||
editing::base::{
|
||||
Axis,
|
||||
CloseFlags,
|
||||
Count,
|
||||
MoveDir1D,
|
||||
OpenTarget,
|
||||
PositionList,
|
||||
ScrollStyle,
|
||||
WordStyle,
|
||||
},
|
||||
input::InputContext,
|
||||
widgets::{TermOffset, TerminalCursor, WindowOps},
|
||||
use modalkit::actions::{
|
||||
Action,
|
||||
Editable,
|
||||
EditorAction,
|
||||
Jumpable,
|
||||
PromptAction,
|
||||
Promptable,
|
||||
Scrollable,
|
||||
};
|
||||
use modalkit::errors::{EditResult, UIError};
|
||||
use modalkit::prelude::*;
|
||||
use modalkit::{editing::completion::CompletionList, keybindings::dialog::PromptYesNo};
|
||||
use modalkit_ratatui::{TermOffset, TerminalCursor, WindowOps};
|
||||
|
||||
use crate::base::{
|
||||
IambAction,
|
||||
IambError,
|
||||
IambId,
|
||||
IambInfo,
|
||||
IambResult,
|
||||
MemberUpdateAction,
|
||||
MessageAction,
|
||||
ProgramAction,
|
||||
ProgramContext,
|
||||
ProgramStore,
|
||||
RoomAction,
|
||||
RoomField,
|
||||
SendAction,
|
||||
SpaceAction,
|
||||
};
|
||||
|
||||
use self::chat::ChatState;
|
||||
use self::space::{Space, SpaceState};
|
||||
|
||||
use std::convert::TryFrom;
|
||||
|
||||
mod chat;
|
||||
mod scrollback;
|
||||
mod space;
|
||||
@@ -62,43 +87,259 @@ macro_rules! delegate {
|
||||
};
|
||||
}
|
||||
|
||||
fn notification_mode(name: impl Into<String>) -> IambResult<RoomNotificationMode> {
|
||||
let name = name.into();
|
||||
|
||||
let mode = match name.to_lowercase().as_str() {
|
||||
"mute" => RoomNotificationMode::Mute,
|
||||
"mentions" | "keywords" => RoomNotificationMode::MentionsAndKeywordsOnly,
|
||||
"all" => RoomNotificationMode::AllMessages,
|
||||
_ => return Err(IambError::InvalidNotificationLevel(name).into()),
|
||||
};
|
||||
|
||||
Ok(mode)
|
||||
}
|
||||
|
||||
fn hist_visibility_mode(name: impl Into<String>) -> IambResult<HistoryVisibility> {
|
||||
let name = name.into();
|
||||
|
||||
let mode = match name.to_lowercase().as_str() {
|
||||
"invited" => HistoryVisibility::Invited,
|
||||
"joined" => HistoryVisibility::Joined,
|
||||
"shared" => HistoryVisibility::Shared,
|
||||
"world" | "world_readable" => HistoryVisibility::WorldReadable,
|
||||
_ => return Err(IambError::InvalidHistoryVisibility(name).into()),
|
||||
};
|
||||
|
||||
Ok(mode)
|
||||
}
|
||||
|
||||
/// State for a Matrix room or space.
|
||||
///
|
||||
/// Since spaces function as special rooms within Matrix, we wrap their window state together, so
|
||||
/// that operations like sending and accepting invites, opening the members window, etc., all work
|
||||
/// similarly.
|
||||
pub enum RoomState {
|
||||
Chat(ChatState),
|
||||
Space(SpaceState),
|
||||
Chat(Box<ChatState>),
|
||||
Space(Box<SpaceState>),
|
||||
}
|
||||
|
||||
impl From<ChatState> for RoomState {
|
||||
fn from(chat: ChatState) -> Self {
|
||||
RoomState::Chat(chat)
|
||||
RoomState::Chat(Box::new(chat))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SpaceState> for RoomState {
|
||||
fn from(space: SpaceState) -> Self {
|
||||
RoomState::Space(space)
|
||||
RoomState::Space(Box::new(space))
|
||||
}
|
||||
}
|
||||
|
||||
impl RoomState {
|
||||
pub fn new(room: MatrixRoom, name: DisplayName, store: &mut ProgramStore) -> Self {
|
||||
pub fn new(
|
||||
room: MatrixRoom,
|
||||
thread: Option<OwnedEventId>,
|
||||
name: RoomDisplayName,
|
||||
tags: Option<Tags>,
|
||||
store: &mut ProgramStore,
|
||||
) -> Self {
|
||||
let room_id = room.room_id().to_owned();
|
||||
let info = store.application.get_room_info(room_id);
|
||||
info.name = name.to_string().into();
|
||||
info.tags = tags;
|
||||
|
||||
if room.is_space() {
|
||||
SpaceState::new(room).into()
|
||||
} else {
|
||||
ChatState::new(room, store).into()
|
||||
ChatState::new(room, thread, store).into()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn room_command(
|
||||
pub fn thread(&self) -> Option<&OwnedEventId> {
|
||||
match self {
|
||||
RoomState::Chat(chat) => chat.thread(),
|
||||
RoomState::Space(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn refresh_room(&mut self, store: &mut ProgramStore) {
|
||||
match self {
|
||||
RoomState::Chat(chat) => chat.refresh_room(store),
|
||||
RoomState::Space(space) => space.refresh_room(store),
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_invite(
|
||||
&self,
|
||||
invited: MatrixRoom,
|
||||
area: Rect,
|
||||
buf: &mut Buffer,
|
||||
store: &mut ProgramStore,
|
||||
) {
|
||||
let inviter = store.application.worker.get_inviter(invited.clone());
|
||||
|
||||
let name = match invited.canonical_alias() {
|
||||
Some(alias) => alias.to_string(),
|
||||
None => format!("{:?}", store.application.get_room_title(self.id())),
|
||||
};
|
||||
|
||||
let mut invited = vec![Span::from(format!("You have been invited to join {name}"))];
|
||||
|
||||
if let Ok(Some(inviter)) = &inviter {
|
||||
let info = store.application.rooms.get_or_default(self.id().to_owned());
|
||||
invited.push(Span::from(" by "));
|
||||
invited.push(store.application.settings.get_user_span(inviter.user_id(), info));
|
||||
}
|
||||
|
||||
let l1 = Line::from(invited);
|
||||
let l2 = Line::from(
|
||||
"You can run `:invite accept` or `:invite reject` to accept or reject this invitation.",
|
||||
);
|
||||
let text = Text::from(vec![l1, l2]);
|
||||
|
||||
Paragraph::new(text).alignment(Alignment::Center).render(area, buf);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
pub async fn message_command(
|
||||
&mut self,
|
||||
act: MessageAction,
|
||||
ctx: ProgramContext,
|
||||
store: &mut ProgramStore,
|
||||
) -> IambResult<EditInfo> {
|
||||
match self {
|
||||
RoomState::Chat(chat) => chat.message_command(act, ctx, store).await,
|
||||
RoomState::Space(_) => Err(IambError::NoSelectedMessage.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn space_command(
|
||||
&mut self,
|
||||
act: SpaceAction,
|
||||
ctx: ProgramContext,
|
||||
store: &mut ProgramStore,
|
||||
) -> IambResult<EditInfo> {
|
||||
match self {
|
||||
RoomState::Space(space) => space.space_command(act, ctx, store).await,
|
||||
RoomState::Chat(_) => Err(IambError::NoSelectedSpace.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn send_command(
|
||||
&mut self,
|
||||
act: SendAction,
|
||||
ctx: ProgramContext,
|
||||
store: &mut ProgramStore,
|
||||
) -> IambResult<EditInfo> {
|
||||
match self {
|
||||
RoomState::Chat(chat) => chat.send_command(act, ctx, store).await,
|
||||
RoomState::Space(_) => Err(IambError::NoSelectedRoom.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn room_command(
|
||||
&mut self,
|
||||
act: RoomAction,
|
||||
_: ProgramContext,
|
||||
ctx: ProgramContext,
|
||||
store: &mut ProgramStore,
|
||||
) -> IambResult<Vec<(Action<IambInfo>, ProgramContext)>> {
|
||||
match act {
|
||||
RoomAction::InviteAccept => {
|
||||
if let Some(room) = store.application.worker.client.get_room(self.id()) {
|
||||
let details = room.invite_details().await.map_err(IambError::from)?;
|
||||
let details = details.invitee.event().original_content();
|
||||
let is_direct = details.and_then(|ev| ev.is_direct).unwrap_or_default();
|
||||
|
||||
room.join().await.map_err(IambError::from)?;
|
||||
|
||||
if is_direct {
|
||||
room.set_is_direct(true).await.map_err(IambError::from)?;
|
||||
}
|
||||
|
||||
Ok(vec![])
|
||||
} else {
|
||||
Err(IambError::NotInvited.into())
|
||||
}
|
||||
},
|
||||
RoomAction::InviteReject => {
|
||||
if let Some(room) = store.application.worker.client.get_room(self.id()) {
|
||||
room.leave().await.map_err(IambError::from)?;
|
||||
|
||||
Ok(vec![])
|
||||
} else {
|
||||
Err(IambError::NotInvited.into())
|
||||
}
|
||||
},
|
||||
RoomAction::InviteSend(user) => {
|
||||
if let Some(room) = store.application.worker.client.get_room(self.id()) {
|
||||
room.invite_user_by_id(user.as_ref()).await.map_err(IambError::from)?;
|
||||
|
||||
Ok(vec![])
|
||||
} else {
|
||||
Err(IambError::NotJoined.into())
|
||||
}
|
||||
},
|
||||
RoomAction::Leave(skip_confirm) => {
|
||||
if let Some(room) = store.application.worker.client.get_room(self.id()) {
|
||||
if skip_confirm {
|
||||
room.leave().await.map_err(IambError::from)?;
|
||||
|
||||
Ok(vec![])
|
||||
} else {
|
||||
let msg = "Do you really want to leave this room?";
|
||||
let leave = IambAction::Room(RoomAction::Leave(true));
|
||||
let prompt = PromptYesNo::new(msg, vec![Action::from(leave)]);
|
||||
let prompt = Box::new(prompt);
|
||||
|
||||
Err(UIError::NeedConfirm(prompt))
|
||||
}
|
||||
} else {
|
||||
Err(IambError::NotJoined.into())
|
||||
}
|
||||
},
|
||||
RoomAction::MemberUpdate(mua, user, reason, skip_confirm) => {
|
||||
let Some(room) = store.application.worker.client.get_room(self.id()) else {
|
||||
return Err(IambError::NotJoined.into());
|
||||
};
|
||||
|
||||
let Ok(user_id) = OwnedUserId::try_from(user.as_str()) else {
|
||||
let err = IambError::InvalidUserId(user);
|
||||
|
||||
return Err(err.into());
|
||||
};
|
||||
|
||||
if !skip_confirm {
|
||||
let msg = format!("Do you really want to {mua} {user} from this room?");
|
||||
let act = RoomAction::MemberUpdate(mua, user, reason, true);
|
||||
let act = IambAction::from(act);
|
||||
let prompt = PromptYesNo::new(msg, vec![Action::from(act)]);
|
||||
let prompt = Box::new(prompt);
|
||||
|
||||
return Err(UIError::NeedConfirm(prompt));
|
||||
}
|
||||
|
||||
match mua {
|
||||
MemberUpdateAction::Ban => {
|
||||
room.ban_user(&user_id, reason.as_deref())
|
||||
.await
|
||||
.map_err(IambError::from)?;
|
||||
},
|
||||
MemberUpdateAction::Unban => {
|
||||
room.unban_user(&user_id, reason.as_deref())
|
||||
.await
|
||||
.map_err(IambError::from)?;
|
||||
},
|
||||
MemberUpdateAction::Kick => {
|
||||
room.kick_user(&user_id, reason.as_deref())
|
||||
.await
|
||||
.map_err(IambError::from)?;
|
||||
},
|
||||
}
|
||||
|
||||
Ok(vec![])
|
||||
},
|
||||
RoomAction::Members(mut cmd) => {
|
||||
let width = Count::Exact(30);
|
||||
let act =
|
||||
@@ -107,20 +348,328 @@ impl RoomState {
|
||||
width.into(),
|
||||
);
|
||||
|
||||
Ok(vec![(act, cmd.context.take())])
|
||||
Ok(vec![(act, cmd.context.clone())])
|
||||
},
|
||||
RoomAction::Set(field) => {
|
||||
store.application.worker.set_room(self.id().to_owned(), field)?;
|
||||
RoomAction::SetDirect(is_direct) => {
|
||||
let room = store
|
||||
.application
|
||||
.get_joined_room(self.id())
|
||||
.ok_or(UIError::Application(IambError::NotJoined))?;
|
||||
|
||||
room.set_is_direct(is_direct).await.map_err(IambError::from)?;
|
||||
|
||||
Ok(vec![])
|
||||
},
|
||||
RoomAction::Set(field, value) => {
|
||||
let room = store
|
||||
.application
|
||||
.get_joined_room(self.id())
|
||||
.ok_or(UIError::Application(IambError::NotJoined))?;
|
||||
|
||||
match field {
|
||||
RoomField::History => {
|
||||
let visibility = hist_visibility_mode(value)?;
|
||||
let ev = RoomHistoryVisibilityEventContent::new(visibility);
|
||||
let _ = room.send_state_event(ev).await.map_err(IambError::from)?;
|
||||
},
|
||||
RoomField::Name => {
|
||||
let ev = RoomNameEventContent::new(value);
|
||||
let _ = room.send_state_event(ev).await.map_err(IambError::from)?;
|
||||
},
|
||||
RoomField::Tag(tag) => {
|
||||
let mut info = TagInfo::new();
|
||||
info.order = Some(1.0);
|
||||
|
||||
let _ = room.set_tag(tag, info).await.map_err(IambError::from)?;
|
||||
},
|
||||
RoomField::Topic => {
|
||||
let ev = RoomTopicEventContent::new(value);
|
||||
let _ = room.send_state_event(ev).await.map_err(IambError::from)?;
|
||||
},
|
||||
RoomField::NotificationMode => {
|
||||
let mode = notification_mode(value)?;
|
||||
let client = &store.application.worker.client;
|
||||
let notifications = client.notification_settings().await;
|
||||
|
||||
notifications
|
||||
.set_room_notification_mode(self.id(), mode)
|
||||
.await
|
||||
.map_err(IambError::from)?;
|
||||
},
|
||||
RoomField::CanonicalAlias => {
|
||||
let client = &mut store.application.worker.client;
|
||||
|
||||
let Ok(orai) = OwnedRoomAliasId::try_from(value.as_str()) else {
|
||||
let err = IambError::InvalidRoomAlias(value);
|
||||
|
||||
return Err(err.into());
|
||||
};
|
||||
|
||||
let mut alt_aliases =
|
||||
room.alt_aliases().into_iter().collect::<HashSet<_>>();
|
||||
let canonical_old = room.canonical_alias();
|
||||
|
||||
// If the room's alias is already that, ignore it
|
||||
if canonical_old.as_ref() == Some(&orai) {
|
||||
let msg = format!("The canonical room alias is already {orai}");
|
||||
|
||||
return Ok(vec![(Action::ShowInfoMessage(msg.into()), ctx)]);
|
||||
}
|
||||
|
||||
// Try creating the room alias on the server.
|
||||
let alias_create_req =
|
||||
CreateAliasRequest::new(orai.clone(), room.room_id().into());
|
||||
if let Err(e) = client.send(alias_create_req).await {
|
||||
if let Some(ClientApiErrorKind::Unknown) = e.client_api_error_kind() {
|
||||
// Ignore when it already exists.
|
||||
} else {
|
||||
return Err(IambError::from(e).into());
|
||||
}
|
||||
}
|
||||
|
||||
// Demote the previous one to an alt alias.
|
||||
alt_aliases.extend(canonical_old);
|
||||
|
||||
// At this point the room alias definitely exists, and we can update the
|
||||
// state event.
|
||||
let mut ev = RoomCanonicalAliasEventContent::new();
|
||||
ev.alias = Some(orai);
|
||||
ev.alt_aliases = alt_aliases.into_iter().collect();
|
||||
let _ = room.send_state_event(ev).await.map_err(IambError::from)?;
|
||||
},
|
||||
RoomField::Alias(alias) => {
|
||||
let client = &mut store.application.worker.client;
|
||||
|
||||
let Ok(orai) = OwnedRoomAliasId::try_from(alias.as_str()) else {
|
||||
let err = IambError::InvalidRoomAlias(alias);
|
||||
|
||||
return Err(err.into());
|
||||
};
|
||||
|
||||
let mut alt_aliases =
|
||||
room.alt_aliases().into_iter().collect::<HashSet<_>>();
|
||||
let canonical = room.canonical_alias();
|
||||
|
||||
if alt_aliases.contains(&orai) || canonical.as_ref() == Some(&orai) {
|
||||
let msg = format!("The alias {orai} already maps to this room");
|
||||
|
||||
return Ok(vec![(Action::ShowInfoMessage(msg.into()), ctx)]);
|
||||
} else {
|
||||
alt_aliases.insert(orai.clone());
|
||||
}
|
||||
|
||||
// If the room alias does not exist on the server, create it
|
||||
let alias_create_req = CreateAliasRequest::new(orai, room.room_id().into());
|
||||
if let Err(e) = client.send(alias_create_req).await {
|
||||
if let Some(ClientApiErrorKind::Unknown) = e.client_api_error_kind() {
|
||||
// Ignore when it already exists.
|
||||
} else {
|
||||
return Err(IambError::from(e).into());
|
||||
}
|
||||
}
|
||||
|
||||
// And add it to the aliases in the state event.
|
||||
let mut ev = RoomCanonicalAliasEventContent::new();
|
||||
ev.alias = canonical;
|
||||
ev.alt_aliases = alt_aliases.into_iter().collect();
|
||||
let _ = room.send_state_event(ev).await.map_err(IambError::from)?;
|
||||
},
|
||||
RoomField::Aliases => {
|
||||
// This never happens, aliases is only used for showing
|
||||
},
|
||||
RoomField::Id => {
|
||||
// This never happens, id is only used for showing
|
||||
},
|
||||
}
|
||||
|
||||
Ok(vec![])
|
||||
},
|
||||
RoomAction::Unset(field) => {
|
||||
let room = store
|
||||
.application
|
||||
.get_joined_room(self.id())
|
||||
.ok_or(UIError::Application(IambError::NotJoined))?;
|
||||
|
||||
match field {
|
||||
RoomField::History => {
|
||||
let visibility = HistoryVisibility::Joined;
|
||||
let ev = RoomHistoryVisibilityEventContent::new(visibility);
|
||||
let _ = room.send_state_event(ev).await.map_err(IambError::from)?;
|
||||
},
|
||||
RoomField::Name => {
|
||||
let ev = RoomNameEventContent::new("".into());
|
||||
let _ = room.send_state_event(ev).await.map_err(IambError::from)?;
|
||||
},
|
||||
RoomField::Tag(tag) => {
|
||||
let _ = room.remove_tag(tag).await.map_err(IambError::from)?;
|
||||
},
|
||||
RoomField::Topic => {
|
||||
let ev = RoomTopicEventContent::new("".into());
|
||||
let _ = room.send_state_event(ev).await.map_err(IambError::from)?;
|
||||
},
|
||||
RoomField::NotificationMode => {
|
||||
let client = &store.application.worker.client;
|
||||
let notifications = client.notification_settings().await;
|
||||
|
||||
notifications
|
||||
.delete_user_defined_room_rules(self.id())
|
||||
.await
|
||||
.map_err(IambError::from)?;
|
||||
},
|
||||
RoomField::CanonicalAlias => {
|
||||
let Some(alias_to_destroy) = room.canonical_alias() else {
|
||||
let msg = "This room has no canonical alias to unset";
|
||||
|
||||
return Ok(vec![(Action::ShowInfoMessage(msg.into()), ctx)]);
|
||||
};
|
||||
|
||||
// Remove the canonical alias from the state event.
|
||||
let mut ev = RoomCanonicalAliasEventContent::new();
|
||||
ev.alias = None;
|
||||
ev.alt_aliases = room.alt_aliases();
|
||||
let _ = room.send_state_event(ev).await.map_err(IambError::from)?;
|
||||
|
||||
// And then unmap it on the server.
|
||||
let del_req = DeleteAliasRequest::new(alias_to_destroy);
|
||||
let _ = store
|
||||
.application
|
||||
.worker
|
||||
.client
|
||||
.send(del_req)
|
||||
.await
|
||||
.map_err(IambError::from)?;
|
||||
},
|
||||
RoomField::Alias(alias) => {
|
||||
let Ok(orai) = OwnedRoomAliasId::try_from(alias.as_str()) else {
|
||||
let err = IambError::InvalidRoomAlias(alias);
|
||||
|
||||
return Err(err.into());
|
||||
};
|
||||
|
||||
let alt_aliases = room.alt_aliases();
|
||||
let canonical = room.canonical_alias();
|
||||
|
||||
if !alt_aliases.contains(&orai) && canonical.as_ref() != Some(&orai) {
|
||||
let msg = format!("The alias {orai:?} isn't mapped to this room");
|
||||
|
||||
return Ok(vec![(Action::ShowInfoMessage(msg.into()), ctx)]);
|
||||
}
|
||||
|
||||
// Remove the alias from the state event if it's in it.
|
||||
let mut ev = RoomCanonicalAliasEventContent::new();
|
||||
ev.alias = canonical.filter(|canon| canon != &orai);
|
||||
ev.alt_aliases = alt_aliases;
|
||||
ev.alt_aliases.retain(|in_orai| in_orai != &orai);
|
||||
let _ = room.send_state_event(ev).await.map_err(IambError::from)?;
|
||||
|
||||
// And then unmap it on the server.
|
||||
let del_req = DeleteAliasRequest::new(orai);
|
||||
let _ = store
|
||||
.application
|
||||
.worker
|
||||
.client
|
||||
.send(del_req)
|
||||
.await
|
||||
.map_err(IambError::from)?;
|
||||
},
|
||||
RoomField::Aliases => {
|
||||
// This will not happen, you cannot unset all aliases
|
||||
},
|
||||
RoomField::Id => {
|
||||
// This never happens, id is only used for showing
|
||||
},
|
||||
}
|
||||
|
||||
Ok(vec![])
|
||||
},
|
||||
RoomAction::Show(field) => {
|
||||
let room = store
|
||||
.application
|
||||
.get_joined_room(self.id())
|
||||
.ok_or(UIError::Application(IambError::NotJoined))?;
|
||||
|
||||
let msg = match field {
|
||||
RoomField::History => {
|
||||
let visibility = room.history_visibility();
|
||||
let visibility = visibility.as_ref().map(|v| v.as_str());
|
||||
format!("Room history visibility: {}", visibility.unwrap_or("<unknown>"))
|
||||
},
|
||||
RoomField::Id => {
|
||||
let id = room.room_id();
|
||||
format!("Room identifier: {id}")
|
||||
},
|
||||
RoomField::Name => {
|
||||
match room.name() {
|
||||
None => "Room has no name".into(),
|
||||
Some(name) => format!("Room name: {name:?}"),
|
||||
}
|
||||
},
|
||||
RoomField::Topic => {
|
||||
match room.topic() {
|
||||
None => "Room has no topic".into(),
|
||||
Some(topic) => format!("Room topic: {topic:?}"),
|
||||
}
|
||||
},
|
||||
RoomField::NotificationMode => {
|
||||
let client = &store.application.worker.client;
|
||||
let notifications = client.notification_settings().await;
|
||||
let mode =
|
||||
notifications.get_user_defined_room_notification_mode(self.id()).await;
|
||||
|
||||
let level = match mode {
|
||||
Some(RoomNotificationMode::Mute) => "mute",
|
||||
Some(RoomNotificationMode::MentionsAndKeywordsOnly) => "keywords",
|
||||
Some(RoomNotificationMode::AllMessages) => "all",
|
||||
None => "default",
|
||||
};
|
||||
|
||||
format!("Room notification level: {level:?}")
|
||||
},
|
||||
RoomField::Aliases => {
|
||||
let aliases = room
|
||||
.alt_aliases()
|
||||
.iter()
|
||||
.map(OwnedRoomAliasId::to_string)
|
||||
.collect::<Vec<String>>();
|
||||
|
||||
if aliases.is_empty() {
|
||||
"No alternative aliases in room".into()
|
||||
} else {
|
||||
format!("Alternative aliases: {}.", aliases.join(", "))
|
||||
}
|
||||
},
|
||||
RoomField::CanonicalAlias => {
|
||||
match room.canonical_alias() {
|
||||
None => "No canonical alias for room".into(),
|
||||
Some(can) => format!("Canonical alias: {can}"),
|
||||
}
|
||||
},
|
||||
RoomField::Tag(_) => "Cannot currently show value for a tag".into(),
|
||||
RoomField::Alias(_) => {
|
||||
"Cannot show a single alias; use `:room aliases show` instead.".into()
|
||||
},
|
||||
};
|
||||
|
||||
let msg = InfoMessage::Pager(msg);
|
||||
let act = Action::ShowInfoMessage(msg);
|
||||
|
||||
Ok(vec![(act, ctx)])
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_title(&self, store: &mut ProgramStore) -> Spans {
|
||||
pub fn get_title(&self, store: &mut ProgramStore) -> Line<'_> {
|
||||
let title = store.application.get_room_title(self.id());
|
||||
let style = Style::default().add_modifier(StyleModifier::BOLD);
|
||||
let mut spans = vec![Span::styled(title, style)];
|
||||
let mut spans = vec![];
|
||||
|
||||
if let RoomState::Chat(chat) = self {
|
||||
if chat.thread().is_some() {
|
||||
spans.push("Thread in ".into());
|
||||
}
|
||||
}
|
||||
|
||||
spans.push(Span::styled(title, style));
|
||||
|
||||
match self.room().topic() {
|
||||
Some(desc) if !desc.is_empty() => {
|
||||
@@ -131,7 +680,7 @@ impl RoomState {
|
||||
_ => {},
|
||||
}
|
||||
|
||||
Spans(spans)
|
||||
Line::from(spans)
|
||||
}
|
||||
|
||||
pub fn focus_toggle(&mut self) {
|
||||
@@ -209,6 +758,14 @@ impl TerminalCursor for RoomState {
|
||||
|
||||
impl WindowOps<IambInfo> for RoomState {
|
||||
fn draw(&mut self, area: Rect, buf: &mut Buffer, focused: bool, store: &mut ProgramStore) {
|
||||
if self.room().state() == MatrixRoomState::Invited {
|
||||
self.refresh_room(store);
|
||||
}
|
||||
|
||||
if self.room().state() == MatrixRoomState::Invited {
|
||||
self.draw_invite(self.room().clone(), area, buf, store);
|
||||
}
|
||||
|
||||
match self {
|
||||
RoomState::Chat(chat) => chat.draw(area, buf, focused, store),
|
||||
RoomState::Space(space) => {
|
||||
@@ -219,15 +776,35 @@ impl WindowOps<IambInfo> for RoomState {
|
||||
|
||||
fn dup(&self, store: &mut ProgramStore) -> Self {
|
||||
match self {
|
||||
RoomState::Chat(chat) => RoomState::Chat(chat.dup(store)),
|
||||
RoomState::Space(space) => RoomState::Space(space.dup(store)),
|
||||
RoomState::Chat(chat) => RoomState::Chat(Box::new(chat.dup(store))),
|
||||
RoomState::Space(space) => RoomState::Space(Box::new(space.dup(store))),
|
||||
}
|
||||
}
|
||||
|
||||
fn close(&mut self, _: CloseFlags, _: &mut ProgramStore) -> bool {
|
||||
// XXX: what's the right closing behaviour for a room?
|
||||
// Should write send a message?
|
||||
true
|
||||
fn close(&mut self, flags: CloseFlags, store: &mut ProgramStore) -> bool {
|
||||
match self {
|
||||
RoomState::Chat(chat) => chat.close(flags, store),
|
||||
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> {
|
||||
@@ -244,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,69 @@
|
||||
//! Window for Matrix spaces
|
||||
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::{
|
||||
room::Room as MatrixRoom,
|
||||
ruma::{OwnedRoomId, RoomId},
|
||||
};
|
||||
|
||||
use modalkit::tui::{buffer::Buffer, layout::Rect, widgets::StatefulWidget};
|
||||
|
||||
use modalkit::{
|
||||
widgets::list::{List, ListState},
|
||||
widgets::{TermOffset, TerminalCursor, WindowOps},
|
||||
use modalkit::prelude::{EditInfo, InfoMessage};
|
||||
use ratatui::{
|
||||
buffer::Buffer,
|
||||
layout::Rect,
|
||||
style::{Color, Style},
|
||||
text::{Line, Span, Text},
|
||||
widgets::StatefulWidget,
|
||||
};
|
||||
|
||||
use crate::base::{IambBufferId, IambInfo, ProgramStore, RoomFocus};
|
||||
use modalkit_ratatui::{
|
||||
list::{List, ListState},
|
||||
TermOffset,
|
||||
TerminalCursor,
|
||||
WindowOps,
|
||||
};
|
||||
|
||||
use crate::windows::RoomItem;
|
||||
use crate::base::{
|
||||
IambBufferId,
|
||||
IambError,
|
||||
IambInfo,
|
||||
IambResult,
|
||||
ProgramContext,
|
||||
ProgramStore,
|
||||
RoomFocus,
|
||||
SpaceAction,
|
||||
};
|
||||
|
||||
use crate::windows::{room_fields_cmp, RoomItem, RoomLikeItem};
|
||||
|
||||
const SPACE_HIERARCHY_DEBOUNCE: Duration = Duration::from_secs(5);
|
||||
|
||||
/// State needed for rendering [Space].
|
||||
pub struct SpaceState {
|
||||
room_id: OwnedRoomId,
|
||||
room: MatrixRoom,
|
||||
list: ListState<RoomItem, IambInfo>,
|
||||
last_fetch: Option<Instant>,
|
||||
}
|
||||
|
||||
impl SpaceState {
|
||||
pub fn new(room: MatrixRoom) -> Self {
|
||||
let room_id = room.room_id().to_owned();
|
||||
let content = IambBufferId::Room(room_id.clone(), RoomFocus::Scrollback);
|
||||
let content = IambBufferId::Room(room_id.clone(), None, RoomFocus::Scrollback);
|
||||
let list = ListState::new(content, vec![]);
|
||||
let last_fetch = None;
|
||||
|
||||
SpaceState { room_id, room, list }
|
||||
SpaceState { room_id, room, list, last_fetch }
|
||||
}
|
||||
|
||||
pub fn refresh_room(&mut self, store: &mut ProgramStore) {
|
||||
if let Some(room) = store.application.worker.client.get_room(self.id()) {
|
||||
self.room = room;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn room(&self) -> &MatrixRoom {
|
||||
@@ -44,6 +79,80 @@ impl SpaceState {
|
||||
room_id: self.room_id.clone(),
|
||||
room: self.room.clone(),
|
||||
list: self.list.dup(store),
|
||||
last_fetch: self.last_fetch,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn space_command(
|
||||
&mut self,
|
||||
act: SpaceAction,
|
||||
_: ProgramContext,
|
||||
store: &mut ProgramStore,
|
||||
) -> IambResult<EditInfo> {
|
||||
match act {
|
||||
SpaceAction::SetChild(child_id, order, suggested) => {
|
||||
if !self
|
||||
.room
|
||||
.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())
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -68,6 +177,7 @@ impl DerefMut for SpaceState {
|
||||
}
|
||||
}
|
||||
|
||||
/// [StatefulWidget] for Matrix spaces.
|
||||
pub struct Space<'a> {
|
||||
focused: bool,
|
||||
store: &'a mut ProgramStore,
|
||||
@@ -84,28 +194,59 @@ impl<'a> Space<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> StatefulWidget for Space<'a> {
|
||||
impl StatefulWidget for Space<'_> {
|
||||
type State = SpaceState;
|
||||
|
||||
fn render(self, area: Rect, buffer: &mut Buffer, state: &mut Self::State) {
|
||||
let members = self.store.application.worker.space_members(state.room_id.clone()).unwrap();
|
||||
let items = members
|
||||
.into_iter()
|
||||
.filter_map(|id| {
|
||||
let (room, name) = self.store.application.worker.get_room(id.clone()).ok()?;
|
||||
let mut empty_message = None;
|
||||
let need_fetch = match state.last_fetch {
|
||||
Some(i) => i.elapsed() >= SPACE_HIERARCHY_DEBOUNCE,
|
||||
None => true,
|
||||
};
|
||||
|
||||
if id != state.room_id {
|
||||
Some(RoomItem::new(room, name, self.store))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if need_fetch {
|
||||
let res = self.store.application.worker.space_members(state.room_id.clone());
|
||||
|
||||
state.list.set(items);
|
||||
match res {
|
||||
Ok(members) => {
|
||||
let mut items = members
|
||||
.into_iter()
|
||||
.filter_map(|id| {
|
||||
let (room, _, tags) =
|
||||
self.store.application.worker.get_room(id.clone()).ok()?;
|
||||
let room_info = std::sync::Arc::new((room, tags));
|
||||
|
||||
List::new(self.store)
|
||||
.focus(self.focused)
|
||||
.render(area, buffer, &mut state.list)
|
||||
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));
|
||||
|
||||
state.list.set(items);
|
||||
state.last_fetch = Some(Instant::now());
|
||||
},
|
||||
Err(e) => {
|
||||
let lines = vec![
|
||||
Line::from("Unable to fetch space room hierarchy:"),
|
||||
Span::styled(e.to_string(), Style::default().fg(Color::Red)).into(),
|
||||
];
|
||||
|
||||
empty_message = Text::from(lines).into();
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
let mut list = List::new(self.store).focus(self.focused);
|
||||
|
||||
if let Some(text) = empty_message {
|
||||
list = list.empty_message(text);
|
||||
}
|
||||
|
||||
list.render(area, buffer, &mut state.list)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
|
||||
- `:dms` will open a list of direct messages
|
||||
- `:rooms` will open a list of joined rooms
|
||||
- `:chats` will open a list containing both direct messages and rooms
|
||||
- `:members` will open a list of members for the currently focused room or space
|
||||
- `:spaces` will open a list of joined spaces
|
||||
- `:join` can be used to switch to join a new room or start a direct message
|
||||
@@ -36,10 +37,10 @@ The different subcommands are:
|
||||
|
||||
## Additional Configuration
|
||||
|
||||
You can customize iamb in your `$CONFIG_DIR/iamb/config.json` file, where
|
||||
`$CONFIG_DIR` is your system's per-user configuration directory.
|
||||
You can customize iamb in your `$CONFIG_DIR/iamb/config.toml` file, where
|
||||
`$CONFIG_DIR` is your system's per-user configuration directory. For example,
|
||||
this is typically `~/.config/iamb/config.toml` on systems that use the XDG
|
||||
Base Directory Specification.
|
||||
|
||||
You can edit the following values in the file:
|
||||
|
||||
- `"default_profile"`, a profile name to use when starting iamb if one wasn't specified
|
||||
- `"cache"`, a directory for cached iamb
|
||||
See the manual pages or <https://iamb.chat> for more details on how to
|
||||
further configure or use iamb.
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
//! Welcome Window
|
||||
use std::ops::{Deref, DerefMut};
|
||||
|
||||
use modalkit::tui::{buffer::Buffer, layout::Rect};
|
||||
use ratatui::{buffer::Buffer, layout::Rect};
|
||||
|
||||
use modalkit::{
|
||||
widgets::textbox::TextBoxState,
|
||||
widgets::WindowOps,
|
||||
widgets::{TermOffset, TerminalCursor},
|
||||
};
|
||||
use modalkit_ratatui::{textbox::TextBoxState, TermOffset, TerminalCursor, WindowOps};
|
||||
|
||||
use modalkit::editing::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");
|
||||
|
||||
@@ -63,6 +61,19 @@ impl WindowOps<IambInfo> for WelcomeState {
|
||||
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> {
|
||||
self.tbox.get_cursor_word(style)
|
||||
}
|
||||
|
||||
1289
src/worker.rs
1289
src/worker.rs
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user