Compare commits
140 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
|
||||||
58
.github/workflows/ci.yml
vendored
58
.github/workflows/ci.yml
vendored
@@ -9,52 +9,48 @@ on:
|
|||||||
name: CI
|
name: CI
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
clippy_check:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v1
|
|
||||||
with:
|
|
||||||
submodules: true
|
|
||||||
- name: Install Rust
|
|
||||||
uses: actions-rs/toolchain@v1
|
|
||||||
with:
|
|
||||||
toolchain: stable
|
|
||||||
components: clippy
|
|
||||||
override: true
|
|
||||||
- name: Check Clippy
|
|
||||||
uses: actions-rs/clippy-check@v1
|
|
||||||
with:
|
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
toolchain: stable
|
|
||||||
args:
|
|
||||||
test:
|
test:
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
platform: [ubuntu-latest, windows-latest, macos-latest]
|
platform: [ubuntu-latest, windows-latest, macos-latest]
|
||||||
runs-on: ${{ matrix.platform }}
|
runs-on: ${{ matrix.platform }}
|
||||||
|
env:
|
||||||
|
SCCACHE_GHA_ENABLED: "true"
|
||||||
|
RUSTC_WRAPPER: "sccache"
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v3
|
||||||
with:
|
with:
|
||||||
submodules: true
|
submodules: true
|
||||||
- name: Install Rust
|
- name: Install Rust (1.70 w/ clippy)
|
||||||
uses: actions-rs/toolchain@v1
|
uses: dtolnay/rust-toolchain@1.70
|
||||||
with:
|
with:
|
||||||
toolchain: nightly
|
components: clippy
|
||||||
override: true
|
- name: Install Rust (nightly w/ rustfmt)
|
||||||
components: rustfmt, clippy
|
run: rustup toolchain install nightly --component rustfmt
|
||||||
- name: Cache cargo registry
|
- name: Cache cargo registry
|
||||||
uses: actions/cache@v1
|
uses: actions/cache@v3
|
||||||
with:
|
with:
|
||||||
path: ~/.cargo/registry
|
path: ~/.cargo/registry
|
||||||
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
|
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
- name: Run sccache-cache
|
||||||
|
uses: mozilla-actions/sccache-action@v0.0.3
|
||||||
- name: Check formatting
|
- name: Check formatting
|
||||||
uses: actions-rs/cargo@v1
|
run: cargo +nightly fmt --all -- --check
|
||||||
|
- name: Check Clippy
|
||||||
|
if: matrix.platform == 'ubuntu-latest'
|
||||||
|
uses: giraffate/clippy-action@v1
|
||||||
with:
|
with:
|
||||||
command: fmt
|
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
args: --all -- --check
|
reporter: 'github-check'
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
uses: actions-rs/cargo@v1
|
run: cargo test --locked
|
||||||
|
- name: Build artifacts
|
||||||
|
run: cargo build --release --locked
|
||||||
|
- name: Upload artifacts
|
||||||
|
uses: actions/upload-artifact@master
|
||||||
with:
|
with:
|
||||||
command: test
|
name: iamb-${{ matrix.platform }}
|
||||||
|
path: |
|
||||||
|
./target/release/iamb
|
||||||
|
./target/release/iamb.exe
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,2 +1,4 @@
|
|||||||
/target
|
/target
|
||||||
|
/result
|
||||||
/TODO
|
/TODO
|
||||||
|
.direnv
|
||||||
|
|||||||
4392
Cargo.lock
generated
4392
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
70
Cargo.toml
70
Cargo.toml
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "iamb"
|
name = "iamb"
|
||||||
version = "0.0.2"
|
version = "0.0.9"
|
||||||
edition = "2018"
|
edition = "2018"
|
||||||
authors = ["Ulyssa <git@ulyssa.dev>"]
|
authors = ["Ulyssa <git@ulyssa.dev>"]
|
||||||
repository = "https://github.com/ulyssa/iamb"
|
repository = "https://github.com/ulyssa/iamb"
|
||||||
@@ -10,29 +10,83 @@ description = "A Matrix chat client that uses Vim keybindings"
|
|||||||
license = "Apache-2.0"
|
license = "Apache-2.0"
|
||||||
exclude = [".github", "CONTRIBUTING.md"]
|
exclude = [".github", "CONTRIBUTING.md"]
|
||||||
keywords = ["matrix", "chat", "tui", "vim"]
|
keywords = ["matrix", "chat", "tui", "vim"]
|
||||||
rust-version = "1.66"
|
categories = ["command-line-utilities"]
|
||||||
|
rust-version = "1.70"
|
||||||
|
build = "build.rs"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = ["bundled"]
|
||||||
|
bundled = ["matrix-sdk/bundled-sqlite", "rustls-tls"]
|
||||||
|
native-tls = ["matrix-sdk/native-tls"]
|
||||||
|
rustls-tls = ["matrix-sdk/rustls-tls"]
|
||||||
|
|
||||||
|
[build-dependencies.vergen]
|
||||||
|
version = "8"
|
||||||
|
default-features = false
|
||||||
|
features = ["build", "git", "gitcl",]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
arboard = "3.3.0"
|
||||||
|
bitflags = "^2.3"
|
||||||
chrono = "0.4"
|
chrono = "0.4"
|
||||||
clap = {version = "4.0", features = ["derive"]}
|
clap = {version = "~4.3", features = ["derive"]}
|
||||||
|
comrak = {version = "0.18.0", features = ["shortcodes"]}
|
||||||
|
css-color-parser = "0.1.2"
|
||||||
dirs = "4.0.0"
|
dirs = "4.0.0"
|
||||||
futures = "0.3.21"
|
emojis = "~0.5.2"
|
||||||
|
futures = "0.3"
|
||||||
gethostname = "0.4.1"
|
gethostname = "0.4.1"
|
||||||
matrix-sdk = {version = "0.6", default-features = false, features = ["e2e-encryption", "sled", "rustls-tls"]}
|
html5ever = "0.26.0"
|
||||||
modalkit = "0.0.9"
|
image = "0.24.5"
|
||||||
|
libc = "0.2"
|
||||||
|
markup5ever_rcdom = "0.2.0"
|
||||||
|
mime = "^0.3.16"
|
||||||
|
mime_guess = "^2.0.4"
|
||||||
|
notify-rust = { version = "4.10.0", default-features = false, features = ["zbus", "serde"] }
|
||||||
|
open = "3.2.0"
|
||||||
|
rand = "0.8.5"
|
||||||
|
ratatui = "0.23"
|
||||||
|
ratatui-image = { version = "0.8.1", features = ["serde"] }
|
||||||
regex = "^1.5"
|
regex = "^1.5"
|
||||||
rpassword = "^7.2"
|
rpassword = "^7.2"
|
||||||
serde = "^1.0"
|
serde = "^1.0"
|
||||||
serde_json = "^1.0"
|
serde_json = "^1.0"
|
||||||
sled = "0.34"
|
sled = "0.34.7"
|
||||||
|
temp-dir = "0.1.12"
|
||||||
thiserror = "^1.0.37"
|
thiserror = "^1.0.37"
|
||||||
tokio = {version = "1.17.0", features = ["full"]}
|
toml = "^0.8.12"
|
||||||
tracing = "~0.1.36"
|
tracing = "~0.1.36"
|
||||||
tracing-appender = "~0.2.2"
|
tracing-appender = "~0.2.2"
|
||||||
tracing-subscriber = "0.3.16"
|
tracing-subscriber = "0.3.16"
|
||||||
unicode-segmentation = "^1.7"
|
unicode-segmentation = "^1.7"
|
||||||
unicode-width = "0.1.10"
|
unicode-width = "0.1.10"
|
||||||
url = {version = "^2.2.2", features = ["serde"]}
|
url = {version = "^2.2.2", features = ["serde"]}
|
||||||
|
edit = "0.1.4"
|
||||||
|
|
||||||
|
[dependencies.modalkit]
|
||||||
|
version = "0.0.18"
|
||||||
|
#git = "https://github.com/ulyssa/modalkit"
|
||||||
|
#rev = "cb8c8aeb9a499b9b16615ce144f9014d78036e01"
|
||||||
|
|
||||||
|
[dependencies.modalkit-ratatui]
|
||||||
|
version = "0.0.18"
|
||||||
|
#git = "https://github.com/ulyssa/modalkit"
|
||||||
|
#rev = "cb8c8aeb9a499b9b16615ce144f9014d78036e01"
|
||||||
|
|
||||||
|
[dependencies.matrix-sdk]
|
||||||
|
version = "0.7.1"
|
||||||
|
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]
|
[dev-dependencies]
|
||||||
lazy_static = "1.4.0"
|
lazy_static = "1.4.0"
|
||||||
|
pretty_assertions = "1.4.0"
|
||||||
|
|
||||||
|
[profile.release-lto]
|
||||||
|
inherits = "release"
|
||||||
|
incremental = false
|
||||||
|
lto = true
|
||||||
|
|||||||
42
PACKAGING.md
Normal file
42
PACKAGING.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# 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:
|
||||||
|
|
||||||
|
| 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 |
|
||||||
|
|
||||||
|
[ring-lto]: https://github.com/briansmith/ring/issues/1444
|
||||||
|
[rustls]: https://crates.io/crates/rustls
|
||||||
149
README.md
149
README.md
@@ -1,11 +1,31 @@
|
|||||||
# 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
|
## About
|
||||||
|
|
||||||
`iamb` is a Matrix client for the terminal that uses Vim keybindings.
|
`iamb` is a Matrix client for the terminal that uses Vim keybindings. It includes support for:
|
||||||
|
|
||||||
This project is a work-in-progress, and there's still a lot to be implemented,
|
- Threads, spaces, E2EE, and read receipts
|
||||||
but much of the basic client functionality is already present.
|
- Image previews in terminals that support it (sixels, Kitty, and iTerm2), or using pixelated blocks for those that don't
|
||||||
|
- Notifications via terminal bell or desktop environment
|
||||||
|
- Creating, joining, and leaving rooms
|
||||||
|
- Sending and accepting room invitations
|
||||||
|
- Editing, redacting, and reacting to messages
|
||||||
|
- Custom keybindings
|
||||||
|
- Multiple profiles
|
||||||
|
|
||||||
|
_You may want to [see this page as it was when the latest version was published][crates-io-iamb]._
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
@@ -14,66 +34,70 @@ website, [iamb.chat].
|
|||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
Install Rust and Cargo, and then run:
|
Install Rust (1.70.0 or above) and Cargo, and then run:
|
||||||
|
|
||||||
```
|
```
|
||||||
cargo install iamb
|
cargo install --locked iamb
|
||||||
|
```
|
||||||
|
|
||||||
|
See [Configuration](#configuration) for getting a profile set up.
|
||||||
|
|
||||||
|
### NetBSD
|
||||||
|
|
||||||
|
On NetBSD a package is available from the official repositories. To install it simply run:
|
||||||
|
|
||||||
|
```
|
||||||
|
pkgin install iamb
|
||||||
|
```
|
||||||
|
|
||||||
|
### Arch Linux
|
||||||
|
|
||||||
|
On Arch Linux a [package](https://aur.archlinux.org/packages/iamb-git) is available in the
|
||||||
|
Arch User Repositories (AUR). To install it simply run with your favorite AUR helper:
|
||||||
|
|
||||||
|
```
|
||||||
|
paru iamb-git
|
||||||
|
```
|
||||||
|
### openSUSE Tumbleweed
|
||||||
|
|
||||||
|
On openSUSE Tumbleweed a [package](https://build.opensuse.org/package/show/home%3Asmolsheep/iamb) is available from openSUSE Build Service (OBS). To install just use OBS Package Installer:
|
||||||
|
|
||||||
|
```
|
||||||
|
opi iamb
|
||||||
|
```
|
||||||
|
|
||||||
|
### Nix / NixOS (flake)
|
||||||
|
|
||||||
|
```
|
||||||
|
nix profile install "github:ulyssa/iamb"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Snap
|
||||||
|
|
||||||
|
A snap for Linux distributions which [support](https://snapcraft.io/docs/installing-snapd) the packaging system.
|
||||||
|
|
||||||
|
```
|
||||||
|
snap install iamb
|
||||||
```
|
```
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
You can create a basic configuration in `$CONFIG_DIR/iamb/config.json` that looks like:
|
You can create a basic configuration in `$CONFIG_DIR/iamb/config.toml` that looks like:
|
||||||
|
|
||||||
```json
|
```toml
|
||||||
{
|
[profiles."example.com"]
|
||||||
"profiles": {
|
user_id = "@user:example.com"
|
||||||
"example.com": {
|
|
||||||
"url": "https://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
|
```toml
|
||||||
Matrix website's [features comparison table][client-comparison-matrix], showing
|
[profiles."example.com"]
|
||||||
two other TUI clients and Element Web:
|
url = "https://example.com"
|
||||||
|
user_id = "@user:example.com"
|
||||||
| | 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
|
## License
|
||||||
|
|
||||||
@@ -81,21 +105,8 @@ iamb is released under the [Apache License, Version 2.0].
|
|||||||
|
|
||||||
[Apache License, Version 2.0]: https://github.com/ulyssa/iamb/blob/master/LICENSE
|
[Apache License, Version 2.0]: https://github.com/ulyssa/iamb/blob/master/LICENSE
|
||||||
[client-comparison-matrix]: https://matrix.org/clients-matrix/
|
[client-comparison-matrix]: https://matrix.org/clients-matrix/
|
||||||
|
[crates-io-iamb]: https://crates.io/crates/iamb
|
||||||
[iamb.chat]: https://iamb.chat
|
[iamb.chat]: https://iamb.chat
|
||||||
[gomuks]: https://github.com/tulir/gomuks
|
[gomuks]: https://github.com/tulir/gomuks
|
||||||
[weechat-matrix]: https://github.com/poljar/weechat-matrix
|
[weechat-matrix]: https://github.com/poljar/weechat-matrix
|
||||||
[#2]: https://github.com/ulyssa/iamb/issues/2
|
[well_known_entry]: https://spec.matrix.org/latest/client-server-api/#getwell-knownmatrixclient
|
||||||
[#3]: https://github.com/ulyssa/iamb/issues/3
|
|
||||||
[#4]: https://github.com/ulyssa/iamb/issues/4
|
|
||||||
[#5]: https://github.com/ulyssa/iamb/issues/5
|
|
||||||
[#6]: https://github.com/ulyssa/iamb/issues/6
|
|
||||||
[#7]: https://github.com/ulyssa/iamb/issues/7
|
|
||||||
[#8]: https://github.com/ulyssa/iamb/issues/8
|
|
||||||
[#9]: https://github.com/ulyssa/iamb/issues/9
|
|
||||||
[#10]: https://github.com/ulyssa/iamb/issues/10
|
|
||||||
[#11]: https://github.com/ulyssa/iamb/issues/11
|
|
||||||
[#12]: https://github.com/ulyssa/iamb/issues/12
|
|
||||||
[#13]: https://github.com/ulyssa/iamb/issues/13
|
|
||||||
[#14]: https://github.com/ulyssa/iamb/issues/14
|
|
||||||
[#15]: https://github.com/ulyssa/iamb/issues/15
|
|
||||||
[#16]: https://github.com/ulyssa/iamb/issues/16
|
|
||||||
|
|||||||
9
build.rs
Normal file
9
build.rs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
use std::error::Error;
|
||||||
|
|
||||||
|
use vergen::EmitBuilder;
|
||||||
|
|
||||||
|
fn main() -> Result<(), Box<dyn Error>> {
|
||||||
|
EmitBuilder::builder().git_sha(true).emit()?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
57
config.example.toml
Normal file
57
config.example.toml
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
default_profile = "default"
|
||||||
|
|
||||||
|
[profiles.default]
|
||||||
|
user_id = "@user:matrix.org"
|
||||||
|
url = "https://matrix.org"
|
||||||
|
|
||||||
|
[settings]
|
||||||
|
default_room = "#iamb-users:0x.badd.cafe"
|
||||||
|
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 |
206
docs/iamb.1
Normal file
206
docs/iamb.1
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
.\" 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"
|
||||||
|
Log out of
|
||||||
|
.Nm .
|
||||||
|
.It Sy ":rooms"
|
||||||
|
View a list of joined rooms.
|
||||||
|
.It Sy ":spaces"
|
||||||
|
View a list of joined spaces.
|
||||||
|
.It Sy ":welcome"
|
||||||
|
View the startup Welcome window.
|
||||||
|
.El
|
||||||
|
|
||||||
|
.Sh "E2EE COMMANDS"
|
||||||
|
.Bl -tag -width Ds
|
||||||
|
.It Sy ":keys export [path] [passphrase]"
|
||||||
|
Export and encrypt keys to
|
||||||
|
.Pa path .
|
||||||
|
.It Sy ":keys import [path] [passphrase]"
|
||||||
|
Import and decrypt keys from
|
||||||
|
.Pa path .
|
||||||
|
.It Sy ":verify"
|
||||||
|
View a list of ongoing E2EE verifications.
|
||||||
|
.El
|
||||||
|
|
||||||
|
.Sh "MESSAGE COMMANDS"
|
||||||
|
.Bl -tag -width Ds
|
||||||
|
.It Sy ":download"
|
||||||
|
Download an attachment from the selected message.
|
||||||
|
.It Sy ":edit"
|
||||||
|
Edit the selected message.
|
||||||
|
.It Sy ":editor"
|
||||||
|
Open an external
|
||||||
|
.Ev $EDITOR
|
||||||
|
to compose a message.
|
||||||
|
.It Sy ":open"
|
||||||
|
Download and then open an attachment, or open a link in a message.
|
||||||
|
.It Sy ":react [shortcode]"
|
||||||
|
React to the selected message with an Emoji.
|
||||||
|
.It Sy ":redact [reason]"
|
||||||
|
Redact the selected message.
|
||||||
|
.It Sy ":reply"
|
||||||
|
Reply to the selected message.
|
||||||
|
.It Sy ":unreact [shortcode]"
|
||||||
|
Remove your reaction from the selected message.
|
||||||
|
When no arguments are given, remove all of your reactions from the message.
|
||||||
|
.It Sy ":upload"
|
||||||
|
Upload an attachment and send it to the currently selected room.
|
||||||
|
.El
|
||||||
|
|
||||||
|
.Sh "ROOM COMMANDS"
|
||||||
|
.Bl -tag -width Ds
|
||||||
|
.It Sy ":create"
|
||||||
|
Create a new room.
|
||||||
|
.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.
|
||||||
|
.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 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.
|
||||||
|
.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 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
|
||||||
555
docs/iamb.5
Normal file
555
docs/iamb.5
Normal file
@@ -0,0 +1,555 @@
|
|||||||
|
.\" 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 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 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 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.
|
||||||
|
.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.
|
||||||
|
|
||||||
|
.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
|
||||||
|
.El
|
||||||
|
|
||||||
|
.Ss Example 1: Group room members by ther 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
|
||||||
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 |
130
flake.lock
generated
Normal file
130
flake.lock
generated
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"flake-utils": {
|
||||||
|
"inputs": {
|
||||||
|
"systems": "systems"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1709126324,
|
||||||
|
"narHash": "sha256-q6EQdSeUZOG26WelxqkmR7kArjgWCdw5sfJVHPH/7j8=",
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"rev": "d465f4819400de7c8d874d50b982301f28a84605",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"flake-utils_2": {
|
||||||
|
"inputs": {
|
||||||
|
"systems": "systems_2"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1705309234,
|
||||||
|
"narHash": "sha256-uNRRNRKmJyCRC/8y1RqBkqWBLM034y4qN7EprSdmgyA=",
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"rev": "1ef2e671c3b0c19053962c07dbda38332dcebf26",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1709703039,
|
||||||
|
"narHash": "sha256-6hqgQ8OK6gsMu1VtcGKBxKQInRLHtzulDo9Z5jxHEFY=",
|
||||||
|
"owner": "nixos",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "9df3e30ce24fd28c7b3e2de0d986769db5d6225d",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nixos",
|
||||||
|
"ref": "nixos-unstable",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs_2": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1706487304,
|
||||||
|
"narHash": "sha256-LE8lVX28MV2jWJsidW13D2qrHU/RUUONendL2Q/WlJg=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "90f456026d284c22b3e3497be980b2e47d0b28ac",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "nixpkgs-unstable",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"flake-utils": "flake-utils",
|
||||||
|
"nixpkgs": "nixpkgs",
|
||||||
|
"rust-overlay": "rust-overlay"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"rust-overlay": {
|
||||||
|
"inputs": {
|
||||||
|
"flake-utils": "flake-utils_2",
|
||||||
|
"nixpkgs": "nixpkgs_2"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1709863839,
|
||||||
|
"narHash": "sha256-QpEL5FmZNi2By3sKZY55wGniFXc4wEn9PQczlE8TG0o=",
|
||||||
|
"owner": "oxalica",
|
||||||
|
"repo": "rust-overlay",
|
||||||
|
"rev": "e5ab9ee98f479081ad971473d2bc13c59e9fbc0a",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "oxalica",
|
||||||
|
"repo": "rust-overlay",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"systems": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1681028828,
|
||||||
|
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"systems_2": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1681028828,
|
||||||
|
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
||||||
44
flake.nix
Normal file
44
flake.nix
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
{
|
||||||
|
description = "iamb";
|
||||||
|
nixConfig.bash-prompt = "\[nix-develop\]$ ";
|
||||||
|
|
||||||
|
inputs = {
|
||||||
|
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
|
||||||
|
flake-utils.url = "github:numtide/flake-utils";
|
||||||
|
rust-overlay.url = "github:oxalica/rust-overlay";
|
||||||
|
};
|
||||||
|
|
||||||
|
outputs = { self, nixpkgs, flake-utils, rust-overlay, ... }:
|
||||||
|
flake-utils.lib.eachDefaultSystem (system:
|
||||||
|
let
|
||||||
|
# We only need the nightly overlay in the devShell because .rs files are formatted with nightly.
|
||||||
|
overlays = [ (import rust-overlay) ];
|
||||||
|
pkgs = import nixpkgs { inherit system overlays; };
|
||||||
|
rustNightly = pkgs.rust-bin.nightly."2024-03-08".default;
|
||||||
|
in
|
||||||
|
with pkgs;
|
||||||
|
{
|
||||||
|
packages.default = rustPlatform.buildRustPackage {
|
||||||
|
pname = "iamb";
|
||||||
|
version = self.shortRev or self.dirtyShortRev;
|
||||||
|
src = ./.;
|
||||||
|
cargoLock = {
|
||||||
|
lockFile = ./Cargo.lock;
|
||||||
|
};
|
||||||
|
nativeBuildInputs = [ pkg-config ];
|
||||||
|
buildInputs = [ openssl ] ++ lib.optionals stdenv.isDarwin
|
||||||
|
(with darwin.apple_sdk.frameworks; [ AppKit Security ]);
|
||||||
|
};
|
||||||
|
|
||||||
|
devShell = mkShell {
|
||||||
|
buildInputs = [
|
||||||
|
(rustNightly.override {
|
||||||
|
extensions = [ "rust-src" "rust-analyzer-preview" "rustfmt" "clippy" ];
|
||||||
|
})
|
||||||
|
pkg-config
|
||||||
|
cargo-tarpaulin
|
||||||
|
cargo-watch
|
||||||
|
];
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
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
|
||||||
1748
src/base.rs
1748
src/base.rs
File diff suppressed because it is too large
Load Diff
832
src/commands.rs
832
src/commands.rs
@@ -1,31 +1,138 @@
|
|||||||
|
//! # Default Commands
|
||||||
|
//!
|
||||||
|
//! The command-bar commands are set up here, and iamb-specific commands are defined here. See
|
||||||
|
//! [modalkit::env::vim::command] for additional Vim commands we pull in.
|
||||||
|
use std::convert::TryFrom;
|
||||||
|
|
||||||
|
use matrix_sdk::ruma::{events::tag::TagName, OwnedUserId};
|
||||||
|
|
||||||
use modalkit::{
|
use modalkit::{
|
||||||
editing::base::OpenTarget,
|
commands::{CommandError, CommandResult, CommandStep},
|
||||||
env::vim::command::{CommandContext, CommandDescription},
|
env::vim::command::{CommandContext, CommandDescription, OptionType},
|
||||||
input::commands::{CommandError, CommandResult, CommandStep},
|
prelude::OpenTarget,
|
||||||
input::InputContext,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::base::{
|
use crate::base::{
|
||||||
|
CreateRoomFlags,
|
||||||
|
CreateRoomType,
|
||||||
|
DownloadFlags,
|
||||||
|
HomeserverAction,
|
||||||
IambAction,
|
IambAction,
|
||||||
IambId,
|
IambId,
|
||||||
|
KeysAction,
|
||||||
|
MessageAction,
|
||||||
ProgramCommand,
|
ProgramCommand,
|
||||||
ProgramCommands,
|
ProgramCommands,
|
||||||
ProgramContext,
|
|
||||||
RoomAction,
|
RoomAction,
|
||||||
SetRoomField,
|
RoomField,
|
||||||
|
SendAction,
|
||||||
VerifyAction,
|
VerifyAction,
|
||||||
};
|
};
|
||||||
|
|
||||||
type ProgContext = CommandContext<ProgramContext>;
|
type ProgContext = CommandContext;
|
||||||
type ProgResult = CommandResult<ProgramCommand>;
|
type ProgResult = CommandResult<ProgramCommand>;
|
||||||
|
|
||||||
|
/// Convert strings the user types into a tag name.
|
||||||
|
fn tag_name(name: String) -> Result<TagName, CommandError> {
|
||||||
|
let tag = match name.as_str() {
|
||||||
|
"fav" | "favorite" | "favourite" | "m.favourite" => TagName::Favorite,
|
||||||
|
"low" | "lowpriority" | "low_priority" | "low-priority" | "m.lowpriority" => {
|
||||||
|
TagName::LowPriority
|
||||||
|
},
|
||||||
|
"servernotice" | "server_notice" | "server-notice" | "m.server_notice" => {
|
||||||
|
TagName::ServerNotice
|
||||||
|
},
|
||||||
|
_ => {
|
||||||
|
if let Ok(tag) = name.parse() {
|
||||||
|
TagName::User(tag)
|
||||||
|
} else {
|
||||||
|
let msg = format!("Invalid user tag name: {name}");
|
||||||
|
|
||||||
|
return Err(CommandError::Error(msg));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn iamb_invite(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
||||||
|
let args = desc.arg.strings()?;
|
||||||
|
|
||||||
|
if args.is_empty() {
|
||||||
|
return Err(CommandError::InvalidArgument);
|
||||||
|
}
|
||||||
|
|
||||||
|
let ract = match args[0].as_str() {
|
||||||
|
"accept" => {
|
||||||
|
if args.len() != 1 {
|
||||||
|
return Err(CommandError::InvalidArgument);
|
||||||
|
}
|
||||||
|
|
||||||
|
RoomAction::InviteAccept
|
||||||
|
},
|
||||||
|
"reject" => {
|
||||||
|
if args.len() != 1 {
|
||||||
|
return Err(CommandError::InvalidArgument);
|
||||||
|
}
|
||||||
|
|
||||||
|
RoomAction::InviteReject
|
||||||
|
},
|
||||||
|
"send" => {
|
||||||
|
if args.len() != 2 {
|
||||||
|
return Err(CommandError::InvalidArgument);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(user) = OwnedUserId::try_from(args[1].as_str()) {
|
||||||
|
RoomAction::InviteSend(user)
|
||||||
|
} else {
|
||||||
|
let msg = format!("Invalid user identifier: {}", args[1]);
|
||||||
|
let err = CommandError::Error(msg);
|
||||||
|
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_ => {
|
||||||
|
return Err(CommandError::InvalidArgument);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let iact = IambAction::from(ract);
|
||||||
|
let step = CommandStep::Continue(iact.into(), ctx.context.clone());
|
||||||
|
|
||||||
|
return Ok(step);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn iamb_keys(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
||||||
|
let mut args = desc.arg.strings()?;
|
||||||
|
|
||||||
|
if args.len() != 3 {
|
||||||
|
return Err(CommandError::InvalidArgument);
|
||||||
|
}
|
||||||
|
|
||||||
|
let act = args.remove(0);
|
||||||
|
let path = args.remove(0);
|
||||||
|
let passphrase = args.remove(0);
|
||||||
|
|
||||||
|
let act = match act.as_str() {
|
||||||
|
"export" => KeysAction::Export(path, passphrase),
|
||||||
|
"import" => KeysAction::Import(path, passphrase),
|
||||||
|
_ => return Err(CommandError::InvalidArgument),
|
||||||
|
};
|
||||||
|
|
||||||
|
let vact = IambAction::Keys(act);
|
||||||
|
let step = CommandStep::Continue(vact.into(), ctx.context.clone());
|
||||||
|
|
||||||
|
return Ok(step);
|
||||||
|
}
|
||||||
|
|
||||||
fn iamb_verify(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
fn iamb_verify(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
||||||
let mut args = desc.arg.strings()?;
|
let mut args = desc.arg.strings()?;
|
||||||
|
|
||||||
match args.len() {
|
match args.len() {
|
||||||
0 => {
|
0 => {
|
||||||
let open = ctx.switch(OpenTarget::Application(IambId::VerifyList));
|
let open = ctx.switch(OpenTarget::Application(IambId::VerifyList));
|
||||||
let step = CommandStep::Continue(open, ctx.context.take());
|
let step = CommandStep::Continue(open, ctx.context.clone());
|
||||||
|
|
||||||
return Ok(step);
|
return Ok(step);
|
||||||
},
|
},
|
||||||
@@ -40,7 +147,7 @@ fn iamb_verify(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
|||||||
"mismatch" => VerifyAction::Mismatch,
|
"mismatch" => VerifyAction::Mismatch,
|
||||||
"request" => {
|
"request" => {
|
||||||
let iact = IambAction::VerifyRequest(args.remove(1));
|
let iact = IambAction::VerifyRequest(args.remove(1));
|
||||||
let step = CommandStep::Continue(iact.into(), ctx.context.take());
|
let step = CommandStep::Continue(iact.into(), ctx.context.clone());
|
||||||
|
|
||||||
return Ok(step);
|
return Ok(step);
|
||||||
},
|
},
|
||||||
@@ -48,7 +155,7 @@ fn iamb_verify(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let vact = IambAction::Verify(act, args.remove(1));
|
let vact = IambAction::Verify(act, args.remove(1));
|
||||||
let step = CommandStep::Continue(vact.into(), ctx.context.take());
|
let step = CommandStep::Continue(vact.into(), ctx.context.clone());
|
||||||
|
|
||||||
return Ok(step);
|
return Ok(step);
|
||||||
},
|
},
|
||||||
@@ -64,7 +171,7 @@ fn iamb_dms(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let open = ctx.switch(OpenTarget::Application(IambId::DirectList));
|
let open = ctx.switch(OpenTarget::Application(IambId::DirectList));
|
||||||
let step = CommandStep::Continue(open, ctx.context.take());
|
let step = CommandStep::Continue(open, ctx.context.clone());
|
||||||
|
|
||||||
return Ok(step);
|
return Ok(step);
|
||||||
}
|
}
|
||||||
@@ -75,7 +182,123 @@ fn iamb_members(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let open = IambAction::Room(RoomAction::Members(ctx.clone().into()));
|
let open = IambAction::Room(RoomAction::Members(ctx.clone().into()));
|
||||||
let step = CommandStep::Continue(open.into(), ctx.context.take());
|
let step = CommandStep::Continue(open.into(), ctx.context.clone());
|
||||||
|
|
||||||
|
return Ok(step);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn iamb_leave(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
||||||
|
if !desc.arg.text.is_empty() {
|
||||||
|
return Result::Err(CommandError::InvalidArgument);
|
||||||
|
}
|
||||||
|
|
||||||
|
let leave = IambAction::Room(RoomAction::Leave(desc.bang));
|
||||||
|
let step = CommandStep::Continue(leave.into(), ctx.context.clone());
|
||||||
|
|
||||||
|
return Ok(step);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn iamb_cancel(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
||||||
|
if !desc.arg.text.is_empty() {
|
||||||
|
return Result::Err(CommandError::InvalidArgument);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mact = IambAction::from(MessageAction::Cancel(desc.bang));
|
||||||
|
let step = CommandStep::Continue(mact.into(), ctx.context.clone());
|
||||||
|
|
||||||
|
return Ok(step);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn iamb_edit(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
||||||
|
if !desc.arg.text.is_empty() {
|
||||||
|
return Result::Err(CommandError::InvalidArgument);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mact = IambAction::from(MessageAction::Edit);
|
||||||
|
let step = CommandStep::Continue(mact.into(), ctx.context.clone());
|
||||||
|
|
||||||
|
return Ok(step);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn iamb_react(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
||||||
|
let args = desc.arg.strings()?;
|
||||||
|
|
||||||
|
if args.len() != 1 {
|
||||||
|
return Result::Err(CommandError::InvalidArgument);
|
||||||
|
}
|
||||||
|
|
||||||
|
let k = args[0].as_str();
|
||||||
|
|
||||||
|
if let Some(emoji) = emojis::get(k).or_else(|| emojis::get_by_shortcode(k)) {
|
||||||
|
let mact = IambAction::from(MessageAction::React(emoji.to_string()));
|
||||||
|
let step = CommandStep::Continue(mact.into(), ctx.context.clone());
|
||||||
|
|
||||||
|
return Ok(step);
|
||||||
|
} else {
|
||||||
|
let msg = format!("Invalid Emoji or shortcode: {k}");
|
||||||
|
|
||||||
|
return Result::Err(CommandError::Error(msg));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn iamb_unreact(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
||||||
|
let mut args = desc.arg.strings()?;
|
||||||
|
|
||||||
|
if args.len() > 1 {
|
||||||
|
return Result::Err(CommandError::InvalidArgument);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mact = if let Some(k) = args.pop() {
|
||||||
|
let k = k.as_str();
|
||||||
|
|
||||||
|
if let Some(emoji) = emojis::get(k).or_else(|| emojis::get_by_shortcode(k)) {
|
||||||
|
IambAction::from(MessageAction::Unreact(Some(emoji.to_string())))
|
||||||
|
} else {
|
||||||
|
let msg = format!("Invalid Emoji or shortcode: {k}");
|
||||||
|
|
||||||
|
return Result::Err(CommandError::Error(msg));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
IambAction::from(MessageAction::Unreact(None))
|
||||||
|
};
|
||||||
|
|
||||||
|
let step = CommandStep::Continue(mact.into(), ctx.context.clone());
|
||||||
|
|
||||||
|
return Ok(step);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn iamb_redact(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
||||||
|
let args = desc.arg.strings()?;
|
||||||
|
|
||||||
|
if args.len() > 1 {
|
||||||
|
return Result::Err(CommandError::InvalidArgument);
|
||||||
|
}
|
||||||
|
|
||||||
|
let reason = args.into_iter().next();
|
||||||
|
let ract = IambAction::from(MessageAction::Redact(reason, desc.bang));
|
||||||
|
let step = CommandStep::Continue(ract.into(), ctx.context.clone());
|
||||||
|
|
||||||
|
return Ok(step);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn iamb_reply(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
||||||
|
if !desc.arg.text.is_empty() {
|
||||||
|
return Result::Err(CommandError::InvalidArgument);
|
||||||
|
}
|
||||||
|
|
||||||
|
let ract = IambAction::from(MessageAction::Reply);
|
||||||
|
let step = CommandStep::Continue(ract.into(), ctx.context.clone());
|
||||||
|
|
||||||
|
return Ok(step);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn iamb_editor(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
||||||
|
if !desc.arg.text.is_empty() {
|
||||||
|
return Result::Err(CommandError::InvalidArgument);
|
||||||
|
}
|
||||||
|
|
||||||
|
let sact = IambAction::from(SendAction::SubmitFromEditor);
|
||||||
|
let step = CommandStep::Continue(sact.into(), ctx.context.clone());
|
||||||
|
|
||||||
return Ok(step);
|
return Ok(step);
|
||||||
}
|
}
|
||||||
@@ -86,7 +309,18 @@ fn iamb_rooms(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let open = ctx.switch(OpenTarget::Application(IambId::RoomList));
|
let open = ctx.switch(OpenTarget::Application(IambId::RoomList));
|
||||||
let step = CommandStep::Continue(open, ctx.context.take());
|
let step = CommandStep::Continue(open, ctx.context.clone());
|
||||||
|
|
||||||
|
return Ok(step);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn iamb_chats(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
||||||
|
if !desc.arg.text.is_empty() {
|
||||||
|
return Result::Err(CommandError::InvalidArgument);
|
||||||
|
}
|
||||||
|
|
||||||
|
let open = ctx.switch(OpenTarget::Application(IambId::ChatList));
|
||||||
|
let step = CommandStep::Continue(open, ctx.context.clone());
|
||||||
|
|
||||||
return Ok(step);
|
return Ok(step);
|
||||||
}
|
}
|
||||||
@@ -97,7 +331,7 @@ fn iamb_spaces(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let open = ctx.switch(OpenTarget::Application(IambId::SpaceList));
|
let open = ctx.switch(OpenTarget::Application(IambId::SpaceList));
|
||||||
let step = CommandStep::Continue(open, ctx.context.take());
|
let step = CommandStep::Continue(open, ctx.context.clone());
|
||||||
|
|
||||||
return Ok(step);
|
return Ok(step);
|
||||||
}
|
}
|
||||||
@@ -108,7 +342,7 @@ fn iamb_welcome(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let open = ctx.switch(OpenTarget::Application(IambId::Welcome));
|
let open = ctx.switch(OpenTarget::Application(IambId::Welcome));
|
||||||
let step = CommandStep::Continue(open, ctx.context.take());
|
let step = CommandStep::Continue(open, ctx.context.clone());
|
||||||
|
|
||||||
return Ok(step);
|
return Ok(step);
|
||||||
}
|
}
|
||||||
@@ -121,45 +355,268 @@ fn iamb_join(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let open = ctx.switch(args.remove(0));
|
let open = ctx.switch(args.remove(0));
|
||||||
let step = CommandStep::Continue(open, ctx.context.take());
|
let step = CommandStep::Continue(open, ctx.context.clone());
|
||||||
|
|
||||||
return Ok(step);
|
return Ok(step);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn iamb_set(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
fn iamb_create(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
||||||
|
let args = desc.arg.options()?;
|
||||||
|
let mut flags = CreateRoomFlags::NONE;
|
||||||
|
let mut alias = None;
|
||||||
|
let mut ct = CreateRoomType::Room;
|
||||||
|
|
||||||
|
for arg in args {
|
||||||
|
match arg {
|
||||||
|
OptionType::Flag(name, Some(arg)) => {
|
||||||
|
match name.as_str() {
|
||||||
|
"alias" => {
|
||||||
|
if alias.is_some() {
|
||||||
|
let msg = "Multiple ++alias arguments are not allowed";
|
||||||
|
let err = CommandError::Error(msg.into());
|
||||||
|
|
||||||
|
return Err(err);
|
||||||
|
} else {
|
||||||
|
alias = Some(arg);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_ => return Err(CommandError::InvalidArgument),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
OptionType::Flag(name, None) => {
|
||||||
|
match name.as_str() {
|
||||||
|
"public" => flags |= CreateRoomFlags::PUBLIC,
|
||||||
|
"space" => ct = CreateRoomType::Space,
|
||||||
|
"enc" | "encrypted" => flags |= CreateRoomFlags::ENCRYPTED,
|
||||||
|
_ => return Err(CommandError::InvalidArgument),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
OptionType::Positional(_) => {
|
||||||
|
let msg = ":create doesn't take any positional arguments";
|
||||||
|
let err = CommandError::Error(msg.into());
|
||||||
|
|
||||||
|
return Err(err);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let hact = HomeserverAction::CreateRoom(alias, ct, flags);
|
||||||
|
let iact = IambAction::from(hact);
|
||||||
|
let step = CommandStep::Continue(iact.into(), ctx.context.clone());
|
||||||
|
|
||||||
|
return Ok(step);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn iamb_room(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
||||||
let mut args = desc.arg.strings()?;
|
let mut args = desc.arg.strings()?;
|
||||||
|
|
||||||
if args.len() != 2 {
|
if args.len() < 2 {
|
||||||
return Result::Err(CommandError::InvalidArgument);
|
return Result::Err(CommandError::InvalidArgument);
|
||||||
}
|
}
|
||||||
|
|
||||||
let field = args.remove(0);
|
let field = args.remove(0);
|
||||||
let value = args.remove(0);
|
let action = args.remove(0);
|
||||||
|
|
||||||
let act: IambAction = match field.as_str() {
|
if args.len() > 1 {
|
||||||
"room.name" => RoomAction::Set(SetRoomField::Name(value)).into(),
|
|
||||||
"room.topic" => RoomAction::Set(SetRoomField::Topic(value)).into(),
|
|
||||||
_ => {
|
|
||||||
return Result::Err(CommandError::InvalidArgument);
|
return Result::Err(CommandError::InvalidArgument);
|
||||||
},
|
}
|
||||||
|
|
||||||
|
let act: IambAction = match (field.as_str(), action.as_str(), args.pop()) {
|
||||||
|
// :room name set <room-name>
|
||||||
|
("name", "set", Some(s)) => RoomAction::Set(RoomField::Name, s).into(),
|
||||||
|
("name", "set", None) => return Result::Err(CommandError::InvalidArgument),
|
||||||
|
|
||||||
|
// :room name unset
|
||||||
|
("name", "unset", None) => RoomAction::Unset(RoomField::Name).into(),
|
||||||
|
("name", "unset", Some(_)) => return Result::Err(CommandError::InvalidArgument),
|
||||||
|
|
||||||
|
// :room topic set <topic>
|
||||||
|
("topic", "set", Some(s)) => RoomAction::Set(RoomField::Topic, s).into(),
|
||||||
|
("topic", "set", None) => return Result::Err(CommandError::InvalidArgument),
|
||||||
|
|
||||||
|
// :room topic unset
|
||||||
|
("topic", "unset", None) => RoomAction::Unset(RoomField::Topic).into(),
|
||||||
|
("topic", "unset", Some(_)) => return Result::Err(CommandError::InvalidArgument),
|
||||||
|
|
||||||
|
// :room tag set <tag-name>
|
||||||
|
("tag", "set", Some(s)) => RoomAction::Set(RoomField::Tag(tag_name(s)?), "".into()).into(),
|
||||||
|
("tag", "set", None) => return Result::Err(CommandError::InvalidArgument),
|
||||||
|
|
||||||
|
// :room tag unset <tag-name>
|
||||||
|
("tag", "unset", Some(s)) => RoomAction::Unset(RoomField::Tag(tag_name(s)?)).into(),
|
||||||
|
("tag", "unset", None) => return Result::Err(CommandError::InvalidArgument),
|
||||||
|
|
||||||
|
_ => return Result::Err(CommandError::InvalidArgument),
|
||||||
};
|
};
|
||||||
|
|
||||||
let step = CommandStep::Continue(act.into(), ctx.context.take());
|
let step = CommandStep::Continue(act.into(), ctx.context.clone());
|
||||||
|
|
||||||
|
return Ok(step);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn iamb_upload(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
||||||
|
let mut args = desc.arg.strings()?;
|
||||||
|
|
||||||
|
if args.len() != 1 {
|
||||||
|
return Result::Err(CommandError::InvalidArgument);
|
||||||
|
}
|
||||||
|
|
||||||
|
let sact = SendAction::Upload(args.remove(0));
|
||||||
|
let iact = IambAction::from(sact);
|
||||||
|
let step = CommandStep::Continue(iact.into(), ctx.context.clone());
|
||||||
|
|
||||||
|
return Ok(step);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn iamb_download(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
||||||
|
let mut args = desc.arg.strings()?;
|
||||||
|
|
||||||
|
if args.len() > 1 {
|
||||||
|
return Result::Err(CommandError::InvalidArgument);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut flags = DownloadFlags::NONE;
|
||||||
|
if desc.bang {
|
||||||
|
flags |= DownloadFlags::FORCE;
|
||||||
|
};
|
||||||
|
let mact = MessageAction::Download(args.pop(), flags);
|
||||||
|
let iact = IambAction::from(mact);
|
||||||
|
let step = CommandStep::Continue(iact.into(), ctx.context.clone());
|
||||||
|
|
||||||
|
return Ok(step);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn iamb_open(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
||||||
|
let mut args = desc.arg.strings()?;
|
||||||
|
|
||||||
|
if args.len() > 1 {
|
||||||
|
return Result::Err(CommandError::InvalidArgument);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut flags = DownloadFlags::OPEN;
|
||||||
|
if desc.bang {
|
||||||
|
flags |= DownloadFlags::FORCE;
|
||||||
|
};
|
||||||
|
let mact = MessageAction::Download(args.pop(), flags);
|
||||||
|
let iact = IambAction::from(mact);
|
||||||
|
let step = CommandStep::Continue(iact.into(), ctx.context.clone());
|
||||||
|
|
||||||
|
return Ok(step);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn iamb_logout(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
||||||
|
let args = desc.arg.strings()?;
|
||||||
|
|
||||||
|
if args.len() != 1 {
|
||||||
|
return Result::Err(CommandError::InvalidArgument);
|
||||||
|
}
|
||||||
|
|
||||||
|
let iact = IambAction::from(HomeserverAction::Logout(args[0].clone(), desc.bang));
|
||||||
|
let step = CommandStep::Continue(iact.into(), ctx.context.clone());
|
||||||
|
|
||||||
return Ok(step);
|
return Ok(step);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn add_iamb_commands(cmds: &mut ProgramCommands) {
|
fn add_iamb_commands(cmds: &mut ProgramCommands) {
|
||||||
cmds.add_command(ProgramCommand { names: vec!["dms".into()], f: iamb_dms });
|
cmds.add_command(ProgramCommand {
|
||||||
cmds.add_command(ProgramCommand { names: vec!["join".into()], f: iamb_join });
|
name: "cancel".into(),
|
||||||
cmds.add_command(ProgramCommand { names: vec!["members".into()], f: iamb_members });
|
aliases: vec![],
|
||||||
cmds.add_command(ProgramCommand { names: vec!["rooms".into()], f: iamb_rooms });
|
f: iamb_cancel,
|
||||||
cmds.add_command(ProgramCommand { names: vec!["set".into()], f: iamb_set });
|
});
|
||||||
cmds.add_command(ProgramCommand { names: vec!["spaces".into()], f: iamb_spaces });
|
cmds.add_command(ProgramCommand {
|
||||||
cmds.add_command(ProgramCommand { names: vec!["verify".into()], f: iamb_verify });
|
name: "create".into(),
|
||||||
cmds.add_command(ProgramCommand { names: vec!["welcome".into()], f: iamb_welcome });
|
aliases: vec![],
|
||||||
|
f: iamb_create,
|
||||||
|
});
|
||||||
|
cmds.add_command(ProgramCommand {
|
||||||
|
name: "chats".into(),
|
||||||
|
aliases: vec![],
|
||||||
|
f: iamb_chats,
|
||||||
|
});
|
||||||
|
cmds.add_command(ProgramCommand { name: "dms".into(), aliases: vec![], f: iamb_dms });
|
||||||
|
cmds.add_command(ProgramCommand {
|
||||||
|
name: "download".into(),
|
||||||
|
aliases: vec![],
|
||||||
|
f: iamb_download,
|
||||||
|
});
|
||||||
|
cmds.add_command(ProgramCommand { name: "open".into(), aliases: vec![], f: iamb_open });
|
||||||
|
cmds.add_command(ProgramCommand { name: "edit".into(), aliases: vec![], f: iamb_edit });
|
||||||
|
cmds.add_command(ProgramCommand {
|
||||||
|
name: "invite".into(),
|
||||||
|
aliases: vec![],
|
||||||
|
f: iamb_invite,
|
||||||
|
});
|
||||||
|
cmds.add_command(ProgramCommand { name: "join".into(), aliases: vec![], f: iamb_join });
|
||||||
|
cmds.add_command(ProgramCommand { name: "keys".into(), aliases: vec![], f: iamb_keys });
|
||||||
|
cmds.add_command(ProgramCommand {
|
||||||
|
name: "leave".into(),
|
||||||
|
aliases: vec![],
|
||||||
|
f: iamb_leave,
|
||||||
|
});
|
||||||
|
cmds.add_command(ProgramCommand {
|
||||||
|
name: "members".into(),
|
||||||
|
aliases: vec![],
|
||||||
|
f: iamb_members,
|
||||||
|
});
|
||||||
|
cmds.add_command(ProgramCommand {
|
||||||
|
name: "react".into(),
|
||||||
|
aliases: vec![],
|
||||||
|
f: iamb_react,
|
||||||
|
});
|
||||||
|
cmds.add_command(ProgramCommand {
|
||||||
|
name: "redact".into(),
|
||||||
|
aliases: vec![],
|
||||||
|
f: iamb_redact,
|
||||||
|
});
|
||||||
|
cmds.add_command(ProgramCommand {
|
||||||
|
name: "reply".into(),
|
||||||
|
aliases: vec![],
|
||||||
|
f: iamb_reply,
|
||||||
|
});
|
||||||
|
cmds.add_command(ProgramCommand {
|
||||||
|
name: "rooms".into(),
|
||||||
|
aliases: vec![],
|
||||||
|
f: iamb_rooms,
|
||||||
|
});
|
||||||
|
cmds.add_command(ProgramCommand { name: "room".into(), aliases: vec![], f: iamb_room });
|
||||||
|
cmds.add_command(ProgramCommand {
|
||||||
|
name: "spaces".into(),
|
||||||
|
aliases: vec![],
|
||||||
|
f: iamb_spaces,
|
||||||
|
});
|
||||||
|
cmds.add_command(ProgramCommand {
|
||||||
|
name: "unreact".into(),
|
||||||
|
aliases: vec![],
|
||||||
|
f: iamb_unreact,
|
||||||
|
});
|
||||||
|
cmds.add_command(ProgramCommand {
|
||||||
|
name: "upload".into(),
|
||||||
|
aliases: vec![],
|
||||||
|
f: iamb_upload,
|
||||||
|
});
|
||||||
|
cmds.add_command(ProgramCommand {
|
||||||
|
name: "verify".into(),
|
||||||
|
aliases: vec![],
|
||||||
|
f: iamb_verify,
|
||||||
|
});
|
||||||
|
cmds.add_command(ProgramCommand {
|
||||||
|
name: "welcome".into(),
|
||||||
|
aliases: vec![],
|
||||||
|
f: iamb_welcome,
|
||||||
|
});
|
||||||
|
cmds.add_command(ProgramCommand {
|
||||||
|
name: "editor".into(),
|
||||||
|
aliases: vec![],
|
||||||
|
f: iamb_editor,
|
||||||
|
});
|
||||||
|
cmds.add_command(ProgramCommand {
|
||||||
|
name: "logout".into(),
|
||||||
|
aliases: vec![],
|
||||||
|
f: iamb_logout,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Initialize the default command state.
|
||||||
pub fn setup_commands() -> ProgramCommands {
|
pub fn setup_commands() -> ProgramCommands {
|
||||||
let mut cmds = ProgramCommands::default();
|
let mut cmds = ProgramCommands::default();
|
||||||
|
|
||||||
@@ -171,12 +628,14 @@ pub fn setup_commands() -> ProgramCommands {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use modalkit::editing::action::WindowAction;
|
use matrix_sdk::ruma::user_id;
|
||||||
|
use modalkit::actions::WindowAction;
|
||||||
|
use modalkit::editing::context::EditContext;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_cmd_verify() {
|
fn test_cmd_verify() {
|
||||||
let mut cmds = setup_commands();
|
let mut cmds = setup_commands();
|
||||||
let ctx = ProgramContext::default();
|
let ctx = EditContext::default();
|
||||||
|
|
||||||
let res = cmds.input_cmd(":verify", ctx.clone()).unwrap();
|
let res = cmds.input_cmd(":verify", ctx.clone()).unwrap();
|
||||||
let act = WindowAction::Switch(OpenTarget::Application(IambId::VerifyList));
|
let act = WindowAction::Switch(OpenTarget::Application(IambId::VerifyList));
|
||||||
@@ -223,7 +682,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_cmd_join() {
|
fn test_cmd_join() {
|
||||||
let mut cmds = setup_commands();
|
let mut cmds = setup_commands();
|
||||||
let ctx = ProgramContext::default();
|
let ctx = EditContext::default();
|
||||||
|
|
||||||
let res = cmds.input_cmd("join #foobar:example.com", ctx.clone()).unwrap();
|
let res = cmds.input_cmd("join #foobar:example.com", ctx.clone()).unwrap();
|
||||||
let act = WindowAction::Switch(OpenTarget::Name("#foobar:example.com".into()));
|
let act = WindowAction::Switch(OpenTarget::Name("#foobar:example.com".into()));
|
||||||
@@ -241,46 +700,315 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_cmd_set() {
|
fn test_cmd_room_invalid() {
|
||||||
let mut cmds = setup_commands();
|
let mut cmds = setup_commands();
|
||||||
let ctx = ProgramContext::default();
|
let ctx = EditContext::default();
|
||||||
|
|
||||||
|
let res = cmds.input_cmd("room", ctx.clone());
|
||||||
|
assert_eq!(res, Err(CommandError::InvalidArgument));
|
||||||
|
|
||||||
|
let res = cmds.input_cmd("room foo", ctx.clone());
|
||||||
|
assert_eq!(res, Err(CommandError::InvalidArgument));
|
||||||
|
|
||||||
|
let res = cmds.input_cmd("room set topic", ctx.clone());
|
||||||
|
assert_eq!(res, Err(CommandError::InvalidArgument));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cmd_room_topic_set() {
|
||||||
|
let mut cmds = setup_commands();
|
||||||
|
let ctx = EditContext::default();
|
||||||
|
|
||||||
let res = cmds
|
let res = cmds
|
||||||
.input_cmd("set room.topic \"Lots of fun discussion!\"", ctx.clone())
|
.input_cmd("room topic set \"Lots of fun discussion!\"", ctx.clone())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let act = IambAction::Room(SetRoomField::Topic("Lots of fun discussion!".into()).into());
|
let act = RoomAction::Set(RoomField::Topic, "Lots of fun discussion!".into());
|
||||||
assert_eq!(res, vec![(act.into(), ctx.clone())]);
|
assert_eq!(res, vec![(act.into(), ctx.clone())]);
|
||||||
|
|
||||||
let res = cmds
|
let res = cmds
|
||||||
.input_cmd("set room.topic The\\ Discussion\\ Room", ctx.clone())
|
.input_cmd("room topic set The\\ Discussion\\ Room", ctx.clone())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let act = IambAction::Room(SetRoomField::Topic("The Discussion Room".into()).into());
|
let act = RoomAction::Set(RoomField::Topic, "The Discussion Room".into());
|
||||||
assert_eq!(res, vec![(act.into(), ctx.clone())]);
|
assert_eq!(res, vec![(act.into(), ctx.clone())]);
|
||||||
|
|
||||||
let res = cmds.input_cmd("set room.topic Development", ctx.clone()).unwrap();
|
let res = cmds.input_cmd("room topic set Development", ctx.clone()).unwrap();
|
||||||
let act = IambAction::Room(SetRoomField::Topic("Development".into()).into());
|
let act = RoomAction::Set(RoomField::Topic, "Development".into());
|
||||||
assert_eq!(res, vec![(act.into(), ctx.clone())]);
|
assert_eq!(res, vec![(act.into(), ctx.clone())]);
|
||||||
|
|
||||||
let res = cmds.input_cmd("set room.name Development", ctx.clone()).unwrap();
|
let res = cmds.input_cmd("room topic", ctx.clone());
|
||||||
let act = IambAction::Room(SetRoomField::Name("Development".into()).into());
|
assert_eq!(res, Err(CommandError::InvalidArgument));
|
||||||
|
|
||||||
|
let res = cmds.input_cmd("room topic set", ctx.clone());
|
||||||
|
assert_eq!(res, Err(CommandError::InvalidArgument));
|
||||||
|
|
||||||
|
let res = cmds.input_cmd("room topic set A B C", ctx.clone());
|
||||||
|
assert_eq!(res, Err(CommandError::InvalidArgument));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cmd_room_name_invalid() {
|
||||||
|
let mut cmds = setup_commands();
|
||||||
|
let ctx = EditContext::default();
|
||||||
|
|
||||||
|
let res = cmds.input_cmd("room name", ctx.clone());
|
||||||
|
assert_eq!(res, Err(CommandError::InvalidArgument));
|
||||||
|
|
||||||
|
let res = cmds.input_cmd("room name foo", ctx.clone());
|
||||||
|
assert_eq!(res, Err(CommandError::InvalidArgument));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cmd_room_name_set() {
|
||||||
|
let mut cmds = setup_commands();
|
||||||
|
let ctx = EditContext::default();
|
||||||
|
|
||||||
|
let res = cmds.input_cmd("room name set Development", ctx.clone()).unwrap();
|
||||||
|
let act = RoomAction::Set(RoomField::Name, "Development".into());
|
||||||
assert_eq!(res, vec![(act.into(), ctx.clone())]);
|
assert_eq!(res, vec![(act.into(), ctx.clone())]);
|
||||||
|
|
||||||
let res = cmds
|
let res = cmds
|
||||||
.input_cmd("set room.name \"Application Development\"", ctx.clone())
|
.input_cmd("room name set \"Application Development\"", ctx.clone())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let act = IambAction::Room(SetRoomField::Name("Application Development".into()).into());
|
let act = RoomAction::Set(RoomField::Name, "Application Development".into());
|
||||||
assert_eq!(res, vec![(act.into(), ctx.clone())]);
|
assert_eq!(res, vec![(act.into(), ctx.clone())]);
|
||||||
|
|
||||||
let res = cmds.input_cmd("set", ctx.clone());
|
let res = cmds.input_cmd("room name set", ctx.clone());
|
||||||
|
assert_eq!(res, Err(CommandError::InvalidArgument));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cmd_room_name_unset() {
|
||||||
|
let mut cmds = setup_commands();
|
||||||
|
let ctx = EditContext::default();
|
||||||
|
|
||||||
|
let res = cmds.input_cmd("room name unset", ctx.clone()).unwrap();
|
||||||
|
let act = RoomAction::Unset(RoomField::Name);
|
||||||
|
assert_eq!(res, vec![(act.into(), ctx.clone())]);
|
||||||
|
|
||||||
|
let res = cmds.input_cmd("room name unset foo", ctx.clone());
|
||||||
|
assert_eq!(res, Err(CommandError::InvalidArgument));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cmd_room_tag_set() {
|
||||||
|
let mut cmds = setup_commands();
|
||||||
|
let ctx = EditContext::default();
|
||||||
|
|
||||||
|
let res = cmds.input_cmd("room tag set favourite", ctx.clone()).unwrap();
|
||||||
|
let act = RoomAction::Set(RoomField::Tag(TagName::Favorite), "".into());
|
||||||
|
assert_eq!(res, vec![(act.into(), ctx.clone())]);
|
||||||
|
|
||||||
|
let res = cmds.input_cmd("room tag set favorite", ctx.clone()).unwrap();
|
||||||
|
let act = RoomAction::Set(RoomField::Tag(TagName::Favorite), "".into());
|
||||||
|
assert_eq!(res, vec![(act.into(), ctx.clone())]);
|
||||||
|
|
||||||
|
let res = cmds.input_cmd("room tag set fav", ctx.clone()).unwrap();
|
||||||
|
let act = RoomAction::Set(RoomField::Tag(TagName::Favorite), "".into());
|
||||||
|
assert_eq!(res, vec![(act.into(), ctx.clone())]);
|
||||||
|
|
||||||
|
let res = cmds.input_cmd("room tag set low_priority", ctx.clone()).unwrap();
|
||||||
|
let act = RoomAction::Set(RoomField::Tag(TagName::LowPriority), "".into());
|
||||||
|
assert_eq!(res, vec![(act.into(), ctx.clone())]);
|
||||||
|
|
||||||
|
let res = cmds.input_cmd("room tag set low-priority", ctx.clone()).unwrap();
|
||||||
|
let act = RoomAction::Set(RoomField::Tag(TagName::LowPriority), "".into());
|
||||||
|
assert_eq!(res, vec![(act.into(), ctx.clone())]);
|
||||||
|
|
||||||
|
let res = cmds.input_cmd("room tag set low", ctx.clone()).unwrap();
|
||||||
|
let act = RoomAction::Set(RoomField::Tag(TagName::LowPriority), "".into());
|
||||||
|
assert_eq!(res, vec![(act.into(), ctx.clone())]);
|
||||||
|
|
||||||
|
let res = cmds.input_cmd("room tag set servernotice", ctx.clone()).unwrap();
|
||||||
|
let act = RoomAction::Set(RoomField::Tag(TagName::ServerNotice), "".into());
|
||||||
|
assert_eq!(res, vec![(act.into(), ctx.clone())]);
|
||||||
|
|
||||||
|
let res = cmds.input_cmd("room tag set server_notice", ctx.clone()).unwrap();
|
||||||
|
let act = RoomAction::Set(RoomField::Tag(TagName::ServerNotice), "".into());
|
||||||
|
assert_eq!(res, vec![(act.into(), ctx.clone())]);
|
||||||
|
|
||||||
|
let res = cmds.input_cmd("room tag set server_notice", ctx.clone()).unwrap();
|
||||||
|
let act = RoomAction::Set(RoomField::Tag(TagName::ServerNotice), "".into());
|
||||||
|
assert_eq!(res, vec![(act.into(), ctx.clone())]);
|
||||||
|
|
||||||
|
let res = cmds.input_cmd("room tag set u.custom-tag", ctx.clone()).unwrap();
|
||||||
|
let act = RoomAction::Set(
|
||||||
|
RoomField::Tag(TagName::User("u.custom-tag".parse().unwrap())),
|
||||||
|
"".into(),
|
||||||
|
);
|
||||||
|
assert_eq!(res, vec![(act.into(), ctx.clone())]);
|
||||||
|
|
||||||
|
let res = cmds.input_cmd("room tag set u.irc", ctx.clone()).unwrap();
|
||||||
|
let act =
|
||||||
|
RoomAction::Set(RoomField::Tag(TagName::User("u.irc".parse().unwrap())), "".into());
|
||||||
|
assert_eq!(res, vec![(act.into(), ctx.clone())]);
|
||||||
|
|
||||||
|
let res = cmds.input_cmd("room tag", ctx.clone());
|
||||||
assert_eq!(res, Err(CommandError::InvalidArgument));
|
assert_eq!(res, Err(CommandError::InvalidArgument));
|
||||||
|
|
||||||
let res = cmds.input_cmd("set room.name", ctx.clone());
|
let res = cmds.input_cmd("room tag set", ctx.clone());
|
||||||
assert_eq!(res, Err(CommandError::InvalidArgument));
|
assert_eq!(res, Err(CommandError::InvalidArgument));
|
||||||
|
|
||||||
let res = cmds.input_cmd("set room.topic", ctx.clone());
|
let res = cmds.input_cmd("room tag set unknown", ctx.clone());
|
||||||
|
assert_eq!(res, Err(CommandError::Error("Invalid user tag name: unknown".into())));
|
||||||
|
|
||||||
|
let res = cmds.input_cmd("room tag set needs-leading-u-dot", ctx.clone());
|
||||||
|
assert_eq!(
|
||||||
|
res,
|
||||||
|
Err(CommandError::Error("Invalid user tag name: needs-leading-u-dot".into()))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cmd_room_tag_unset() {
|
||||||
|
let mut cmds = setup_commands();
|
||||||
|
let ctx = EditContext::default();
|
||||||
|
|
||||||
|
let res = cmds.input_cmd("room tag unset favourite", ctx.clone()).unwrap();
|
||||||
|
let act = RoomAction::Unset(RoomField::Tag(TagName::Favorite));
|
||||||
|
assert_eq!(res, vec![(act.into(), ctx.clone())]);
|
||||||
|
|
||||||
|
let res = cmds.input_cmd("room tag unset favorite", ctx.clone()).unwrap();
|
||||||
|
let act = RoomAction::Unset(RoomField::Tag(TagName::Favorite));
|
||||||
|
assert_eq!(res, vec![(act.into(), ctx.clone())]);
|
||||||
|
|
||||||
|
let res = cmds.input_cmd("room tag unset fav", ctx.clone()).unwrap();
|
||||||
|
let act = RoomAction::Unset(RoomField::Tag(TagName::Favorite));
|
||||||
|
assert_eq!(res, vec![(act.into(), ctx.clone())]);
|
||||||
|
|
||||||
|
let res = cmds.input_cmd("room tag unset low_priority", ctx.clone()).unwrap();
|
||||||
|
let act = RoomAction::Unset(RoomField::Tag(TagName::LowPriority));
|
||||||
|
assert_eq!(res, vec![(act.into(), ctx.clone())]);
|
||||||
|
|
||||||
|
let res = cmds.input_cmd("room tag unset low-priority", ctx.clone()).unwrap();
|
||||||
|
let act = RoomAction::Unset(RoomField::Tag(TagName::LowPriority));
|
||||||
|
assert_eq!(res, vec![(act.into(), ctx.clone())]);
|
||||||
|
|
||||||
|
let res = cmds.input_cmd("room tag unset low", ctx.clone()).unwrap();
|
||||||
|
let act = RoomAction::Unset(RoomField::Tag(TagName::LowPriority));
|
||||||
|
assert_eq!(res, vec![(act.into(), ctx.clone())]);
|
||||||
|
|
||||||
|
let res = cmds.input_cmd("room tag unset servernotice", ctx.clone()).unwrap();
|
||||||
|
let act = RoomAction::Unset(RoomField::Tag(TagName::ServerNotice));
|
||||||
|
assert_eq!(res, vec![(act.into(), ctx.clone())]);
|
||||||
|
|
||||||
|
let res = cmds.input_cmd("room tag unset server_notice", ctx.clone()).unwrap();
|
||||||
|
let act = RoomAction::Unset(RoomField::Tag(TagName::ServerNotice));
|
||||||
|
assert_eq!(res, vec![(act.into(), ctx.clone())]);
|
||||||
|
|
||||||
|
let res = cmds.input_cmd("room tag unset server_notice", ctx.clone()).unwrap();
|
||||||
|
let act = RoomAction::Unset(RoomField::Tag(TagName::ServerNotice));
|
||||||
|
assert_eq!(res, vec![(act.into(), ctx.clone())]);
|
||||||
|
|
||||||
|
let res = cmds.input_cmd("room tag unset u.custom-tag", ctx.clone()).unwrap();
|
||||||
|
let act = RoomAction::Unset(RoomField::Tag(TagName::User("u.custom-tag".parse().unwrap())));
|
||||||
|
assert_eq!(res, vec![(act.into(), ctx.clone())]);
|
||||||
|
|
||||||
|
let res = cmds.input_cmd("room tag unset u.irc", ctx.clone()).unwrap();
|
||||||
|
let act = RoomAction::Unset(RoomField::Tag(TagName::User("u.irc".parse().unwrap())));
|
||||||
|
assert_eq!(res, vec![(act.into(), ctx.clone())]);
|
||||||
|
|
||||||
|
let res = cmds.input_cmd("room tag", ctx.clone());
|
||||||
assert_eq!(res, Err(CommandError::InvalidArgument));
|
assert_eq!(res, Err(CommandError::InvalidArgument));
|
||||||
|
|
||||||
let res = cmds.input_cmd("set room.topic A B C", ctx.clone());
|
let res = cmds.input_cmd("room tag set", ctx.clone());
|
||||||
|
assert_eq!(res, Err(CommandError::InvalidArgument));
|
||||||
|
|
||||||
|
let res = cmds.input_cmd("room tag unset unknown", ctx.clone());
|
||||||
|
assert_eq!(res, Err(CommandError::Error("Invalid user tag name: unknown".into())));
|
||||||
|
|
||||||
|
let res = cmds.input_cmd("room tag unset needs-leading-u-dot", ctx.clone());
|
||||||
|
assert_eq!(
|
||||||
|
res,
|
||||||
|
Err(CommandError::Error("Invalid user tag name: needs-leading-u-dot".into()))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cmd_invite() {
|
||||||
|
let mut cmds = setup_commands();
|
||||||
|
let ctx = EditContext::default();
|
||||||
|
|
||||||
|
let res = cmds.input_cmd("invite accept", ctx.clone()).unwrap();
|
||||||
|
let act = IambAction::Room(RoomAction::InviteAccept);
|
||||||
|
assert_eq!(res, vec![(act.into(), ctx.clone())]);
|
||||||
|
|
||||||
|
let res = cmds.input_cmd("invite reject", ctx.clone()).unwrap();
|
||||||
|
let act = IambAction::Room(RoomAction::InviteReject);
|
||||||
|
assert_eq!(res, vec![(act.into(), ctx.clone())]);
|
||||||
|
|
||||||
|
let res = cmds.input_cmd("invite send @user:example.com", ctx.clone()).unwrap();
|
||||||
|
let act =
|
||||||
|
IambAction::Room(RoomAction::InviteSend(user_id!("@user:example.com").to_owned()));
|
||||||
|
assert_eq!(res, vec![(act.into(), ctx.clone())]);
|
||||||
|
|
||||||
|
let res = cmds.input_cmd("invite", ctx.clone());
|
||||||
|
assert_eq!(res, Err(CommandError::InvalidArgument));
|
||||||
|
|
||||||
|
let res = cmds.input_cmd("invite foo", ctx.clone());
|
||||||
|
assert_eq!(res, Err(CommandError::InvalidArgument));
|
||||||
|
|
||||||
|
let res = cmds.input_cmd("invite accept @user:example.com", ctx.clone());
|
||||||
|
assert_eq!(res, Err(CommandError::InvalidArgument));
|
||||||
|
|
||||||
|
let res = cmds.input_cmd("invite reject @user:example.com", ctx.clone());
|
||||||
|
assert_eq!(res, Err(CommandError::InvalidArgument));
|
||||||
|
|
||||||
|
let res = cmds.input_cmd("invite send", ctx.clone());
|
||||||
|
assert_eq!(res, Err(CommandError::InvalidArgument));
|
||||||
|
|
||||||
|
let res = cmds.input_cmd("invite @user:example.com", ctx.clone());
|
||||||
|
assert_eq!(res, Err(CommandError::InvalidArgument));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cmd_redact() {
|
||||||
|
let mut cmds = setup_commands();
|
||||||
|
let ctx = EditContext::default();
|
||||||
|
|
||||||
|
let res = cmds.input_cmd("redact", ctx.clone()).unwrap();
|
||||||
|
let act = IambAction::Message(MessageAction::Redact(None, false));
|
||||||
|
assert_eq!(res, vec![(act.into(), ctx.clone())]);
|
||||||
|
|
||||||
|
let res = cmds.input_cmd("redact!", ctx.clone()).unwrap();
|
||||||
|
let act = IambAction::Message(MessageAction::Redact(None, true));
|
||||||
|
assert_eq!(res, vec![(act.into(), ctx.clone())]);
|
||||||
|
|
||||||
|
let res = cmds.input_cmd("redact Removed", ctx.clone()).unwrap();
|
||||||
|
let act = IambAction::Message(MessageAction::Redact(Some("Removed".into()), false));
|
||||||
|
assert_eq!(res, vec![(act.into(), ctx.clone())]);
|
||||||
|
|
||||||
|
let res = cmds.input_cmd("redact \"Removed\"", ctx.clone()).unwrap();
|
||||||
|
let act = IambAction::Message(MessageAction::Redact(Some("Removed".into()), false));
|
||||||
|
assert_eq!(res, vec![(act.into(), ctx.clone())]);
|
||||||
|
|
||||||
|
let res = cmds.input_cmd("redact Removed Removed", ctx.clone());
|
||||||
|
assert_eq!(res, Err(CommandError::InvalidArgument));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cmd_keys() {
|
||||||
|
let mut cmds = setup_commands();
|
||||||
|
let ctx = EditContext::default();
|
||||||
|
|
||||||
|
let res = cmds.input_cmd("keys import /a/b/c pword", ctx.clone()).unwrap();
|
||||||
|
let act = IambAction::Keys(KeysAction::Import("/a/b/c".into(), "pword".into()));
|
||||||
|
assert_eq!(res, vec![(act.into(), ctx.clone())]);
|
||||||
|
|
||||||
|
let res = cmds.input_cmd("keys export /a/b/c pword", ctx.clone()).unwrap();
|
||||||
|
let act = IambAction::Keys(KeysAction::Export("/a/b/c".into(), "pword".into()));
|
||||||
|
assert_eq!(res, vec![(act.into(), ctx.clone())]);
|
||||||
|
|
||||||
|
// Invalid invocations.
|
||||||
|
let res = cmds.input_cmd("keys", ctx.clone());
|
||||||
|
assert_eq!(res, Err(CommandError::InvalidArgument));
|
||||||
|
|
||||||
|
let res = cmds.input_cmd("keys import", ctx.clone());
|
||||||
|
assert_eq!(res, Err(CommandError::InvalidArgument));
|
||||||
|
|
||||||
|
let res = cmds.input_cmd("keys import foo", ctx.clone());
|
||||||
|
assert_eq!(res, Err(CommandError::InvalidArgument));
|
||||||
|
|
||||||
|
let res = cmds.input_cmd("keys import foo bar baz", ctx.clone());
|
||||||
assert_eq!(res, Err(CommandError::InvalidArgument));
|
assert_eq!(res, Err(CommandError::InvalidArgument));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
999
src/config.rs
999
src/config.rs
File diff suppressed because it is too large
Load Diff
@@ -1,62 +1,77 @@
|
|||||||
|
//! # Default Keybindings
|
||||||
|
//!
|
||||||
|
//! The keybindings are set up here. We define some iamb-specific keybindings, but the default Vim
|
||||||
|
//! keys come from [modalkit::env::vim::keybindings].
|
||||||
use modalkit::{
|
use modalkit::{
|
||||||
editing::action::WindowAction,
|
actions::{MacroAction, WindowAction},
|
||||||
editing::base::WordStyle,
|
|
||||||
env::vim::keybindings::{InputStep, VimBindings},
|
env::vim::keybindings::{InputStep, VimBindings},
|
||||||
env::vim::VimMode,
|
env::vim::VimMode,
|
||||||
input::bindings::{EdgeEvent, EdgeRepeat, InputBindings},
|
env::CommonKeyClass,
|
||||||
input::key::TerminalKey,
|
key::TerminalKey,
|
||||||
|
keybindings::{EdgeEvent, EdgeRepeat, InputBindings},
|
||||||
|
prelude::Count,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::base::{IambAction, Keybindings};
|
use crate::base::{IambAction, IambInfo, Keybindings, MATRIX_ID_WORD};
|
||||||
|
use crate::config::{ApplicationSettings, Keys};
|
||||||
|
|
||||||
/// Find the boundaries for a Matrix username, room alias, or room ID.
|
pub type IambStep = InputStep<IambInfo>;
|
||||||
///
|
|
||||||
/// Technically "[" and "]" should be here since IPv6 addresses are allowed
|
fn once(key: &TerminalKey) -> (EdgeRepeat, EdgeEvent<TerminalKey, CommonKeyClass>) {
|
||||||
/// in the server name, but in practice that should be uncommon, and people
|
(EdgeRepeat::Once, EdgeEvent::Key(*key))
|
||||||
/// can just use `gf` and friends in Visual mode instead.
|
|
||||||
fn is_mxid_char(c: char) -> bool {
|
|
||||||
return c >= 'a' && c <= 'z' ||
|
|
||||||
c >= 'A' && c <= 'Z' ||
|
|
||||||
c >= '0' && c <= '9' ||
|
|
||||||
":-./@_#!".contains(c);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Initialize the default keybinding state.
|
||||||
pub fn setup_keybindings() -> Keybindings {
|
pub fn setup_keybindings() -> Keybindings {
|
||||||
let mut ism = Keybindings::empty();
|
let mut ism = Keybindings::empty();
|
||||||
|
|
||||||
let vim = VimBindings::default()
|
let vim = VimBindings::default()
|
||||||
.submit_on_enter()
|
.submit_on_enter()
|
||||||
.cursor_open(WordStyle::CharSet(is_mxid_char));
|
.cursor_open(MATRIX_ID_WORD.clone());
|
||||||
|
|
||||||
vim.setup(&mut ism);
|
vim.setup(&mut ism);
|
||||||
|
|
||||||
let ctrl_w = EdgeEvent::Key("<C-W>".parse::<TerminalKey>().unwrap());
|
let ctrl_w = "<C-W>".parse::<TerminalKey>().unwrap();
|
||||||
let ctrl_m = EdgeEvent::Key("<C-M>".parse::<TerminalKey>().unwrap());
|
let ctrl_m = "<C-M>".parse::<TerminalKey>().unwrap();
|
||||||
let ctrl_z = EdgeEvent::Key("<C-Z>".parse::<TerminalKey>().unwrap());
|
let ctrl_z = "<C-Z>".parse::<TerminalKey>().unwrap();
|
||||||
let key_m_lc = EdgeEvent::Key("m".parse::<TerminalKey>().unwrap());
|
let key_m_lc = "m".parse::<TerminalKey>().unwrap();
|
||||||
let key_z_lc = EdgeEvent::Key("z".parse::<TerminalKey>().unwrap());
|
let key_z_lc = "z".parse::<TerminalKey>().unwrap();
|
||||||
|
|
||||||
let cwz = vec![
|
let cwz = vec![once(&ctrl_w), once(&key_z_lc)];
|
||||||
(EdgeRepeat::Once, ctrl_w.clone()),
|
let cwcz = vec![once(&ctrl_w), once(&ctrl_z)];
|
||||||
(EdgeRepeat::Once, key_z_lc),
|
let zoom = IambStep::new()
|
||||||
];
|
.actions(vec![WindowAction::ZoomToggle.into()])
|
||||||
let cwcz = vec![
|
.goto(VimMode::Normal);
|
||||||
(EdgeRepeat::Once, ctrl_w.clone()),
|
|
||||||
(EdgeRepeat::Once, ctrl_z),
|
|
||||||
];
|
|
||||||
let zoom = InputStep::new().actions(vec![WindowAction::ZoomToggle.into()]);
|
|
||||||
|
|
||||||
ism.add_mapping(VimMode::Normal, &cwz, &zoom);
|
ism.add_mapping(VimMode::Normal, &cwz, &zoom);
|
||||||
|
ism.add_mapping(VimMode::Visual, &cwz, &zoom);
|
||||||
ism.add_mapping(VimMode::Normal, &cwcz, &zoom);
|
ism.add_mapping(VimMode::Normal, &cwcz, &zoom);
|
||||||
|
ism.add_mapping(VimMode::Visual, &cwcz, &zoom);
|
||||||
|
|
||||||
let cwm = vec![
|
let cwm = vec![once(&ctrl_w), once(&key_m_lc)];
|
||||||
(EdgeRepeat::Once, ctrl_w.clone()),
|
let cwcm = vec![once(&ctrl_w), once(&ctrl_m)];
|
||||||
(EdgeRepeat::Once, key_m_lc),
|
let stoggle = IambStep::new()
|
||||||
];
|
.actions(vec![IambAction::ToggleScrollbackFocus.into()])
|
||||||
let cwcm = vec![(EdgeRepeat::Once, ctrl_w), (EdgeRepeat::Once, ctrl_m)];
|
.goto(VimMode::Normal);
|
||||||
let stoggle = InputStep::new().actions(vec![IambAction::ToggleScrollbackFocus.into()]);
|
|
||||||
ism.add_mapping(VimMode::Normal, &cwm, &stoggle);
|
ism.add_mapping(VimMode::Normal, &cwm, &stoggle);
|
||||||
|
ism.add_mapping(VimMode::Visual, &cwm, &stoggle);
|
||||||
ism.add_mapping(VimMode::Normal, &cwcm, &stoggle);
|
ism.add_mapping(VimMode::Normal, &cwcm, &stoggle);
|
||||||
|
ism.add_mapping(VimMode::Visual, &cwcm, &stoggle);
|
||||||
return ism;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
706
src/main.rs
706
src/main.rs
@@ -1,3 +1,16 @@
|
|||||||
|
//! # iamb
|
||||||
|
//!
|
||||||
|
//! The iamb client loops over user input and commands, and turns them into actions, [some of
|
||||||
|
//! which][IambAction] are specific to iamb, and [some of which][Action] come from [modalkit]. When
|
||||||
|
//! adding new functionality, you will usually want to extend [IambAction] or one of its variants
|
||||||
|
//! (like [RoomAction][base::RoomAction]), and then add an appropriate [command][commands] or
|
||||||
|
//! [keybinding][keybindings].
|
||||||
|
//!
|
||||||
|
//! For more complicated changes, you may need to update [the async worker thread][worker], which
|
||||||
|
//! handles background Matrix tasks with [matrix-rust-sdk][matrix_sdk].
|
||||||
|
//!
|
||||||
|
//! Most rendering logic lives under the [windows] module, but [Matrix messages][message] have
|
||||||
|
//! their own module.
|
||||||
#![allow(clippy::manual_range_contains)]
|
#![allow(clippy::manual_range_contains)]
|
||||||
#![allow(clippy::needless_return)]
|
#![allow(clippy::needless_return)]
|
||||||
#![allow(clippy::result_large_err)]
|
#![allow(clippy::result_large_err)]
|
||||||
@@ -6,28 +19,41 @@ use std::collections::VecDeque;
|
|||||||
use std::convert::TryFrom;
|
use std::convert::TryFrom;
|
||||||
use std::fmt::Display;
|
use std::fmt::Display;
|
||||||
use std::fs::{create_dir_all, File};
|
use std::fs::{create_dir_all, File};
|
||||||
use std::io::{stdout, BufReader, Stdout};
|
use std::io::{stdout, BufWriter, Stdout, Write};
|
||||||
use std::ops::DerefMut;
|
use std::ops::DerefMut;
|
||||||
use std::process;
|
use std::process;
|
||||||
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use tokio::sync::Mutex as AsyncMutex;
|
use matrix_sdk::crypto::encrypt_room_key_export;
|
||||||
use tracing::{self, Level};
|
use matrix_sdk::ruma::api::client::error::ErrorKind;
|
||||||
use tracing_subscriber::FmtSubscriber;
|
|
||||||
|
|
||||||
use matrix_sdk::ruma::OwnedUserId;
|
use matrix_sdk::ruma::OwnedUserId;
|
||||||
|
use modalkit::keybindings::InputBindings;
|
||||||
|
use rand::{distributions::Alphanumeric, Rng};
|
||||||
|
use temp_dir::TempDir;
|
||||||
|
use tokio::sync::Mutex as AsyncMutex;
|
||||||
|
use tracing_subscriber::FmtSubscriber;
|
||||||
|
|
||||||
use modalkit::crossterm::{
|
use modalkit::crossterm::{
|
||||||
self,
|
self,
|
||||||
cursor::Show as CursorShow,
|
cursor::Show as CursorShow,
|
||||||
event::{poll, read, Event},
|
event::{
|
||||||
|
poll,
|
||||||
|
read,
|
||||||
|
DisableBracketedPaste,
|
||||||
|
DisableFocusChange,
|
||||||
|
EnableBracketedPaste,
|
||||||
|
EnableFocusChange,
|
||||||
|
Event,
|
||||||
|
KeyEventKind,
|
||||||
|
},
|
||||||
execute,
|
execute,
|
||||||
terminal::{EnterAlternateScreen, LeaveAlternateScreen, SetTitle},
|
terminal::{EnterAlternateScreen, LeaveAlternateScreen, SetTitle},
|
||||||
};
|
};
|
||||||
|
|
||||||
use modalkit::tui::{
|
use ratatui::{
|
||||||
backend::CrosstermBackend,
|
backend::CrosstermBackend,
|
||||||
layout::Rect,
|
layout::Rect,
|
||||||
style::{Color, Style},
|
style::{Color, Style},
|
||||||
@@ -41,6 +67,10 @@ mod commands;
|
|||||||
mod config;
|
mod config;
|
||||||
mod keybindings;
|
mod keybindings;
|
||||||
mod message;
|
mod message;
|
||||||
|
mod notifications;
|
||||||
|
mod preview;
|
||||||
|
mod sled_export;
|
||||||
|
mod util;
|
||||||
mod windows;
|
mod windows;
|
||||||
mod worker;
|
mod worker;
|
||||||
|
|
||||||
@@ -51,62 +81,168 @@ use crate::{
|
|||||||
base::{
|
base::{
|
||||||
AsyncProgramStore,
|
AsyncProgramStore,
|
||||||
ChatStore,
|
ChatStore,
|
||||||
|
HomeserverAction,
|
||||||
IambAction,
|
IambAction,
|
||||||
IambBufferId,
|
|
||||||
IambError,
|
IambError,
|
||||||
IambId,
|
IambId,
|
||||||
IambInfo,
|
IambInfo,
|
||||||
IambResult,
|
IambResult,
|
||||||
|
KeysAction,
|
||||||
ProgramAction,
|
ProgramAction,
|
||||||
ProgramCommands,
|
|
||||||
ProgramContext,
|
ProgramContext,
|
||||||
ProgramStore,
|
ProgramStore,
|
||||||
},
|
},
|
||||||
config::{ApplicationSettings, Iamb},
|
config::{ApplicationSettings, Iamb},
|
||||||
message::{Message, MessageContent, MessageTimeStamp},
|
|
||||||
windows::IambWindow,
|
windows::IambWindow,
|
||||||
worker::{ClientWorker, LoginStyle, Requester},
|
worker::{create_room, ClientWorker, LoginStyle, Requester},
|
||||||
};
|
};
|
||||||
|
|
||||||
use modalkit::{
|
use modalkit::{
|
||||||
editing::{
|
actions::{
|
||||||
action::{
|
|
||||||
Action,
|
Action,
|
||||||
Commandable,
|
Commandable,
|
||||||
EditError,
|
|
||||||
EditInfo,
|
|
||||||
Editable,
|
Editable,
|
||||||
|
EditorAction,
|
||||||
|
InsertTextAction,
|
||||||
Jumpable,
|
Jumpable,
|
||||||
Promptable,
|
Promptable,
|
||||||
Scrollable,
|
Scrollable,
|
||||||
|
TabAction,
|
||||||
TabContainer,
|
TabContainer,
|
||||||
TabCount,
|
TabCount,
|
||||||
WindowAction,
|
WindowAction,
|
||||||
WindowContainer,
|
WindowContainer,
|
||||||
},
|
},
|
||||||
base::{OpenTarget, RepeatType},
|
editing::{context::Resolve, key::KeyManager, store::Store},
|
||||||
context::Resolve,
|
errors::{EditError, UIError},
|
||||||
key::KeyManager,
|
key::TerminalKey,
|
||||||
store::Store,
|
keybindings::{
|
||||||
|
dialog::{Pager, PromptYesNo},
|
||||||
|
BindingMachine,
|
||||||
},
|
},
|
||||||
input::{bindings::BindingMachine, key::TerminalKey},
|
prelude::*,
|
||||||
widgets::{
|
ui::FocusList,
|
||||||
|
};
|
||||||
|
|
||||||
|
use modalkit_ratatui::{
|
||||||
cmdbar::CommandBarState,
|
cmdbar::CommandBarState,
|
||||||
screen::{Screen, ScreenState},
|
screen::{Screen, ScreenState, TabLayoutDescription},
|
||||||
|
windows::WindowLayoutDescription,
|
||||||
TerminalCursor,
|
TerminalCursor,
|
||||||
TerminalExtOps,
|
TerminalExtOps,
|
||||||
Window,
|
Window,
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
fn config_tab_to_desc(
|
||||||
|
layout: config::WindowLayout,
|
||||||
|
store: &mut ProgramStore,
|
||||||
|
) -> IambResult<WindowLayoutDescription<IambInfo>> {
|
||||||
|
let desc = match layout {
|
||||||
|
config::WindowLayout::Window { window } => {
|
||||||
|
let ChatStore { names, worker, .. } = &mut store.application;
|
||||||
|
|
||||||
|
let window = match window {
|
||||||
|
config::WindowPath::UserId(user_id) => {
|
||||||
|
let name = user_id.to_string();
|
||||||
|
let room_id = worker.join_room(name.clone())?;
|
||||||
|
names.insert(name, room_id.clone());
|
||||||
|
IambId::Room(room_id, None)
|
||||||
|
},
|
||||||
|
config::WindowPath::RoomId(room_id) => IambId::Room(room_id, None),
|
||||||
|
config::WindowPath::AliasId(alias) => {
|
||||||
|
let name = alias.to_string();
|
||||||
|
let room_id = worker.join_room(name.clone())?;
|
||||||
|
names.insert(name, room_id.clone());
|
||||||
|
IambId::Room(room_id, None)
|
||||||
|
},
|
||||||
|
config::WindowPath::Window(id) => id,
|
||||||
|
};
|
||||||
|
|
||||||
|
WindowLayoutDescription::Window { window, length: None }
|
||||||
|
},
|
||||||
|
config::WindowLayout::Split { split } => {
|
||||||
|
let children = split
|
||||||
|
.into_iter()
|
||||||
|
.map(|child| config_tab_to_desc(child, store))
|
||||||
|
.collect::<IambResult<Vec<_>>>()?;
|
||||||
|
|
||||||
|
WindowLayoutDescription::Split { children, length: None }
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(desc)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn setup_screen(
|
||||||
|
settings: ApplicationSettings,
|
||||||
|
store: &mut ProgramStore,
|
||||||
|
) -> IambResult<ScreenState<IambWindow, IambInfo>> {
|
||||||
|
let cmd = CommandBarState::new(store);
|
||||||
|
let dims = crossterm::terminal::size()?;
|
||||||
|
let area = Rect::new(0, 0, dims.0, dims.1);
|
||||||
|
|
||||||
|
match settings.layout {
|
||||||
|
config::Layout::Restore => {
|
||||||
|
if let Ok(layout) = std::fs::read(&settings.layout_json) {
|
||||||
|
let tabs: TabLayoutDescription<IambInfo> =
|
||||||
|
serde_json::from_slice(&layout).map_err(IambError::from)?;
|
||||||
|
let tabs = tabs.to_layout(area.into(), store)?;
|
||||||
|
|
||||||
|
return Ok(ScreenState::from_list(tabs, cmd));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
config::Layout::New => {},
|
||||||
|
config::Layout::Config { tabs } => {
|
||||||
|
let mut list = FocusList::default();
|
||||||
|
|
||||||
|
for tab in tabs.into_iter() {
|
||||||
|
let tab = config_tab_to_desc(tab, store)?;
|
||||||
|
let tab = tab.to_layout(area.into(), store)?;
|
||||||
|
list.push(tab);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(ScreenState::from_list(list, cmd));
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
let win = settings
|
||||||
|
.tunables
|
||||||
|
.default_room
|
||||||
|
.and_then(|room| IambWindow::find(room, store).ok())
|
||||||
|
.or_else(|| IambWindow::open(IambId::Welcome, store).ok())
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
return Ok(ScreenState::new(win, cmd));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The main application state and event loop.
|
||||||
struct Application {
|
struct Application {
|
||||||
store: AsyncProgramStore,
|
/// Terminal backend.
|
||||||
worker: Requester,
|
|
||||||
terminal: Terminal<CrosstermBackend<Stdout>>,
|
terminal: Terminal<CrosstermBackend<Stdout>>,
|
||||||
bindings: KeyManager<TerminalKey, ProgramAction, RepeatType, ProgramContext>,
|
|
||||||
actstack: VecDeque<(ProgramAction, ProgramContext)>,
|
/// State for the Matrix client, editing, etc.
|
||||||
cmds: ProgramCommands,
|
store: AsyncProgramStore,
|
||||||
|
|
||||||
|
/// UI state (open tabs, command bar, etc.) to use when rendering.
|
||||||
screen: ScreenState<IambWindow, IambInfo>,
|
screen: ScreenState<IambWindow, IambInfo>,
|
||||||
|
|
||||||
|
/// Handle to communicate synchronously with the Matrix worker task.
|
||||||
|
worker: Requester,
|
||||||
|
|
||||||
|
/// Mapped keybindings.
|
||||||
|
bindings: KeyManager<TerminalKey, ProgramAction, RepeatType>,
|
||||||
|
|
||||||
|
/// Pending actions to run.
|
||||||
|
actstack: VecDeque<(ProgramAction, ProgramContext)>,
|
||||||
|
|
||||||
|
/// Whether or not the terminal is currently focused.
|
||||||
|
focused: bool,
|
||||||
|
|
||||||
|
/// The tab layout before the last executed [TabAction].
|
||||||
|
last_layout: Option<TabLayoutDescription<IambInfo>>,
|
||||||
|
|
||||||
|
/// Whether we need to do a full redraw (e.g., after running a subprocess).
|
||||||
|
dirty: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Application {
|
impl Application {
|
||||||
@@ -117,6 +253,8 @@ impl Application {
|
|||||||
let mut stdout = stdout();
|
let mut stdout = stdout();
|
||||||
crossterm::terminal::enable_raw_mode()?;
|
crossterm::terminal::enable_raw_mode()?;
|
||||||
crossterm::execute!(stdout, EnterAlternateScreen)?;
|
crossterm::execute!(stdout, EnterAlternateScreen)?;
|
||||||
|
crossterm::execute!(stdout, EnableBracketedPaste)?;
|
||||||
|
crossterm::execute!(stdout, EnableFocusChange)?;
|
||||||
|
|
||||||
let title = format!("iamb ({})", settings.profile.user_id);
|
let title = format!("iamb ({})", settings.profile.user_id);
|
||||||
crossterm::execute!(stdout, SetTitle(title))?;
|
crossterm::execute!(stdout, SetTitle(title))?;
|
||||||
@@ -124,16 +262,15 @@ impl Application {
|
|||||||
let backend = CrosstermBackend::new(stdout);
|
let backend = CrosstermBackend::new(stdout);
|
||||||
let terminal = Terminal::new(backend)?;
|
let terminal = Terminal::new(backend)?;
|
||||||
|
|
||||||
let bindings = crate::keybindings::setup_keybindings();
|
let mut bindings = crate::keybindings::setup_keybindings();
|
||||||
|
settings.setup(&mut bindings);
|
||||||
let bindings = KeyManager::new(bindings);
|
let bindings = KeyManager::new(bindings);
|
||||||
let cmds = crate::commands::setup_commands();
|
|
||||||
|
|
||||||
let mut locked = store.lock().await;
|
let mut locked = store.lock().await;
|
||||||
let win = IambWindow::open(IambId::Welcome, locked.deref_mut()).unwrap();
|
let screen = setup_screen(settings, locked.deref_mut())?;
|
||||||
let cmd = CommandBarState::new(IambBufferId::Command, locked.deref_mut());
|
|
||||||
let screen = ScreenState::new(win, cmd);
|
|
||||||
|
|
||||||
let worker = locked.application.worker.clone();
|
let worker = locked.application.worker.clone();
|
||||||
|
|
||||||
drop(locked);
|
drop(locked);
|
||||||
|
|
||||||
let actstack = VecDeque::new();
|
let actstack = VecDeque::new();
|
||||||
@@ -144,17 +281,23 @@ impl Application {
|
|||||||
terminal,
|
terminal,
|
||||||
bindings,
|
bindings,
|
||||||
actstack,
|
actstack,
|
||||||
cmds,
|
|
||||||
screen,
|
screen,
|
||||||
|
focused: true,
|
||||||
|
last_layout: None,
|
||||||
|
dirty: true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn redraw(&mut self, full: bool, store: &mut ProgramStore) -> Result<(), std::io::Error> {
|
fn redraw(&mut self, full: bool, store: &mut ProgramStore) -> Result<(), std::io::Error> {
|
||||||
let modestr = self.bindings.showmode();
|
let bindings = &mut self.bindings;
|
||||||
let cursor = self.bindings.get_cursor_indicator();
|
let focused = self.focused;
|
||||||
let sstate = &mut self.screen;
|
let sstate = &mut self.screen;
|
||||||
let term = &mut self.terminal;
|
let term = &mut self.terminal;
|
||||||
|
|
||||||
|
if store.application.ring_bell {
|
||||||
|
store.application.ring_bell = term.backend_mut().write_all(&[7]).is_err();
|
||||||
|
}
|
||||||
|
|
||||||
if full {
|
if full {
|
||||||
term.clear()?;
|
term.clear()?;
|
||||||
}
|
}
|
||||||
@@ -162,9 +305,25 @@ impl Application {
|
|||||||
term.draw(|f| {
|
term.draw(|f| {
|
||||||
let area = f.size();
|
let area = f.size();
|
||||||
|
|
||||||
let screen = Screen::new(store).showmode(modestr).borders(true);
|
let modestr = bindings.show_mode();
|
||||||
|
let cursor = bindings.get_cursor_indicator();
|
||||||
|
let dialogstr = bindings.show_dialog(area.height as usize, area.width as usize);
|
||||||
|
|
||||||
|
// Don't show terminal cursor when we show a dialog.
|
||||||
|
let hide_cursor = !dialogstr.is_empty();
|
||||||
|
|
||||||
|
store.application.draw_curr = Some(Instant::now());
|
||||||
|
let screen = Screen::new(store)
|
||||||
|
.show_dialog(dialogstr)
|
||||||
|
.show_mode(modestr)
|
||||||
|
.borders(true)
|
||||||
|
.focus(focused);
|
||||||
f.render_stateful_widget(screen, area, sstate);
|
f.render_stateful_widget(screen, area, sstate);
|
||||||
|
|
||||||
|
if hide_cursor {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if let Some((cx, cy)) = sstate.get_term_cursor() {
|
if let Some((cx, cy)) = sstate.get_term_cursor() {
|
||||||
if let Some(c) = cursor {
|
if let Some(c) = cursor {
|
||||||
let style = Style::default().fg(Color::Green);
|
let style = Style::default().fg(Color::Green);
|
||||||
@@ -175,8 +334,6 @@ impl Application {
|
|||||||
}
|
}
|
||||||
f.set_cursor(cx, cy);
|
f.set_cursor(cx, cy);
|
||||||
}
|
}
|
||||||
|
|
||||||
store.application.load_older(area.height as u32);
|
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -184,25 +341,50 @@ impl Application {
|
|||||||
|
|
||||||
async fn step(&mut self) -> Result<TerminalKey, std::io::Error> {
|
async fn step(&mut self) -> Result<TerminalKey, std::io::Error> {
|
||||||
loop {
|
loop {
|
||||||
self.redraw(false, self.store.clone().lock().await.deref_mut())?;
|
self.redraw(self.dirty, self.store.clone().lock().await.deref_mut())?;
|
||||||
|
self.dirty = false;
|
||||||
|
|
||||||
if !poll(Duration::from_millis(500))? {
|
if !poll(Duration::from_secs(1))? {
|
||||||
|
// Redraw in case there's new messages to show.
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
match read()? {
|
match read()? {
|
||||||
Event::Key(ke) => return Ok(ke.into()),
|
Event::Key(ke) => {
|
||||||
|
if ke.kind == KeyEventKind::Release {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(ke.into());
|
||||||
|
},
|
||||||
Event::Mouse(_) => {
|
Event::Mouse(_) => {
|
||||||
// Do nothing for now.
|
// Do nothing for now.
|
||||||
},
|
},
|
||||||
Event::FocusGained | Event::FocusLost => {
|
Event::FocusGained => {
|
||||||
// Do nothing for now.
|
self.focused = true;
|
||||||
|
},
|
||||||
|
Event::FocusLost => {
|
||||||
|
self.focused = false;
|
||||||
},
|
},
|
||||||
Event::Resize(_, _) => {
|
Event::Resize(_, _) => {
|
||||||
// We'll redraw for the new size next time step() is called.
|
// We'll redraw for the new size next time step() is called.
|
||||||
},
|
},
|
||||||
Event::Paste(_) => {
|
Event::Paste(s) => {
|
||||||
// Do nothing for now.
|
let act = InsertTextAction::Transcribe(s, MoveDir1D::Previous, 1.into());
|
||||||
|
let act = EditorAction::from(act);
|
||||||
|
let ctx = ProgramContext::default();
|
||||||
|
let mut store = self.store.lock().await;
|
||||||
|
|
||||||
|
match self.screen.editor_command(&act, &ctx, store.deref_mut()) {
|
||||||
|
Ok(None) => {},
|
||||||
|
Ok(Some(info)) => {
|
||||||
|
drop(store);
|
||||||
|
self.handle_info(info);
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
self.screen.push_error(e);
|
||||||
|
},
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -226,7 +408,7 @@ impl Application {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn action_run(
|
async fn action_run(
|
||||||
&mut self,
|
&mut self,
|
||||||
action: ProgramAction,
|
action: ProgramAction,
|
||||||
ctx: ProgramContext,
|
ctx: ProgramContext,
|
||||||
@@ -257,12 +439,11 @@ impl Application {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// Simple delegations.
|
// Simple delegations.
|
||||||
Action::Application(act) => self.iamb_run(act, ctx, store)?,
|
Action::Application(act) => self.iamb_run(act, ctx, store).await?,
|
||||||
Action::CommandBar(act) => self.screen.command_bar(&act, &ctx)?,
|
Action::CommandBar(act) => self.screen.command_bar(&act, &ctx)?,
|
||||||
Action::Macro(act) => self.bindings.macro_command(&act, &ctx, store)?,
|
Action::Macro(act) => self.bindings.macro_command(&act, &ctx, store)?,
|
||||||
Action::Scroll(style) => self.screen.scroll(&style, &ctx, store)?,
|
Action::Scroll(style) => self.screen.scroll(&style, &ctx, store)?,
|
||||||
Action::Suspend => self.terminal.program_suspend()?,
|
Action::ShowInfoMessage(info) => Some(info),
|
||||||
Action::Tab(cmd) => self.screen.tab_command(&cmd, &ctx, store)?,
|
|
||||||
Action::Window(cmd) => self.screen.window_command(&cmd, &ctx, store)?,
|
Action::Window(cmd) => self.screen.window_command(&cmd, &ctx, store)?,
|
||||||
|
|
||||||
Action::Jump(l, dir, count) => {
|
Action::Jump(l, dir, count) => {
|
||||||
@@ -271,8 +452,20 @@ impl Application {
|
|||||||
|
|
||||||
None
|
None
|
||||||
},
|
},
|
||||||
|
Action::Suspend => {
|
||||||
|
self.terminal.program_suspend()?;
|
||||||
|
|
||||||
|
None
|
||||||
|
},
|
||||||
|
|
||||||
// UI actions.
|
// UI actions.
|
||||||
|
Action::Tab(cmd) => {
|
||||||
|
if let TabAction::Close(_, _) = &cmd {
|
||||||
|
self.last_layout = self.screen.as_description().into();
|
||||||
|
}
|
||||||
|
|
||||||
|
self.screen.tab_command(&cmd, &ctx, store)?
|
||||||
|
},
|
||||||
Action::RedrawScreen => {
|
Action::RedrawScreen => {
|
||||||
self.screen.clear_message();
|
self.screen.clear_message();
|
||||||
self.redraw(true, store)?;
|
self.redraw(true, store)?;
|
||||||
@@ -288,7 +481,7 @@ impl Application {
|
|||||||
None
|
None
|
||||||
},
|
},
|
||||||
Action::Command(act) => {
|
Action::Command(act) => {
|
||||||
let acts = self.cmds.command(&act, &ctx)?;
|
let acts = store.application.cmds.command(&act, &ctx)?;
|
||||||
self.action_prepend(acts);
|
self.action_prepend(acts);
|
||||||
|
|
||||||
None
|
None
|
||||||
@@ -314,12 +507,16 @@ impl Application {
|
|||||||
return Ok(info);
|
return Ok(info);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn iamb_run(
|
async fn iamb_run(
|
||||||
&mut self,
|
&mut self,
|
||||||
action: IambAction,
|
action: IambAction,
|
||||||
ctx: ProgramContext,
|
ctx: ProgramContext,
|
||||||
store: &mut ProgramStore,
|
store: &mut ProgramStore,
|
||||||
) -> IambResult<EditInfo> {
|
) -> IambResult<EditInfo> {
|
||||||
|
if action.scribbles() {
|
||||||
|
self.dirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
let info = match action {
|
let info = match action {
|
||||||
IambAction::ToggleScrollbackFocus => {
|
IambAction::ToggleScrollbackFocus => {
|
||||||
self.screen.current_window_mut()?.focus_toggle();
|
self.screen.current_window_mut()?.focus_toggle();
|
||||||
@@ -327,24 +524,34 @@ impl Application {
|
|||||||
None
|
None
|
||||||
},
|
},
|
||||||
|
|
||||||
IambAction::Room(act) => {
|
IambAction::Homeserver(act) => {
|
||||||
let acts = self.screen.current_window_mut()?.room_command(act, ctx, store)?;
|
let acts = self.homeserver_command(act, ctx, store).await?;
|
||||||
self.action_prepend(acts);
|
self.action_prepend(acts);
|
||||||
|
|
||||||
None
|
None
|
||||||
},
|
},
|
||||||
|
IambAction::Keys(act) => self.keys_command(act, ctx, store).await?,
|
||||||
IambAction::SendMessage(room_id, msg) => {
|
IambAction::Message(act) => {
|
||||||
let (event_id, msg) = self.worker.send_message(room_id.clone(), msg)?;
|
self.screen.current_window_mut()?.message_command(act, ctx, store).await?
|
||||||
let user = store.application.settings.profile.user_id.clone();
|
},
|
||||||
let info = store.application.get_room_info(room_id);
|
IambAction::Room(act) => {
|
||||||
let key = (MessageTimeStamp::LocalEcho, event_id);
|
let acts = self.screen.current_window_mut()?.room_command(act, ctx, store).await?;
|
||||||
let msg = MessageContent::Original(msg.into());
|
self.action_prepend(acts);
|
||||||
let msg = Message::new(msg, user, MessageTimeStamp::LocalEcho);
|
|
||||||
info.messages.insert(key, msg);
|
|
||||||
|
|
||||||
None
|
None
|
||||||
},
|
},
|
||||||
|
IambAction::Send(act) => {
|
||||||
|
self.screen.current_window_mut()?.send_command(act, ctx, store).await?
|
||||||
|
},
|
||||||
|
|
||||||
|
IambAction::OpenLink(url) => {
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
return open::that(url);
|
||||||
|
});
|
||||||
|
|
||||||
|
None
|
||||||
|
},
|
||||||
|
|
||||||
IambAction::Verify(act, user_dev) => {
|
IambAction::Verify(act, user_dev) => {
|
||||||
if let Some(sas) = store.application.verifications.get(&user_dev) {
|
if let Some(sas) = store.application.verifications.get(&user_dev) {
|
||||||
self.worker.verify(act, sas.clone())?
|
self.worker.verify(act, sas.clone())?
|
||||||
@@ -364,6 +571,82 @@ impl Application {
|
|||||||
Ok(info)
|
Ok(info)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn homeserver_command(
|
||||||
|
&mut self,
|
||||||
|
action: HomeserverAction,
|
||||||
|
ctx: ProgramContext,
|
||||||
|
store: &mut ProgramStore,
|
||||||
|
) -> IambResult<Vec<(Action<IambInfo>, ProgramContext)>> {
|
||||||
|
match action {
|
||||||
|
HomeserverAction::CreateRoom(alias, vis, flags) => {
|
||||||
|
let client = &store.application.worker.client;
|
||||||
|
let room_id = create_room(client, alias, vis, flags).await?;
|
||||||
|
let room = IambId::Room(room_id, None);
|
||||||
|
let target = OpenTarget::Application(room);
|
||||||
|
let action = WindowAction::Switch(target);
|
||||||
|
|
||||||
|
Ok(vec![(action.into(), ctx)])
|
||||||
|
},
|
||||||
|
HomeserverAction::Logout(user, true) => {
|
||||||
|
self.worker.logout(user)?;
|
||||||
|
let flags = CloseFlags::QUIT | CloseFlags::FORCE;
|
||||||
|
let act = TabAction::Close(TabTarget::All, flags);
|
||||||
|
|
||||||
|
Ok(vec![(act.into(), ctx)])
|
||||||
|
},
|
||||||
|
HomeserverAction::Logout(user, false) => {
|
||||||
|
let msg = "Would you like to logout?";
|
||||||
|
let act = IambAction::from(HomeserverAction::Logout(user, true));
|
||||||
|
let prompt = PromptYesNo::new(msg, vec![Action::from(act)]);
|
||||||
|
let prompt = Box::new(prompt);
|
||||||
|
|
||||||
|
Err(UIError::NeedConfirm(prompt))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn keys_command(
|
||||||
|
&mut self,
|
||||||
|
action: KeysAction,
|
||||||
|
_: ProgramContext,
|
||||||
|
store: &mut ProgramStore,
|
||||||
|
) -> IambResult<EditInfo> {
|
||||||
|
let encryption = store.application.worker.client.encryption();
|
||||||
|
|
||||||
|
match action {
|
||||||
|
KeysAction::Export(path, passphrase) => {
|
||||||
|
encryption
|
||||||
|
.export_room_keys(path.into(), &passphrase, |_| true)
|
||||||
|
.await
|
||||||
|
.map_err(IambError::from)?;
|
||||||
|
|
||||||
|
Ok(Some("Successfully exported room keys".into()))
|
||||||
|
},
|
||||||
|
KeysAction::Import(path, passphrase) => {
|
||||||
|
let res = encryption
|
||||||
|
.import_room_keys(path.into(), &passphrase)
|
||||||
|
.await
|
||||||
|
.map_err(IambError::from)?;
|
||||||
|
|
||||||
|
let msg = format!("Imported {} of {} keys", res.imported_count, res.total_count);
|
||||||
|
|
||||||
|
Ok(Some(msg.into()))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_info(&mut self, info: InfoMessage) {
|
||||||
|
match info {
|
||||||
|
InfoMessage::Message(info) => {
|
||||||
|
self.screen.push_info(info);
|
||||||
|
},
|
||||||
|
InfoMessage::Pager(text) => {
|
||||||
|
let pager = Box::new(Pager::new(text, vec![]));
|
||||||
|
self.bindings.run_dialog(pager);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn run(&mut self) -> Result<(), std::io::Error> {
|
pub async fn run(&mut self) -> Result<(), std::io::Error> {
|
||||||
self.terminal.clear()?;
|
self.terminal.clear()?;
|
||||||
|
|
||||||
@@ -378,17 +661,24 @@ impl Application {
|
|||||||
let mut keyskip = false;
|
let mut keyskip = false;
|
||||||
|
|
||||||
while let Some((action, ctx)) = self.action_pop(keyskip) {
|
while let Some((action, ctx)) = self.action_pop(keyskip) {
|
||||||
match self.action_run(action, ctx, locked.deref_mut()) {
|
match self.action_run(action, ctx, locked.deref_mut()).await {
|
||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
// Continue processing.
|
// Continue processing.
|
||||||
continue;
|
continue;
|
||||||
},
|
},
|
||||||
Ok(Some(info)) => {
|
Ok(Some(info)) => {
|
||||||
self.screen.push_info(info);
|
self.handle_info(info);
|
||||||
|
|
||||||
// Continue processing; we'll redraw later.
|
// Continue processing; we'll redraw later.
|
||||||
continue;
|
continue;
|
||||||
},
|
},
|
||||||
|
Err(
|
||||||
|
UIError::NeedConfirm(dialog) |
|
||||||
|
UIError::EditingFailure(EditError::NeedConfirm(dialog)),
|
||||||
|
) => {
|
||||||
|
self.bindings.run_dialog(dialog);
|
||||||
|
continue;
|
||||||
|
},
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
self.screen.push_error(e);
|
self.screen.push_error(e);
|
||||||
|
|
||||||
@@ -400,6 +690,19 @@ impl Application {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(ref layout) = self.last_layout {
|
||||||
|
let locked = self.store.lock().await;
|
||||||
|
let path = locked.application.settings.layout_json.as_path();
|
||||||
|
path.parent().map(create_dir_all).transpose()?;
|
||||||
|
|
||||||
|
let file = File::create(path)?;
|
||||||
|
let writer = BufWriter::new(file);
|
||||||
|
|
||||||
|
if let Err(e) = serde_json::to_writer(writer, layout) {
|
||||||
|
tracing::error!("Failed to save window layout while exiting: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
crossterm::terminal::disable_raw_mode()?;
|
crossterm::terminal::disable_raw_mode()?;
|
||||||
execute!(self.terminal.backend_mut(), LeaveAlternateScreen)?;
|
execute!(self.terminal.backend_mut(), LeaveAlternateScreen)?;
|
||||||
self.terminal.show_cursor()?;
|
self.terminal.show_cursor()?;
|
||||||
@@ -408,32 +711,68 @@ impl Application {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn login(worker: Requester, settings: &ApplicationSettings) -> IambResult<()> {
|
fn gen_passphrase() -> String {
|
||||||
println!("Logging in for {}...", settings.profile.user_id);
|
rand::thread_rng()
|
||||||
|
.sample_iter(&Alphanumeric)
|
||||||
|
.take(20)
|
||||||
|
.map(char::from)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_response(question: &str) -> String {
|
||||||
|
println!("{question}");
|
||||||
|
let mut input = String::new();
|
||||||
|
let _ = std::io::stdin().read_line(&mut input);
|
||||||
|
input
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_yesno(question: &str) -> Option<char> {
|
||||||
|
read_response(question).chars().next().map(|c| c.to_ascii_lowercase())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn login(worker: &Requester, settings: &ApplicationSettings) -> IambResult<()> {
|
||||||
if settings.session_json.is_file() {
|
if settings.session_json.is_file() {
|
||||||
let file = File::open(settings.session_json.as_path())?;
|
let session = settings.read_session(&settings.session_json)?;
|
||||||
let reader = BufReader::new(file);
|
worker.login(LoginStyle::SessionRestore(session.into()))?;
|
||||||
let session = serde_json::from_reader(reader).map_err(IambError::from)?;
|
|
||||||
|
|
||||||
worker.login(LoginStyle::SessionRestore(session))?;
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
if settings.session_json_old.is_file() && !settings.sled_dir.is_dir() {
|
||||||
|
let session = settings.read_session(&settings.session_json_old)?;
|
||||||
|
worker.login(LoginStyle::SessionRestore(session.into()))?;
|
||||||
|
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
|
let login_style =
|
||||||
|
match read_response("Please select login type: [p]assword / [s]ingle sign on")
|
||||||
|
.chars()
|
||||||
|
.next()
|
||||||
|
.map(|c| c.to_ascii_lowercase())
|
||||||
|
{
|
||||||
|
None | Some('p') => {
|
||||||
let password = rpassword::prompt_password("Password: ")?;
|
let password = rpassword::prompt_password("Password: ")?;
|
||||||
|
LoginStyle::Password(password)
|
||||||
|
},
|
||||||
|
Some('s') => LoginStyle::SingleSignOn,
|
||||||
|
Some(_) => {
|
||||||
|
println!("Failed to login. Please enter 'p' or 's'");
|
||||||
|
continue;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
match worker.login(LoginStyle::Password(password)) {
|
match worker.login(login_style) {
|
||||||
Ok(info) => {
|
Ok(info) => {
|
||||||
if let Some(msg) = info {
|
if let Some(msg) = info {
|
||||||
println!("{}", msg);
|
println!("{msg}");
|
||||||
}
|
}
|
||||||
|
|
||||||
break;
|
break;
|
||||||
},
|
},
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
println!("Failed to login: {}", err);
|
println!("Failed to login: {err}");
|
||||||
continue;
|
continue;
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -443,49 +782,174 @@ fn login(worker: Requester, settings: &ApplicationSettings) -> IambResult<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn print_exit<T: Display, N>(v: T) -> N {
|
fn print_exit<T: Display, N>(v: T) -> N {
|
||||||
println!("{}", v);
|
eprintln!("{v}");
|
||||||
process::exit(2);
|
process::exit(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
// We can't access the OlmMachine directly, so write the keys to a temporary
|
||||||
async fn main() -> IambResult<()> {
|
// file first, and then import them later.
|
||||||
// Parse command-line flags.
|
async fn check_import_keys(
|
||||||
let iamb = Iamb::parse();
|
settings: &ApplicationSettings,
|
||||||
|
) -> IambResult<Option<(temp_dir::TempDir, String)>> {
|
||||||
|
let do_import = settings.sled_dir.is_dir() && !settings.sqlite_dir.is_dir();
|
||||||
|
|
||||||
// Load configuration and set up the Matrix SDK.
|
if !do_import {
|
||||||
let settings = ApplicationSettings::load(iamb).unwrap_or_else(print_exit);
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
// Set up the tracing subscriber so we can log client messages.
|
let question = format!(
|
||||||
let log_prefix = format!("iamb-log-{}", settings.profile_name);
|
"Found old sled store in {}. Would you like to export room keys from it? [y]es/[n]o",
|
||||||
let log_dir = settings.dirs.logs.as_path();
|
settings.sled_dir.display()
|
||||||
|
);
|
||||||
|
|
||||||
create_dir_all(settings.matrix_dir.as_path())?;
|
loop {
|
||||||
create_dir_all(log_dir)?;
|
match read_yesno(&question) {
|
||||||
|
Some('y') => {
|
||||||
|
break;
|
||||||
|
},
|
||||||
|
Some('n') => {
|
||||||
|
return Ok(None);
|
||||||
|
},
|
||||||
|
Some(_) | None => {
|
||||||
|
continue;
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let appender = tracing_appender::rolling::daily(log_dir, log_prefix);
|
let keys = sled_export::export_room_keys(&settings.sled_dir).await?;
|
||||||
let (appender, _) = tracing_appender::non_blocking(appender);
|
let passphrase = gen_passphrase();
|
||||||
|
|
||||||
let subscriber = FmtSubscriber::builder()
|
println!("* Encrypting {} room keys with the passphrase {passphrase:?}...", keys.len());
|
||||||
.with_writer(appender)
|
|
||||||
.with_max_level(Level::WARN)
|
let encrypted = match encrypt_room_key_export(&keys, &passphrase, 500000) {
|
||||||
.finish();
|
Ok(encrypted) => encrypted,
|
||||||
tracing::subscriber::set_global_default(subscriber).expect("setting default subscriber failed");
|
Err(e) => {
|
||||||
|
format!("* Failed to encrypt room keys during export: {e}");
|
||||||
|
process::exit(2);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let tmpdir = TempDir::new()?;
|
||||||
|
let exported = tmpdir.child("keys");
|
||||||
|
|
||||||
|
println!("* Writing encrypted room keys to {}...", exported.display());
|
||||||
|
tokio::fs::write(&exported, &encrypted).await?;
|
||||||
|
|
||||||
|
Ok(Some((tmpdir, passphrase)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn login_upgrade(
|
||||||
|
keydir: TempDir,
|
||||||
|
passphrase: String,
|
||||||
|
worker: &Requester,
|
||||||
|
settings: &ApplicationSettings,
|
||||||
|
store: &AsyncProgramStore,
|
||||||
|
) -> IambResult<()> {
|
||||||
|
println!(
|
||||||
|
"Please log in for {} to import the room keys into a new session",
|
||||||
|
settings.profile.user_id
|
||||||
|
);
|
||||||
|
|
||||||
|
login(worker, settings).await?;
|
||||||
|
|
||||||
|
println!("* Importing room keys...");
|
||||||
|
|
||||||
|
let exported = keydir.child("keys");
|
||||||
|
let imported = worker.client.encryption().import_room_keys(exported, &passphrase).await;
|
||||||
|
|
||||||
|
match imported {
|
||||||
|
Ok(res) => {
|
||||||
|
println!(
|
||||||
|
"* Successfully imported {} out of {} keys",
|
||||||
|
res.imported_count, res.total_count
|
||||||
|
);
|
||||||
|
let _ = keydir.cleanup();
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
println!(
|
||||||
|
"Failed to import room keys from {}/keys: {e}\n\n\
|
||||||
|
They have been encrypted with the passphrase {passphrase:?}.\
|
||||||
|
Please save them and try importing them manually instead\n",
|
||||||
|
keydir.path().display()
|
||||||
|
);
|
||||||
|
|
||||||
|
loop {
|
||||||
|
match read_yesno("Would you like to continue logging in? [y]es/[n]o") {
|
||||||
|
Some('y') => break,
|
||||||
|
Some('n') => print_exit("* Exiting..."),
|
||||||
|
Some(_) | None => continue,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("* Syncing...");
|
||||||
|
worker::do_first_sync(&worker.client, store)
|
||||||
|
.await
|
||||||
|
.map_err(IambError::from)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn login_normal(
|
||||||
|
worker: &Requester,
|
||||||
|
settings: &ApplicationSettings,
|
||||||
|
store: &AsyncProgramStore,
|
||||||
|
) -> IambResult<()> {
|
||||||
|
println!("* Logging in for {}...", settings.profile.user_id);
|
||||||
|
login(worker, settings).await?;
|
||||||
|
println!("* Syncing...");
|
||||||
|
worker::do_first_sync(&worker.client, store)
|
||||||
|
.await
|
||||||
|
.map_err(IambError::from)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run(settings: ApplicationSettings) -> IambResult<()> {
|
||||||
|
// Get old keys the first time we run w/ the upgraded SDK.
|
||||||
|
let import_keys = check_import_keys(&settings).await?;
|
||||||
|
|
||||||
|
// Set up client state.
|
||||||
|
create_dir_all(settings.sqlite_dir.as_path())?;
|
||||||
|
let client = worker::create_client(&settings).await;
|
||||||
|
|
||||||
// Set up the async worker thread and global store.
|
// Set up the async worker thread and global store.
|
||||||
let worker = ClientWorker::spawn(settings.clone());
|
let worker = ClientWorker::spawn(client.clone(), settings.clone()).await;
|
||||||
let store = ChatStore::new(worker.clone(), settings.clone());
|
let store = ChatStore::new(worker.clone(), settings.clone());
|
||||||
let store = Store::new(store);
|
let store = Store::new(store);
|
||||||
let store = Arc::new(AsyncMutex::new(store));
|
let store = Arc::new(AsyncMutex::new(store));
|
||||||
worker.init(store.clone());
|
worker.init(store.clone());
|
||||||
|
|
||||||
login(worker, &settings).unwrap_or_else(print_exit);
|
let res = if let Some((keydir, pass)) = import_keys {
|
||||||
|
login_upgrade(keydir, pass, &worker, &settings, &store).await
|
||||||
|
} else {
|
||||||
|
login_normal(&worker, &settings, &store).await
|
||||||
|
};
|
||||||
|
|
||||||
|
match res {
|
||||||
|
Err(UIError::Application(IambError::Matrix(e))) => {
|
||||||
|
if let Some(ErrorKind::UnknownToken { .. }) = e.client_api_error_kind() {
|
||||||
|
print_exit("Server did not recognize our API token; did you log out from this session elsewhere?")
|
||||||
|
} else {
|
||||||
|
print_exit(e)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(e) => print_exit(e),
|
||||||
|
Ok(()) => (),
|
||||||
|
}
|
||||||
|
|
||||||
|
fn restore_tty() {
|
||||||
|
let _ = crossterm::terminal::disable_raw_mode();
|
||||||
|
let _ = crossterm::execute!(stdout(), DisableBracketedPaste);
|
||||||
|
let _ = crossterm::execute!(stdout(), DisableFocusChange);
|
||||||
|
let _ = crossterm::execute!(stdout(), LeaveAlternateScreen);
|
||||||
|
let _ = crossterm::execute!(stdout(), CursorShow);
|
||||||
|
}
|
||||||
|
|
||||||
// Make sure panics clean up the terminal properly.
|
// Make sure panics clean up the terminal properly.
|
||||||
let orig_hook = std::panic::take_hook();
|
let orig_hook = std::panic::take_hook();
|
||||||
std::panic::set_hook(Box::new(move |panic_info| {
|
std::panic::set_hook(Box::new(move |panic_info| {
|
||||||
let _ = crossterm::terminal::disable_raw_mode();
|
restore_tty();
|
||||||
let _ = crossterm::execute!(stdout(), LeaveAlternateScreen);
|
|
||||||
let _ = crossterm::execute!(stdout(), CursorShow);
|
|
||||||
orig_hook(panic_info);
|
orig_hook(panic_info);
|
||||||
process::exit(1);
|
process::exit(1);
|
||||||
}));
|
}));
|
||||||
@@ -494,6 +958,50 @@ async fn main() -> IambResult<()> {
|
|||||||
|
|
||||||
// We can now run the application.
|
// We can now run the application.
|
||||||
application.run().await?;
|
application.run().await?;
|
||||||
|
restore_tty();
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() -> IambResult<()> {
|
||||||
|
// Parse command-line flags.
|
||||||
|
let iamb = Iamb::parse();
|
||||||
|
|
||||||
|
// Load configuration and set up the Matrix SDK.
|
||||||
|
let settings = ApplicationSettings::load(iamb).unwrap_or_else(print_exit);
|
||||||
|
|
||||||
|
// Set umask on Unix platforms so that tokens, keys, etc. are only readable by the user.
|
||||||
|
#[cfg(unix)]
|
||||||
|
unsafe {
|
||||||
|
libc::umask(0o077);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set up the tracing subscriber so we can log client messages.
|
||||||
|
let log_prefix = format!("iamb-log-{}", settings.profile_name);
|
||||||
|
let log_dir = settings.dirs.logs.as_path();
|
||||||
|
|
||||||
|
let appender = tracing_appender::rolling::daily(log_dir, log_prefix);
|
||||||
|
let (appender, guard) = tracing_appender::non_blocking(appender);
|
||||||
|
|
||||||
|
let subscriber = FmtSubscriber::builder()
|
||||||
|
.with_writer(appender)
|
||||||
|
.with_max_level(settings.tunables.log_level)
|
||||||
|
.finish();
|
||||||
|
tracing::subscriber::set_global_default(subscriber).expect("setting default subscriber failed");
|
||||||
|
|
||||||
|
let rt = tokio::runtime::Builder::new_multi_thread()
|
||||||
|
.enable_all()
|
||||||
|
.worker_threads(2)
|
||||||
|
.thread_name_fn(|| {
|
||||||
|
static ATOMIC_ID: AtomicUsize = AtomicUsize::new(0);
|
||||||
|
let id = ATOMIC_ID.fetch_add(1, Ordering::SeqCst);
|
||||||
|
format!("iamb-worker-{id}")
|
||||||
|
})
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
rt.block_on(async move { run(settings).await })?;
|
||||||
|
|
||||||
|
drop(guard);
|
||||||
process::exit(0);
|
process::exit(0);
|
||||||
}
|
}
|
||||||
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
1455
src/message/html.rs
Normal file
1455
src/message/html.rs
Normal file
File diff suppressed because it is too large
Load Diff
1380
src/message/mod.rs
Normal file
1380
src/message/mod.rs
Normal file
File diff suppressed because it is too large
Load Diff
278
src/message/printer.rs
Normal file
278
src/message/printer.rs
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
//! # 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::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,
|
||||||
|
emoji_shortcodes: bool,
|
||||||
|
|
||||||
|
alignment: Alignment,
|
||||||
|
curr_spans: Vec<Span<'a>>,
|
||||||
|
curr_width: usize,
|
||||||
|
literal: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> TextPrinter<'a> {
|
||||||
|
/// Create a new printer.
|
||||||
|
pub fn new(width: usize, base_style: Style, hide_reply: bool, emoji_shortcodes: bool) -> Self {
|
||||||
|
TextPrinter {
|
||||||
|
text: Text::default(),
|
||||||
|
width,
|
||||||
|
base_style,
|
||||||
|
hide_reply,
|
||||||
|
emoji_shortcodes,
|
||||||
|
|
||||||
|
alignment: Alignment::Left,
|
||||||
|
curr_spans: vec![],
|
||||||
|
curr_width: 0,
|
||||||
|
literal: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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.emoji_shortcodes
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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,
|
||||||
|
emoji_shortcodes: self.emoji_shortcodes,
|
||||||
|
|
||||||
|
alignment: self.alignment,
|
||||||
|
curr_spans: vec![],
|
||||||
|
curr_width: 0,
|
||||||
|
literal: self.literal,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remaining(&self) -> usize {
|
||||||
|
self.width - 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 cow = if self.emoji_shortcodes {
|
||||||
|
Cow::Owned(replace_emojis_in_str(word))
|
||||||
|
} else {
|
||||||
|
Cow::Borrowed(word)
|
||||||
|
};
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
240
src/notifications.rs
Normal file
240
src/notifications.rs
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
use std::time::SystemTime;
|
||||||
|
|
||||||
|
use matrix_sdk::{
|
||||||
|
notification_settings::{IsEncrypted, IsOneToOne, NotificationSettings, RoomNotificationMode},
|
||||||
|
room::Room as MatrixRoom,
|
||||||
|
ruma::{
|
||||||
|
api::client::push::get_notifications::v3::Notification,
|
||||||
|
events::{room::message::MessageType, AnyMessageLikeEventContent, AnySyncTimelineEvent},
|
||||||
|
MilliSecondsSinceUnixEpoch,
|
||||||
|
RoomId,
|
||||||
|
},
|
||||||
|
Client,
|
||||||
|
};
|
||||||
|
use unicode_segmentation::UnicodeSegmentation;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
base::{AsyncProgramStore, IambError, IambResult},
|
||||||
|
config::{ApplicationSettings, NotifyVia},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub async fn register_notifications(
|
||||||
|
client: &Client,
|
||||||
|
settings: &ApplicationSettings,
|
||||||
|
store: &AsyncProgramStore,
|
||||||
|
) {
|
||||||
|
if !settings.tunables.notifications.enabled {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let notify_via = settings.tunables.notifications.via;
|
||||||
|
let show_message = settings.tunables.notifications.show_message;
|
||||||
|
let server_settings = client.notification_settings().await;
|
||||||
|
let Some(startup_ts) = MilliSecondsSinceUnixEpoch::from_system_time(SystemTime::now()) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let store = store.clone();
|
||||||
|
client
|
||||||
|
.register_notification_handler(move |notification, room: MatrixRoom, client: Client| {
|
||||||
|
let store = store.clone();
|
||||||
|
let server_settings = server_settings.clone();
|
||||||
|
async move {
|
||||||
|
let mode = global_or_room_mode(&server_settings, &room).await;
|
||||||
|
if mode == RoomNotificationMode::Mute {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if is_open(&store, room.room_id()).await {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
match parse_notification(notification, room, show_message).await {
|
||||||
|
Ok((summary, body, server_ts)) => {
|
||||||
|
if server_ts < startup_ts {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if is_missing_mention(&body, mode, &client) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
match notify_via {
|
||||||
|
NotifyVia::Desktop => send_notification_desktop(summary, body),
|
||||||
|
NotifyVia::Bell => send_notification_bell(&store).await,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(err) => {
|
||||||
|
tracing::error!("Failed to extract notification data: {err}")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_notification_bell(store: &AsyncProgramStore) {
|
||||||
|
let mut locked = store.lock().await;
|
||||||
|
locked.application.ring_bell = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send_notification_desktop(summary: String, body: Option<String>) {
|
||||||
|
let mut desktop_notification = notify_rust::Notification::new();
|
||||||
|
desktop_notification
|
||||||
|
.summary(&summary)
|
||||||
|
.appname("iamb")
|
||||||
|
.timeout(notify_rust::Timeout::Milliseconds(3000))
|
||||||
|
.action("default", "default");
|
||||||
|
|
||||||
|
if let Some(body) = body {
|
||||||
|
desktop_notification.body(&body);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(err) = desktop_notification.show() {
|
||||||
|
tracing::error!("Failed to send notification: {err}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn global_or_room_mode(
|
||||||
|
settings: &NotificationSettings,
|
||||||
|
room: &MatrixRoom,
|
||||||
|
) -> RoomNotificationMode {
|
||||||
|
let room_mode = settings.get_user_defined_room_notification_mode(room.room_id()).await;
|
||||||
|
if let Some(mode) = room_mode {
|
||||||
|
return mode;
|
||||||
|
}
|
||||||
|
let is_one_to_one = match room.is_direct().await {
|
||||||
|
Ok(true) => IsOneToOne::Yes,
|
||||||
|
_ => IsOneToOne::No,
|
||||||
|
};
|
||||||
|
let is_encrypted = match room.is_encrypted().await {
|
||||||
|
Ok(true) => IsEncrypted::Yes,
|
||||||
|
_ => IsEncrypted::No,
|
||||||
|
};
|
||||||
|
settings
|
||||||
|
.get_default_room_notification_mode(is_encrypted, is_one_to_one)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_missing_mention(body: &Option<String>, mode: RoomNotificationMode, client: &Client) -> bool {
|
||||||
|
if let Some(body) = body {
|
||||||
|
if mode == RoomNotificationMode::MentionsAndKeywordsOnly {
|
||||||
|
let mentioned = match client.user_id() {
|
||||||
|
Some(user_id) => body.contains(user_id.localpart()),
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
return !mentioned;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn is_open(store: &AsyncProgramStore, room_id: &RoomId) -> bool {
|
||||||
|
let mut locked = store.lock().await;
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn parse_notification(
|
||||||
|
notification: Notification,
|
||||||
|
room: MatrixRoom,
|
||||||
|
show_body: bool,
|
||||||
|
) -> IambResult<(String, Option<String>, MilliSecondsSinceUnixEpoch)> {
|
||||||
|
let event = notification.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 body = if show_body {
|
||||||
|
event_notification_body(
|
||||||
|
&event,
|
||||||
|
sender_name,
|
||||||
|
room.is_direct().await.map_err(IambError::from)?,
|
||||||
|
)
|
||||||
|
.map(truncate)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
return Ok((sender_name.to_string(), body, server_ts));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn event_notification_body(
|
||||||
|
event: &AnySyncTimelineEvent,
|
||||||
|
sender_name: &str,
|
||||||
|
is_direct: bool,
|
||||||
|
) -> 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) => {
|
||||||
|
let message = &content.body;
|
||||||
|
format!("{sender_name}: {message}")
|
||||||
|
},
|
||||||
|
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) => {
|
||||||
|
let message = &content.body;
|
||||||
|
format!("{sender_name}: {message}")
|
||||||
|
},
|
||||||
|
MessageType::ServerNotice(content) => {
|
||||||
|
let message = &content.body;
|
||||||
|
format!("{sender_name}: {message}")
|
||||||
|
},
|
||||||
|
MessageType::Text(content) => {
|
||||||
|
if is_direct {
|
||||||
|
content.body
|
||||||
|
} else {
|
||||||
|
let message = &content.body;
|
||||||
|
format!("{sender_name}: {message}")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
MessageType::Video(_) => {
|
||||||
|
format!("{sender_name} sent a video.")
|
||||||
|
},
|
||||||
|
MessageType::VerificationRequest(_) => {
|
||||||
|
format!("{sender_name} sent a verification request.")
|
||||||
|
},
|
||||||
|
_ => unimplemented!(),
|
||||||
|
};
|
||||||
|
Some(body)
|
||||||
|
},
|
||||||
|
AnyMessageLikeEventContent::Sticker(_) => Some(format!("{sender_name} sent a sticker.")),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn truncate(s: String) -> String {
|
||||||
|
static MAX_LENGTH: usize = 100;
|
||||||
|
if s.graphemes(true).count() > MAX_LENGTH {
|
||||||
|
let truncated: String = s.graphemes(true).take(MAX_LENGTH).collect();
|
||||||
|
truncated + "..."
|
||||||
|
} else {
|
||||||
|
s
|
||||||
|
}
|
||||||
|
}
|
||||||
172
src/preview.rs
Normal file
172
src/preview.rs
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
use std::{
|
||||||
|
fs::File,
|
||||||
|
io::{Read, Write},
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
};
|
||||||
|
|
||||||
|
use matrix_sdk::{
|
||||||
|
media::{MediaFormat, MediaRequest},
|
||||||
|
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::io::Reader::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)
|
||||||
|
.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(&MediaRequest { 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)
|
||||||
|
}
|
||||||
199
src/tests.rs
199
src/tests.rs
@@ -1,29 +1,44 @@
|
|||||||
use std::collections::BTreeMap;
|
use std::collections::HashMap;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use matrix_sdk::ruma::{
|
use matrix_sdk::ruma::{
|
||||||
event_id,
|
event_id,
|
||||||
events::room::message::RoomMessageEventContent,
|
events::room::message::{OriginalRoomMessageEvent, RoomMessageEventContent},
|
||||||
server_name,
|
server_name,
|
||||||
user_id,
|
user_id,
|
||||||
EventId,
|
EventId,
|
||||||
|
OwnedEventId,
|
||||||
OwnedRoomId,
|
OwnedRoomId,
|
||||||
OwnedUserId,
|
OwnedUserId,
|
||||||
RoomId,
|
RoomId,
|
||||||
UInt,
|
UInt,
|
||||||
};
|
};
|
||||||
|
|
||||||
use std::path::PathBuf;
|
use lazy_static::lazy_static;
|
||||||
use std::sync::mpsc::sync_channel;
|
use ratatui::style::{Color, Style};
|
||||||
|
use tokio::sync::mpsc::unbounded_channel;
|
||||||
|
use tracing::Level;
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
use lazy_static::lazy_static;
|
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
base::{ChatStore, ProgramStore, RoomFetchStatus, RoomInfo},
|
base::{ChatStore, EventLocation, ProgramStore, RoomInfo},
|
||||||
config::{ApplicationSettings, DirectoryValues, ProfileConfig, TunableValues},
|
config::{
|
||||||
|
user_color,
|
||||||
|
user_style_from_color,
|
||||||
|
ApplicationSettings,
|
||||||
|
DirectoryValues,
|
||||||
|
Notifications,
|
||||||
|
NotifyVia,
|
||||||
|
ProfileConfig,
|
||||||
|
SortOverrides,
|
||||||
|
TunableValues,
|
||||||
|
UserColor,
|
||||||
|
UserDisplayStyle,
|
||||||
|
UserDisplayTunables,
|
||||||
|
},
|
||||||
message::{
|
message::{
|
||||||
Message,
|
Message,
|
||||||
MessageContent,
|
MessageEvent,
|
||||||
MessageKey,
|
MessageKey,
|
||||||
MessageTimeStamp::{LocalEcho, OriginServer},
|
MessageTimeStamp::{LocalEcho, OriginServer},
|
||||||
Messages,
|
Messages,
|
||||||
@@ -31,65 +46,98 @@ use crate::{
|
|||||||
worker::Requester,
|
worker::Requester,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const TEST_ROOM1_ALIAS: &str = "#room1:example.com";
|
||||||
|
|
||||||
lazy_static! {
|
lazy_static! {
|
||||||
pub static ref TEST_ROOM1_ID: OwnedRoomId = RoomId::new(server_name!("example.com")).to_owned();
|
pub static ref TEST_ROOM1_ID: OwnedRoomId = RoomId::new(server_name!("example.com")).to_owned();
|
||||||
pub static ref TEST_USER1: OwnedUserId = user_id!("@user1:example.com").to_owned();
|
pub static ref TEST_USER1: OwnedUserId = user_id!("@user1:example.com").to_owned();
|
||||||
pub static ref TEST_USER2: OwnedUserId = user_id!("@user2:example.com").to_owned();
|
pub static ref TEST_USER2: OwnedUserId = user_id!("@user2:example.com").to_owned();
|
||||||
pub static ref TEST_USER3: OwnedUserId = user_id!("@user2:example.com").to_owned();
|
pub static ref TEST_USER3: OwnedUserId = user_id!("@user3:example.com").to_owned();
|
||||||
pub static ref TEST_USER4: OwnedUserId = user_id!("@user2: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!("@user2:example.com").to_owned();
|
pub static ref TEST_USER5: OwnedUserId = user_id!("@user5:example.com").to_owned();
|
||||||
pub static ref MSG1_KEY: MessageKey = (LocalEcho, EventId::new(server_name!("example.com")));
|
pub static ref MSG1_EVID: OwnedEventId = EventId::new(server_name!("example.com"));
|
||||||
pub static ref MSG2_KEY: MessageKey =
|
pub static ref MSG2_EVID: OwnedEventId = EventId::new(server_name!("example.com"));
|
||||||
(OriginServer(UInt::new(1).unwrap()), EventId::new(server_name!("example.com")));
|
pub static ref MSG3_EVID: OwnedEventId =
|
||||||
pub static ref MSG3_KEY: MessageKey = (
|
event_id!("$5jRz3KfVhaUzXtVj7k:example.com").to_owned();
|
||||||
OriginServer(UInt::new(2).unwrap()),
|
pub static ref MSG4_EVID: OwnedEventId =
|
||||||
event_id!("$5jRz3KfVhaUzXtVj7k:example.com").to_owned()
|
event_id!("$JP6qFV7WyXk5ZnexM3:example.com").to_owned();
|
||||||
);
|
pub static ref MSG5_EVID: OwnedEventId = EventId::new(server_name!("example.com"));
|
||||||
pub static ref MSG4_KEY: MessageKey = (
|
pub static ref MSG1_KEY: MessageKey = (LocalEcho, MSG1_EVID.clone());
|
||||||
OriginServer(UInt::new(2).unwrap()),
|
pub static ref MSG2_KEY: MessageKey = (OriginServer(UInt::new(1).unwrap()), MSG2_EVID.clone());
|
||||||
event_id!("$JP6qFV7WyXk5ZnexM3:example.com").to_owned()
|
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 =
|
pub static ref MSG5_KEY: MessageKey = (OriginServer(UInt::new(8).unwrap()), MSG5_EVID.clone());
|
||||||
(OriginServer(UInt::new(8).unwrap()), EventId::new(server_name!("example.com")));
|
}
|
||||||
|
|
||||||
|
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 {
|
pub fn mock_message1() -> Message {
|
||||||
let content = RoomMessageEventContent::text_plain("writhe");
|
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)
|
Message::new(content, TEST_USER1.clone(), MSG1_KEY.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn mock_message2() -> Message {
|
pub fn mock_message2() -> Message {
|
||||||
let content = RoomMessageEventContent::text_plain("helium");
|
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 {
|
pub fn mock_message3() -> Message {
|
||||||
let content = RoomMessageEventContent::text_plain("this\nis\na\nmultiline\nmessage");
|
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 {
|
pub fn mock_message4() -> Message {
|
||||||
let content = RoomMessageEventContent::text_plain("help");
|
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 {
|
pub fn mock_message5() -> Message {
|
||||||
let content = RoomMessageEventContent::text_plain("character");
|
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 {
|
pub fn mock_messages() -> Messages {
|
||||||
let mut messages = BTreeMap::new();
|
let mut messages = Messages::default();
|
||||||
|
|
||||||
messages.insert(MSG1_KEY.clone(), mock_message1());
|
messages.insert(MSG1_KEY.clone(), mock_message1());
|
||||||
messages.insert(MSG2_KEY.clone(), mock_message2());
|
messages.insert(MSG2_KEY.clone(), mock_message2());
|
||||||
@@ -101,48 +149,99 @@ pub fn mock_messages() -> Messages {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn mock_room() -> RoomInfo {
|
pub fn mock_room() -> RoomInfo {
|
||||||
RoomInfo {
|
let mut room = RoomInfo::default();
|
||||||
name: Some("Watercooler Discussion".into()),
|
room.name = Some("Watercooler Discussion".into());
|
||||||
messages: mock_messages(),
|
room.keys = mock_keys();
|
||||||
fetch_id: RoomFetchStatus::NotStarted,
|
*room.get_thread_mut(None) = mock_messages();
|
||||||
fetch_last: None,
|
room
|
||||||
users_typing: None,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn mock_dirs() -> DirectoryValues {
|
pub fn mock_dirs() -> DirectoryValues {
|
||||||
DirectoryValues {
|
DirectoryValues {
|
||||||
cache: PathBuf::new(),
|
cache: PathBuf::new(),
|
||||||
|
data: PathBuf::new(),
|
||||||
logs: PathBuf::new(),
|
logs: PathBuf::new(),
|
||||||
downloads: PathBuf::new(),
|
downloads: None,
|
||||||
|
image_previews: PathBuf::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn mock_tunables() -> TunableValues {
|
||||||
|
TunableValues {
|
||||||
|
default_room: None,
|
||||||
|
log_level: Level::INFO,
|
||||||
|
message_shortcode_display: false,
|
||||||
|
reaction_display: true,
|
||||||
|
reaction_shortcode_display: false,
|
||||||
|
read_receipt_send: true,
|
||||||
|
read_receipt_display: true,
|
||||||
|
request_timeout: 120,
|
||||||
|
sort: SortOverrides::default().values(),
|
||||||
|
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,
|
||||||
|
username_display: UserDisplayStyle::Username,
|
||||||
|
message_user_color: false,
|
||||||
|
notifications: Notifications {
|
||||||
|
enabled: false,
|
||||||
|
via: NotifyVia::Desktop,
|
||||||
|
show_message: true,
|
||||||
|
},
|
||||||
|
image_preview: None,
|
||||||
|
user_gutter_width: 30,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn mock_settings() -> ApplicationSettings {
|
pub fn mock_settings() -> ApplicationSettings {
|
||||||
ApplicationSettings {
|
ApplicationSettings {
|
||||||
matrix_dir: PathBuf::new(),
|
layout_json: PathBuf::new(),
|
||||||
session_json: PathBuf::new(),
|
session_json: PathBuf::new(),
|
||||||
|
session_json_old: PathBuf::new(),
|
||||||
|
sled_dir: PathBuf::new(),
|
||||||
|
sqlite_dir: PathBuf::new(),
|
||||||
|
|
||||||
profile_name: "test".into(),
|
profile_name: "test".into(),
|
||||||
profile: ProfileConfig {
|
profile: ProfileConfig {
|
||||||
user_id: user_id!("@user:example.com").to_owned(),
|
user_id: user_id!("@user:example.com").to_owned(),
|
||||||
url: Url::parse("https://example.com").unwrap(),
|
url: None,
|
||||||
settings: None,
|
settings: None,
|
||||||
dirs: None,
|
dirs: None,
|
||||||
|
layout: None,
|
||||||
|
macros: None,
|
||||||
},
|
},
|
||||||
tunables: TunableValues { typing_notice: true, typing_notice_display: true },
|
tunables: mock_tunables(),
|
||||||
dirs: mock_dirs(),
|
dirs: mock_dirs(),
|
||||||
|
layout: Default::default(),
|
||||||
|
macros: HashMap::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn mock_store() -> ProgramStore {
|
pub async fn mock_store() -> ProgramStore {
|
||||||
let (tx, _) = sync_channel(5);
|
let (tx, _) = unbounded_channel();
|
||||||
let worker = Requester { tx };
|
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());
|
let mut store = ChatStore::new(worker, mock_settings());
|
||||||
|
|
||||||
|
// Add presence information.
|
||||||
|
store.presences.get_or_default(TEST_USER1.clone());
|
||||||
|
store.presences.get_or_default(TEST_USER2.clone());
|
||||||
|
store.presences.get_or_default(TEST_USER3.clone());
|
||||||
|
store.presences.get_or_default(TEST_USER4.clone());
|
||||||
|
store.presences.get_or_default(TEST_USER5.clone());
|
||||||
|
|
||||||
let room_id = TEST_ROOM1_ID.clone();
|
let room_id = TEST_ROOM1_ID.clone();
|
||||||
let info = mock_room();
|
let info = mock_room();
|
||||||
|
|
||||||
store.rooms.insert(room_id, info);
|
store.rooms.insert(room_id.clone(), info);
|
||||||
|
store.names.insert(TEST_ROOM1_ALIAS.to_string(), room_id);
|
||||||
|
|
||||||
ProgramStore::new(store)
|
ProgramStore::new(store)
|
||||||
}
|
}
|
||||||
|
|||||||
216
src/util.rs
Normal file
216
src/util.rs
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
//! # 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 {
|
||||||
|
lines: 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
1028
src/windows/mod.rs
1028
src/windows/mod.rs
File diff suppressed because it is too large
Load Diff
@@ -1,47 +1,96 @@
|
|||||||
|
//! Window for Matrix rooms
|
||||||
|
use std::borrow::Cow;
|
||||||
|
use std::ffi::{OsStr, OsString};
|
||||||
|
use std::fs;
|
||||||
|
use std::ops::Deref;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use edit::edit as external_edit;
|
||||||
|
use modalkit::editing::store::RegisterError;
|
||||||
|
use std::process::Command;
|
||||||
|
use tokio;
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
use matrix_sdk::{
|
use matrix_sdk::{
|
||||||
|
attachment::AttachmentConfig,
|
||||||
|
media::{MediaFormat, MediaRequest},
|
||||||
room::Room as MatrixRoom,
|
room::Room as MatrixRoom,
|
||||||
ruma::{OwnedRoomId, RoomId},
|
ruma::{
|
||||||
|
events::reaction::ReactionEventContent,
|
||||||
|
events::relation::{Annotation, Replacement},
|
||||||
|
events::room::message::{
|
||||||
|
AddMentions,
|
||||||
|
ForwardThread,
|
||||||
|
MessageType,
|
||||||
|
OriginalRoomMessageEvent,
|
||||||
|
Relation,
|
||||||
|
ReplyWithinThread,
|
||||||
|
RoomMessageEventContent,
|
||||||
|
TextMessageEventContent,
|
||||||
|
},
|
||||||
|
OwnedEventId,
|
||||||
|
OwnedRoomId,
|
||||||
|
RoomId,
|
||||||
|
},
|
||||||
|
RoomState,
|
||||||
};
|
};
|
||||||
|
|
||||||
use modalkit::tui::{buffer::Buffer, layout::Rect, widgets::StatefulWidget};
|
use ratatui::{
|
||||||
|
buffer::Buffer,
|
||||||
use modalkit::{
|
layout::Rect,
|
||||||
widgets::textbox::{TextBox, TextBoxState},
|
text::{Line, Span},
|
||||||
widgets::TerminalCursor,
|
widgets::{Paragraph, StatefulWidget, Widget},
|
||||||
widgets::{PromptActions, WindowOps},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
use modalkit::editing::{
|
use modalkit::keybindings::dialog::{MultiChoice, MultiChoiceItem, PromptYesNo};
|
||||||
action::{
|
|
||||||
EditError,
|
use modalkit_ratatui::{
|
||||||
EditInfo,
|
textbox::{TextBox, TextBoxState},
|
||||||
EditResult,
|
PromptActions,
|
||||||
|
TerminalCursor,
|
||||||
|
WindowOps,
|
||||||
|
};
|
||||||
|
|
||||||
|
use modalkit::actions::{
|
||||||
|
Action,
|
||||||
Editable,
|
Editable,
|
||||||
EditorAction,
|
EditorAction,
|
||||||
Jumpable,
|
Jumpable,
|
||||||
PromptAction,
|
PromptAction,
|
||||||
Promptable,
|
Promptable,
|
||||||
Scrollable,
|
Scrollable,
|
||||||
},
|
};
|
||||||
base::{CloseFlags, Count, MoveDir1D, PositionList, ScrollStyle, WordStyle},
|
use modalkit::editing::{
|
||||||
|
completion::CompletionList,
|
||||||
context::Resolve,
|
context::Resolve,
|
||||||
history::{self, HistoryList},
|
history::{self, HistoryList},
|
||||||
rope::EditRope,
|
rope::EditRope,
|
||||||
};
|
};
|
||||||
|
use modalkit::errors::{EditError, EditResult, UIError};
|
||||||
|
use modalkit::prelude::*;
|
||||||
|
|
||||||
use crate::base::{
|
use crate::base::{
|
||||||
|
DownloadFlags,
|
||||||
IambAction,
|
IambAction,
|
||||||
IambBufferId,
|
IambBufferId,
|
||||||
|
IambError,
|
||||||
IambInfo,
|
IambInfo,
|
||||||
IambResult,
|
IambResult,
|
||||||
|
MessageAction,
|
||||||
ProgramAction,
|
ProgramAction,
|
||||||
ProgramContext,
|
ProgramContext,
|
||||||
ProgramStore,
|
ProgramStore,
|
||||||
RoomFocus,
|
RoomFocus,
|
||||||
|
RoomInfo,
|
||||||
|
SendAction,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use crate::message::{text_to_message, Message, MessageEvent, MessageKey, MessageTimeStamp};
|
||||||
|
use crate::worker::Requester;
|
||||||
|
|
||||||
use super::scrollback::{Scrollback, ScrollbackState};
|
use super::scrollback::{Scrollback, ScrollbackState};
|
||||||
|
|
||||||
|
/// State needed for rendering [Chat].
|
||||||
pub struct ChatState {
|
pub struct ChatState {
|
||||||
room_id: OwnedRoomId,
|
room_id: OwnedRoomId,
|
||||||
room: MatrixRoom,
|
room: MatrixRoom,
|
||||||
@@ -52,13 +101,16 @@ pub struct ChatState {
|
|||||||
|
|
||||||
scrollback: ScrollbackState,
|
scrollback: ScrollbackState,
|
||||||
focus: RoomFocus,
|
focus: RoomFocus,
|
||||||
|
|
||||||
|
reply_to: Option<MessageKey>,
|
||||||
|
editing: Option<MessageKey>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ChatState {
|
impl ChatState {
|
||||||
pub fn new(room: MatrixRoom, store: &mut ProgramStore) -> Self {
|
pub fn new(room: MatrixRoom, thread: Option<OwnedEventId>, store: &mut ProgramStore) -> Self {
|
||||||
let room_id = room.room_id().to_owned();
|
let room_id = room.room_id().to_owned();
|
||||||
let scrollback = ScrollbackState::new(room_id.clone());
|
let scrollback = ScrollbackState::new(room_id.clone(), thread.clone());
|
||||||
let id = IambBufferId::Room(room_id.clone(), RoomFocus::MessageBar);
|
let id = IambBufferId::Room(room_id.clone(), thread, RoomFocus::MessageBar);
|
||||||
let ebuf = store.load_buffer(id);
|
let ebuf = store.load_buffer(id);
|
||||||
let tbox = TextBoxState::new(ebuf);
|
let tbox = TextBoxState::new(ebuf);
|
||||||
|
|
||||||
@@ -72,9 +124,464 @@ impl ChatState {
|
|||||||
|
|
||||||
scrollback,
|
scrollback,
|
||||||
focus: RoomFocus::MessageBar,
|
focus: RoomFocus::MessageBar,
|
||||||
|
|
||||||
|
reply_to: None,
|
||||||
|
editing: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn thread(&self) -> Option<&OwnedEventId> {
|
||||||
|
self.scrollback.thread()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_joined(&self, worker: &Requester) -> Result<MatrixRoom, IambError> {
|
||||||
|
let Some(room) = worker.client.get_room(self.id()) else {
|
||||||
|
return Err(IambError::NotJoined);
|
||||||
|
};
|
||||||
|
|
||||||
|
if room.state() == RoomState::Joined {
|
||||||
|
Ok(room)
|
||||||
|
} else {
|
||||||
|
Err(IambError::NotJoined)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_reply_to<'a>(&self, info: &'a RoomInfo) -> Option<&'a OriginalRoomMessageEvent> {
|
||||||
|
let thread = self.scrollback.get_thread(info)?;
|
||||||
|
let key = self.reply_to.as_ref()?;
|
||||||
|
let msg = thread.get(key)?;
|
||||||
|
|
||||||
|
if let MessageEvent::Original(ev) = &msg.event {
|
||||||
|
Some(ev)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reset(&mut self) -> EditRope {
|
||||||
|
self.reply_to = None;
|
||||||
|
self.editing = None;
|
||||||
|
self.tbox.reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
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 async fn message_command(
|
||||||
|
&mut self,
|
||||||
|
act: MessageAction,
|
||||||
|
_: ProgramContext,
|
||||||
|
store: &mut ProgramStore,
|
||||||
|
) -> IambResult<EditInfo> {
|
||||||
|
let client = &store.application.worker.client;
|
||||||
|
|
||||||
|
let settings = &store.application.settings;
|
||||||
|
let info = store.application.rooms.get_or_default(self.room_id.clone());
|
||||||
|
|
||||||
|
let msg = self.scrollback.get_mut(info).ok_or(IambError::NoSelectedMessage)?;
|
||||||
|
|
||||||
|
match act {
|
||||||
|
MessageAction::Cancel(skip_confirm) => {
|
||||||
|
if skip_confirm {
|
||||||
|
self.reset();
|
||||||
|
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.reply_to = None;
|
||||||
|
self.editing = None;
|
||||||
|
|
||||||
|
let msg = "Would you like to clear the message bar?";
|
||||||
|
let act = PromptAction::Abort(false);
|
||||||
|
let prompt = PromptYesNo::new(msg, vec![Action::from(act)]);
|
||||||
|
let prompt = Box::new(prompt);
|
||||||
|
|
||||||
|
Err(UIError::NeedConfirm(prompt))
|
||||||
|
},
|
||||||
|
MessageAction::Download(filename, flags) => {
|
||||||
|
if let MessageEvent::Original(ev) = &msg.event {
|
||||||
|
let media = client.media();
|
||||||
|
|
||||||
|
let mut filename = match (filename, &settings.dirs.downloads) {
|
||||||
|
(Some(f), _) => PathBuf::from(f),
|
||||||
|
(None, Some(downloads)) => downloads.clone(),
|
||||||
|
(None, None) => return Err(IambError::NoDownloadDir.into()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let (source, msg_filename) = match &ev.content.msgtype {
|
||||||
|
MessageType::Audio(c) => (c.source.clone(), c.body.as_str()),
|
||||||
|
MessageType::File(c) => {
|
||||||
|
(c.source.clone(), c.filename.as_deref().unwrap_or(c.body.as_str()))
|
||||||
|
},
|
||||||
|
MessageType::Image(c) => (c.source.clone(), c.body.as_str()),
|
||||||
|
MessageType::Video(c) => (c.source.clone(), c.body.as_str()),
|
||||||
|
_ => {
|
||||||
|
if !flags.contains(DownloadFlags::OPEN) {
|
||||||
|
return Err(IambError::NoAttachment.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let links = if let Some(html) = &msg.html {
|
||||||
|
html.get_links()
|
||||||
|
} else if let Ok(url) = Url::parse(&msg.event.body()) {
|
||||||
|
vec![('0', url)]
|
||||||
|
} else {
|
||||||
|
vec![]
|
||||||
|
};
|
||||||
|
|
||||||
|
if links.is_empty() {
|
||||||
|
return Err(IambError::NoAttachment.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let choices = links
|
||||||
|
.into_iter()
|
||||||
|
.map(|l| {
|
||||||
|
let url = l.1.to_string();
|
||||||
|
let act = IambAction::OpenLink(url.clone()).into();
|
||||||
|
MultiChoiceItem::new(l.0, url, vec![act])
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let dialog = MultiChoice::new(choices);
|
||||||
|
let err = UIError::NeedConfirm(Box::new(dialog));
|
||||||
|
|
||||||
|
return Err(err);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if filename.is_dir() {
|
||||||
|
filename.push(msg_filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
if filename.exists() && !flags.contains(DownloadFlags::FORCE) {
|
||||||
|
// Find an incrementally suffixed filename, e.g. image-2.jpg -> image-3.jpg
|
||||||
|
if let Some(stem) = filename.file_stem().and_then(OsStr::to_str) {
|
||||||
|
let ext = filename.extension();
|
||||||
|
let mut filename_incr = filename.clone();
|
||||||
|
for n in 1..=1000 {
|
||||||
|
if let Some(ext) = ext.and_then(OsStr::to_str) {
|
||||||
|
filename_incr.set_file_name(format!("{}-{}.{}", stem, n, ext));
|
||||||
|
} else {
|
||||||
|
filename_incr.set_file_name(format!("{}-{}", stem, n));
|
||||||
|
}
|
||||||
|
|
||||||
|
if !filename_incr.exists() {
|
||||||
|
filename = filename_incr;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !filename.exists() || flags.contains(DownloadFlags::FORCE) {
|
||||||
|
let req = MediaRequest { source, format: MediaFormat::File };
|
||||||
|
|
||||||
|
let bytes =
|
||||||
|
media.get_media_content(&req, true).await.map_err(IambError::from)?;
|
||||||
|
|
||||||
|
fs::write(filename.as_path(), bytes.as_slice())?;
|
||||||
|
|
||||||
|
msg.downloaded = true;
|
||||||
|
} else if !flags.contains(DownloadFlags::OPEN) {
|
||||||
|
let msg = format!(
|
||||||
|
"The file {} already exists; add ! to end of command to overwrite it.",
|
||||||
|
filename.display()
|
||||||
|
);
|
||||||
|
let err = UIError::Failure(msg);
|
||||||
|
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
let info = if flags.contains(DownloadFlags::OPEN) {
|
||||||
|
let target = filename.clone().into_os_string();
|
||||||
|
match open_command(
|
||||||
|
store.application.settings.tunables.open_command.as_ref(),
|
||||||
|
target,
|
||||||
|
) {
|
||||||
|
Ok(_) => {
|
||||||
|
InfoMessage::from(format!(
|
||||||
|
"Attachment downloaded to {} and opened",
|
||||||
|
filename.display()
|
||||||
|
))
|
||||||
|
},
|
||||||
|
Err(err) => {
|
||||||
|
return Err(err);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
InfoMessage::from(format!(
|
||||||
|
"Attachment downloaded to {}",
|
||||||
|
filename.display()
|
||||||
|
))
|
||||||
|
};
|
||||||
|
|
||||||
|
return Ok(info.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(IambError::NoAttachment.into())
|
||||||
|
},
|
||||||
|
MessageAction::Edit => {
|
||||||
|
if msg.sender != settings.profile.user_id {
|
||||||
|
let msg = "Cannot edit messages sent by someone else";
|
||||||
|
let err = UIError::Failure(msg.into());
|
||||||
|
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
let ev = match &msg.event {
|
||||||
|
MessageEvent::Original(ev) => &ev.content,
|
||||||
|
MessageEvent::Local(_, ev) => ev.deref(),
|
||||||
|
_ => {
|
||||||
|
let msg = "Cannot edit a redacted message";
|
||||||
|
let err = UIError::Failure(msg.into());
|
||||||
|
|
||||||
|
return Err(err);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let text = match &ev.msgtype {
|
||||||
|
MessageType::Text(msg) => msg.body.as_str(),
|
||||||
|
_ => {
|
||||||
|
let msg = "Cannot edit a non-text message";
|
||||||
|
let err = UIError::Failure(msg.into());
|
||||||
|
|
||||||
|
return Err(err);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
self.tbox.set_text(text);
|
||||||
|
self.reply_to = msg.reply_to().and_then(|id| info.get_message_key(&id)).cloned();
|
||||||
|
self.editing = self.scrollback.get_key(info);
|
||||||
|
self.focus = RoomFocus::MessageBar;
|
||||||
|
|
||||||
|
Ok(None)
|
||||||
|
},
|
||||||
|
MessageAction::React(emoji) => {
|
||||||
|
let room = self.get_joined(&store.application.worker)?;
|
||||||
|
let event_id = match &msg.event {
|
||||||
|
MessageEvent::EncryptedOriginal(ev) => ev.event_id.clone(),
|
||||||
|
MessageEvent::EncryptedRedacted(ev) => ev.event_id.clone(),
|
||||||
|
MessageEvent::Original(ev) => ev.event_id.clone(),
|
||||||
|
MessageEvent::Local(event_id, _) => event_id.clone(),
|
||||||
|
MessageEvent::Redacted(_) => {
|
||||||
|
let msg = "Cannot react to a redacted message";
|
||||||
|
let err = UIError::Failure(msg.into());
|
||||||
|
|
||||||
|
return Err(err);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let reaction = Annotation::new(event_id, emoji);
|
||||||
|
let msg = ReactionEventContent::new(reaction);
|
||||||
|
let _ = room.send(msg).await.map_err(IambError::from)?;
|
||||||
|
|
||||||
|
Ok(None)
|
||||||
|
},
|
||||||
|
MessageAction::Redact(reason, skip_confirm) => {
|
||||||
|
if !skip_confirm {
|
||||||
|
let msg = "Are you sure you want to redact this message?";
|
||||||
|
let act = IambAction::Message(MessageAction::Redact(reason, true));
|
||||||
|
let prompt = PromptYesNo::new(msg, vec![Action::from(act)]);
|
||||||
|
let prompt = Box::new(prompt);
|
||||||
|
|
||||||
|
return Err(UIError::NeedConfirm(prompt));
|
||||||
|
}
|
||||||
|
|
||||||
|
let room = self.get_joined(&store.application.worker)?;
|
||||||
|
let event_id = match &msg.event {
|
||||||
|
MessageEvent::EncryptedOriginal(ev) => ev.event_id.clone(),
|
||||||
|
MessageEvent::EncryptedRedacted(ev) => ev.event_id.clone(),
|
||||||
|
MessageEvent::Original(ev) => ev.event_id.clone(),
|
||||||
|
MessageEvent::Local(event_id, _) => event_id.clone(),
|
||||||
|
MessageEvent::Redacted(_) => {
|
||||||
|
let msg = "Cannot redact already redacted message";
|
||||||
|
let err = UIError::Failure(msg.into());
|
||||||
|
|
||||||
|
return Err(err);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let event_id = event_id.as_ref();
|
||||||
|
let reason = reason.as_deref();
|
||||||
|
let _ = room.redact(event_id, reason, None).await.map_err(IambError::from)?;
|
||||||
|
|
||||||
|
Ok(None)
|
||||||
|
},
|
||||||
|
MessageAction::Reply => {
|
||||||
|
self.reply_to = self.scrollback.get_key(info);
|
||||||
|
self.focus = RoomFocus::MessageBar;
|
||||||
|
|
||||||
|
Ok(None)
|
||||||
|
},
|
||||||
|
MessageAction::Unreact(emoji) => {
|
||||||
|
let room = self.get_joined(&store.application.worker)?;
|
||||||
|
let event_id = match &msg.event {
|
||||||
|
MessageEvent::EncryptedOriginal(ev) => ev.event_id.clone(),
|
||||||
|
MessageEvent::EncryptedRedacted(ev) => ev.event_id.clone(),
|
||||||
|
MessageEvent::Original(ev) => ev.event_id.clone(),
|
||||||
|
MessageEvent::Local(event_id, _) => event_id.clone(),
|
||||||
|
MessageEvent::Redacted(_) => {
|
||||||
|
let msg = "Cannot unreact to a redacted message";
|
||||||
|
let err = UIError::Failure(msg.into());
|
||||||
|
|
||||||
|
return Err(err);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let reactions = match info.reactions.get(&event_id) {
|
||||||
|
Some(r) => r,
|
||||||
|
None => return Ok(None),
|
||||||
|
};
|
||||||
|
|
||||||
|
let reactions = reactions.iter().filter_map(|(event_id, (reaction, user_id))| {
|
||||||
|
if user_id != &settings.profile.user_id {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(emoji) = &emoji {
|
||||||
|
if emoji == reaction {
|
||||||
|
return Some(event_id);
|
||||||
|
} else {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return Some(event_id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for reaction in reactions {
|
||||||
|
let _ = room.redact(reaction, None, None).await.map_err(IambError::from)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(None)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn send_command(
|
||||||
|
&mut self,
|
||||||
|
act: SendAction,
|
||||||
|
_: ProgramContext,
|
||||||
|
store: &mut ProgramStore,
|
||||||
|
) -> IambResult<EditInfo> {
|
||||||
|
let room = self.get_joined(&store.application.worker)?;
|
||||||
|
let info = store.application.rooms.get_or_default(self.id().to_owned());
|
||||||
|
let mut show_echo = true;
|
||||||
|
|
||||||
|
let (event_id, msg) = match act {
|
||||||
|
SendAction::Submit | SendAction::SubmitFromEditor => {
|
||||||
|
let msg = self.tbox.get();
|
||||||
|
|
||||||
|
let msg = if let SendAction::SubmitFromEditor = act {
|
||||||
|
external_edit(msg.trim_end().to_string())?
|
||||||
|
} else if msg.is_blank() {
|
||||||
|
return Ok(None);
|
||||||
|
} else {
|
||||||
|
msg.trim_end().to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut msg = text_to_message(msg);
|
||||||
|
|
||||||
|
if let Some((_, event_id)) = &self.editing {
|
||||||
|
msg.relates_to = Some(Relation::Replacement(Replacement::new(
|
||||||
|
event_id.clone(),
|
||||||
|
msg.msgtype.clone().into(),
|
||||||
|
)));
|
||||||
|
|
||||||
|
show_echo = false;
|
||||||
|
} else if let Some(thread_root) = self.scrollback.thread() {
|
||||||
|
if let Some(m) = self.get_reply_to(info) {
|
||||||
|
msg = msg.make_for_thread(m, ReplyWithinThread::Yes, AddMentions::No);
|
||||||
|
} else if let Some(m) = info.get_thread_last(thread_root) {
|
||||||
|
msg = msg.make_for_thread(m, ReplyWithinThread::No, AddMentions::No);
|
||||||
|
} else {
|
||||||
|
// Internal state is wonky?
|
||||||
|
}
|
||||||
|
} else if let Some(m) = self.get_reply_to(info) {
|
||||||
|
msg = msg.make_reply_to(m, ForwardThread::Yes, AddMentions::No);
|
||||||
|
}
|
||||||
|
|
||||||
|
// XXX: second parameter can be a locally unique transaction id.
|
||||||
|
// Useful for doing retries.
|
||||||
|
let resp = room.send(msg.clone()).await.map_err(IambError::from)?;
|
||||||
|
let event_id = resp.event_id;
|
||||||
|
|
||||||
|
// Reset message bar state now that it's been sent.
|
||||||
|
self.reset();
|
||||||
|
|
||||||
|
(event_id, msg)
|
||||||
|
},
|
||||||
|
SendAction::Upload(file) => {
|
||||||
|
let path = Path::new(file.as_str());
|
||||||
|
let mime = mime_guess::from_path(path).first_or(mime::APPLICATION_OCTET_STREAM);
|
||||||
|
|
||||||
|
let bytes = fs::read(path)?;
|
||||||
|
let name = path
|
||||||
|
.file_name()
|
||||||
|
.map(OsStr::to_string_lossy)
|
||||||
|
.unwrap_or_else(|| Cow::from("Attachment"));
|
||||||
|
let config = AttachmentConfig::new();
|
||||||
|
|
||||||
|
let resp = room
|
||||||
|
.send_attachment(name.as_ref(), &mime, bytes, config)
|
||||||
|
.await
|
||||||
|
.map_err(IambError::from)?;
|
||||||
|
|
||||||
|
// Mock up the local echo message for the scrollback.
|
||||||
|
let msg = TextMessageEventContent::plain(format!("[Attached File: {name}]"));
|
||||||
|
let msg = MessageType::Text(msg);
|
||||||
|
let msg = RoomMessageEventContent::new(msg);
|
||||||
|
|
||||||
|
(resp.event_id, msg)
|
||||||
|
},
|
||||||
|
SendAction::UploadImage(width, height, bytes) => {
|
||||||
|
// Convert to png because arboard does not give us the mime type.
|
||||||
|
let bytes =
|
||||||
|
image::ImageBuffer::from_raw(width as _, height as _, bytes.into_owned())
|
||||||
|
.ok_or(IambError::Clipboard)
|
||||||
|
.and_then(|imagebuf| {
|
||||||
|
let dynimage = image::DynamicImage::ImageRgba8(imagebuf);
|
||||||
|
let bytes = Vec::<u8>::new();
|
||||||
|
let mut buff = std::io::Cursor::new(bytes);
|
||||||
|
dynimage.write_to(&mut buff, image::ImageOutputFormat::Png)?;
|
||||||
|
Ok(buff.into_inner())
|
||||||
|
})
|
||||||
|
.map_err(IambError::from)?;
|
||||||
|
let mime = mime::IMAGE_PNG;
|
||||||
|
|
||||||
|
let name = "Clipboard.png";
|
||||||
|
let config = AttachmentConfig::new();
|
||||||
|
|
||||||
|
let resp = room
|
||||||
|
.send_attachment(name.as_ref(), &mime, bytes, config)
|
||||||
|
.await
|
||||||
|
.map_err(IambError::from)?;
|
||||||
|
|
||||||
|
// Mock up the local echo message for the scrollback.
|
||||||
|
let msg = TextMessageEventContent::plain(format!("[Attached File: {name}]"));
|
||||||
|
let msg = MessageType::Text(msg);
|
||||||
|
let msg = RoomMessageEventContent::new(msg);
|
||||||
|
|
||||||
|
(resp.event_id, msg)
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if show_echo {
|
||||||
|
let user = store.application.settings.profile.user_id.clone();
|
||||||
|
let key = (MessageTimeStamp::LocalEcho, event_id.clone());
|
||||||
|
let msg = MessageEvent::Local(event_id, msg.into());
|
||||||
|
let msg = Message::new(msg, user, MessageTimeStamp::LocalEcho);
|
||||||
|
let thread = self.scrollback.get_thread_mut(info);
|
||||||
|
thread.insert(key, msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Jump to the end of the scrollback to show the message.
|
||||||
|
self.scrollback.goto_latest();
|
||||||
|
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn focus_toggle(&mut self) {
|
pub fn focus_toggle(&mut self) {
|
||||||
self.focus = match self.focus {
|
self.focus = match self.focus {
|
||||||
RoomFocus::Scrollback => RoomFocus::MessageBar,
|
RoomFocus::Scrollback => RoomFocus::MessageBar,
|
||||||
@@ -100,7 +607,7 @@ impl ChatState {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if !store.application.settings.tunables.typing_notice {
|
if !store.application.settings.tunables.typing_notice_send {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,12 +640,14 @@ impl WindowOps<IambInfo> for ChatState {
|
|||||||
fn dup(&self, store: &mut ProgramStore) -> Self {
|
fn dup(&self, store: &mut ProgramStore) -> Self {
|
||||||
// XXX: I want each WindowSlot to have its own shared buffer, instead of each Room; need to
|
// XXX: I want each WindowSlot to have its own shared buffer, instead of each Room; need to
|
||||||
// find a good way to pass that info here so that it can be part of the content id.
|
// find a good way to pass that info here so that it can be part of the content id.
|
||||||
let id = IambBufferId::Room(self.room_id.clone(), RoomFocus::MessageBar);
|
let room_id = self.room_id.clone();
|
||||||
|
let thread = self.thread().cloned();
|
||||||
|
let id = IambBufferId::Room(room_id.clone(), thread, RoomFocus::MessageBar);
|
||||||
let ebuf = store.load_buffer(id);
|
let ebuf = store.load_buffer(id);
|
||||||
let tbox = TextBoxState::new(ebuf);
|
let tbox = TextBoxState::new(ebuf);
|
||||||
|
|
||||||
ChatState {
|
ChatState {
|
||||||
room_id: self.room_id.clone(),
|
room_id,
|
||||||
room: self.room.clone(),
|
room: self.room.clone(),
|
||||||
|
|
||||||
tbox,
|
tbox,
|
||||||
@@ -147,6 +656,9 @@ impl WindowOps<IambInfo> for ChatState {
|
|||||||
|
|
||||||
scrollback: self.scrollback.dup(store),
|
scrollback: self.scrollback.dup(store),
|
||||||
focus: self.focus,
|
focus: self.focus,
|
||||||
|
|
||||||
|
reply_to: None,
|
||||||
|
editing: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,6 +668,21 @@ impl WindowOps<IambInfo> for ChatState {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn write(
|
||||||
|
&mut self,
|
||||||
|
_: Option<&str>,
|
||||||
|
_: WriteFlags,
|
||||||
|
_: &mut ProgramStore,
|
||||||
|
) -> IambResult<EditInfo> {
|
||||||
|
// XXX: what's the right writing behaviour for a room?
|
||||||
|
// Should write send a message?
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_completions(&self) -> Option<CompletionList> {
|
||||||
|
delegate!(self, w => w.get_completions())
|
||||||
|
}
|
||||||
|
|
||||||
fn get_cursor_word(&self, style: &WordStyle) -> Option<String> {
|
fn get_cursor_word(&self, style: &WordStyle) -> Option<String> {
|
||||||
delegate!(self, w => w.get_cursor_word(style))
|
delegate!(self, w => w.get_cursor_word(style))
|
||||||
}
|
}
|
||||||
@@ -176,8 +703,10 @@ impl Editable<ProgramContext, ProgramStore, IambInfo> for ChatState {
|
|||||||
|
|
||||||
match delegate!(self, w => w.editor_command(act, ctx, store)) {
|
match delegate!(self, w => w.editor_command(act, ctx, store)) {
|
||||||
res @ Ok(_) => res,
|
res @ Ok(_) => res,
|
||||||
Err(EditError::WrongBuffer(IambBufferId::Room(room_id, focus)))
|
Err(EditError::WrongBuffer(IambBufferId::Room(room_id, thread, focus)))
|
||||||
if room_id == self.room_id && act.is_switchable(ctx) =>
|
if room_id == self.room_id &&
|
||||||
|
thread.as_ref() == self.thread() &&
|
||||||
|
act.is_switchable(ctx) =>
|
||||||
{
|
{
|
||||||
// Switch focus.
|
// Switch focus.
|
||||||
self.focus = focus;
|
self.focus = focus;
|
||||||
@@ -185,6 +714,15 @@ impl Editable<ProgramContext, ProgramStore, IambInfo> for ChatState {
|
|||||||
// Run command again.
|
// Run command again.
|
||||||
delegate!(self, w => w.editor_command(act, ctx, store))
|
delegate!(self, w => w.editor_command(act, ctx, store))
|
||||||
},
|
},
|
||||||
|
Err(EditError::Register(RegisterError::ClipboardImage(data))) => {
|
||||||
|
let msg = "Do you really want to upload the image from your system clipboard?";
|
||||||
|
let send =
|
||||||
|
IambAction::Send(SendAction::UploadImage(data.width, data.height, data.bytes));
|
||||||
|
let prompt = PromptYesNo::new(msg, vec![Action::from(send)]);
|
||||||
|
let prompt = Box::new(prompt);
|
||||||
|
|
||||||
|
Err(EditError::NeedConfirm(prompt))
|
||||||
|
},
|
||||||
res @ Err(_) => res,
|
res @ Err(_) => res,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -229,17 +767,9 @@ impl PromptActions<ProgramContext, ProgramStore, IambInfo> for ChatState {
|
|||||||
ctx: &ProgramContext,
|
ctx: &ProgramContext,
|
||||||
_: &mut ProgramStore,
|
_: &mut ProgramStore,
|
||||||
) -> EditResult<Vec<(ProgramAction, ProgramContext)>, IambInfo> {
|
) -> EditResult<Vec<(ProgramAction, ProgramContext)>, IambInfo> {
|
||||||
let txt = self.tbox.reset_text();
|
let act = SendAction::Submit;
|
||||||
|
|
||||||
let act = if txt.is_empty() {
|
Ok(vec![(IambAction::from(act).into(), ctx.clone())])
|
||||||
vec![]
|
|
||||||
} else {
|
|
||||||
let act = IambAction::SendMessage(self.room_id.clone(), txt).into();
|
|
||||||
|
|
||||||
vec![(act, ctx.clone())]
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(act)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn abort(
|
fn abort(
|
||||||
@@ -254,7 +784,7 @@ impl PromptActions<ProgramContext, ProgramStore, IambInfo> for ChatState {
|
|||||||
return Ok(vec![]);
|
return Ok(vec![]);
|
||||||
}
|
}
|
||||||
|
|
||||||
let text = self.tbox.reset().trim();
|
let text = self.reset().trim();
|
||||||
|
|
||||||
if text.is_empty() {
|
if text.is_empty() {
|
||||||
let _ = self.sent.end();
|
let _ = self.sent.end();
|
||||||
@@ -269,13 +799,14 @@ impl PromptActions<ProgramContext, ProgramStore, IambInfo> for ChatState {
|
|||||||
&mut self,
|
&mut self,
|
||||||
dir: &MoveDir1D,
|
dir: &MoveDir1D,
|
||||||
count: &Count,
|
count: &Count,
|
||||||
|
prefixed: bool,
|
||||||
ctx: &ProgramContext,
|
ctx: &ProgramContext,
|
||||||
_: &mut ProgramStore,
|
_: &mut ProgramStore,
|
||||||
) -> EditResult<Vec<(ProgramAction, ProgramContext)>, IambInfo> {
|
) -> EditResult<Vec<(ProgramAction, ProgramContext)>, IambInfo> {
|
||||||
let count = ctx.resolve(count);
|
let count = ctx.resolve(count);
|
||||||
let rope = self.tbox.get();
|
let rope = self.tbox.get();
|
||||||
|
|
||||||
let text = self.sent.recall(&rope, &mut self.sent_scrollback, *dir, count);
|
let text = self.sent.recall(&rope, &mut self.sent_scrollback, *dir, prefixed, count);
|
||||||
|
|
||||||
if let Some(text) = text {
|
if let Some(text) = text {
|
||||||
self.tbox.set_text(text);
|
self.tbox.set_text(text);
|
||||||
@@ -293,18 +824,20 @@ impl Promptable<ProgramContext, ProgramStore, IambInfo> for ChatState {
|
|||||||
store: &mut ProgramStore,
|
store: &mut ProgramStore,
|
||||||
) -> EditResult<Vec<(ProgramAction, ProgramContext)>, IambInfo> {
|
) -> EditResult<Vec<(ProgramAction, ProgramContext)>, IambInfo> {
|
||||||
if let RoomFocus::Scrollback = self.focus {
|
if let RoomFocus::Scrollback = self.focus {
|
||||||
return Ok(vec![]);
|
return self.scrollback.prompt(act, ctx, store);
|
||||||
}
|
}
|
||||||
|
|
||||||
match act {
|
match act {
|
||||||
PromptAction::Submit => self.submit(ctx, store),
|
PromptAction::Submit => self.submit(ctx, store),
|
||||||
PromptAction::Abort(empty) => self.abort(*empty, ctx, store),
|
PromptAction::Abort(empty) => self.abort(*empty, ctx, store),
|
||||||
PromptAction::Recall(dir, count) => self.recall(dir, count, ctx, store),
|
PromptAction::Recall(dir, count, prefixed) => {
|
||||||
_ => Err(EditError::Unimplemented("unknown prompt action".to_string())),
|
self.recall(dir, count, *prefixed, ctx, store)
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// [StatefulWidget] for Matrix rooms.
|
||||||
pub struct Chat<'a> {
|
pub struct Chat<'a> {
|
||||||
store: &'a mut ProgramStore,
|
store: &'a mut ProgramStore,
|
||||||
focused: bool,
|
focused: bool,
|
||||||
@@ -325,21 +858,83 @@ impl<'a> StatefulWidget for Chat<'a> {
|
|||||||
type State = ChatState;
|
type State = ChatState;
|
||||||
|
|
||||||
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
|
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
|
||||||
|
// Determine whether we have a description to show for the message bar.
|
||||||
|
let desc_spans = match (&state.editing, &state.reply_to, state.thread()) {
|
||||||
|
(None, None, None) => None,
|
||||||
|
(None, None, Some(_)) => Some(Line::from("Replying in thread")),
|
||||||
|
(Some(_), None, None) => Some(Line::from("Editing message")),
|
||||||
|
(Some(_), None, Some(_)) => Some(Line::from("Editing message in thread")),
|
||||||
|
(editing, Some(_), thread) => {
|
||||||
|
self.store.application.rooms.get(state.id()).and_then(|room| {
|
||||||
|
let msg = state.get_reply_to(room)?;
|
||||||
|
let user =
|
||||||
|
self.store.application.settings.get_user_span(msg.sender.as_ref(), room);
|
||||||
|
let prefix = match (editing.is_some(), thread.is_some()) {
|
||||||
|
(true, false) => Span::from("Editing reply to "),
|
||||||
|
(true, true) => Span::from("Editing reply in thread to "),
|
||||||
|
(false, false) => Span::from("Replying to "),
|
||||||
|
(false, true) => Span::from("Replying in thread to "),
|
||||||
|
};
|
||||||
|
let spans = Line::from(vec![prefix, user]);
|
||||||
|
|
||||||
|
spans.into()
|
||||||
|
})
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Determine the region to show each UI element.
|
||||||
let lines = state.tbox.has_lines(5).max(1) as u16;
|
let lines = state.tbox.has_lines(5).max(1) as u16;
|
||||||
let drawh = area.height;
|
let drawh = area.height;
|
||||||
let texth = lines.min(drawh).clamp(1, 5);
|
let texth = lines.min(drawh).clamp(1, 5);
|
||||||
let scrollh = drawh.saturating_sub(texth);
|
let desch = if desc_spans.is_some() {
|
||||||
|
drawh.saturating_sub(texth).min(1)
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
let scrollh = drawh.saturating_sub(texth).saturating_sub(desch);
|
||||||
|
|
||||||
let scrollarea = Rect::new(area.x, area.y, area.width, scrollh);
|
let scrollarea = Rect::new(area.x, area.y, area.width, scrollh);
|
||||||
let textarea = Rect::new(scrollarea.x, scrollarea.y + scrollh, scrollarea.width, texth);
|
let descarea = Rect::new(area.x, scrollarea.y + scrollh, area.width, desch);
|
||||||
|
let textarea = Rect::new(area.x, descarea.y + desch, area.width, texth);
|
||||||
|
|
||||||
let scrollback_focused = state.focus.is_scrollback() && self.focused;
|
// Render the message bar and any description for it.
|
||||||
let scrollback = Scrollback::new(self.store).focus(scrollback_focused);
|
if let Some(desc_spans) = desc_spans {
|
||||||
scrollback.render(scrollarea, buf, &mut state.scrollback);
|
Paragraph::new(desc_spans).render(descarea, buf);
|
||||||
|
}
|
||||||
|
|
||||||
let prompt = if self.focused { "> " } else { " " };
|
let prompt = if self.focused { "> " } else { " " };
|
||||||
|
|
||||||
let tbox = TextBox::new().prompt(prompt);
|
let tbox = TextBox::new().prompt(prompt);
|
||||||
tbox.render(textarea, buf, &mut state.tbox);
|
tbox.render(textarea, buf, &mut state.tbox);
|
||||||
|
|
||||||
|
// Render the message scrollback.
|
||||||
|
let scrollback_focused = state.focus.is_scrollback() && self.focused;
|
||||||
|
let scrollback = Scrollback::new(self.store)
|
||||||
|
.focus(scrollback_focused)
|
||||||
|
.room_focus(self.focused);
|
||||||
|
scrollback.render(scrollarea, buf, &mut state.scrollback);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn open_command(open_command: Option<&Vec<String>>, target: OsString) -> IambResult<()> {
|
||||||
|
if let Some(mut cmd) = open_command.and_then(cmd) {
|
||||||
|
cmd.arg(target);
|
||||||
|
cmd.spawn()?;
|
||||||
|
return Ok(());
|
||||||
|
} else {
|
||||||
|
// open::that may not return until the spawned program closes.
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
return open::that(target);
|
||||||
|
});
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cmd(open_command: &Vec<String>) -> Option<Command> {
|
||||||
|
if let [program, args @ ..] = open_command.as_slice() {
|
||||||
|
let mut cmd = Command::new(program);
|
||||||
|
cmd.args(args);
|
||||||
|
return Some(cmd);
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,49 +1,53 @@
|
|||||||
use matrix_sdk::room::Room as MatrixRoom;
|
//! # Windows for Matrix rooms and spaces
|
||||||
use matrix_sdk::ruma::RoomId;
|
use matrix_sdk::{
|
||||||
use matrix_sdk::DisplayName;
|
room::Room as MatrixRoom,
|
||||||
|
ruma::{
|
||||||
use modalkit::tui::{
|
events::{
|
||||||
buffer::Buffer,
|
room::{name::RoomNameEventContent, topic::RoomTopicEventContent},
|
||||||
layout::Rect,
|
tag::{TagInfo, Tags},
|
||||||
style::{Modifier as StyleModifier, Style},
|
},
|
||||||
text::{Span, Spans},
|
OwnedEventId,
|
||||||
widgets::StatefulWidget,
|
RoomId,
|
||||||
|
},
|
||||||
|
DisplayName,
|
||||||
|
RoomState as MatrixRoomState,
|
||||||
};
|
};
|
||||||
|
|
||||||
use modalkit::{
|
use ratatui::{
|
||||||
editing::action::{
|
buffer::Buffer,
|
||||||
|
layout::{Alignment, Rect},
|
||||||
|
style::{Modifier as StyleModifier, Style},
|
||||||
|
text::{Line, Span, Text},
|
||||||
|
widgets::{Paragraph, StatefulWidget, Widget},
|
||||||
|
};
|
||||||
|
|
||||||
|
use modalkit::actions::{
|
||||||
Action,
|
Action,
|
||||||
EditInfo,
|
|
||||||
EditResult,
|
|
||||||
Editable,
|
Editable,
|
||||||
EditorAction,
|
EditorAction,
|
||||||
Jumpable,
|
Jumpable,
|
||||||
PromptAction,
|
PromptAction,
|
||||||
Promptable,
|
Promptable,
|
||||||
Scrollable,
|
Scrollable,
|
||||||
},
|
|
||||||
editing::base::{
|
|
||||||
Axis,
|
|
||||||
CloseFlags,
|
|
||||||
Count,
|
|
||||||
MoveDir1D,
|
|
||||||
OpenTarget,
|
|
||||||
PositionList,
|
|
||||||
ScrollStyle,
|
|
||||||
WordStyle,
|
|
||||||
},
|
|
||||||
input::InputContext,
|
|
||||||
widgets::{TermOffset, TerminalCursor, WindowOps},
|
|
||||||
};
|
};
|
||||||
|
use modalkit::errors::{EditResult, UIError};
|
||||||
|
use modalkit::prelude::*;
|
||||||
|
use modalkit::{editing::completion::CompletionList, keybindings::dialog::PromptYesNo};
|
||||||
|
use modalkit_ratatui::{TermOffset, TerminalCursor, WindowOps};
|
||||||
|
|
||||||
use crate::base::{
|
use crate::base::{
|
||||||
|
IambAction,
|
||||||
|
IambError,
|
||||||
IambId,
|
IambId,
|
||||||
IambInfo,
|
IambInfo,
|
||||||
IambResult,
|
IambResult,
|
||||||
|
MessageAction,
|
||||||
ProgramAction,
|
ProgramAction,
|
||||||
ProgramContext,
|
ProgramContext,
|
||||||
ProgramStore,
|
ProgramStore,
|
||||||
RoomAction,
|
RoomAction,
|
||||||
|
RoomField,
|
||||||
|
SendAction,
|
||||||
};
|
};
|
||||||
|
|
||||||
use self::chat::ChatState;
|
use self::chat::ChatState;
|
||||||
@@ -62,6 +66,11 @@ macro_rules! delegate {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// State for a Matrix room or space.
|
||||||
|
///
|
||||||
|
/// Since spaces function as special rooms within Matrix, we wrap their window state together, so
|
||||||
|
/// that operations like sending and accepting invites, opening the members window, etc., all work
|
||||||
|
/// similarly.
|
||||||
pub enum RoomState {
|
pub enum RoomState {
|
||||||
Chat(ChatState),
|
Chat(ChatState),
|
||||||
Space(SpaceState),
|
Space(SpaceState),
|
||||||
@@ -80,25 +89,156 @@ impl From<SpaceState> for RoomState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl RoomState {
|
impl RoomState {
|
||||||
pub fn new(room: MatrixRoom, name: DisplayName, store: &mut ProgramStore) -> Self {
|
pub fn new(
|
||||||
|
room: MatrixRoom,
|
||||||
|
thread: Option<OwnedEventId>,
|
||||||
|
name: DisplayName,
|
||||||
|
tags: Option<Tags>,
|
||||||
|
store: &mut ProgramStore,
|
||||||
|
) -> Self {
|
||||||
let room_id = room.room_id().to_owned();
|
let room_id = room.room_id().to_owned();
|
||||||
let info = store.application.get_room_info(room_id);
|
let info = store.application.get_room_info(room_id);
|
||||||
info.name = name.to_string().into();
|
info.name = name.to_string().into();
|
||||||
|
info.tags = tags;
|
||||||
|
|
||||||
if room.is_space() {
|
if room.is_space() {
|
||||||
SpaceState::new(room).into()
|
SpaceState::new(room).into()
|
||||||
} else {
|
} else {
|
||||||
ChatState::new(room, store).into()
|
ChatState::new(room, thread, store).into()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn 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 { lines: 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 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,
|
&mut self,
|
||||||
act: RoomAction,
|
act: RoomAction,
|
||||||
_: ProgramContext,
|
_: ProgramContext,
|
||||||
store: &mut ProgramStore,
|
store: &mut ProgramStore,
|
||||||
) -> IambResult<Vec<(Action<IambInfo>, ProgramContext)>> {
|
) -> IambResult<Vec<(Action<IambInfo>, ProgramContext)>> {
|
||||||
match act {
|
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::Members(mut cmd) => {
|
RoomAction::Members(mut cmd) => {
|
||||||
let width = Count::Exact(30);
|
let width = Count::Exact(30);
|
||||||
let act =
|
let act =
|
||||||
@@ -107,20 +247,70 @@ impl RoomState {
|
|||||||
width.into(),
|
width.into(),
|
||||||
);
|
);
|
||||||
|
|
||||||
Ok(vec![(act, cmd.context.take())])
|
Ok(vec![(act, cmd.context.clone())])
|
||||||
},
|
},
|
||||||
RoomAction::Set(field) => {
|
RoomAction::Set(field, value) => {
|
||||||
store.application.worker.set_room(self.id().to_owned(), field)?;
|
let room = store
|
||||||
|
.application
|
||||||
|
.get_joined_room(self.id())
|
||||||
|
.ok_or(UIError::Application(IambError::NotJoined))?;
|
||||||
|
|
||||||
|
match field {
|
||||||
|
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)?;
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(vec![])
|
||||||
|
},
|
||||||
|
RoomAction::Unset(field) => {
|
||||||
|
let room = store
|
||||||
|
.application
|
||||||
|
.get_joined_room(self.id())
|
||||||
|
.ok_or(UIError::Application(IambError::NotJoined))?;
|
||||||
|
|
||||||
|
match field {
|
||||||
|
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)?;
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
Ok(vec![])
|
Ok(vec![])
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_title(&self, store: &mut ProgramStore) -> Spans {
|
pub fn get_title(&self, store: &mut ProgramStore) -> Line {
|
||||||
let title = store.application.get_room_title(self.id());
|
let title = store.application.get_room_title(self.id());
|
||||||
let style = Style::default().add_modifier(StyleModifier::BOLD);
|
let style = Style::default().add_modifier(StyleModifier::BOLD);
|
||||||
let mut spans = vec![Span::styled(title, style)];
|
let mut spans = vec![];
|
||||||
|
|
||||||
|
if let RoomState::Chat(chat) = self {
|
||||||
|
if chat.thread().is_some() {
|
||||||
|
spans.push("Thread in ".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
spans.push(Span::styled(title, style));
|
||||||
|
|
||||||
match self.room().topic() {
|
match self.room().topic() {
|
||||||
Some(desc) if !desc.is_empty() => {
|
Some(desc) if !desc.is_empty() => {
|
||||||
@@ -131,7 +321,7 @@ impl RoomState {
|
|||||||
_ => {},
|
_ => {},
|
||||||
}
|
}
|
||||||
|
|
||||||
Spans(spans)
|
Line::from(spans)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn focus_toggle(&mut self) {
|
pub fn focus_toggle(&mut self) {
|
||||||
@@ -209,6 +399,14 @@ impl TerminalCursor for RoomState {
|
|||||||
|
|
||||||
impl WindowOps<IambInfo> for RoomState {
|
impl WindowOps<IambInfo> for RoomState {
|
||||||
fn draw(&mut self, area: Rect, buf: &mut Buffer, focused: bool, store: &mut ProgramStore) {
|
fn draw(&mut self, area: Rect, buf: &mut Buffer, focused: bool, store: &mut ProgramStore) {
|
||||||
|
if 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 {
|
match self {
|
||||||
RoomState::Chat(chat) => chat.draw(area, buf, focused, store),
|
RoomState::Chat(chat) => chat.draw(area, buf, focused, store),
|
||||||
RoomState::Space(space) => {
|
RoomState::Space(space) => {
|
||||||
@@ -224,10 +422,30 @@ impl WindowOps<IambInfo> for RoomState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn close(&mut self, _: CloseFlags, _: &mut ProgramStore) -> bool {
|
fn close(&mut self, flags: CloseFlags, store: &mut ProgramStore) -> bool {
|
||||||
// XXX: what's the right closing behaviour for a room?
|
match self {
|
||||||
// Should write send a message?
|
RoomState::Chat(chat) => chat.close(flags, store),
|
||||||
true
|
RoomState::Space(space) => space.close(flags, store),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write(
|
||||||
|
&mut self,
|
||||||
|
path: Option<&str>,
|
||||||
|
flags: WriteFlags,
|
||||||
|
store: &mut ProgramStore,
|
||||||
|
) -> IambResult<EditInfo> {
|
||||||
|
match self {
|
||||||
|
RoomState::Chat(chat) => chat.write(path, flags, store),
|
||||||
|
RoomState::Space(space) => space.write(path, flags, store),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_completions(&self) -> Option<CompletionList> {
|
||||||
|
match self {
|
||||||
|
RoomState::Chat(chat) => chat.get_completions(),
|
||||||
|
RoomState::Space(space) => space.get_completions(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_cursor_word(&self, style: &WordStyle) -> Option<String> {
|
fn get_cursor_word(&self, style: &WordStyle) -> Option<String> {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,34 +1,55 @@
|
|||||||
|
//! Window for Matrix spaces
|
||||||
use std::ops::{Deref, DerefMut};
|
use std::ops::{Deref, DerefMut};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use matrix_sdk::{
|
use matrix_sdk::{
|
||||||
room::Room as MatrixRoom,
|
room::Room as MatrixRoom,
|
||||||
ruma::{OwnedRoomId, RoomId},
|
ruma::{OwnedRoomId, RoomId},
|
||||||
};
|
};
|
||||||
|
|
||||||
use modalkit::tui::{buffer::Buffer, layout::Rect, widgets::StatefulWidget};
|
use ratatui::{
|
||||||
|
buffer::Buffer,
|
||||||
|
layout::Rect,
|
||||||
|
style::{Color, Style},
|
||||||
|
text::{Line, Span, Text},
|
||||||
|
widgets::StatefulWidget,
|
||||||
|
};
|
||||||
|
|
||||||
use modalkit::{
|
use modalkit_ratatui::{
|
||||||
widgets::list::{List, ListState},
|
list::{List, ListState},
|
||||||
widgets::{TermOffset, TerminalCursor, WindowOps},
|
TermOffset,
|
||||||
|
TerminalCursor,
|
||||||
|
WindowOps,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::base::{IambBufferId, IambInfo, ProgramStore, RoomFocus};
|
use crate::base::{IambBufferId, IambInfo, ProgramStore, RoomFocus};
|
||||||
|
|
||||||
use crate::windows::RoomItem;
|
use crate::windows::{room_fields_cmp, RoomItem};
|
||||||
|
|
||||||
|
const SPACE_HIERARCHY_DEBOUNCE: Duration = Duration::from_secs(5);
|
||||||
|
|
||||||
|
/// State needed for rendering [Space].
|
||||||
pub struct SpaceState {
|
pub struct SpaceState {
|
||||||
room_id: OwnedRoomId,
|
room_id: OwnedRoomId,
|
||||||
room: MatrixRoom,
|
room: MatrixRoom,
|
||||||
list: ListState<RoomItem, IambInfo>,
|
list: ListState<RoomItem, IambInfo>,
|
||||||
|
last_fetch: Option<Instant>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SpaceState {
|
impl SpaceState {
|
||||||
pub fn new(room: MatrixRoom) -> Self {
|
pub fn new(room: MatrixRoom) -> Self {
|
||||||
let room_id = room.room_id().to_owned();
|
let room_id = room.room_id().to_owned();
|
||||||
let content = IambBufferId::Room(room_id.clone(), RoomFocus::Scrollback);
|
let content = IambBufferId::Room(room_id.clone(), None, RoomFocus::Scrollback);
|
||||||
let list = ListState::new(content, vec![]);
|
let list = ListState::new(content, vec![]);
|
||||||
|
let last_fetch = None;
|
||||||
|
|
||||||
SpaceState { room_id, room, list }
|
SpaceState { room_id, room, list, last_fetch }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn refresh_room(&mut self, store: &mut ProgramStore) {
|
||||||
|
if let Some(room) = store.application.worker.client.get_room(self.id()) {
|
||||||
|
self.room = room;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn room(&self) -> &MatrixRoom {
|
pub fn room(&self) -> &MatrixRoom {
|
||||||
@@ -44,6 +65,7 @@ impl SpaceState {
|
|||||||
room_id: self.room_id.clone(),
|
room_id: self.room_id.clone(),
|
||||||
room: self.room.clone(),
|
room: self.room.clone(),
|
||||||
list: self.list.dup(store),
|
list: self.list.dup(store),
|
||||||
|
last_fetch: self.last_fetch,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -68,6 +90,7 @@ impl DerefMut for SpaceState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// [StatefulWidget] for Matrix spaces.
|
||||||
pub struct Space<'a> {
|
pub struct Space<'a> {
|
||||||
focused: bool,
|
focused: bool,
|
||||||
store: &'a mut ProgramStore,
|
store: &'a mut ProgramStore,
|
||||||
@@ -88,24 +111,54 @@ impl<'a> StatefulWidget for Space<'a> {
|
|||||||
type State = SpaceState;
|
type State = SpaceState;
|
||||||
|
|
||||||
fn render(self, area: Rect, buffer: &mut Buffer, state: &mut Self::State) {
|
fn render(self, area: Rect, buffer: &mut Buffer, state: &mut Self::State) {
|
||||||
let members = self.store.application.worker.space_members(state.room_id.clone()).unwrap();
|
let mut empty_message = None;
|
||||||
let items = members
|
let need_fetch = match state.last_fetch {
|
||||||
|
Some(i) => i.elapsed() >= SPACE_HIERARCHY_DEBOUNCE,
|
||||||
|
None => true,
|
||||||
|
};
|
||||||
|
|
||||||
|
if need_fetch {
|
||||||
|
let res = self.store.application.worker.space_members(state.room_id.clone());
|
||||||
|
|
||||||
|
match res {
|
||||||
|
Ok(members) => {
|
||||||
|
let mut items = members
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter_map(|id| {
|
.filter_map(|id| {
|
||||||
let (room, name) = self.store.application.worker.get_room(id.clone()).ok()?;
|
let (room, _, tags) =
|
||||||
|
self.store.application.worker.get_room(id.clone()).ok()?;
|
||||||
|
let room_info = std::sync::Arc::new((room, tags));
|
||||||
|
|
||||||
if id != state.room_id {
|
if id != state.room_id {
|
||||||
Some(RoomItem::new(room, name, self.store))
|
Some(RoomItem::new(room_info, self.store))
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect();
|
.collect::<Vec<_>>();
|
||||||
|
let fields = &self.store.application.settings.tunables.sort.rooms;
|
||||||
|
items.sort_by(|a, b| room_fields_cmp(a, b, fields));
|
||||||
|
|
||||||
state.list.set(items);
|
state.list.set(items);
|
||||||
|
state.last_fetch = Some(Instant::now());
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
let lines = vec![
|
||||||
|
Line::from("Unable to fetch space room hierarchy:"),
|
||||||
|
Span::styled(e.to_string(), Style::default().fg(Color::Red)).into(),
|
||||||
|
];
|
||||||
|
|
||||||
List::new(self.store)
|
empty_message = Text { lines }.into();
|
||||||
.focus(self.focused)
|
},
|
||||||
.render(area, buffer, &mut state.list)
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut list = List::new(self.store).focus(self.focused);
|
||||||
|
|
||||||
|
if let Some(text) = empty_message {
|
||||||
|
list = list.empty_message(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
list.render(area, buffer, &mut state.list)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
|
|
||||||
- `:dms` will open a list of direct messages
|
- `:dms` will open a list of direct messages
|
||||||
- `:rooms` will open a list of joined rooms
|
- `:rooms` will open a list of joined rooms
|
||||||
|
- `:chats` will open a list containing both direct messages and rooms
|
||||||
- `:members` will open a list of members for the currently focused room or space
|
- `:members` will open a list of members for the currently focused room or space
|
||||||
- `:spaces` will open a list of joined spaces
|
- `:spaces` will open a list of joined spaces
|
||||||
- `:join` can be used to switch to join a new room or start a direct message
|
- `:join` can be used to switch to join a new room or start a direct message
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
|
//! Welcome Window
|
||||||
use std::ops::{Deref, DerefMut};
|
use std::ops::{Deref, DerefMut};
|
||||||
|
|
||||||
use modalkit::tui::{buffer::Buffer, layout::Rect};
|
use ratatui::{buffer::Buffer, layout::Rect};
|
||||||
|
|
||||||
use modalkit::{
|
use modalkit_ratatui::{textbox::TextBoxState, TermOffset, TerminalCursor, WindowOps};
|
||||||
widgets::textbox::TextBoxState,
|
|
||||||
widgets::WindowOps,
|
|
||||||
widgets::{TermOffset, TerminalCursor},
|
|
||||||
};
|
|
||||||
|
|
||||||
use modalkit::editing::base::{CloseFlags, WordStyle};
|
use modalkit::editing::completion::CompletionList;
|
||||||
|
use modalkit::prelude::*;
|
||||||
|
|
||||||
use crate::base::{IambBufferId, IambInfo, ProgramStore};
|
use crate::base::{IambBufferId, IambInfo, IambResult, ProgramStore};
|
||||||
|
|
||||||
const WELCOME_TEXT: &str = include_str!("welcome.md");
|
const WELCOME_TEXT: &str = include_str!("welcome.md");
|
||||||
|
|
||||||
@@ -63,6 +61,19 @@ impl WindowOps<IambInfo> for WelcomeState {
|
|||||||
self.tbox.close(flags, store)
|
self.tbox.close(flags, store)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn write(
|
||||||
|
&mut self,
|
||||||
|
path: Option<&str>,
|
||||||
|
flags: WriteFlags,
|
||||||
|
store: &mut ProgramStore,
|
||||||
|
) -> IambResult<EditInfo> {
|
||||||
|
self.tbox.write(path, flags, store)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_completions(&self) -> Option<CompletionList> {
|
||||||
|
self.tbox.get_completions()
|
||||||
|
}
|
||||||
|
|
||||||
fn get_cursor_word(&self, style: &WordStyle) -> Option<String> {
|
fn get_cursor_word(&self, style: &WordStyle) -> Option<String> {
|
||||||
self.tbox.get_cursor_word(style)
|
self.tbox.get_cursor_word(style)
|
||||||
}
|
}
|
||||||
|
|||||||
1210
src/worker.rs
1210
src/worker.rs
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user