Compare commits
59 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 |
4
.gitattributes
vendored
4
.gitattributes
vendored
@@ -1 +1,3 @@
|
||||
* text eol=lf
|
||||
*.rs text eol=lf
|
||||
*.toml text eol=lf
|
||||
*.md text eol=lf
|
||||
|
||||
18
.github/workflows/ci.yml
vendored
18
.github/workflows/ci.yml
vendored
@@ -14,13 +14,16 @@ jobs:
|
||||
matrix:
|
||||
platform: [ubuntu-latest, windows-latest, macos-latest]
|
||||
runs-on: ${{ matrix.platform }}
|
||||
env:
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
RUSTC_WRAPPER: "sccache"
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
submodules: true
|
||||
- name: Install Rust (1.66 w/ clippy)
|
||||
uses: dtolnay/rust-toolchain@1.66
|
||||
- name: Install Rust (1.70 w/ clippy)
|
||||
uses: dtolnay/rust-toolchain@1.70
|
||||
with:
|
||||
components: clippy
|
||||
- name: Install Rust (nightly w/ rustfmt)
|
||||
@@ -30,11 +33,8 @@ jobs:
|
||||
with:
|
||||
path: ~/.cargo/registry
|
||||
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
|
||||
- name: Cache cargo build
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: target
|
||||
key: ${{ runner.os }}-cargo-build-${{ hashFiles('**/Cargo.lock') }}
|
||||
- name: Run sccache-cache
|
||||
uses: mozilla-actions/sccache-action@v0.0.3
|
||||
- name: Check formatting
|
||||
run: cargo +nightly fmt --all -- --check
|
||||
- name: Check Clippy
|
||||
@@ -44,9 +44,9 @@ jobs:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
reporter: 'github-check'
|
||||
- name: Run tests
|
||||
run: cargo test
|
||||
run: cargo test --locked
|
||||
- name: Build artifacts
|
||||
run: cargo build --release
|
||||
run: cargo build --release --locked
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@master
|
||||
with:
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,4 +1,4 @@
|
||||
/target
|
||||
/result
|
||||
/TODO
|
||||
/docs/iamb.[15]
|
||||
.direnv
|
||||
|
||||
3836
Cargo.lock
generated
3836
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
43
Cargo.toml
43
Cargo.toml
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "iamb"
|
||||
version = "0.0.8"
|
||||
version = "0.0.9"
|
||||
edition = "2018"
|
||||
authors = ["Ulyssa <git@ulyssa.dev>"]
|
||||
repository = "https://github.com/ulyssa/iamb"
|
||||
@@ -11,11 +11,14 @@ license = "Apache-2.0"
|
||||
exclude = [".github", "CONTRIBUTING.md"]
|
||||
keywords = ["matrix", "chat", "tui", "vim"]
|
||||
categories = ["command-line-utilities"]
|
||||
rust-version = "1.66"
|
||||
rust-version = "1.70"
|
||||
build = "build.rs"
|
||||
|
||||
[build-dependencies]
|
||||
mandown = "0.1.3"
|
||||
[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"
|
||||
@@ -23,10 +26,10 @@ default-features = false
|
||||
features = ["build", "git", "gitcl",]
|
||||
|
||||
[dependencies]
|
||||
arboard = "3.2.0"
|
||||
bitflags = "1.3.2"
|
||||
arboard = "3.3.0"
|
||||
bitflags = "^2.3"
|
||||
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"
|
||||
@@ -39,26 +42,41 @@ 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"
|
||||
rpassword = "^7.2"
|
||||
serde = "^1.0"
|
||||
serde_json = "^1.0"
|
||||
sled = "0.34.7"
|
||||
temp-dir = "0.1.12"
|
||||
thiserror = "^1.0.37"
|
||||
toml = "^0.8.12"
|
||||
tracing = "~0.1.36"
|
||||
tracing-appender = "~0.2.2"
|
||||
tracing-subscriber = "0.3.16"
|
||||
unicode-segmentation = "^1.7"
|
||||
unicode-width = "0.1.10"
|
||||
url = {version = "^2.2.2", features = ["serde"]}
|
||||
edit = "0.1.4"
|
||||
|
||||
[dependencies.modalkit]
|
||||
version = "0.0.16"
|
||||
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.6"
|
||||
version = "0.7.1"
|
||||
default-features = false
|
||||
features = ["e2e-encryption", "sled", "rustls-tls"]
|
||||
features = ["e2e-encryption", "sqlite", "sso-login"]
|
||||
|
||||
[dependencies.tokio]
|
||||
version = "1.24.1"
|
||||
@@ -68,6 +86,7 @@ features = ["macros", "net", "rt-multi-thread", "sync", "time"]
|
||||
lazy_static = "1.4.0"
|
||||
pretty_assertions = "1.4.0"
|
||||
|
||||
[profile.release]
|
||||
lto = true
|
||||
[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
|
||||
116
README.md
116
README.md
@@ -1,18 +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+)
|
||||
[](https://crates.io/crates/iamb)
|
||||
[](https://github.com/ulyssa/iamb/actions?query=workflow%3ACI+)
|
||||
[][crates-io-iamb]
|
||||
[](https://matrix.to/#/#iamb:0x.badd.cafe)
|
||||
[](https://crates.io/crates/iamb)
|
||||
[][crates-io-iamb]
|
||||
[](https://snapcraft.io/iamb)
|
||||
|
||||

|
||||
|
||||
</div>
|
||||
|
||||
|
||||
## About
|
||||
|
||||
`iamb` is a Matrix client for the terminal that uses Vim keybindings.
|
||||
`iamb` is a Matrix client for the terminal that uses Vim keybindings. It includes support for:
|
||||
|
||||
This project is a work-in-progress, and there's still a lot to be implemented,
|
||||
but much of the basic client functionality is already present.
|
||||
- Threads, spaces, E2EE, and read receipts
|
||||
- Image previews in terminals that support it (sixels, Kitty, and iTerm2), or using pixelated blocks for those that don't
|
||||
- Notifications via terminal bell or desktop environment
|
||||
- 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
|
||||
|
||||
@@ -21,12 +34,14 @@ website, [iamb.chat].
|
||||
|
||||
## Installation
|
||||
|
||||
Install Rust (1.66.0 or above) and Cargo, and then run:
|
||||
Install Rust (1.70.0 or above) and Cargo, and then run:
|
||||
|
||||
```
|
||||
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:
|
||||
@@ -43,6 +58,13 @@ Arch User Repositories (AUR). To install it simply run with your favorite AUR he
|
||||
```
|
||||
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)
|
||||
|
||||
@@ -50,71 +72,41 @@ paru iamb-git
|
||||
nix profile install "github:ulyssa/iamb"
|
||||
```
|
||||
|
||||
## Configuration
|
||||
### Snap
|
||||
|
||||
You can create a basic configuration in `$CONFIG_DIR/iamb/config.json` that looks like:
|
||||
A snap for Linux distributions which [support](https://snapcraft.io/docs/installing-snapd) the packaging system.
|
||||
|
||||
```json
|
||||
{
|
||||
"profiles": {
|
||||
"example.com": {
|
||||
"url": "https://example.com",
|
||||
"user_id": "@user:example.com"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
snap install iamb
|
||||
```
|
||||
|
||||
## Comparison With Other Clients
|
||||
## Configuration
|
||||
|
||||
To get an idea of what is and isn't yet implemented, here is a subset of the
|
||||
Matrix website's [features comparison table][client-comparison-matrix], showing
|
||||
two other TUI clients and Element Web:
|
||||
You can create a basic configuration in `$CONFIG_DIR/iamb/config.toml` that looks like:
|
||||
|
||||
```toml
|
||||
[profiles."example.com"]
|
||||
user_id = "@user:example.com"
|
||||
```
|
||||
|
||||
If you homeserver is located on a different domain than the server part of the
|
||||
`user_id` and you don't have a [`/.well-known`][well_known_entry] entry, then
|
||||
you can explicitly specify the homeserver URL to use:
|
||||
|
||||
```toml
|
||||
[profiles."example.com"]
|
||||
url = "https://example.com"
|
||||
user_id = "@user:example.com"
|
||||
```
|
||||
|
||||
| | iamb | [gomuks] | [weechat-matrix] | Element Web/Desktop |
|
||||
| --------------------------------------- | :---------- | :------: | :--------------: | :-----------------: |
|
||||
| Room directory | ❌ ([#14]) | ❌ | ✔️ | ✔️ |
|
||||
| Room tag showing | ✔️ | ✔️ | ❌ | ✔️ |
|
||||
| Room tag editing | ✔️ | ✔️ | ❌ | ✔️ |
|
||||
| Search joined rooms | ❌ ([#16]) | ✔️ | ❌ | ✔️ |
|
||||
| Room user list | ✔️ | ✔️ | ✔️ | ✔️ |
|
||||
| Display Room Description | ✔️ | ✔️ | ✔️ | ✔️ |
|
||||
| Edit Room Description | ✔️ | ❌ | ✔️ | ✔️ |
|
||||
| Highlights | ❌ ([#8]) | ✔️ | ✔️ | ✔️ |
|
||||
| Pushrules | ❌ | ✔️ | ❌ | ✔️ |
|
||||
| Send read markers | ✔️ | ✔️ | ✔️ | ✔️ |
|
||||
| Display read markers | ✔️ | ❌ | ❌ | ✔️ |
|
||||
| Sending Invites | ✔️ | ✔️ | ✔️ | ✔️ |
|
||||
| Accepting Invites | ✔️ | ✔️ | ✔️ | ✔️ |
|
||||
| Typing Notification | ✔️ | ✔️ | ✔️ | ✔️ |
|
||||
| E2E | ✔️ | ✔️ | ✔️ | ✔️ |
|
||||
| Replies | ✔️ | ✔️ | ❌ | ✔️ |
|
||||
| Attachment uploading | ✔️ | ❌ | ✔️ | ✔️ |
|
||||
| Attachment downloading | ✔️ | ✔️ | ✔️ | ✔️ |
|
||||
| Send stickers | ❌ | ❌ | ❌ | ✔️ |
|
||||
| Send formatted messages (markdown) | ✔️ | ✔️ | ✔️ | ✔️ |
|
||||
| Rich Text Editor for formatted messages | ❌ | ❌ | ❌ | ✔️ |
|
||||
| Display formatted messages | ✔️ | ✔️ | ✔️ | ✔️ |
|
||||
| Redacting | ✔️ | ✔️ | ✔️ | ✔️ |
|
||||
| Multiple Matrix Accounts | ✔️ | ❌ | ✔️ | ❌ |
|
||||
| New user registration | ❌ | ❌ | ❌ | ✔️ |
|
||||
| VOIP | ❌ | ❌ | ❌ | ✔️ |
|
||||
| Reactions | ✔️ | ✔️ | ❌ | ✔️ |
|
||||
| Message editing | ✔️ | ✔️ | ❌ | ✔️ |
|
||||
| Room upgrades | ❌ ([#41]) | ✔️ | ❌ | ✔️ |
|
||||
| Localisations | ❌ | 1 | ❌ | 44 |
|
||||
| SSO Support | ❌ | ✔️ | ✔️ | ✔️ |
|
||||
|
||||
## License
|
||||
|
||||
iamb is released under the [Apache License, Version 2.0].
|
||||
|
||||
[Apache License, Version 2.0]: https://github.com/ulyssa/iamb/blob/master/LICENSE
|
||||
[client-comparison-matrix]: https://matrix.org/clients-matrix/
|
||||
[crates-io-iamb]: https://crates.io/crates/iamb
|
||||
[iamb.chat]: https://iamb.chat
|
||||
[gomuks]: https://github.com/tulir/gomuks
|
||||
[weechat-matrix]: https://github.com/poljar/weechat-matrix
|
||||
[#8]: https://github.com/ulyssa/iamb/issues/8
|
||||
[#14]: https://github.com/ulyssa/iamb/issues/14
|
||||
[#16]: https://github.com/ulyssa/iamb/issues/16
|
||||
[#41]: https://github.com/ulyssa/iamb/issues/41
|
||||
[well_known_entry]: https://spec.matrix.org/latest/client-server-api/#getwell-knownmatrixclient
|
||||
|
||||
20
build.rs
20
build.rs
@@ -1,29 +1,9 @@
|
||||
use std::error::Error;
|
||||
use std::fs;
|
||||
use std::iter::FromIterator;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use mandown::convert;
|
||||
use vergen::EmitBuilder;
|
||||
|
||||
const IAMB_1_MD: &str = include_str!("docs/iamb.1.md");
|
||||
const IAMB_5_MD: &str = include_str!("docs/iamb.5.md");
|
||||
|
||||
fn main() -> Result<(), Box<dyn Error>> {
|
||||
EmitBuilder::builder().git_sha(true).emit()?;
|
||||
|
||||
// Build the manual pages.
|
||||
println!("cargo:rerun-if-changed=docs/iamb.1.md");
|
||||
println!("cargo:rerun-if-changed=docs/iamb.5.md");
|
||||
|
||||
let iamb_1 = convert(IAMB_1_MD, "IAMB", 1);
|
||||
let iamb_5 = convert(IAMB_5_MD, "IAMB", 5);
|
||||
|
||||
let out_dir = std::env::var("OUT_DIR");
|
||||
let out_dir = out_dir.as_deref().unwrap_or("docs");
|
||||
|
||||
fs::write(PathBuf::from_iter([out_dir, "iamb.1"]), iamb_1.as_bytes())?;
|
||||
fs::write(PathBuf::from_iter([out_dir, "iamb.5"]), iamb_5.as_bytes())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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/"
|
||||
@@ -1,32 +0,0 @@
|
||||
{
|
||||
"default_profile": "default",
|
||||
"profiles": {
|
||||
"default": {
|
||||
"user_id": "",
|
||||
"url": "https://matrix.org",
|
||||
"settings": {},
|
||||
"dirs": {}
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"log_level": "warn",
|
||||
"reaction_display": true,
|
||||
"reaction_shortcode_display": false,
|
||||
"read_receipt_send": true,
|
||||
"read_receipt_display": true,
|
||||
"request_timeout": 10000,
|
||||
"typing_notice_send": true,
|
||||
"typing_notice_display": true,
|
||||
"users": {
|
||||
"@user:matrix.org": {
|
||||
"name": "John Doe",
|
||||
"color": "magenta"
|
||||
}
|
||||
},
|
||||
"default_room": "#iamb-users:0x.badd.cafe"
|
||||
},
|
||||
"dirs": {
|
||||
"cache": "~/.cache/iamb/",
|
||||
"logs": "~/.local/share/iamb/logs/",
|
||||
"downloads": "~/Downloads/"
|
||||
}
|
||||
BIN
docs/iamb-256x256.png
Normal file
BIN
docs/iamb-256x256.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.5 KiB |
BIN
docs/iamb-512x512.png
Normal file
BIN
docs/iamb-512x512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
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
|
||||
@@ -1,29 +0,0 @@
|
||||
# NAME
|
||||
|
||||
iamb – a terminal-based client for Matrix for the Vim addict
|
||||
|
||||
# SYNOPSIS
|
||||
|
||||
**iamb** [**--profile** _profile_] [**--config-directory** _directory_] [**--help** | **--version**]
|
||||
|
||||
# OPTIONS
|
||||
|
||||
These options are primitives at the top-level of the file.
|
||||
|
||||
**--profile**, **-P**
|
||||
> The profile to start with. Overrides **default_profile** from **iamb(5)**.
|
||||
|
||||
**--config-directory**, **-C**
|
||||
> Path to the directory the configuration file is located in.
|
||||
|
||||
**--help**, **-h**
|
||||
> Show a short help text and quit.
|
||||
|
||||
**--version**, **-V**
|
||||
> Show the iamb version and quit.
|
||||
|
||||
# SEE ALSO
|
||||
|
||||
**iamb(5)**
|
||||
|
||||
Full documentation is available online at \<https://iamb.chat\>
|
||||
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
|
||||
134
docs/iamb.5.md
134
docs/iamb.5.md
@@ -1,134 +0,0 @@
|
||||
# NAME
|
||||
|
||||
config.json – configuration file for iamb
|
||||
|
||||
# SYNOPSIS
|
||||
|
||||
Configuration must be placed under _~/.config/iamb/_ and is named config.json.
|
||||
|
||||
Example configuration usually comes bundled with your installation and can
|
||||
typically be found in _/usr/share/iamb_.
|
||||
|
||||
As implied by the filename, the configuration is formatted in JSON. It's
|
||||
structure and fields are described below.
|
||||
|
||||
# BASIC SETTINGS
|
||||
|
||||
These options are primitives at the top-level of the file.
|
||||
|
||||
**default_profile** (type: string)
|
||||
> The default profile to connect to, unless overwritten by a commandline
|
||||
> switch. It has to be defined in the *PROFILES* section.
|
||||
|
||||
# PROFILES
|
||||
|
||||
These options are configured as a map under the profiles name.
|
||||
|
||||
**user_id** (type: string)
|
||||
> The user ID to use when connecting to the server. For example "user" for
|
||||
> "@user:matrix.org".
|
||||
|
||||
**url** (type: string)
|
||||
> The URL of the users server. For example "https://matrix.org" for
|
||||
> "@user:matrix.org".
|
||||
|
||||
**settings** (type: settings object)
|
||||
> Overwrite general settings for this account. The fields are identical to
|
||||
> those in *TUNABLES*.
|
||||
|
||||
**layout** (type: startup layout object)
|
||||
> Overwrite general settings for this account. The fields are identical to
|
||||
> those in *STARTUP LAYOUT*.
|
||||
|
||||
**dirs** (type: XDG overrides object)
|
||||
> Overwrite general settings for this account. The fields are identical to
|
||||
> those in *DIRECTORIES*.
|
||||
|
||||
# TUNABLES
|
||||
|
||||
These options are configured as a map under the *settings* key and can be
|
||||
overridden as described in *PROFILES*.
|
||||
|
||||
**log_level** (type: string)
|
||||
> Specifies the lowest log level that should be shown. Possible values
|
||||
> are: _trace_, _debug_, _info_, _warn_, and _error_.
|
||||
|
||||
**reaction_display** (type: boolean)
|
||||
> Defines whether or not reactions should be shown.
|
||||
|
||||
**reaction_shortcode_display** (type: boolean)
|
||||
> Defines whether or not reactions should be shown as their respective
|
||||
> shortcode.
|
||||
|
||||
**read_receipt_send** (type: boolean)
|
||||
> Defines whether or not read confirmations are sent.
|
||||
|
||||
**read_receipt_display** (type: boolean)
|
||||
> Defines whether or not read confirmations are displayed.
|
||||
|
||||
**request_timeout** (type: uint64)
|
||||
> Defines the maximum time per request in seconds.
|
||||
|
||||
**typing_notice_send** (type: boolean)
|
||||
> Defines whether or not the typing state is sent.
|
||||
|
||||
**typing_notice_display** (type: boolean)
|
||||
> Defines whether or not the typing state is displayed.
|
||||
|
||||
**user** (type: map)
|
||||
> Overrides values for the specified user. See *USER OVERRIDES* for
|
||||
> details on the format.
|
||||
|
||||
**default_room** (type: string)
|
||||
> The room to show by default instead of a welcome-screen.
|
||||
|
||||
## USER OVERRIDES
|
||||
|
||||
Overrides are mapped onto matrix User IDs such as _@user:matrix.org_ and are
|
||||
maps containing the following key value pairs.
|
||||
|
||||
**name** (type: string)
|
||||
> Change the display name of the user.
|
||||
|
||||
**color** (type: string)
|
||||
> Change the color the user is shown as. Possible values are: _black_,
|
||||
> _blue_, _cyan_, _dark-gray_, _gray_, _green_, _light-blue_,
|
||||
> _light-cyan_, _light-green_, _light-magenta_, _light-red_,
|
||||
> _light-yellow_, _magenta_, _none_, _red_, _white_, _yellow_
|
||||
|
||||
# STARTUP LAYOUT
|
||||
|
||||
Specifies what initial set of tabs and windows to show when starting the
|
||||
client. Configured as an object under the key *layout*.
|
||||
|
||||
**style** (type: string)
|
||||
> Specifies what window layout to load when starting. Valid values are
|
||||
> _restore_ to restore the layout from the last time the client was exited,
|
||||
> _new_ to open a single window (uses the value of _default\_room_ if set), or
|
||||
> _config_ to open the layout described under _tabs_.
|
||||
|
||||
**tabs** (type: array of window objects)
|
||||
> If **style** is set to _config_, then this value will be used to open a set
|
||||
> of tabs and windows at startup. Each object can contain either a **window**
|
||||
> key specifying a username, room identifier or room alias to show, or a
|
||||
> **split** key specifying an array of window objects.
|
||||
|
||||
# DIRECTORIES
|
||||
|
||||
Specifies the directories to save data in. Configured as a map under the key
|
||||
*dirs*.
|
||||
|
||||
**cache** (type: string)
|
||||
> Specifies where to store assets and temporary data in.
|
||||
|
||||
**logs** (type: string)
|
||||
> Specifies where to store log files.
|
||||
|
||||
**downloads** (type: string)
|
||||
> Specifies where to store downloaded files.
|
||||
|
||||
# SEE ALSO
|
||||
|
||||
*iamb(1)*
|
||||
|
||||
Full documentation is available online at \<https://iamb.chat\>
|
||||
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 |
66
flake.lock
generated
66
flake.lock
generated
@@ -1,12 +1,15 @@
|
||||
{
|
||||
"nodes": {
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1678901627,
|
||||
"narHash": "sha256-U02riOqrKKzwjsxc/400XnElV+UtPUQWpANPlyazjH0=",
|
||||
"lastModified": 1709126324,
|
||||
"narHash": "sha256-q6EQdSeUZOG26WelxqkmR7kArjgWCdw5sfJVHPH/7j8=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "93a2b84fc4b70d9e089d029deacc3583435c2ed6",
|
||||
"rev": "d465f4819400de7c8d874d50b982301f28a84605",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -16,12 +19,15 @@
|
||||
}
|
||||
},
|
||||
"flake-utils_2": {
|
||||
"inputs": {
|
||||
"systems": "systems_2"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1659877975,
|
||||
"narHash": "sha256-zllb8aq3YO3h8B/U0/J1WBgAL8EX5yWf5pMj3G0NAmc=",
|
||||
"lastModified": 1705309234,
|
||||
"narHash": "sha256-uNRRNRKmJyCRC/8y1RqBkqWBLM034y4qN7EprSdmgyA=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "c0e246b9b83f637f4681389ecabcb2681b4f3af0",
|
||||
"rev": "1ef2e671c3b0c19053962c07dbda38332dcebf26",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -32,11 +38,11 @@
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1679437018,
|
||||
"narHash": "sha256-vOuiDPLHSEo/7NkiWtxpHpHgoXoNmrm+wkXZ6a072Fc=",
|
||||
"lastModified": 1709703039,
|
||||
"narHash": "sha256-6hqgQ8OK6gsMu1VtcGKBxKQInRLHtzulDo9Z5jxHEFY=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "19cf008bb18e47b6e3b4e16e32a9a4bdd4b45f7e",
|
||||
"rev": "9df3e30ce24fd28c7b3e2de0d986769db5d6225d",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -48,11 +54,11 @@
|
||||
},
|
||||
"nixpkgs_2": {
|
||||
"locked": {
|
||||
"lastModified": 1665296151,
|
||||
"narHash": "sha256-uOB0oxqxN9K7XGF1hcnY+PQnlQJ+3bP2vCn/+Ru/bbc=",
|
||||
"lastModified": 1706487304,
|
||||
"narHash": "sha256-LE8lVX28MV2jWJsidW13D2qrHU/RUUONendL2Q/WlJg=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "14ccaaedd95a488dd7ae142757884d8e125b3363",
|
||||
"rev": "90f456026d284c22b3e3497be980b2e47d0b28ac",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -75,11 +81,11 @@
|
||||
"nixpkgs": "nixpkgs_2"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1679624450,
|
||||
"narHash": "sha256-wiDqUaklmc31E1+wz5sv52sMcWvZKsL1FBeGJCxz628=",
|
||||
"lastModified": 1709863839,
|
||||
"narHash": "sha256-QpEL5FmZNi2By3sKZY55wGniFXc4wEn9PQczlE8TG0o=",
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"rev": "afbdcf305fd6f05f708fe76d52f24d37d066c251",
|
||||
"rev": "e5ab9ee98f479081ad971473d2bc13c59e9fbc0a",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -87,6 +93,36 @@
|
||||
"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",
|
||||
|
||||
26
flake.nix
26
flake.nix
@@ -14,26 +14,30 @@
|
||||
# We only need the nightly overlay in the devShell because .rs files are formatted with nightly.
|
||||
overlays = [ (import rust-overlay) ];
|
||||
pkgs = import nixpkgs { inherit system overlays; };
|
||||
rustNightly = pkgs.rust-bin.nightly."2023-03-17".default;
|
||||
in
|
||||
rustNightly = pkgs.rust-bin.nightly."2024-03-08".default;
|
||||
in
|
||||
with pkgs;
|
||||
{
|
||||
packages.default = pkgs.rustPlatform.buildRustPackage {
|
||||
packages.default = rustPlatform.buildRustPackage {
|
||||
pname = "iamb";
|
||||
version = "0.0.7";
|
||||
version = self.shortRev or self.dirtyShortRev;
|
||||
src = ./.;
|
||||
cargoLock.lockFile = ./Cargo.lock;
|
||||
nativeBuildInputs = [ pkgs.pkgconfig ];
|
||||
buildInputs = [ pkgs.openssl ] ++ pkgs.lib.optionals pkgs.stdenv.isDarwin
|
||||
(with pkgs.darwin.apple_sdk.frameworks; [ AppKit Security ]);
|
||||
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" ]; })
|
||||
(rustNightly.override {
|
||||
extensions = [ "rust-src" "rust-analyzer-preview" "rustfmt" "clippy" ];
|
||||
})
|
||||
pkg-config
|
||||
cargo-tarpaulin
|
||||
rust-analyzer
|
||||
rustfmt
|
||||
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
|
||||
941
src/base.rs
941
src/base.rs
File diff suppressed because it is too large
Load Diff
184
src/commands.rs
184
src/commands.rs
@@ -1,12 +1,15 @@
|
||||
//! # 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::{
|
||||
editing::base::OpenTarget,
|
||||
commands::{CommandError, CommandResult, CommandStep},
|
||||
env::vim::command::{CommandContext, CommandDescription, OptionType},
|
||||
input::commands::{CommandError, CommandResult, CommandStep},
|
||||
input::InputContext,
|
||||
prelude::OpenTarget,
|
||||
};
|
||||
|
||||
use crate::base::{
|
||||
@@ -16,17 +19,17 @@ use crate::base::{
|
||||
HomeserverAction,
|
||||
IambAction,
|
||||
IambId,
|
||||
KeysAction,
|
||||
MessageAction,
|
||||
ProgramCommand,
|
||||
ProgramCommands,
|
||||
ProgramContext,
|
||||
RoomAction,
|
||||
RoomField,
|
||||
SendAction,
|
||||
VerifyAction,
|
||||
};
|
||||
|
||||
type ProgContext = CommandContext<ProgramContext>;
|
||||
type ProgContext = CommandContext;
|
||||
type ProgResult = CommandResult<ProgramCommand>;
|
||||
|
||||
/// Convert strings the user types into a tag name.
|
||||
@@ -95,7 +98,30 @@ fn iamb_invite(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
||||
};
|
||||
|
||||
let iact = IambAction::from(ract);
|
||||
let step = CommandStep::Continue(iact.into(), ctx.context.take());
|
||||
let step = CommandStep::Continue(iact.into(), ctx.context.clone());
|
||||
|
||||
return Ok(step);
|
||||
}
|
||||
|
||||
fn iamb_keys(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
||||
let mut args = desc.arg.strings()?;
|
||||
|
||||
if args.len() != 3 {
|
||||
return Err(CommandError::InvalidArgument);
|
||||
}
|
||||
|
||||
let act = args.remove(0);
|
||||
let path = args.remove(0);
|
||||
let passphrase = args.remove(0);
|
||||
|
||||
let act = match act.as_str() {
|
||||
"export" => KeysAction::Export(path, passphrase),
|
||||
"import" => KeysAction::Import(path, passphrase),
|
||||
_ => return Err(CommandError::InvalidArgument),
|
||||
};
|
||||
|
||||
let vact = IambAction::Keys(act);
|
||||
let step = CommandStep::Continue(vact.into(), ctx.context.clone());
|
||||
|
||||
return Ok(step);
|
||||
}
|
||||
@@ -106,7 +132,7 @@ fn iamb_verify(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
||||
match args.len() {
|
||||
0 => {
|
||||
let open = ctx.switch(OpenTarget::Application(IambId::VerifyList));
|
||||
let step = CommandStep::Continue(open, ctx.context.take());
|
||||
let step = CommandStep::Continue(open, ctx.context.clone());
|
||||
|
||||
return Ok(step);
|
||||
},
|
||||
@@ -121,7 +147,7 @@ fn iamb_verify(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
||||
"mismatch" => VerifyAction::Mismatch,
|
||||
"request" => {
|
||||
let iact = IambAction::VerifyRequest(args.remove(1));
|
||||
let step = CommandStep::Continue(iact.into(), ctx.context.take());
|
||||
let step = CommandStep::Continue(iact.into(), ctx.context.clone());
|
||||
|
||||
return Ok(step);
|
||||
},
|
||||
@@ -129,7 +155,7 @@ fn iamb_verify(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
||||
};
|
||||
|
||||
let vact = IambAction::Verify(act, args.remove(1));
|
||||
let step = CommandStep::Continue(vact.into(), ctx.context.take());
|
||||
let step = CommandStep::Continue(vact.into(), ctx.context.clone());
|
||||
|
||||
return Ok(step);
|
||||
},
|
||||
@@ -145,7 +171,7 @@ fn iamb_dms(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
||||
}
|
||||
|
||||
let open = ctx.switch(OpenTarget::Application(IambId::DirectList));
|
||||
let step = CommandStep::Continue(open, ctx.context.take());
|
||||
let step = CommandStep::Continue(open, ctx.context.clone());
|
||||
|
||||
return Ok(step);
|
||||
}
|
||||
@@ -156,7 +182,7 @@ fn iamb_members(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
||||
}
|
||||
|
||||
let open = IambAction::Room(RoomAction::Members(ctx.clone().into()));
|
||||
let step = CommandStep::Continue(open.into(), ctx.context.take());
|
||||
let step = CommandStep::Continue(open.into(), ctx.context.clone());
|
||||
|
||||
return Ok(step);
|
||||
}
|
||||
@@ -167,7 +193,7 @@ fn iamb_leave(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
||||
}
|
||||
|
||||
let leave = IambAction::Room(RoomAction::Leave(desc.bang));
|
||||
let step = CommandStep::Continue(leave.into(), ctx.context.take());
|
||||
let step = CommandStep::Continue(leave.into(), ctx.context.clone());
|
||||
|
||||
return Ok(step);
|
||||
}
|
||||
@@ -178,7 +204,7 @@ fn iamb_cancel(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
||||
}
|
||||
|
||||
let mact = IambAction::from(MessageAction::Cancel(desc.bang));
|
||||
let step = CommandStep::Continue(mact.into(), ctx.context.take());
|
||||
let step = CommandStep::Continue(mact.into(), ctx.context.clone());
|
||||
|
||||
return Ok(step);
|
||||
}
|
||||
@@ -189,7 +215,7 @@ fn iamb_edit(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
||||
}
|
||||
|
||||
let mact = IambAction::from(MessageAction::Edit);
|
||||
let step = CommandStep::Continue(mact.into(), ctx.context.take());
|
||||
let step = CommandStep::Continue(mact.into(), ctx.context.clone());
|
||||
|
||||
return Ok(step);
|
||||
}
|
||||
@@ -205,7 +231,7 @@ fn iamb_react(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
||||
|
||||
if let Some(emoji) = emojis::get(k).or_else(|| emojis::get_by_shortcode(k)) {
|
||||
let mact = IambAction::from(MessageAction::React(emoji.to_string()));
|
||||
let step = CommandStep::Continue(mact.into(), ctx.context.take());
|
||||
let step = CommandStep::Continue(mact.into(), ctx.context.clone());
|
||||
|
||||
return Ok(step);
|
||||
} else {
|
||||
@@ -236,7 +262,7 @@ fn iamb_unreact(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
||||
IambAction::from(MessageAction::Unreact(None))
|
||||
};
|
||||
|
||||
let step = CommandStep::Continue(mact.into(), ctx.context.take());
|
||||
let step = CommandStep::Continue(mact.into(), ctx.context.clone());
|
||||
|
||||
return Ok(step);
|
||||
}
|
||||
@@ -250,7 +276,7 @@ fn iamb_redact(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
||||
|
||||
let reason = args.into_iter().next();
|
||||
let ract = IambAction::from(MessageAction::Redact(reason, desc.bang));
|
||||
let step = CommandStep::Continue(ract.into(), ctx.context.take());
|
||||
let step = CommandStep::Continue(ract.into(), ctx.context.clone());
|
||||
|
||||
return Ok(step);
|
||||
}
|
||||
@@ -261,7 +287,18 @@ fn iamb_reply(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
||||
}
|
||||
|
||||
let ract = IambAction::from(MessageAction::Reply);
|
||||
let step = CommandStep::Continue(ract.into(), ctx.context.take());
|
||||
let step = CommandStep::Continue(ract.into(), ctx.context.clone());
|
||||
|
||||
return Ok(step);
|
||||
}
|
||||
|
||||
fn iamb_editor(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
||||
if !desc.arg.text.is_empty() {
|
||||
return Result::Err(CommandError::InvalidArgument);
|
||||
}
|
||||
|
||||
let sact = IambAction::from(SendAction::SubmitFromEditor);
|
||||
let step = CommandStep::Continue(sact.into(), ctx.context.clone());
|
||||
|
||||
return Ok(step);
|
||||
}
|
||||
@@ -272,7 +309,18 @@ fn iamb_rooms(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
||||
}
|
||||
|
||||
let open = ctx.switch(OpenTarget::Application(IambId::RoomList));
|
||||
let step = CommandStep::Continue(open, ctx.context.take());
|
||||
let step = CommandStep::Continue(open, ctx.context.clone());
|
||||
|
||||
return Ok(step);
|
||||
}
|
||||
|
||||
fn iamb_chats(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
||||
if !desc.arg.text.is_empty() {
|
||||
return Result::Err(CommandError::InvalidArgument);
|
||||
}
|
||||
|
||||
let open = ctx.switch(OpenTarget::Application(IambId::ChatList));
|
||||
let step = CommandStep::Continue(open, ctx.context.clone());
|
||||
|
||||
return Ok(step);
|
||||
}
|
||||
@@ -283,7 +331,7 @@ fn iamb_spaces(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -294,7 +342,7 @@ fn iamb_welcome(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
||||
}
|
||||
|
||||
let open = ctx.switch(OpenTarget::Application(IambId::Welcome));
|
||||
let step = CommandStep::Continue(open, ctx.context.take());
|
||||
let step = CommandStep::Continue(open, ctx.context.clone());
|
||||
|
||||
return Ok(step);
|
||||
}
|
||||
@@ -307,7 +355,7 @@ fn iamb_join(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
||||
}
|
||||
|
||||
let open = ctx.switch(args.remove(0));
|
||||
let step = CommandStep::Continue(open, ctx.context.take());
|
||||
let step = CommandStep::Continue(open, ctx.context.clone());
|
||||
|
||||
return Ok(step);
|
||||
}
|
||||
@@ -354,7 +402,7 @@ fn iamb_create(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
||||
|
||||
let hact = HomeserverAction::CreateRoom(alias, ct, flags);
|
||||
let iact = IambAction::from(hact);
|
||||
let step = CommandStep::Continue(iact.into(), ctx.context.take());
|
||||
let step = CommandStep::Continue(iact.into(), ctx.context.clone());
|
||||
|
||||
return Ok(step);
|
||||
}
|
||||
@@ -401,7 +449,7 @@ fn iamb_room(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
||||
_ => 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);
|
||||
}
|
||||
@@ -415,7 +463,7 @@ fn iamb_upload(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
||||
|
||||
let sact = SendAction::Upload(args.remove(0));
|
||||
let iact = IambAction::from(sact);
|
||||
let step = CommandStep::Continue(iact.into(), ctx.context.take());
|
||||
let step = CommandStep::Continue(iact.into(), ctx.context.clone());
|
||||
|
||||
return Ok(step);
|
||||
}
|
||||
@@ -433,7 +481,7 @@ fn iamb_download(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult
|
||||
};
|
||||
let mact = MessageAction::Download(args.pop(), flags);
|
||||
let iact = IambAction::from(mact);
|
||||
let step = CommandStep::Continue(iact.into(), ctx.context.take());
|
||||
let step = CommandStep::Continue(iact.into(), ctx.context.clone());
|
||||
|
||||
return Ok(step);
|
||||
}
|
||||
@@ -451,7 +499,20 @@ fn iamb_open(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
||||
};
|
||||
let mact = MessageAction::Download(args.pop(), flags);
|
||||
let iact = IambAction::from(mact);
|
||||
let step = CommandStep::Continue(iact.into(), ctx.context.take());
|
||||
let step = CommandStep::Continue(iact.into(), ctx.context.clone());
|
||||
|
||||
return Ok(step);
|
||||
}
|
||||
|
||||
fn iamb_logout(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
||||
let args = desc.arg.strings()?;
|
||||
|
||||
if args.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);
|
||||
}
|
||||
@@ -467,6 +528,11 @@ fn add_iamb_commands(cmds: &mut ProgramCommands) {
|
||||
aliases: vec![],
|
||||
f: iamb_create,
|
||||
});
|
||||
cmds.add_command(ProgramCommand {
|
||||
name: "chats".into(),
|
||||
aliases: vec![],
|
||||
f: iamb_chats,
|
||||
});
|
||||
cmds.add_command(ProgramCommand { name: "dms".into(), aliases: vec![], f: iamb_dms });
|
||||
cmds.add_command(ProgramCommand {
|
||||
name: "download".into(),
|
||||
@@ -481,6 +547,7 @@ fn add_iamb_commands(cmds: &mut ProgramCommands) {
|
||||
f: iamb_invite,
|
||||
});
|
||||
cmds.add_command(ProgramCommand { name: "join".into(), aliases: vec![], f: iamb_join });
|
||||
cmds.add_command(ProgramCommand { name: "keys".into(), aliases: vec![], f: iamb_keys });
|
||||
cmds.add_command(ProgramCommand {
|
||||
name: "leave".into(),
|
||||
aliases: vec![],
|
||||
@@ -537,8 +604,19 @@ fn add_iamb_commands(cmds: &mut ProgramCommands) {
|
||||
aliases: vec![],
|
||||
f: iamb_welcome,
|
||||
});
|
||||
cmds.add_command(ProgramCommand {
|
||||
name: "editor".into(),
|
||||
aliases: vec![],
|
||||
f: iamb_editor,
|
||||
});
|
||||
cmds.add_command(ProgramCommand {
|
||||
name: "logout".into(),
|
||||
aliases: vec![],
|
||||
f: iamb_logout,
|
||||
});
|
||||
}
|
||||
|
||||
/// Initialize the default command state.
|
||||
pub fn setup_commands() -> ProgramCommands {
|
||||
let mut cmds = ProgramCommands::default();
|
||||
|
||||
@@ -551,12 +629,13 @@ pub fn setup_commands() -> ProgramCommands {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use matrix_sdk::ruma::user_id;
|
||||
use modalkit::editing::action::WindowAction;
|
||||
use modalkit::actions::WindowAction;
|
||||
use modalkit::editing::context::EditContext;
|
||||
|
||||
#[test]
|
||||
fn test_cmd_verify() {
|
||||
let mut cmds = setup_commands();
|
||||
let ctx = ProgramContext::default();
|
||||
let ctx = EditContext::default();
|
||||
|
||||
let res = cmds.input_cmd(":verify", ctx.clone()).unwrap();
|
||||
let act = WindowAction::Switch(OpenTarget::Application(IambId::VerifyList));
|
||||
@@ -603,7 +682,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_cmd_join() {
|
||||
let mut cmds = setup_commands();
|
||||
let ctx = ProgramContext::default();
|
||||
let ctx = EditContext::default();
|
||||
|
||||
let res = cmds.input_cmd("join #foobar:example.com", ctx.clone()).unwrap();
|
||||
let act = WindowAction::Switch(OpenTarget::Name("#foobar:example.com".into()));
|
||||
@@ -623,7 +702,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_cmd_room_invalid() {
|
||||
let mut cmds = setup_commands();
|
||||
let ctx = ProgramContext::default();
|
||||
let ctx = EditContext::default();
|
||||
|
||||
let res = cmds.input_cmd("room", ctx.clone());
|
||||
assert_eq!(res, Err(CommandError::InvalidArgument));
|
||||
@@ -638,7 +717,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_cmd_room_topic_set() {
|
||||
let mut cmds = setup_commands();
|
||||
let ctx = ProgramContext::default();
|
||||
let ctx = EditContext::default();
|
||||
|
||||
let res = cmds
|
||||
.input_cmd("room topic set \"Lots of fun discussion!\"", ctx.clone())
|
||||
@@ -669,7 +748,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_cmd_room_name_invalid() {
|
||||
let mut cmds = setup_commands();
|
||||
let ctx = ProgramContext::default();
|
||||
let ctx = EditContext::default();
|
||||
|
||||
let res = cmds.input_cmd("room name", ctx.clone());
|
||||
assert_eq!(res, Err(CommandError::InvalidArgument));
|
||||
@@ -681,7 +760,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_cmd_room_name_set() {
|
||||
let mut cmds = setup_commands();
|
||||
let ctx = ProgramContext::default();
|
||||
let ctx = EditContext::default();
|
||||
|
||||
let res = cmds.input_cmd("room name set Development", ctx.clone()).unwrap();
|
||||
let act = RoomAction::Set(RoomField::Name, "Development".into());
|
||||
@@ -700,7 +779,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_cmd_room_name_unset() {
|
||||
let mut cmds = setup_commands();
|
||||
let ctx = ProgramContext::default();
|
||||
let ctx = EditContext::default();
|
||||
|
||||
let res = cmds.input_cmd("room name unset", ctx.clone()).unwrap();
|
||||
let act = RoomAction::Unset(RoomField::Name);
|
||||
@@ -713,7 +792,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_cmd_room_tag_set() {
|
||||
let mut cmds = setup_commands();
|
||||
let ctx = ProgramContext::default();
|
||||
let ctx = EditContext::default();
|
||||
|
||||
let res = cmds.input_cmd("room tag set favourite", ctx.clone()).unwrap();
|
||||
let act = RoomAction::Set(RoomField::Tag(TagName::Favorite), "".into());
|
||||
@@ -782,7 +861,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_cmd_room_tag_unset() {
|
||||
let mut cmds = setup_commands();
|
||||
let ctx = ProgramContext::default();
|
||||
let ctx = EditContext::default();
|
||||
|
||||
let res = cmds.input_cmd("room tag unset favourite", ctx.clone()).unwrap();
|
||||
let act = RoomAction::Unset(RoomField::Tag(TagName::Favorite));
|
||||
@@ -847,7 +926,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_cmd_invite() {
|
||||
let mut cmds = setup_commands();
|
||||
let ctx = ProgramContext::default();
|
||||
let ctx = EditContext::default();
|
||||
|
||||
let res = cmds.input_cmd("invite accept", ctx.clone()).unwrap();
|
||||
let act = IambAction::Room(RoomAction::InviteAccept);
|
||||
@@ -884,7 +963,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_cmd_redact() {
|
||||
let mut cmds = setup_commands();
|
||||
let ctx = ProgramContext::default();
|
||||
let ctx = EditContext::default();
|
||||
|
||||
let res = cmds.input_cmd("redact", ctx.clone()).unwrap();
|
||||
let act = IambAction::Message(MessageAction::Redact(None, false));
|
||||
@@ -905,4 +984,31 @@ mod tests {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
513
src/config.rs
513
src/config.rs
@@ -1,25 +1,37 @@
|
||||
//! # Logic for loading and validating application configuration
|
||||
use std::borrow::Cow;
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::collections::HashMap;
|
||||
use std::fmt;
|
||||
use std::fs::File;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::io::BufReader;
|
||||
use std::io::{BufReader, BufWriter};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process;
|
||||
|
||||
use clap::Parser;
|
||||
use matrix_sdk::ruma::{OwnedRoomAliasId, OwnedRoomId, OwnedUserId, UserId};
|
||||
use serde::{de::Error as SerdeError, de::Visitor, Deserialize, Deserializer};
|
||||
use matrix_sdk::matrix_auth::MatrixSession;
|
||||
use matrix_sdk::ruma::{OwnedDeviceId, OwnedRoomAliasId, OwnedRoomId, OwnedUserId, UserId};
|
||||
use ratatui::style::{Color, Modifier as StyleModifier, Style};
|
||||
use ratatui::text::Span;
|
||||
use ratatui_image::picker::ProtocolType;
|
||||
use serde::{de::Error as SerdeError, de::Visitor, Deserialize, Deserializer, Serialize};
|
||||
use tracing::Level;
|
||||
use url::Url;
|
||||
|
||||
use modalkit::tui::{
|
||||
style::{Color, Modifier as StyleModifier, Style},
|
||||
text::Span,
|
||||
use modalkit::{env::vim::VimMode, key::TerminalKey, keybindings::InputKey};
|
||||
|
||||
use super::base::{
|
||||
IambError,
|
||||
IambId,
|
||||
RoomInfo,
|
||||
SortColumn,
|
||||
SortFieldRoom,
|
||||
SortFieldUser,
|
||||
SortOrder,
|
||||
};
|
||||
|
||||
use super::base::{IambId, RoomInfo};
|
||||
type Macros = HashMap<VimModes, HashMap<Keys, Keys>>;
|
||||
|
||||
macro_rules! usage {
|
||||
( $($args: tt)* ) => {
|
||||
@@ -28,6 +40,18 @@ macro_rules! usage {
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_MEMBERS_SORT: [SortColumn<SortFieldUser>; 2] = [
|
||||
SortColumn(SortFieldUser::PowerLevel, SortOrder::Ascending),
|
||||
SortColumn(SortFieldUser::UserId, SortOrder::Ascending),
|
||||
];
|
||||
|
||||
const DEFAULT_ROOM_SORT: [SortColumn<SortFieldRoom>; 4] = [
|
||||
SortColumn(SortFieldRoom::Favorite, SortOrder::Ascending),
|
||||
SortColumn(SortFieldRoom::LowPriority, SortOrder::Ascending),
|
||||
SortColumn(SortFieldRoom::Unread, SortOrder::Ascending),
|
||||
SortColumn(SortFieldRoom::Name, SortOrder::Ascending),
|
||||
];
|
||||
|
||||
const DEFAULT_REQ_TIMEOUT: u64 = 120;
|
||||
|
||||
const COLORS: [Color; 13] = [
|
||||
@@ -62,6 +86,10 @@ fn is_profile_char(c: char) -> bool {
|
||||
c.is_ascii_alphanumeric() || c == '.' || c == '-'
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn validate_profile_name(name: &str) -> bool {
|
||||
if name.is_empty() {
|
||||
return false;
|
||||
@@ -113,7 +141,85 @@ pub enum ConfigError {
|
||||
IO(#[from] std::io::Error),
|
||||
|
||||
#[error("Error loading configuration file: {0}")]
|
||||
Invalid(#[from] serde_json::Error),
|
||||
Invalid(#[from] toml::de::Error),
|
||||
|
||||
#[error("Error loading JSON configuration file: {0}")]
|
||||
InvalidJSON(#[from] serde_json::Error),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
|
||||
pub struct Keys(pub Vec<TerminalKey>, pub String);
|
||||
pub struct KeysVisitor;
|
||||
|
||||
impl<'de> Visitor<'de> for KeysVisitor {
|
||||
type Value = Keys;
|
||||
|
||||
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
|
||||
formatter.write_str("a valid Vim mode (e.g. \"normal\" or \"insert\")")
|
||||
}
|
||||
|
||||
fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
|
||||
where
|
||||
E: SerdeError,
|
||||
{
|
||||
match TerminalKey::from_macro_str(value) {
|
||||
Ok(keys) => Ok(Keys(keys, value.to_string())),
|
||||
Err(e) => Err(E::custom(format!("Could not parse key sequence: {e}"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for Keys {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
deserializer.deserialize_str(KeysVisitor)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
|
||||
pub struct VimModes(pub Vec<VimMode>);
|
||||
pub struct VimModesVisitor;
|
||||
|
||||
impl<'de> Visitor<'de> for VimModesVisitor {
|
||||
type Value = VimModes;
|
||||
|
||||
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
|
||||
formatter.write_str("a valid Vim mode (e.g. \"normal\" or \"insert\")")
|
||||
}
|
||||
|
||||
fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
|
||||
where
|
||||
E: SerdeError,
|
||||
{
|
||||
let mut modes = vec![];
|
||||
|
||||
for mode in value.split('|') {
|
||||
let mode = match mode.to_ascii_lowercase().as_str() {
|
||||
"insert" | "i" => VimMode::Insert,
|
||||
"normal" | "n" => VimMode::Normal,
|
||||
"visual" | "v" => VimMode::Visual,
|
||||
"command" | "c" => VimMode::Command,
|
||||
"select" => VimMode::Select,
|
||||
"operator-pending" => VimMode::OperationPending,
|
||||
_ => return Err(E::custom("Could not parse into a Vim mode")),
|
||||
};
|
||||
|
||||
modes.push(mode);
|
||||
}
|
||||
|
||||
Ok(VimModes(modes))
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for VimModes {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
deserializer.deserialize_str(VimModesVisitor)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
@@ -204,6 +310,40 @@ impl<'de> Deserialize<'de> for UserColor {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
pub struct Session {
|
||||
access_token: String,
|
||||
refresh_token: Option<String>,
|
||||
user_id: OwnedUserId,
|
||||
device_id: OwnedDeviceId,
|
||||
}
|
||||
|
||||
impl From<Session> for MatrixSession {
|
||||
fn from(session: Session) -> Self {
|
||||
MatrixSession {
|
||||
tokens: matrix_sdk::matrix_auth::MatrixSessionTokens {
|
||||
access_token: session.access_token,
|
||||
refresh_token: session.refresh_token,
|
||||
},
|
||||
meta: matrix_sdk::SessionMeta {
|
||||
user_id: session.user_id,
|
||||
device_id: session.device_id,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<MatrixSession> for Session {
|
||||
fn from(session: MatrixSession) -> Self {
|
||||
Session {
|
||||
access_token: session.tokens.access_token,
|
||||
refresh_token: session.tokens.refresh_token,
|
||||
user_id: session.meta.user_id,
|
||||
device_id: session.meta.device_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)]
|
||||
pub struct UserDisplayTunables {
|
||||
pub color: Option<UserColor>,
|
||||
@@ -212,7 +352,20 @@ pub struct UserDisplayTunables {
|
||||
|
||||
pub type UserOverrides = HashMap<OwnedUserId, UserDisplayTunables>;
|
||||
|
||||
fn merge_users(a: Option<UserOverrides>, b: Option<UserOverrides>) -> Option<UserOverrides> {
|
||||
fn merge_sorts(a: SortOverrides, b: SortOverrides) -> SortOverrides {
|
||||
SortOverrides {
|
||||
chats: b.chats.or(a.chats),
|
||||
dms: b.dms.or(a.dms),
|
||||
rooms: b.rooms.or(a.rooms),
|
||||
spaces: b.spaces.or(a.spaces),
|
||||
members: b.members.or(a.members),
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_maps<K, V>(a: Option<HashMap<K, V>>, b: Option<HashMap<K, V>>) -> Option<HashMap<K, V>>
|
||||
where
|
||||
K: Eq + Hash,
|
||||
{
|
||||
match (a, b) {
|
||||
(Some(a), None) => Some(a),
|
||||
(None, Some(b)) => Some(b),
|
||||
@@ -245,42 +398,147 @@ pub enum UserDisplayStyle {
|
||||
DisplayName,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum NotifyVia {
|
||||
/// Deliver notifications via terminal bell.
|
||||
Bell,
|
||||
/// Deliver notifications via desktop mechanism.
|
||||
#[default]
|
||||
Desktop,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)]
|
||||
pub struct Notifications {
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
#[serde(default)]
|
||||
pub via: NotifyVia,
|
||||
#[serde(default = "default_true")]
|
||||
pub show_message: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ImagePreviewValues {
|
||||
pub size: ImagePreviewSize,
|
||||
pub protocol: Option<ImagePreviewProtocolValues>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default, Deserialize)]
|
||||
pub struct ImagePreview {
|
||||
pub size: Option<ImagePreviewSize>,
|
||||
pub protocol: Option<ImagePreviewProtocolValues>,
|
||||
}
|
||||
|
||||
impl ImagePreview {
|
||||
fn values(self) -> ImagePreviewValues {
|
||||
ImagePreviewValues {
|
||||
size: self.size.unwrap_or_default(),
|
||||
protocol: self.protocol,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
pub struct ImagePreviewSize {
|
||||
pub width: usize,
|
||||
pub height: usize,
|
||||
}
|
||||
|
||||
impl Default for ImagePreviewSize {
|
||||
fn default() -> Self {
|
||||
ImagePreviewSize { width: 66, height: 10 }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
pub struct ImagePreviewProtocolValues {
|
||||
pub r#type: Option<ProtocolType>,
|
||||
pub font_size: Option<(u16, u16)>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SortValues {
|
||||
pub chats: Vec<SortColumn<SortFieldRoom>>,
|
||||
pub dms: Vec<SortColumn<SortFieldRoom>>,
|
||||
pub rooms: Vec<SortColumn<SortFieldRoom>>,
|
||||
pub spaces: Vec<SortColumn<SortFieldRoom>>,
|
||||
pub members: Vec<SortColumn<SortFieldUser>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default, Deserialize)]
|
||||
pub struct SortOverrides {
|
||||
pub chats: Option<Vec<SortColumn<SortFieldRoom>>>,
|
||||
pub dms: Option<Vec<SortColumn<SortFieldRoom>>>,
|
||||
pub rooms: Option<Vec<SortColumn<SortFieldRoom>>>,
|
||||
pub spaces: Option<Vec<SortColumn<SortFieldRoom>>>,
|
||||
pub members: Option<Vec<SortColumn<SortFieldUser>>>,
|
||||
}
|
||||
|
||||
impl SortOverrides {
|
||||
pub fn values(self) -> SortValues {
|
||||
let rooms = self.rooms.unwrap_or_else(|| Vec::from(DEFAULT_ROOM_SORT));
|
||||
let chats = self.chats.unwrap_or_else(|| rooms.clone());
|
||||
let dms = self.dms.unwrap_or_else(|| rooms.clone());
|
||||
let spaces = self.spaces.unwrap_or_else(|| rooms.clone());
|
||||
let members = self.members.unwrap_or_else(|| Vec::from(DEFAULT_MEMBERS_SORT));
|
||||
|
||||
SortValues { rooms, members, chats, dms, spaces }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct TunableValues {
|
||||
pub log_level: Level,
|
||||
pub message_shortcode_display: bool,
|
||||
pub reaction_display: bool,
|
||||
pub reaction_shortcode_display: bool,
|
||||
pub read_receipt_send: bool,
|
||||
pub read_receipt_display: bool,
|
||||
pub request_timeout: u64,
|
||||
pub sort: SortValues,
|
||||
pub typing_notice_send: bool,
|
||||
pub typing_notice_display: bool,
|
||||
pub users: UserOverrides,
|
||||
pub username_display: UserDisplayStyle,
|
||||
pub message_user_color: bool,
|
||||
pub default_room: Option<String>,
|
||||
pub open_command: Option<Vec<String>>,
|
||||
pub notifications: Notifications,
|
||||
pub image_preview: Option<ImagePreviewValues>,
|
||||
pub user_gutter_width: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default, Deserialize)]
|
||||
pub struct Tunables {
|
||||
pub log_level: Option<LogLevel>,
|
||||
pub message_shortcode_display: Option<bool>,
|
||||
pub reaction_display: Option<bool>,
|
||||
pub reaction_shortcode_display: Option<bool>,
|
||||
pub read_receipt_send: Option<bool>,
|
||||
pub read_receipt_display: Option<bool>,
|
||||
pub request_timeout: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub sort: SortOverrides,
|
||||
pub typing_notice_send: Option<bool>,
|
||||
pub typing_notice_display: Option<bool>,
|
||||
pub users: Option<UserOverrides>,
|
||||
pub username_display: Option<UserDisplayStyle>,
|
||||
pub message_user_color: Option<bool>,
|
||||
pub default_room: Option<String>,
|
||||
pub open_command: Option<Vec<String>>,
|
||||
pub notifications: Option<Notifications>,
|
||||
pub image_preview: Option<ImagePreview>,
|
||||
pub user_gutter_width: Option<usize>,
|
||||
}
|
||||
|
||||
impl Tunables {
|
||||
fn merge(self, other: Self) -> Self {
|
||||
Tunables {
|
||||
log_level: self.log_level.or(other.log_level),
|
||||
message_shortcode_display: self
|
||||
.message_shortcode_display
|
||||
.or(other.message_shortcode_display),
|
||||
reaction_display: self.reaction_display.or(other.reaction_display),
|
||||
reaction_shortcode_display: self
|
||||
.reaction_shortcode_display
|
||||
@@ -288,29 +546,40 @@ impl Tunables {
|
||||
read_receipt_send: self.read_receipt_send.or(other.read_receipt_send),
|
||||
read_receipt_display: self.read_receipt_display.or(other.read_receipt_display),
|
||||
request_timeout: self.request_timeout.or(other.request_timeout),
|
||||
sort: merge_sorts(self.sort, other.sort),
|
||||
typing_notice_send: self.typing_notice_send.or(other.typing_notice_send),
|
||||
typing_notice_display: self.typing_notice_display.or(other.typing_notice_display),
|
||||
users: merge_users(self.users, other.users),
|
||||
users: merge_maps(self.users, other.users),
|
||||
username_display: self.username_display.or(other.username_display),
|
||||
message_user_color: self.message_user_color.or(other.message_user_color),
|
||||
default_room: self.default_room.or(other.default_room),
|
||||
open_command: self.open_command.or(other.open_command),
|
||||
notifications: self.notifications.or(other.notifications),
|
||||
image_preview: self.image_preview.or(other.image_preview),
|
||||
user_gutter_width: self.user_gutter_width.or(other.user_gutter_width),
|
||||
}
|
||||
}
|
||||
|
||||
fn values(self) -> TunableValues {
|
||||
TunableValues {
|
||||
log_level: self.log_level.map(Level::from).unwrap_or(Level::INFO),
|
||||
message_shortcode_display: self.message_shortcode_display.unwrap_or(false),
|
||||
reaction_display: self.reaction_display.unwrap_or(true),
|
||||
reaction_shortcode_display: self.reaction_shortcode_display.unwrap_or(false),
|
||||
read_receipt_send: self.read_receipt_send.unwrap_or(true),
|
||||
read_receipt_display: self.read_receipt_display.unwrap_or(true),
|
||||
request_timeout: self.request_timeout.unwrap_or(DEFAULT_REQ_TIMEOUT),
|
||||
sort: self.sort.values(),
|
||||
typing_notice_send: self.typing_notice_send.unwrap_or(true),
|
||||
typing_notice_display: self.typing_notice_display.unwrap_or(true),
|
||||
users: self.users.unwrap_or_default(),
|
||||
username_display: self.username_display.unwrap_or_default(),
|
||||
message_user_color: self.message_user_color.unwrap_or(false),
|
||||
default_room: self.default_room,
|
||||
open_command: self.open_command,
|
||||
notifications: self.notifications.unwrap_or_default(),
|
||||
image_preview: self.image_preview.map(ImagePreview::values),
|
||||
user_gutter_width: self.user_gutter_width.unwrap_or(30),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -318,23 +587,48 @@ impl Tunables {
|
||||
#[derive(Clone)]
|
||||
pub struct DirectoryValues {
|
||||
pub cache: PathBuf,
|
||||
pub data: PathBuf,
|
||||
pub logs: PathBuf,
|
||||
pub downloads: Option<PathBuf>,
|
||||
pub image_previews: PathBuf,
|
||||
}
|
||||
|
||||
impl DirectoryValues {
|
||||
fn create_dir_all(&self) -> std::io::Result<()> {
|
||||
use std::fs::create_dir_all;
|
||||
|
||||
let Self { cache, data, logs, downloads, image_previews } = self;
|
||||
|
||||
create_dir_all(cache)?;
|
||||
create_dir_all(data)?;
|
||||
create_dir_all(logs)?;
|
||||
create_dir_all(image_previews)?;
|
||||
|
||||
if let Some(downloads) = downloads {
|
||||
create_dir_all(downloads)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Default, Deserialize)]
|
||||
pub struct Directories {
|
||||
pub cache: Option<PathBuf>,
|
||||
pub data: Option<PathBuf>,
|
||||
pub logs: Option<PathBuf>,
|
||||
pub downloads: Option<PathBuf>,
|
||||
pub image_previews: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl Directories {
|
||||
fn merge(self, other: Self) -> Self {
|
||||
Directories {
|
||||
cache: self.cache.or(other.cache),
|
||||
data: self.data.or(other.data),
|
||||
logs: self.logs.or(other.logs),
|
||||
downloads: self.downloads.or(other.downloads),
|
||||
image_previews: self.image_previews.or(other.image_previews),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -348,6 +642,15 @@ impl Directories {
|
||||
})
|
||||
.expect("no dirs.cache value configured!");
|
||||
|
||||
let data = self
|
||||
.data
|
||||
.or_else(|| {
|
||||
let mut dir = dirs::data_dir()?;
|
||||
dir.push("iamb");
|
||||
dir.into()
|
||||
})
|
||||
.expect("no dirs.data value configured!");
|
||||
|
||||
let logs = self.logs.unwrap_or_else(|| {
|
||||
let mut dir = cache.clone();
|
||||
dir.push("logs");
|
||||
@@ -356,7 +659,13 @@ impl Directories {
|
||||
|
||||
let downloads = self.downloads.or_else(dirs::download_dir);
|
||||
|
||||
DirectoryValues { cache, logs, downloads }
|
||||
let image_previews = self.image_previews.unwrap_or_else(|| {
|
||||
let mut dir = cache.clone();
|
||||
dir.push("image_preview_downloads");
|
||||
dir
|
||||
});
|
||||
|
||||
DirectoryValues { cache, data, logs, downloads, image_previews }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -393,10 +702,11 @@ pub enum Layout {
|
||||
#[derive(Clone, Deserialize)]
|
||||
pub struct ProfileConfig {
|
||||
pub user_id: OwnedUserId,
|
||||
pub url: Url,
|
||||
pub url: Option<Url>,
|
||||
pub settings: Option<Tunables>,
|
||||
pub dirs: Option<Directories>,
|
||||
pub layout: Option<Layout>,
|
||||
pub macros: Option<Macros>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
@@ -406,21 +716,20 @@ pub struct IambConfig {
|
||||
pub settings: Option<Tunables>,
|
||||
pub dirs: Option<Directories>,
|
||||
pub layout: Option<Layout>,
|
||||
pub macros: Option<Macros>,
|
||||
}
|
||||
|
||||
impl IambConfig {
|
||||
pub fn load(config_json: &Path) -> Result<Self, ConfigError> {
|
||||
if !config_json.is_file() {
|
||||
usage!(
|
||||
"Please create a configuration file at {}\n\n\
|
||||
For more information try '--help'",
|
||||
config_json.display(),
|
||||
);
|
||||
}
|
||||
pub fn load_toml(path: &Path) -> Result<Self, ConfigError> {
|
||||
let s = std::fs::read_to_string(path)?;
|
||||
let config = toml::from_str(&s)?;
|
||||
|
||||
let file = File::open(config_json)?;
|
||||
let reader = BufReader::new(file);
|
||||
let config = serde_json::from_reader(reader)?;
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
pub fn load_json(path: &Path) -> Result<Self, ConfigError> {
|
||||
let s = std::fs::read_to_string(path)?;
|
||||
let config = serde_json::from_str(&s)?;
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
@@ -428,14 +737,17 @@ impl IambConfig {
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ApplicationSettings {
|
||||
pub matrix_dir: PathBuf,
|
||||
pub layout_json: PathBuf,
|
||||
pub session_json: PathBuf,
|
||||
pub session_json_old: PathBuf,
|
||||
pub sled_dir: PathBuf,
|
||||
pub sqlite_dir: PathBuf,
|
||||
pub profile_name: String,
|
||||
pub profile: ProfileConfig,
|
||||
pub tunables: TunableValues,
|
||||
pub dirs: DirectoryValues,
|
||||
pub layout: Layout,
|
||||
pub macros: Macros,
|
||||
}
|
||||
|
||||
impl ApplicationSettings {
|
||||
@@ -447,9 +759,22 @@ impl ApplicationSettings {
|
||||
For more information try '--help'"
|
||||
);
|
||||
});
|
||||
|
||||
config_dir.push("iamb");
|
||||
let mut config_json = config_dir.clone();
|
||||
config_json.push("config.json");
|
||||
let config_json = config_dir.join("config.json");
|
||||
let config_toml = config_dir.join("config.toml");
|
||||
|
||||
let config = if config_toml.is_file() {
|
||||
IambConfig::load_toml(config_toml.as_path())?
|
||||
} else if config_json.is_file() {
|
||||
IambConfig::load_json(config_json.as_path())?
|
||||
} else {
|
||||
usage!(
|
||||
"Please create a configuration file at {}\n\n\
|
||||
For more information try '--help'",
|
||||
config_toml.display(),
|
||||
);
|
||||
};
|
||||
|
||||
let IambConfig {
|
||||
mut profiles,
|
||||
@@ -457,7 +782,8 @@ impl ApplicationSettings {
|
||||
dirs,
|
||||
settings: global,
|
||||
layout,
|
||||
} = IambConfig::load(config_json.as_path())?;
|
||||
macros,
|
||||
} = config;
|
||||
|
||||
validate_profile_names(&profiles);
|
||||
|
||||
@@ -474,12 +800,12 @@ impl ApplicationSettings {
|
||||
} else {
|
||||
usage!(
|
||||
"No profile specified. \
|
||||
Please use -P or add \"default_profile\" to {}.\n\n\
|
||||
Please use -P or add \"default_profile\" to your configuration.\n\n\
|
||||
For more information try '--help'",
|
||||
config_json.display()
|
||||
);
|
||||
};
|
||||
|
||||
let macros = merge_maps(macros, profile.macros.take()).unwrap_or_default();
|
||||
let layout = profile.layout.take().or(layout).unwrap_or_default();
|
||||
|
||||
let tunables = global.unwrap_or_default();
|
||||
@@ -490,17 +816,30 @@ impl ApplicationSettings {
|
||||
let dirs = profile.dirs.take().unwrap_or_default().merge(dirs);
|
||||
let dirs = dirs.values();
|
||||
|
||||
// Create directories
|
||||
dirs.create_dir_all()?;
|
||||
|
||||
// Set up paths that live inside the profile's data directory.
|
||||
let mut profile_dir = config_dir.clone();
|
||||
profile_dir.push("profiles");
|
||||
profile_dir.push(profile_name.as_str());
|
||||
|
||||
let mut matrix_dir = profile_dir.clone();
|
||||
matrix_dir.push("matrix");
|
||||
let mut profile_data_dir = dirs.data.clone();
|
||||
profile_data_dir.push("profiles");
|
||||
profile_data_dir.push(profile_name.as_str());
|
||||
|
||||
let mut session_json = profile_dir;
|
||||
let mut sled_dir = profile_dir.clone();
|
||||
sled_dir.push("matrix");
|
||||
|
||||
let mut sqlite_dir = profile_data_dir.clone();
|
||||
sqlite_dir.push("sqlite");
|
||||
|
||||
let mut session_json = profile_data_dir.clone();
|
||||
session_json.push("session.json");
|
||||
|
||||
let mut session_json_old = profile_dir;
|
||||
session_json_old.push("session.json");
|
||||
|
||||
// Set up paths that live inside the profile's cache directory.
|
||||
let mut cache_dir = dirs.cache.clone();
|
||||
cache_dir.push("profiles");
|
||||
@@ -510,19 +849,37 @@ impl ApplicationSettings {
|
||||
layout_json.push("layout.json");
|
||||
|
||||
let settings = ApplicationSettings {
|
||||
matrix_dir,
|
||||
sled_dir,
|
||||
layout_json,
|
||||
session_json,
|
||||
session_json_old,
|
||||
sqlite_dir,
|
||||
profile_name,
|
||||
profile,
|
||||
tunables,
|
||||
dirs,
|
||||
layout,
|
||||
macros,
|
||||
};
|
||||
|
||||
Ok(settings)
|
||||
}
|
||||
|
||||
pub fn read_session(&self, path: impl AsRef<Path>) -> Result<Session, IambError> {
|
||||
let file = File::open(path)?;
|
||||
let reader = BufReader::new(file);
|
||||
let session = serde_json::from_reader(reader).map_err(IambError::from)?;
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
pub fn write_session(&self, session: MatrixSession) -> Result<(), IambError> {
|
||||
let file = File::create(self.session_json.as_path())?;
|
||||
let writer = BufWriter::new(file);
|
||||
let session = Session::from(session);
|
||||
serde_json::to_writer(writer, &session).map_err(IambError::from)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_user_char_span<'a>(&self, user_id: &'a UserId) -> Span<'a> {
|
||||
let (color, c) = self
|
||||
.tunables
|
||||
@@ -633,22 +990,22 @@ mod tests {
|
||||
.into_iter()
|
||||
.collect::<HashMap<_, _>>();
|
||||
|
||||
let res = merge_users(a.clone(), a.clone());
|
||||
let res = merge_maps(a.clone(), a.clone());
|
||||
assert_eq!(res, None);
|
||||
|
||||
let res = merge_users(a.clone(), Some(b.clone()));
|
||||
let res = merge_maps(a.clone(), Some(b.clone()));
|
||||
assert_eq!(res, Some(b.clone()));
|
||||
|
||||
let res = merge_users(Some(b.clone()), a.clone());
|
||||
let res = merge_maps(Some(b.clone()), a.clone());
|
||||
assert_eq!(res, Some(b.clone()));
|
||||
|
||||
let res = merge_users(Some(b.clone()), Some(b.clone()));
|
||||
let res = merge_maps(Some(b.clone()), Some(b.clone()));
|
||||
assert_eq!(res, Some(b.clone()));
|
||||
|
||||
let res = merge_users(Some(b.clone()), Some(c.clone()));
|
||||
let res = merge_maps(Some(b.clone()), Some(c.clone()));
|
||||
assert_eq!(res, Some(c.clone()));
|
||||
|
||||
let res = merge_users(Some(c.clone()), Some(b.clone()));
|
||||
let res = merge_maps(Some(c.clone()), Some(b.clone()));
|
||||
assert_eq!(res, Some(b.clone()));
|
||||
}
|
||||
|
||||
@@ -700,6 +1057,43 @@ mod tests {
|
||||
assert_eq!(res.username_display, Some(UserDisplayStyle::DisplayName));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_tunables_sort() {
|
||||
let res: Tunables = serde_json::from_str(
|
||||
r#"{"sort": {"members": ["server","~localpart"],"spaces":["~favorite", "alias"]}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
res.sort.members,
|
||||
Some(vec![
|
||||
SortColumn(SortFieldUser::Server, SortOrder::Ascending),
|
||||
SortColumn(SortFieldUser::LocalPart, SortOrder::Descending),
|
||||
])
|
||||
);
|
||||
assert_eq!(
|
||||
res.sort.spaces,
|
||||
Some(vec![
|
||||
SortColumn(SortFieldRoom::Favorite, SortOrder::Descending),
|
||||
SortColumn(SortFieldRoom::Alias, SortOrder::Ascending),
|
||||
])
|
||||
);
|
||||
assert_eq!(res.sort.rooms, None);
|
||||
assert_eq!(res.sort.dms, None);
|
||||
|
||||
// Check that we get the right default "rooms" and "dms" values.
|
||||
let res = res.values();
|
||||
assert_eq!(res.sort.members, vec![
|
||||
SortColumn(SortFieldUser::Server, SortOrder::Ascending),
|
||||
SortColumn(SortFieldUser::LocalPart, SortOrder::Descending),
|
||||
]);
|
||||
assert_eq!(res.sort.spaces, vec![
|
||||
SortColumn(SortFieldRoom::Favorite, SortOrder::Descending),
|
||||
SortColumn(SortFieldRoom::Alias, SortOrder::Ascending),
|
||||
]);
|
||||
assert_eq!(res.sort.rooms, Vec::from(DEFAULT_ROOM_SORT));
|
||||
assert_eq!(res.sort.dms, Vec::from(DEFAULT_ROOM_SORT));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_layout() {
|
||||
let user = WindowPath::UserId(user_id!("@user:example.com").to_owned());
|
||||
@@ -757,4 +1151,45 @@ mod tests {
|
||||
let tabs = vec![split1, split3];
|
||||
assert_eq!(res, Layout::Config { tabs });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_macros() {
|
||||
let res: Macros = serde_json::from_str("{\"i|c\":{\"jj\":\"<Esc>\"}}").unwrap();
|
||||
assert_eq!(res.len(), 1);
|
||||
|
||||
let modes = VimModes(vec![VimMode::Insert, VimMode::Command]);
|
||||
let mapped = res.get(&modes).unwrap();
|
||||
assert_eq!(mapped.len(), 1);
|
||||
|
||||
let j = "j".parse::<TerminalKey>().unwrap();
|
||||
let esc = "<Esc>".parse::<TerminalKey>().unwrap();
|
||||
|
||||
let jj = Keys(vec![j.clone(), j], "jj".into());
|
||||
let run = mapped.get(&jj).unwrap();
|
||||
let exp = Keys(vec![esc], "<Esc>".into());
|
||||
assert_eq!(run, &exp);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_example_config_toml() {
|
||||
let path = PathBuf::from("config.example.toml");
|
||||
let config = IambConfig::load_toml(&path).expect("can load example_config.toml");
|
||||
|
||||
let IambConfig {
|
||||
profiles,
|
||||
default_profile,
|
||||
settings,
|
||||
dirs,
|
||||
layout,
|
||||
macros,
|
||||
} = &config;
|
||||
|
||||
// There should be an example object for each top-level field.
|
||||
assert!(!profiles.is_empty());
|
||||
assert!(default_profile.is_some());
|
||||
assert!(settings.is_some());
|
||||
assert!(dirs.is_some());
|
||||
assert!(layout.is_some());
|
||||
assert!(macros.is_some());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,27 @@
|
||||
//! # Default Keybindings
|
||||
//!
|
||||
//! The keybindings are set up here. We define some iamb-specific keybindings, but the default Vim
|
||||
//! keys come from [modalkit::env::vim::keybindings].
|
||||
use modalkit::{
|
||||
editing::action::WindowAction,
|
||||
actions::{MacroAction, WindowAction},
|
||||
env::vim::keybindings::{InputStep, VimBindings},
|
||||
env::vim::VimMode,
|
||||
input::bindings::{EdgeEvent, EdgeRepeat, InputBindings},
|
||||
input::key::TerminalKey,
|
||||
env::CommonKeyClass,
|
||||
key::TerminalKey,
|
||||
keybindings::{EdgeEvent, EdgeRepeat, InputBindings},
|
||||
prelude::Count,
|
||||
};
|
||||
|
||||
use crate::base::{IambAction, IambInfo, Keybindings, MATRIX_ID_WORD};
|
||||
use crate::config::{ApplicationSettings, Keys};
|
||||
|
||||
type IambStep = InputStep<IambInfo>;
|
||||
pub type IambStep = InputStep<IambInfo>;
|
||||
|
||||
fn once(key: &TerminalKey) -> (EdgeRepeat, EdgeEvent<TerminalKey, CommonKeyClass>) {
|
||||
(EdgeRepeat::Once, EdgeEvent::Key(*key))
|
||||
}
|
||||
|
||||
/// Initialize the default keybinding state.
|
||||
pub fn setup_keybindings() -> Keybindings {
|
||||
let mut ism = Keybindings::empty();
|
||||
|
||||
@@ -19,20 +31,14 @@ pub fn setup_keybindings() -> Keybindings {
|
||||
|
||||
vim.setup(&mut ism);
|
||||
|
||||
let ctrl_w = EdgeEvent::Key("<C-W>".parse::<TerminalKey>().unwrap());
|
||||
let ctrl_m = EdgeEvent::Key("<C-M>".parse::<TerminalKey>().unwrap());
|
||||
let ctrl_z = EdgeEvent::Key("<C-Z>".parse::<TerminalKey>().unwrap());
|
||||
let key_m_lc = EdgeEvent::Key("m".parse::<TerminalKey>().unwrap());
|
||||
let key_z_lc = EdgeEvent::Key("z".parse::<TerminalKey>().unwrap());
|
||||
let ctrl_w = "<C-W>".parse::<TerminalKey>().unwrap();
|
||||
let ctrl_m = "<C-M>".parse::<TerminalKey>().unwrap();
|
||||
let ctrl_z = "<C-Z>".parse::<TerminalKey>().unwrap();
|
||||
let key_m_lc = "m".parse::<TerminalKey>().unwrap();
|
||||
let key_z_lc = "z".parse::<TerminalKey>().unwrap();
|
||||
|
||||
let cwz = vec![
|
||||
(EdgeRepeat::Once, ctrl_w.clone()),
|
||||
(EdgeRepeat::Once, key_z_lc),
|
||||
];
|
||||
let cwcz = vec![
|
||||
(EdgeRepeat::Once, ctrl_w.clone()),
|
||||
(EdgeRepeat::Once, ctrl_z),
|
||||
];
|
||||
let cwz = vec![once(&ctrl_w), once(&key_z_lc)];
|
||||
let cwcz = vec![once(&ctrl_w), once(&ctrl_z)];
|
||||
let zoom = IambStep::new()
|
||||
.actions(vec![WindowAction::ZoomToggle.into()])
|
||||
.goto(VimMode::Normal);
|
||||
@@ -42,11 +48,8 @@ pub fn setup_keybindings() -> Keybindings {
|
||||
ism.add_mapping(VimMode::Normal, &cwcz, &zoom);
|
||||
ism.add_mapping(VimMode::Visual, &cwcz, &zoom);
|
||||
|
||||
let cwm = vec![
|
||||
(EdgeRepeat::Once, ctrl_w.clone()),
|
||||
(EdgeRepeat::Once, key_m_lc),
|
||||
];
|
||||
let cwcm = vec![(EdgeRepeat::Once, ctrl_w), (EdgeRepeat::Once, ctrl_m)];
|
||||
let cwm = vec![once(&ctrl_w), once(&key_m_lc)];
|
||||
let cwcm = vec![once(&ctrl_w), once(&ctrl_m)];
|
||||
let stoggle = IambStep::new()
|
||||
.actions(vec![IambAction::ToggleScrollbackFocus.into()])
|
||||
.goto(VimMode::Normal);
|
||||
@@ -54,6 +57,21 @@ pub fn setup_keybindings() -> Keybindings {
|
||||
ism.add_mapping(VimMode::Visual, &cwm, &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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
413
src/main.rs
413
src/main.rs
@@ -1,3 +1,16 @@
|
||||
//! # iamb
|
||||
//!
|
||||
//! The iamb client loops over user input and commands, and turns them into actions, [some of
|
||||
//! which][IambAction] are specific to iamb, and [some of which][Action] come from [modalkit]. When
|
||||
//! adding new functionality, you will usually want to extend [IambAction] or one of its variants
|
||||
//! (like [RoomAction][base::RoomAction]), and then add an appropriate [command][commands] or
|
||||
//! [keybinding][keybindings].
|
||||
//!
|
||||
//! For more complicated changes, you may need to update [the async worker thread][worker], which
|
||||
//! handles background Matrix tasks with [matrix-rust-sdk][matrix_sdk].
|
||||
//!
|
||||
//! Most rendering logic lives under the [windows] module, but [Matrix messages][message] have
|
||||
//! their own module.
|
||||
#![allow(clippy::manual_range_contains)]
|
||||
#![allow(clippy::needless_return)]
|
||||
#![allow(clippy::result_large_err)]
|
||||
@@ -6,25 +19,23 @@ use std::collections::VecDeque;
|
||||
use std::convert::TryFrom;
|
||||
use std::fmt::Display;
|
||||
use std::fs::{create_dir_all, File};
|
||||
use std::io::{stdout, BufReader, BufWriter, Stdout};
|
||||
use std::io::{stdout, BufWriter, Stdout, Write};
|
||||
use std::ops::DerefMut;
|
||||
use std::process;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use clap::Parser;
|
||||
use matrix_sdk::crypto::encrypt_room_key_export;
|
||||
use matrix_sdk::ruma::api::client::error::ErrorKind;
|
||||
use matrix_sdk::ruma::OwnedUserId;
|
||||
use modalkit::keybindings::InputBindings;
|
||||
use rand::{distributions::Alphanumeric, Rng};
|
||||
use temp_dir::TempDir;
|
||||
use tokio::sync::Mutex as AsyncMutex;
|
||||
use tracing_subscriber::FmtSubscriber;
|
||||
|
||||
use matrix_sdk::{
|
||||
config::SyncSettings,
|
||||
ruma::{
|
||||
api::client::filter::{FilterDefinition, LazyLoadOptions, RoomEventFilter, RoomFilter},
|
||||
OwnedUserId,
|
||||
},
|
||||
};
|
||||
|
||||
use modalkit::crossterm::{
|
||||
self,
|
||||
cursor::Show as CursorShow,
|
||||
@@ -36,12 +47,13 @@ use modalkit::crossterm::{
|
||||
EnableBracketedPaste,
|
||||
EnableFocusChange,
|
||||
Event,
|
||||
KeyEventKind,
|
||||
},
|
||||
execute,
|
||||
terminal::{EnterAlternateScreen, LeaveAlternateScreen, SetTitle},
|
||||
};
|
||||
|
||||
use modalkit::tui::{
|
||||
use ratatui::{
|
||||
backend::CrosstermBackend,
|
||||
layout::Rect,
|
||||
style::{Color, Style},
|
||||
@@ -55,6 +67,9 @@ mod commands;
|
||||
mod config;
|
||||
mod keybindings;
|
||||
mod message;
|
||||
mod notifications;
|
||||
mod preview;
|
||||
mod sled_export;
|
||||
mod util;
|
||||
mod windows;
|
||||
mod worker;
|
||||
@@ -72,6 +87,7 @@ use crate::{
|
||||
IambId,
|
||||
IambInfo,
|
||||
IambResult,
|
||||
KeysAction,
|
||||
ProgramAction,
|
||||
ProgramContext,
|
||||
ProgramStore,
|
||||
@@ -82,40 +98,39 @@ use crate::{
|
||||
};
|
||||
|
||||
use modalkit::{
|
||||
editing::{
|
||||
action::{
|
||||
Action,
|
||||
Commandable,
|
||||
EditError,
|
||||
EditInfo,
|
||||
Editable,
|
||||
EditorAction,
|
||||
InfoMessage,
|
||||
InsertTextAction,
|
||||
Jumpable,
|
||||
Promptable,
|
||||
Scrollable,
|
||||
TabAction,
|
||||
TabContainer,
|
||||
TabCount,
|
||||
UIError,
|
||||
WindowAction,
|
||||
WindowContainer,
|
||||
},
|
||||
base::{MoveDir1D, OpenTarget, RepeatType},
|
||||
context::Resolve,
|
||||
key::KeyManager,
|
||||
store::Store,
|
||||
actions::{
|
||||
Action,
|
||||
Commandable,
|
||||
Editable,
|
||||
EditorAction,
|
||||
InsertTextAction,
|
||||
Jumpable,
|
||||
Promptable,
|
||||
Scrollable,
|
||||
TabAction,
|
||||
TabContainer,
|
||||
TabCount,
|
||||
WindowAction,
|
||||
WindowContainer,
|
||||
},
|
||||
input::{bindings::BindingMachine, dialog::Pager, key::TerminalKey},
|
||||
widgets::{
|
||||
cmdbar::CommandBarState,
|
||||
screen::{FocusList, Screen, ScreenState, TabLayoutDescription},
|
||||
windows::WindowLayoutDescription,
|
||||
TerminalCursor,
|
||||
TerminalExtOps,
|
||||
Window,
|
||||
editing::{context::Resolve, key::KeyManager, store::Store},
|
||||
errors::{EditError, UIError},
|
||||
key::TerminalKey,
|
||||
keybindings::{
|
||||
dialog::{Pager, PromptYesNo},
|
||||
BindingMachine,
|
||||
},
|
||||
prelude::*,
|
||||
ui::FocusList,
|
||||
};
|
||||
|
||||
use modalkit_ratatui::{
|
||||
cmdbar::CommandBarState,
|
||||
screen::{Screen, ScreenState, TabLayoutDescription},
|
||||
windows::WindowLayoutDescription,
|
||||
TerminalCursor,
|
||||
TerminalExtOps,
|
||||
Window,
|
||||
};
|
||||
|
||||
fn config_tab_to_desc(
|
||||
@@ -131,14 +146,14 @@ fn config_tab_to_desc(
|
||||
let name = user_id.to_string();
|
||||
let room_id = worker.join_room(name.clone())?;
|
||||
names.insert(name, room_id.clone());
|
||||
IambId::Room(room_id)
|
||||
IambId::Room(room_id, None)
|
||||
},
|
||||
config::WindowPath::RoomId(room_id) => IambId::Room(room_id),
|
||||
config::WindowPath::RoomId(room_id) => IambId::Room(room_id, None),
|
||||
config::WindowPath::AliasId(alias) => {
|
||||
let name = alias.to_string();
|
||||
let room_id = worker.join_room(name.clone())?;
|
||||
names.insert(name, room_id.clone());
|
||||
IambId::Room(room_id)
|
||||
IambId::Room(room_id, None)
|
||||
},
|
||||
config::WindowPath::Window(id) => id,
|
||||
};
|
||||
@@ -200,6 +215,7 @@ fn setup_screen(
|
||||
return Ok(ScreenState::new(win, cmd));
|
||||
}
|
||||
|
||||
/// The main application state and event loop.
|
||||
struct Application {
|
||||
/// Terminal backend.
|
||||
terminal: Terminal<CrosstermBackend<Stdout>>,
|
||||
@@ -214,7 +230,7 @@ struct Application {
|
||||
worker: Requester,
|
||||
|
||||
/// Mapped keybindings.
|
||||
bindings: KeyManager<TerminalKey, ProgramAction, RepeatType, ProgramContext>,
|
||||
bindings: KeyManager<TerminalKey, ProgramAction, RepeatType>,
|
||||
|
||||
/// Pending actions to run.
|
||||
actstack: VecDeque<(ProgramAction, ProgramContext)>,
|
||||
@@ -224,6 +240,9 @@ struct Application {
|
||||
|
||||
/// 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 {
|
||||
@@ -243,13 +262,15 @@ impl Application {
|
||||
let backend = CrosstermBackend::new(stdout);
|
||||
let terminal = Terminal::new(backend)?;
|
||||
|
||||
let bindings = crate::keybindings::setup_keybindings();
|
||||
let mut bindings = crate::keybindings::setup_keybindings();
|
||||
settings.setup(&mut bindings);
|
||||
let bindings = KeyManager::new(bindings);
|
||||
|
||||
let mut locked = store.lock().await;
|
||||
let screen = setup_screen(settings, locked.deref_mut())?;
|
||||
|
||||
let worker = locked.application.worker.clone();
|
||||
|
||||
drop(locked);
|
||||
|
||||
let actstack = VecDeque::new();
|
||||
@@ -263,6 +284,7 @@ impl Application {
|
||||
screen,
|
||||
focused: true,
|
||||
last_layout: None,
|
||||
dirty: true,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -272,6 +294,10 @@ impl Application {
|
||||
let sstate = &mut self.screen;
|
||||
let term = &mut self.terminal;
|
||||
|
||||
if store.application.ring_bell {
|
||||
store.application.ring_bell = term.backend_mut().write_all(&[7]).is_err();
|
||||
}
|
||||
|
||||
if full {
|
||||
term.clear()?;
|
||||
}
|
||||
@@ -286,6 +312,7 @@ impl Application {
|
||||
// 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)
|
||||
@@ -314,7 +341,8 @@ impl Application {
|
||||
|
||||
async fn step(&mut self) -> Result<TerminalKey, std::io::Error> {
|
||||
loop {
|
||||
self.redraw(false, self.store.clone().lock().await.deref_mut())?;
|
||||
self.redraw(self.dirty, self.store.clone().lock().await.deref_mut())?;
|
||||
self.dirty = false;
|
||||
|
||||
if !poll(Duration::from_secs(1))? {
|
||||
// Redraw in case there's new messages to show.
|
||||
@@ -322,7 +350,13 @@ impl Application {
|
||||
}
|
||||
|
||||
match read()? {
|
||||
Event::Key(ke) => return Ok(ke.into()),
|
||||
Event::Key(ke) => {
|
||||
if ke.kind == KeyEventKind::Release {
|
||||
continue;
|
||||
}
|
||||
|
||||
return Ok(ke.into());
|
||||
},
|
||||
Event::Mouse(_) => {
|
||||
// Do nothing for now.
|
||||
},
|
||||
@@ -479,6 +513,10 @@ impl Application {
|
||||
ctx: ProgramContext,
|
||||
store: &mut ProgramStore,
|
||||
) -> IambResult<EditInfo> {
|
||||
if action.scribbles() {
|
||||
self.dirty = true;
|
||||
}
|
||||
|
||||
let info = match action {
|
||||
IambAction::ToggleScrollbackFocus => {
|
||||
self.screen.current_window_mut()?.focus_toggle();
|
||||
@@ -492,6 +530,7 @@ impl Application {
|
||||
|
||||
None
|
||||
},
|
||||
IambAction::Keys(act) => self.keys_command(act, ctx, store).await?,
|
||||
IambAction::Message(act) => {
|
||||
self.screen.current_window_mut()?.message_command(act, ctx, store).await?
|
||||
},
|
||||
@@ -505,6 +544,14 @@ impl Application {
|
||||
self.screen.current_window_mut()?.send_command(act, ctx, store).await?
|
||||
},
|
||||
|
||||
IambAction::OpenLink(url) => {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
return open::that(url);
|
||||
});
|
||||
|
||||
None
|
||||
},
|
||||
|
||||
IambAction::Verify(act, user_dev) => {
|
||||
if let Some(sas) = store.application.verifications.get(&user_dev) {
|
||||
self.worker.verify(act, sas.clone())?
|
||||
@@ -533,13 +580,58 @@ impl Application {
|
||||
match action {
|
||||
HomeserverAction::CreateRoom(alias, vis, flags) => {
|
||||
let client = &store.application.worker.client;
|
||||
let room_id = create_room(client, alias.as_deref(), vis, flags).await?;
|
||||
let room = IambId::Room(room_id);
|
||||
let room_id = create_room(client, alias, vis, flags).await?;
|
||||
let room = IambId::Room(room_id, None);
|
||||
let target = OpenTarget::Application(room);
|
||||
let action = WindowAction::Switch(target);
|
||||
|
||||
Ok(vec![(action.into(), ctx)])
|
||||
},
|
||||
HomeserverAction::Logout(user, true) => {
|
||||
self.worker.logout(user)?;
|
||||
let flags = CloseFlags::QUIT | CloseFlags::FORCE;
|
||||
let act = TabAction::Close(TabTarget::All, flags);
|
||||
|
||||
Ok(vec![(act.into(), ctx)])
|
||||
},
|
||||
HomeserverAction::Logout(user, false) => {
|
||||
let msg = "Would you like to logout?";
|
||||
let act = IambAction::from(HomeserverAction::Logout(user, true));
|
||||
let prompt = PromptYesNo::new(msg, vec![Action::from(act)]);
|
||||
let prompt = Box::new(prompt);
|
||||
|
||||
Err(UIError::NeedConfirm(prompt))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async fn keys_command(
|
||||
&mut self,
|
||||
action: KeysAction,
|
||||
_: ProgramContext,
|
||||
store: &mut ProgramStore,
|
||||
) -> IambResult<EditInfo> {
|
||||
let encryption = store.application.worker.client.encryption();
|
||||
|
||||
match action {
|
||||
KeysAction::Export(path, passphrase) => {
|
||||
encryption
|
||||
.export_room_keys(path.into(), &passphrase, |_| true)
|
||||
.await
|
||||
.map_err(IambError::from)?;
|
||||
|
||||
Ok(Some("Successfully exported room keys".into()))
|
||||
},
|
||||
KeysAction::Import(path, passphrase) => {
|
||||
let res = encryption
|
||||
.import_room_keys(path.into(), &passphrase)
|
||||
.await
|
||||
.map_err(IambError::from)?;
|
||||
|
||||
let msg = format!("Imported {} of {} keys", res.imported_count, res.total_count);
|
||||
|
||||
Ok(Some(msg.into()))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -619,23 +711,59 @@ impl Application {
|
||||
}
|
||||
}
|
||||
|
||||
async fn login(worker: Requester, settings: &ApplicationSettings) -> IambResult<()> {
|
||||
println!("Logging in for {}...", settings.profile.user_id);
|
||||
fn gen_passphrase() -> String {
|
||||
rand::thread_rng()
|
||||
.sample_iter(&Alphanumeric)
|
||||
.take(20)
|
||||
.map(char::from)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn read_response(question: &str) -> String {
|
||||
println!("{question}");
|
||||
let mut input = String::new();
|
||||
let _ = std::io::stdin().read_line(&mut input);
|
||||
input
|
||||
}
|
||||
|
||||
fn read_yesno(question: &str) -> Option<char> {
|
||||
read_response(question).chars().next().map(|c| c.to_ascii_lowercase())
|
||||
}
|
||||
|
||||
async fn login(worker: &Requester, settings: &ApplicationSettings) -> IambResult<()> {
|
||||
if settings.session_json.is_file() {
|
||||
let file = File::open(settings.session_json.as_path())?;
|
||||
let reader = BufReader::new(file);
|
||||
let session = serde_json::from_reader(reader).map_err(IambError::from)?;
|
||||
let session = settings.read_session(&settings.session_json)?;
|
||||
worker.login(LoginStyle::SessionRestore(session.into()))?;
|
||||
|
||||
worker.login(LoginStyle::SessionRestore(session))?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if settings.session_json_old.is_file() && !settings.sled_dir.is_dir() {
|
||||
let session = settings.read_session(&settings.session_json_old)?;
|
||||
worker.login(LoginStyle::SessionRestore(session.into()))?;
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
loop {
|
||||
let password = rpassword::prompt_password("Password: ")?;
|
||||
let login_style =
|
||||
match read_response("Please select login type: [p]assword / [s]ingle sign on")
|
||||
.chars()
|
||||
.next()
|
||||
.map(|c| c.to_ascii_lowercase())
|
||||
{
|
||||
None | Some('p') => {
|
||||
let password = rpassword::prompt_password("Password: ")?;
|
||||
LoginStyle::Password(password)
|
||||
},
|
||||
Some('s') => LoginStyle::SingleSignOn,
|
||||
Some(_) => {
|
||||
println!("Failed to login. Please enter 'p' or 's'");
|
||||
continue;
|
||||
},
|
||||
};
|
||||
|
||||
match worker.login(LoginStyle::Password(password)) {
|
||||
match worker.login(login_style) {
|
||||
Ok(info) => {
|
||||
if let Some(msg) = info {
|
||||
println!("{msg}");
|
||||
@@ -650,37 +778,165 @@ async fn login(worker: Requester, settings: &ApplicationSettings) -> IambResult<
|
||||
}
|
||||
}
|
||||
|
||||
// Perform an initial, lazily-loaded sync.
|
||||
let mut room = RoomEventFilter::default();
|
||||
room.lazy_load_options = LazyLoadOptions::Enabled { include_redundant_members: false };
|
||||
|
||||
let mut room_ev = RoomFilter::default();
|
||||
room_ev.state = room;
|
||||
|
||||
let mut filter = FilterDefinition::default();
|
||||
filter.room = room_ev;
|
||||
|
||||
let settings = SyncSettings::new().filter(filter.into());
|
||||
|
||||
worker.client.sync_once(settings).await.map_err(IambError::from)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_exit<T: Display, N>(v: T) -> N {
|
||||
println!("{v}");
|
||||
eprintln!("{v}");
|
||||
process::exit(2);
|
||||
}
|
||||
|
||||
// We can't access the OlmMachine directly, so write the keys to a temporary
|
||||
// file first, and then import them later.
|
||||
async fn check_import_keys(
|
||||
settings: &ApplicationSettings,
|
||||
) -> IambResult<Option<(temp_dir::TempDir, String)>> {
|
||||
let do_import = settings.sled_dir.is_dir() && !settings.sqlite_dir.is_dir();
|
||||
|
||||
if !do_import {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let question = format!(
|
||||
"Found old sled store in {}. Would you like to export room keys from it? [y]es/[n]o",
|
||||
settings.sled_dir.display()
|
||||
);
|
||||
|
||||
loop {
|
||||
match read_yesno(&question) {
|
||||
Some('y') => {
|
||||
break;
|
||||
},
|
||||
Some('n') => {
|
||||
return Ok(None);
|
||||
},
|
||||
Some(_) | None => {
|
||||
continue;
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
let keys = sled_export::export_room_keys(&settings.sled_dir).await?;
|
||||
let passphrase = gen_passphrase();
|
||||
|
||||
println!("* Encrypting {} room keys with the passphrase {passphrase:?}...", keys.len());
|
||||
|
||||
let encrypted = match encrypt_room_key_export(&keys, &passphrase, 500000) {
|
||||
Ok(encrypted) => encrypted,
|
||||
Err(e) => {
|
||||
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.
|
||||
let worker = ClientWorker::spawn(settings.clone()).await;
|
||||
let worker = ClientWorker::spawn(client.clone(), settings.clone()).await;
|
||||
let store = ChatStore::new(worker.clone(), settings.clone());
|
||||
let store = Store::new(store);
|
||||
let store = Arc::new(AsyncMutex::new(store));
|
||||
worker.init(store.clone());
|
||||
|
||||
login(worker, &settings).await.unwrap_or_else(print_exit);
|
||||
let res = if let Some((keydir, pass)) = import_keys {
|
||||
login_upgrade(keydir, pass, &worker, &settings, &store).await
|
||||
} else {
|
||||
login_normal(&worker, &settings, &store).await
|
||||
};
|
||||
|
||||
match res {
|
||||
Err(UIError::Application(IambError::Matrix(e))) => {
|
||||
if let Some(ErrorKind::UnknownToken { .. }) = e.client_api_error_kind() {
|
||||
print_exit("Server did not recognize our API token; did you log out from this session elsewhere?")
|
||||
} else {
|
||||
print_exit(e)
|
||||
}
|
||||
},
|
||||
Err(e) => print_exit(e),
|
||||
Ok(()) => (),
|
||||
}
|
||||
|
||||
fn restore_tty() {
|
||||
let _ = crossterm::terminal::disable_raw_mode();
|
||||
@@ -724,9 +980,6 @@ fn main() -> IambResult<()> {
|
||||
let log_prefix = format!("iamb-log-{}", settings.profile_name);
|
||||
let log_dir = settings.dirs.logs.as_path();
|
||||
|
||||
create_dir_all(settings.matrix_dir.as_path())?;
|
||||
create_dir_all(log_dir)?;
|
||||
|
||||
let appender = tracing_appender::rolling::daily(log_dir, log_prefix);
|
||||
let (appender, guard) = tracing_appender::non_blocking(appender);
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,18 +1,31 @@
|
||||
//! # Line Wrapping Logic
|
||||
//!
|
||||
//! The [TextPrinter] handles wrapping stylized text and inserting spaces for padding at the end of
|
||||
//! lines to make concatenation work right (e.g., combining table cells after wrapping their
|
||||
//! contents).
|
||||
use std::borrow::Cow;
|
||||
|
||||
use modalkit::tui::layout::Alignment;
|
||||
use modalkit::tui::style::Style;
|
||||
use modalkit::tui::text::{Span, Spans, Text};
|
||||
use ratatui::layout::Alignment;
|
||||
use ratatui::style::Style;
|
||||
use ratatui::text::{Line, Span, Text};
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use crate::util::{space_span, take_width};
|
||||
use crate::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>>,
|
||||
@@ -21,12 +34,14 @@ pub struct TextPrinter<'a> {
|
||||
}
|
||||
|
||||
impl<'a> TextPrinter<'a> {
|
||||
pub fn new(width: usize, base_style: Style, hide_reply: bool) -> Self {
|
||||
/// Create a new printer.
|
||||
pub fn new(width: usize, base_style: Style, hide_reply: bool, emoji_shortcodes: bool) -> Self {
|
||||
TextPrinter {
|
||||
text: Text::default(),
|
||||
width,
|
||||
base_style,
|
||||
hide_reply,
|
||||
emoji_shortcodes,
|
||||
|
||||
alignment: Alignment::Left,
|
||||
curr_spans: vec![],
|
||||
@@ -35,30 +50,41 @@ impl<'a> TextPrinter<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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![],
|
||||
@@ -71,6 +97,7 @@ impl<'a> TextPrinter<'a> {
|
||||
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();
|
||||
@@ -79,9 +106,10 @@ impl<'a> TextPrinter<'a> {
|
||||
|
||||
fn push(&mut self) {
|
||||
self.curr_width = 0;
|
||||
self.text.lines.push(Spans(std::mem::take(&mut self.curr_spans)));
|
||||
self.text.lines.push(Line::from(std::mem::take(&mut self.curr_spans)));
|
||||
}
|
||||
|
||||
/// Start a new line.
|
||||
pub fn push_break(&mut self) {
|
||||
if self.curr_width == 0 && self.text.lines.is_empty() {
|
||||
// Disallow leading breaks.
|
||||
@@ -149,7 +177,11 @@ impl<'a> TextPrinter<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push_span_nobreak(&mut self, span: Span<'a>) {
|
||||
/// Push a [Span] that isn't allowed to break across lines.
|
||||
pub fn push_span_nobreak(&mut self, mut span: Span<'a>) {
|
||||
if self.emoji_shortcodes {
|
||||
replace_emojis_in_span(&mut span);
|
||||
}
|
||||
let sw = UnicodeWidthStr::width(span.content.as_ref());
|
||||
|
||||
if self.curr_width + sw > self.width {
|
||||
@@ -161,6 +193,7 @@ impl<'a> TextPrinter<'a> {
|
||||
self.curr_width += sw;
|
||||
}
|
||||
|
||||
/// Push text with a [Style].
|
||||
pub fn push_str(&mut self, s: &'a str, style: Style) {
|
||||
let style = self.base_style.patch(style);
|
||||
|
||||
@@ -184,10 +217,15 @@ impl<'a> TextPrinter<'a> {
|
||||
continue;
|
||||
}
|
||||
|
||||
let sw = UnicodeWidthStr::width(word);
|
||||
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(word, style);
|
||||
self.push_str_wrapped(cow, style);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -195,13 +233,13 @@ impl<'a> TextPrinter<'a> {
|
||||
// Word doesn't fit on this line, so start a new one.
|
||||
self.commit();
|
||||
|
||||
if !self.literal && word.chars().all(char::is_whitespace) {
|
||||
if !self.literal && cow.chars().all(char::is_whitespace) {
|
||||
// Drop leading whitespace.
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let span = Span::styled(word, style);
|
||||
let span = Span::styled(cow, style);
|
||||
self.curr_spans.push(span);
|
||||
self.curr_width += sw;
|
||||
}
|
||||
@@ -212,16 +250,27 @@ impl<'a> TextPrinter<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push_line(&mut self, spans: Spans<'a>) {
|
||||
/// Push a [Line] into the printer.
|
||||
pub fn push_line(&mut self, mut line: Line<'a>) {
|
||||
self.commit();
|
||||
self.text.lines.push(spans);
|
||||
if self.emoji_shortcodes {
|
||||
replace_emojis_in_line(&mut line);
|
||||
}
|
||||
self.text.lines.push(line);
|
||||
}
|
||||
|
||||
pub fn push_text(&mut self, text: Text<'a>) {
|
||||
/// Push multiline [Text] into the printer.
|
||||
pub fn push_text(&mut self, mut text: Text<'a>) {
|
||||
self.commit();
|
||||
if self.emoji_shortcodes {
|
||||
for line in &mut text.lines {
|
||||
replace_emojis_in_line(line);
|
||||
}
|
||||
}
|
||||
self.text.lines.extend(text.lines);
|
||||
}
|
||||
|
||||
/// Render the contents of this printer as [Text].
|
||||
pub fn finish(mut self) -> Text<'a> {
|
||||
self.commit();
|
||||
self.text
|
||||
|
||||
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)
|
||||
}
|
||||
63
src/tests.rs
63
src/tests.rs
@@ -1,4 +1,4 @@
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use matrix_sdk::ruma::{
|
||||
@@ -15,19 +15,22 @@ use matrix_sdk::ruma::{
|
||||
};
|
||||
|
||||
use lazy_static::lazy_static;
|
||||
use modalkit::tui::style::{Color, Style};
|
||||
use ratatui::style::{Color, Style};
|
||||
use tokio::sync::mpsc::unbounded_channel;
|
||||
use tracing::Level;
|
||||
use url::Url;
|
||||
|
||||
use crate::{
|
||||
base::{ChatStore, EventLocation, ProgramStore, RoomFetchStatus, RoomInfo},
|
||||
base::{ChatStore, EventLocation, ProgramStore, RoomInfo},
|
||||
config::{
|
||||
user_color,
|
||||
user_style_from_color,
|
||||
ApplicationSettings,
|
||||
DirectoryValues,
|
||||
Notifications,
|
||||
NotifyVia,
|
||||
ProfileConfig,
|
||||
SortOverrides,
|
||||
TunableValues,
|
||||
UserColor,
|
||||
UserDisplayStyle,
|
||||
@@ -124,17 +127,17 @@ pub fn mock_message5() -> Message {
|
||||
pub fn mock_keys() -> HashMap<OwnedEventId, EventLocation> {
|
||||
let mut keys = HashMap::new();
|
||||
|
||||
keys.insert(MSG1_EVID.clone(), EventLocation::Message(MSG1_KEY.clone()));
|
||||
keys.insert(MSG2_EVID.clone(), EventLocation::Message(MSG2_KEY.clone()));
|
||||
keys.insert(MSG3_EVID.clone(), EventLocation::Message(MSG3_KEY.clone()));
|
||||
keys.insert(MSG4_EVID.clone(), EventLocation::Message(MSG4_KEY.clone()));
|
||||
keys.insert(MSG5_EVID.clone(), EventLocation::Message(MSG5_KEY.clone()));
|
||||
keys.insert(MSG1_EVID.clone(), EventLocation::Message(None, MSG1_KEY.clone()));
|
||||
keys.insert(MSG2_EVID.clone(), EventLocation::Message(None, MSG2_KEY.clone()));
|
||||
keys.insert(MSG3_EVID.clone(), EventLocation::Message(None, MSG3_KEY.clone()));
|
||||
keys.insert(MSG4_EVID.clone(), EventLocation::Message(None, MSG4_KEY.clone()));
|
||||
keys.insert(MSG5_EVID.clone(), EventLocation::Message(None, MSG5_KEY.clone()));
|
||||
|
||||
keys
|
||||
}
|
||||
|
||||
pub fn mock_messages() -> Messages {
|
||||
let mut messages = BTreeMap::new();
|
||||
let mut messages = Messages::default();
|
||||
|
||||
messages.insert(MSG1_KEY.clone(), mock_message1());
|
||||
messages.insert(MSG2_KEY.clone(), mock_message2());
|
||||
@@ -146,30 +149,20 @@ pub fn mock_messages() -> Messages {
|
||||
}
|
||||
|
||||
pub fn mock_room() -> RoomInfo {
|
||||
RoomInfo {
|
||||
name: Some("Watercooler Discussion".into()),
|
||||
tags: None,
|
||||
|
||||
keys: mock_keys(),
|
||||
messages: mock_messages(),
|
||||
|
||||
receipts: HashMap::new(),
|
||||
read_till: None,
|
||||
reactions: HashMap::new(),
|
||||
|
||||
fetching: false,
|
||||
fetch_id: RoomFetchStatus::NotStarted,
|
||||
fetch_last: None,
|
||||
users_typing: None,
|
||||
display_names: HashMap::new(),
|
||||
}
|
||||
let mut room = RoomInfo::default();
|
||||
room.name = Some("Watercooler Discussion".into());
|
||||
room.keys = mock_keys();
|
||||
*room.get_thread_mut(None) = mock_messages();
|
||||
room
|
||||
}
|
||||
|
||||
pub fn mock_dirs() -> DirectoryValues {
|
||||
DirectoryValues {
|
||||
cache: PathBuf::new(),
|
||||
data: PathBuf::new(),
|
||||
logs: PathBuf::new(),
|
||||
downloads: None,
|
||||
image_previews: PathBuf::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,11 +170,13 @@ 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 {
|
||||
@@ -192,26 +187,38 @@ pub fn mock_tunables() -> TunableValues {
|
||||
.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 {
|
||||
ApplicationSettings {
|
||||
matrix_dir: PathBuf::new(),
|
||||
layout_json: PathBuf::new(),
|
||||
session_json: PathBuf::new(),
|
||||
session_json_old: PathBuf::new(),
|
||||
sled_dir: PathBuf::new(),
|
||||
sqlite_dir: PathBuf::new(),
|
||||
|
||||
profile_name: "test".into(),
|
||||
profile: ProfileConfig {
|
||||
user_id: user_id!("@user:example.com").to_owned(),
|
||||
url: Url::parse("https://example.com").unwrap(),
|
||||
url: None,
|
||||
settings: None,
|
||||
dirs: None,
|
||||
layout: None,
|
||||
macros: None,
|
||||
},
|
||||
tunables: mock_tunables(),
|
||||
dirs: mock_dirs(),
|
||||
layout: Default::default(),
|
||||
macros: HashMap::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
63
src/util.rs
63
src/util.rs
@@ -1,10 +1,11 @@
|
||||
//! # Utility functions
|
||||
use std::borrow::Cow;
|
||||
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use modalkit::tui::style::Style;
|
||||
use modalkit::tui::text::{Span, Spans, Text};
|
||||
use ratatui::style::Style;
|
||||
use ratatui::text::{Line, Span, Text};
|
||||
|
||||
pub fn split_cow(cow: Cow<'_, str>, idx: usize) -> (Cow<'_, str>, Cow<'_, str>) {
|
||||
match cow {
|
||||
@@ -25,19 +26,19 @@ pub fn split_cow(cow: Cow<'_, str>, idx: usize) -> (Cow<'_, str>, Cow<'_, str>)
|
||||
|
||||
pub fn take_width(s: Cow<'_, str>, width: usize) -> ((Cow<'_, str>, usize), Cow<'_, str>) {
|
||||
// Find where to split the line.
|
||||
let mut idx = 0;
|
||||
let mut w = 0;
|
||||
|
||||
for (i, g) in UnicodeSegmentation::grapheme_indices(s.as_ref(), true) {
|
||||
let gw = UnicodeWidthStr::width(g);
|
||||
idx = i;
|
||||
|
||||
if w + gw > width {
|
||||
break;
|
||||
}
|
||||
|
||||
w += gw;
|
||||
}
|
||||
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);
|
||||
|
||||
@@ -105,7 +106,7 @@ where
|
||||
|
||||
for (line, w) in wrap(s, width) {
|
||||
let space = space_span(width.saturating_sub(w), style);
|
||||
let spans = Spans(vec![Span::styled(line, style), space]);
|
||||
let spans = Line::from(vec![Span::styled(line, style), space]);
|
||||
|
||||
text.lines.push(spans);
|
||||
}
|
||||
@@ -127,23 +128,47 @@ pub fn space_text(width: usize, style: Style) -> Text<'static> {
|
||||
|
||||
pub fn join_cell_text<'a>(texts: Vec<(Text<'a>, usize)>, join: Span<'a>, style: Style) -> Text<'a> {
|
||||
let height = texts.iter().map(|t| t.0.height()).max().unwrap_or(0);
|
||||
let mut text = Text { lines: vec![Spans(vec![join.clone()]); height] };
|
||||
let mut text = Text {
|
||||
lines: vec![Line::from(vec![join.clone()]); height],
|
||||
};
|
||||
|
||||
for (mut t, w) in texts.into_iter() {
|
||||
for i in 0..height {
|
||||
if let Some(spans) = t.lines.get_mut(i) {
|
||||
text.lines[i].0.append(&mut spans.0);
|
||||
if let Some(line) = t.lines.get_mut(i) {
|
||||
text.lines[i].spans.append(&mut line.spans);
|
||||
} else {
|
||||
text.lines[i].0.push(space_span(w, style));
|
||||
text.lines[i].spans.push(space_span(w, style));
|
||||
}
|
||||
|
||||
text.lines[i].0.push(join.clone());
|
||||
text.lines[i].spans.push(join.clone());
|
||||
}
|
||||
}
|
||||
|
||||
text
|
||||
}
|
||||
|
||||
fn replace_emoji_in_grapheme(grapheme: &str) -> String {
|
||||
emojis::get(grapheme)
|
||||
.and_then(|emoji| emoji.shortcode())
|
||||
.map(|shortcode| format!(":{shortcode}:"))
|
||||
.unwrap_or_else(|| grapheme.to_owned())
|
||||
}
|
||||
|
||||
pub fn replace_emojis_in_str(s: &str) -> String {
|
||||
let graphemes = s.graphemes(true);
|
||||
graphemes.map(replace_emoji_in_grapheme).collect()
|
||||
}
|
||||
|
||||
pub fn replace_emojis_in_span(span: &mut Span) {
|
||||
span.content = Cow::Owned(replace_emojis_in_str(span.content.as_ref()))
|
||||
}
|
||||
|
||||
pub fn replace_emojis_in_line(line: &mut Line) {
|
||||
for span in &mut line.spans {
|
||||
replace_emojis_in_span(span);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub mod tests {
|
||||
use super::*;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,67 +1,73 @@
|
||||
//! 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::{
|
||||
attachment::AttachmentConfig,
|
||||
media::{MediaFormat, MediaRequest},
|
||||
room::{Joined, Room as MatrixRoom},
|
||||
room::Room as MatrixRoom,
|
||||
ruma::{
|
||||
events::reaction::{ReactionEventContent, Relation as Reaction},
|
||||
events::reaction::ReactionEventContent,
|
||||
events::relation::{Annotation, Replacement},
|
||||
events::room::message::{
|
||||
AddMentions,
|
||||
ForwardThread,
|
||||
MessageType,
|
||||
OriginalRoomMessageEvent,
|
||||
Relation,
|
||||
Replacement,
|
||||
ReplyWithinThread,
|
||||
RoomMessageEventContent,
|
||||
TextMessageEventContent,
|
||||
},
|
||||
EventId,
|
||||
OwnedEventId,
|
||||
OwnedRoomId,
|
||||
RoomId,
|
||||
},
|
||||
RoomState,
|
||||
};
|
||||
|
||||
use modalkit::{
|
||||
input::dialog::PromptYesNo,
|
||||
tui::{
|
||||
buffer::Buffer,
|
||||
layout::Rect,
|
||||
text::{Span, Spans},
|
||||
widgets::{Paragraph, StatefulWidget, Widget},
|
||||
},
|
||||
widgets::textbox::{TextBox, TextBoxState},
|
||||
widgets::TerminalCursor,
|
||||
widgets::{PromptActions, WindowOps},
|
||||
use ratatui::{
|
||||
buffer::Buffer,
|
||||
layout::Rect,
|
||||
text::{Line, Span},
|
||||
widgets::{Paragraph, StatefulWidget, Widget},
|
||||
};
|
||||
|
||||
use modalkit::keybindings::dialog::{MultiChoice, MultiChoiceItem, PromptYesNo};
|
||||
|
||||
use modalkit_ratatui::{
|
||||
textbox::{TextBox, TextBoxState},
|
||||
PromptActions,
|
||||
TerminalCursor,
|
||||
WindowOps,
|
||||
};
|
||||
|
||||
use modalkit::actions::{
|
||||
Action,
|
||||
Editable,
|
||||
EditorAction,
|
||||
Jumpable,
|
||||
PromptAction,
|
||||
Promptable,
|
||||
Scrollable,
|
||||
};
|
||||
use modalkit::editing::{
|
||||
action::{
|
||||
Action,
|
||||
EditError,
|
||||
EditInfo,
|
||||
EditResult,
|
||||
Editable,
|
||||
EditorAction,
|
||||
InfoMessage,
|
||||
Jumpable,
|
||||
PromptAction,
|
||||
Promptable,
|
||||
Scrollable,
|
||||
UIError,
|
||||
},
|
||||
base::{CloseFlags, Count, MoveDir1D, PositionList, ScrollStyle, WordStyle, WriteFlags},
|
||||
completion::CompletionList,
|
||||
context::Resolve,
|
||||
history::{self, HistoryList},
|
||||
rope::EditRope,
|
||||
};
|
||||
use modalkit::errors::{EditError, EditResult, UIError};
|
||||
use modalkit::prelude::*;
|
||||
|
||||
use crate::base::{
|
||||
DownloadFlags,
|
||||
@@ -84,6 +90,7 @@ use crate::worker::Requester;
|
||||
|
||||
use super::scrollback::{Scrollback, ScrollbackState};
|
||||
|
||||
/// State needed for rendering [Chat].
|
||||
pub struct ChatState {
|
||||
room_id: OwnedRoomId,
|
||||
room: MatrixRoom,
|
||||
@@ -100,10 +107,10 @@ pub struct ChatState {
|
||||
}
|
||||
|
||||
impl ChatState {
|
||||
pub fn new(room: MatrixRoom, store: &mut ProgramStore) -> Self {
|
||||
pub fn new(room: MatrixRoom, thread: Option<OwnedEventId>, store: &mut ProgramStore) -> Self {
|
||||
let room_id = room.room_id().to_owned();
|
||||
let scrollback = ScrollbackState::new(room_id.clone());
|
||||
let id = IambBufferId::Room(room_id.clone(), RoomFocus::MessageBar);
|
||||
let scrollback = ScrollbackState::new(room_id.clone(), thread.clone());
|
||||
let id = IambBufferId::Room(room_id.clone(), thread, RoomFocus::MessageBar);
|
||||
let ebuf = store.load_buffer(id);
|
||||
let tbox = TextBoxState::new(ebuf);
|
||||
|
||||
@@ -123,13 +130,26 @@ impl ChatState {
|
||||
}
|
||||
}
|
||||
|
||||
fn get_joined(&self, worker: &Requester) -> Result<Joined, IambError> {
|
||||
worker.client.get_joined_room(self.id()).ok_or(IambError::NotJoined)
|
||||
pub fn thread(&self) -> Option<&OwnedEventId> {
|
||||
self.scrollback.thread()
|
||||
}
|
||||
|
||||
fn get_joined(&self, worker: &Requester) -> Result<MatrixRoom, IambError> {
|
||||
let Some(room) = worker.client.get_room(self.id()) else {
|
||||
return Err(IambError::NotJoined);
|
||||
};
|
||||
|
||||
if room.state() == RoomState::Joined {
|
||||
Ok(room)
|
||||
} else {
|
||||
Err(IambError::NotJoined)
|
||||
}
|
||||
}
|
||||
|
||||
fn get_reply_to<'a>(&self, info: &'a RoomInfo) -> Option<&'a OriginalRoomMessageEvent> {
|
||||
let thread = self.scrollback.get_thread(info)?;
|
||||
let key = self.reply_to.as_ref()?;
|
||||
let msg = info.messages.get(key)?;
|
||||
let msg = thread.get(key)?;
|
||||
|
||||
if let MessageEvent::Original(ev) = &msg.event {
|
||||
Some(ev)
|
||||
@@ -161,20 +181,19 @@ impl ChatState {
|
||||
let settings = &store.application.settings;
|
||||
let info = store.application.rooms.get_or_default(self.room_id.clone());
|
||||
|
||||
let msg = self
|
||||
.scrollback
|
||||
.get_mut(&mut info.messages)
|
||||
.ok_or(IambError::NoSelectedMessage)?;
|
||||
let msg = self.scrollback.get_mut(info).ok_or(IambError::NoSelectedMessage)?;
|
||||
|
||||
match act {
|
||||
MessageAction::Cancel(skip_confirm) => {
|
||||
self.reply_to = None;
|
||||
self.editing = None;
|
||||
|
||||
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)]);
|
||||
@@ -200,7 +219,34 @@ impl ChatState {
|
||||
MessageType::Image(c) => (c.source.clone(), c.body.as_str()),
|
||||
MessageType::Video(c) => (c.source.clone(), c.body.as_str()),
|
||||
_ => {
|
||||
return Err(IambError::NoAttachment.into());
|
||||
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);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -326,9 +372,9 @@ impl ChatState {
|
||||
},
|
||||
};
|
||||
|
||||
let reaction = Reaction::new(event_id, emoji);
|
||||
let reaction = Annotation::new(event_id, emoji);
|
||||
let msg = ReactionEventContent::new(reaction);
|
||||
let _ = room.send(msg, None).await.map_err(IambError::from)?;
|
||||
let _ = room.send(msg).await.map_err(IambError::from)?;
|
||||
|
||||
Ok(None)
|
||||
},
|
||||
@@ -370,11 +416,11 @@ impl ChatState {
|
||||
},
|
||||
MessageAction::Unreact(emoji) => {
|
||||
let room = self.get_joined(&store.application.worker)?;
|
||||
let event_id: &EventId = match &msg.event {
|
||||
MessageEvent::EncryptedOriginal(ev) => ev.event_id.as_ref(),
|
||||
MessageEvent::EncryptedRedacted(ev) => ev.event_id.as_ref(),
|
||||
MessageEvent::Original(ev) => ev.event_id.as_ref(),
|
||||
MessageEvent::Local(event_id, _) => event_id.as_ref(),
|
||||
let event_id = match &msg.event {
|
||||
MessageEvent::EncryptedOriginal(ev) => ev.event_id.clone(),
|
||||
MessageEvent::EncryptedRedacted(ev) => ev.event_id.clone(),
|
||||
MessageEvent::Original(ev) => ev.event_id.clone(),
|
||||
MessageEvent::Local(event_id, _) => event_id.clone(),
|
||||
MessageEvent::Redacted(_) => {
|
||||
let msg = "Cannot unreact to a redacted message";
|
||||
let err = UIError::Failure(msg.into());
|
||||
@@ -383,7 +429,7 @@ impl ChatState {
|
||||
},
|
||||
};
|
||||
|
||||
let reactions = match info.reactions.get(event_id) {
|
||||
let reactions = match info.reactions.get(&event_id) {
|
||||
Some(r) => r,
|
||||
None => return Ok(None),
|
||||
};
|
||||
@@ -419,40 +465,46 @@ impl ChatState {
|
||||
_: ProgramContext,
|
||||
store: &mut ProgramStore,
|
||||
) -> IambResult<EditInfo> {
|
||||
let room = store
|
||||
.application
|
||||
.worker
|
||||
.client
|
||||
.get_joined_room(self.id())
|
||||
.ok_or(IambError::NotJoined)?;
|
||||
let room = self.get_joined(&store.application.worker)?;
|
||||
let info = store.application.rooms.get_or_default(self.id().to_owned());
|
||||
let mut show_echo = true;
|
||||
|
||||
let (event_id, msg) = match act {
|
||||
SendAction::Submit => {
|
||||
SendAction::Submit | SendAction::SubmitFromEditor => {
|
||||
let msg = self.tbox.get();
|
||||
|
||||
if msg.is_blank() {
|
||||
let msg = if let SendAction::SubmitFromEditor = act {
|
||||
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.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(),
|
||||
Box::new(msg.clone()),
|
||||
msg.msgtype.clone().into(),
|
||||
)));
|
||||
|
||||
show_echo = false;
|
||||
} else if let Some(thread_root) = self.scrollback.thread() {
|
||||
if let Some(m) = self.get_reply_to(info) {
|
||||
msg = msg.make_for_thread(m, ReplyWithinThread::Yes, AddMentions::No);
|
||||
} else if let Some(m) = info.get_thread_last(thread_root) {
|
||||
msg = msg.make_for_thread(m, ReplyWithinThread::No, AddMentions::No);
|
||||
} else {
|
||||
// Internal state is wonky?
|
||||
}
|
||||
} else if let Some(m) = self.get_reply_to(info) {
|
||||
// XXX: Switch to RoomMessageEventContent::reply() once it's stable?
|
||||
msg = msg.make_reply_to(m);
|
||||
msg = msg.make_reply_to(m, ForwardThread::Yes, AddMentions::No);
|
||||
}
|
||||
|
||||
// XXX: second parameter can be a locally unique transaction id.
|
||||
// Useful for doing retries.
|
||||
let resp = room.send(msg.clone(), None).await.map_err(IambError::from)?;
|
||||
let resp = room.send(msg.clone()).await.map_err(IambError::from)?;
|
||||
let event_id = resp.event_id;
|
||||
|
||||
// Reset message bar state now that it's been sent.
|
||||
@@ -472,7 +524,7 @@ impl ChatState {
|
||||
let config = AttachmentConfig::new();
|
||||
|
||||
let resp = room
|
||||
.send_attachment(name.as_ref(), &mime, bytes.as_ref(), config)
|
||||
.send_attachment(name.as_ref(), &mime, bytes, config)
|
||||
.await
|
||||
.map_err(IambError::from)?;
|
||||
|
||||
@@ -502,7 +554,7 @@ impl ChatState {
|
||||
let config = AttachmentConfig::new();
|
||||
|
||||
let resp = room
|
||||
.send_attachment(name.as_ref(), &mime, bytes.as_ref(), config)
|
||||
.send_attachment(name.as_ref(), &mime, bytes, config)
|
||||
.await
|
||||
.map_err(IambError::from)?;
|
||||
|
||||
@@ -520,7 +572,8 @@ impl ChatState {
|
||||
let key = (MessageTimeStamp::LocalEcho, event_id.clone());
|
||||
let msg = MessageEvent::Local(event_id, msg.into());
|
||||
let msg = Message::new(msg, user, MessageTimeStamp::LocalEcho);
|
||||
info.messages.insert(key, msg);
|
||||
let thread = self.scrollback.get_thread_mut(info);
|
||||
thread.insert(key, msg);
|
||||
}
|
||||
|
||||
// Jump to the end of the scrollback to show the message.
|
||||
@@ -587,12 +640,14 @@ impl WindowOps<IambInfo> for ChatState {
|
||||
fn dup(&self, store: &mut ProgramStore) -> Self {
|
||||
// XXX: I want each WindowSlot to have its own shared buffer, instead of each Room; need to
|
||||
// find a good way to pass that info here so that it can be part of the content id.
|
||||
let id = IambBufferId::Room(self.room_id.clone(), RoomFocus::MessageBar);
|
||||
let room_id = self.room_id.clone();
|
||||
let thread = self.thread().cloned();
|
||||
let id = IambBufferId::Room(room_id.clone(), thread, RoomFocus::MessageBar);
|
||||
let ebuf = store.load_buffer(id);
|
||||
let tbox = TextBoxState::new(ebuf);
|
||||
|
||||
ChatState {
|
||||
room_id: self.room_id.clone(),
|
||||
room_id,
|
||||
room: self.room.clone(),
|
||||
|
||||
tbox,
|
||||
@@ -648,8 +703,10 @@ impl Editable<ProgramContext, ProgramStore, IambInfo> for ChatState {
|
||||
|
||||
match delegate!(self, w => w.editor_command(act, ctx, store)) {
|
||||
res @ Ok(_) => res,
|
||||
Err(EditError::WrongBuffer(IambBufferId::Room(room_id, focus)))
|
||||
if room_id == self.room_id && act.is_switchable(ctx) =>
|
||||
Err(EditError::WrongBuffer(IambBufferId::Room(room_id, thread, focus)))
|
||||
if room_id == self.room_id &&
|
||||
thread.as_ref() == self.thread() &&
|
||||
act.is_switchable(ctx) =>
|
||||
{
|
||||
// Switch focus.
|
||||
self.focus = focus;
|
||||
@@ -767,7 +824,7 @@ impl Promptable<ProgramContext, ProgramStore, IambInfo> for ChatState {
|
||||
store: &mut ProgramStore,
|
||||
) -> EditResult<Vec<(ProgramAction, ProgramContext)>, IambInfo> {
|
||||
if let RoomFocus::Scrollback = self.focus {
|
||||
return Ok(vec![]);
|
||||
return self.scrollback.prompt(act, ctx, store);
|
||||
}
|
||||
|
||||
match act {
|
||||
@@ -776,11 +833,11 @@ impl Promptable<ProgramContext, ProgramStore, IambInfo> for ChatState {
|
||||
PromptAction::Recall(dir, count, prefixed) => {
|
||||
self.recall(dir, count, *prefixed, ctx, store)
|
||||
},
|
||||
_ => Err(EditError::Unimplemented("unknown prompt action".to_string())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// [StatefulWidget] for Matrix rooms.
|
||||
pub struct Chat<'a> {
|
||||
store: &'a mut ProgramStore,
|
||||
focused: bool,
|
||||
@@ -802,21 +859,23 @@ impl<'a> StatefulWidget for Chat<'a> {
|
||||
|
||||
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) {
|
||||
(None, None) => None,
|
||||
(Some(_), None) => Some(Spans::from("Editing message")),
|
||||
(editing, Some(_)) => {
|
||||
state.reply_to.as_ref().and_then(|k| {
|
||||
let room = self.store.application.rooms.get(state.id())?;
|
||||
let msg = room.messages.get(k)?;
|
||||
let 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 = if editing.is_some() {
|
||||
Span::from("Editing reply to ")
|
||||
} else {
|
||||
Span::from("Replying to ")
|
||||
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 = Spans(vec![prefix, user]);
|
||||
let spans = Line::from(vec![prefix, user]);
|
||||
|
||||
spans.into()
|
||||
})
|
||||
|
||||
@@ -1,52 +1,39 @@
|
||||
//! # Windows for Matrix rooms and spaces
|
||||
use matrix_sdk::{
|
||||
room::{Invited, Room as MatrixRoom},
|
||||
room::Room as MatrixRoom,
|
||||
ruma::{
|
||||
events::{
|
||||
room::{name::RoomNameEventContent, topic::RoomTopicEventContent},
|
||||
tag::{TagInfo, Tags},
|
||||
},
|
||||
OwnedEventId,
|
||||
RoomId,
|
||||
},
|
||||
DisplayName,
|
||||
RoomState as MatrixRoomState,
|
||||
};
|
||||
|
||||
use modalkit::tui::{
|
||||
use ratatui::{
|
||||
buffer::Buffer,
|
||||
layout::{Alignment, Rect},
|
||||
style::{Modifier as StyleModifier, Style},
|
||||
text::{Span, Spans, Text},
|
||||
text::{Line, Span, Text},
|
||||
widgets::{Paragraph, StatefulWidget, Widget},
|
||||
};
|
||||
|
||||
use modalkit::{
|
||||
editing::action::{
|
||||
Action,
|
||||
EditInfo,
|
||||
EditResult,
|
||||
Editable,
|
||||
EditorAction,
|
||||
Jumpable,
|
||||
PromptAction,
|
||||
Promptable,
|
||||
Scrollable,
|
||||
UIError,
|
||||
},
|
||||
editing::base::{
|
||||
Axis,
|
||||
CloseFlags,
|
||||
Count,
|
||||
MoveDir1D,
|
||||
OpenTarget,
|
||||
PositionList,
|
||||
ScrollStyle,
|
||||
WordStyle,
|
||||
WriteFlags,
|
||||
},
|
||||
editing::completion::CompletionList,
|
||||
input::dialog::PromptYesNo,
|
||||
input::InputContext,
|
||||
widgets::{TermOffset, TerminalCursor, WindowOps},
|
||||
use modalkit::actions::{
|
||||
Action,
|
||||
Editable,
|
||||
EditorAction,
|
||||
Jumpable,
|
||||
PromptAction,
|
||||
Promptable,
|
||||
Scrollable,
|
||||
};
|
||||
use modalkit::errors::{EditResult, UIError};
|
||||
use modalkit::prelude::*;
|
||||
use modalkit::{editing::completion::CompletionList, keybindings::dialog::PromptYesNo};
|
||||
use modalkit_ratatui::{TermOffset, TerminalCursor, WindowOps};
|
||||
|
||||
use crate::base::{
|
||||
IambAction,
|
||||
@@ -79,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 {
|
||||
Chat(ChatState),
|
||||
Space(SpaceState),
|
||||
@@ -99,6 +91,7 @@ impl From<SpaceState> for RoomState {
|
||||
impl RoomState {
|
||||
pub fn new(
|
||||
room: MatrixRoom,
|
||||
thread: Option<OwnedEventId>,
|
||||
name: DisplayName,
|
||||
tags: Option<Tags>,
|
||||
store: &mut ProgramStore,
|
||||
@@ -111,7 +104,14 @@ impl RoomState {
|
||||
if room.is_space() {
|
||||
SpaceState::new(room).into()
|
||||
} else {
|
||||
ChatState::new(room, store).into()
|
||||
ChatState::new(room, thread, store).into()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn thread(&self) -> Option<&OwnedEventId> {
|
||||
match self {
|
||||
RoomState::Chat(chat) => chat.thread(),
|
||||
RoomState::Space(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,7 +124,7 @@ impl RoomState {
|
||||
|
||||
fn draw_invite(
|
||||
&self,
|
||||
invited: Invited,
|
||||
invited: MatrixRoom,
|
||||
area: Rect,
|
||||
buf: &mut Buffer,
|
||||
store: &mut ProgramStore,
|
||||
@@ -144,8 +144,8 @@ impl RoomState {
|
||||
invited.push(store.application.settings.get_user_span(inviter.user_id(), info));
|
||||
}
|
||||
|
||||
let l1 = Spans(invited);
|
||||
let l2 = Spans::from(
|
||||
let l1 = Line::from(invited);
|
||||
let l2 = Line::from(
|
||||
"You can run `:invite accept` or `:invite reject` to accept or reject this invitation.",
|
||||
);
|
||||
let text = Text { lines: vec![l1, l2] };
|
||||
@@ -187,12 +187,12 @@ impl RoomState {
|
||||
) -> IambResult<Vec<(Action<IambInfo>, ProgramContext)>> {
|
||||
match act {
|
||||
RoomAction::InviteAccept => {
|
||||
if let Some(room) = store.application.worker.client.get_invited_room(self.id()) {
|
||||
if let Some(room) = store.application.worker.client.get_room(self.id()) {
|
||||
let details = room.invite_details().await.map_err(IambError::from)?;
|
||||
let details = details.invitee.event().original_content();
|
||||
let is_direct = details.and_then(|ev| ev.is_direct).unwrap_or_default();
|
||||
|
||||
room.accept_invitation().await.map_err(IambError::from)?;
|
||||
room.join().await.map_err(IambError::from)?;
|
||||
|
||||
if is_direct {
|
||||
room.set_is_direct(true).await.map_err(IambError::from)?;
|
||||
@@ -204,8 +204,8 @@ impl RoomState {
|
||||
}
|
||||
},
|
||||
RoomAction::InviteReject => {
|
||||
if let Some(room) = store.application.worker.client.get_invited_room(self.id()) {
|
||||
room.reject_invitation().await.map_err(IambError::from)?;
|
||||
if let Some(room) = store.application.worker.client.get_room(self.id()) {
|
||||
room.leave().await.map_err(IambError::from)?;
|
||||
|
||||
Ok(vec![])
|
||||
} else {
|
||||
@@ -213,7 +213,7 @@ impl RoomState {
|
||||
}
|
||||
},
|
||||
RoomAction::InviteSend(user) => {
|
||||
if let Some(room) = store.application.worker.client.get_joined_room(self.id()) {
|
||||
if let Some(room) = store.application.worker.client.get_room(self.id()) {
|
||||
room.invite_user_by_id(user.as_ref()).await.map_err(IambError::from)?;
|
||||
|
||||
Ok(vec![])
|
||||
@@ -222,7 +222,7 @@ impl RoomState {
|
||||
}
|
||||
},
|
||||
RoomAction::Leave(skip_confirm) => {
|
||||
if let Some(room) = store.application.worker.client.get_joined_room(self.id()) {
|
||||
if let Some(room) = store.application.worker.client.get_room(self.id()) {
|
||||
if skip_confirm {
|
||||
room.leave().await.map_err(IambError::from)?;
|
||||
|
||||
@@ -247,7 +247,7 @@ impl RoomState {
|
||||
width.into(),
|
||||
);
|
||||
|
||||
Ok(vec![(act, cmd.context.take())])
|
||||
Ok(vec![(act, cmd.context.clone())])
|
||||
},
|
||||
RoomAction::Set(field, value) => {
|
||||
let room = store
|
||||
@@ -257,7 +257,7 @@ impl RoomState {
|
||||
|
||||
match field {
|
||||
RoomField::Name => {
|
||||
let ev = RoomNameEventContent::new(value.into());
|
||||
let ev = RoomNameEventContent::new(value);
|
||||
let _ = room.send_state_event(ev).await.map_err(IambError::from)?;
|
||||
},
|
||||
RoomField::Tag(tag) => {
|
||||
@@ -282,7 +282,7 @@ impl RoomState {
|
||||
|
||||
match field {
|
||||
RoomField::Name => {
|
||||
let ev = RoomNameEventContent::new(None);
|
||||
let ev = RoomNameEventContent::new("".into());
|
||||
let _ = room.send_state_event(ev).await.map_err(IambError::from)?;
|
||||
},
|
||||
RoomField::Tag(tag) => {
|
||||
@@ -299,10 +299,18 @@ impl RoomState {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_title(&self, store: &mut ProgramStore) -> Spans {
|
||||
pub fn get_title(&self, store: &mut ProgramStore) -> Line {
|
||||
let title = store.application.get_room_title(self.id());
|
||||
let style = Style::default().add_modifier(StyleModifier::BOLD);
|
||||
let mut spans = vec![Span::styled(title, style)];
|
||||
let mut spans = vec![];
|
||||
|
||||
if let RoomState::Chat(chat) = self {
|
||||
if chat.thread().is_some() {
|
||||
spans.push("Thread in ".into());
|
||||
}
|
||||
}
|
||||
|
||||
spans.push(Span::styled(title, style));
|
||||
|
||||
match self.room().topic() {
|
||||
Some(desc) if !desc.is_empty() => {
|
||||
@@ -313,7 +321,7 @@ impl RoomState {
|
||||
_ => {},
|
||||
}
|
||||
|
||||
Spans(spans)
|
||||
Line::from(spans)
|
||||
}
|
||||
|
||||
pub fn focus_toggle(&mut self) {
|
||||
@@ -391,12 +399,12 @@ impl TerminalCursor for RoomState {
|
||||
|
||||
impl WindowOps<IambInfo> for RoomState {
|
||||
fn draw(&mut self, area: Rect, buf: &mut Buffer, focused: bool, store: &mut ProgramStore) {
|
||||
if let MatrixRoom::Invited(_) = self.room() {
|
||||
if self.room().state() == MatrixRoomState::Invited {
|
||||
self.refresh_room(store);
|
||||
}
|
||||
|
||||
if let MatrixRoom::Invited(invited) = self.room() {
|
||||
self.draw_invite(invited.clone(), area, buf, store);
|
||||
if self.room().state() == MatrixRoomState::Invited {
|
||||
self.draw_invite(self.room().clone(), area, buf, store);
|
||||
}
|
||||
|
||||
match self {
|
||||
|
||||
@@ -1,84 +1,71 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
//! Message scrollback
|
||||
use ratatui_image::Image;
|
||||
use regex::Regex;
|
||||
|
||||
use matrix_sdk::ruma::OwnedRoomId;
|
||||
use matrix_sdk::ruma::{OwnedEventId, OwnedRoomId};
|
||||
|
||||
use modalkit::tui::{
|
||||
use modalkit_ratatui::{ScrollActions, TerminalCursor, WindowOps};
|
||||
use ratatui::{
|
||||
buffer::Buffer,
|
||||
layout::{Alignment, Rect},
|
||||
style::{Modifier as StyleModifier, Style},
|
||||
text::{Span, Spans},
|
||||
text::{Line, Span},
|
||||
widgets::{Paragraph, StatefulWidget, Widget},
|
||||
};
|
||||
use modalkit::widgets::{ScrollActions, TerminalCursor, WindowOps};
|
||||
|
||||
use modalkit::actions::{
|
||||
Action,
|
||||
CursorAction,
|
||||
EditAction,
|
||||
Editable,
|
||||
EditorAction,
|
||||
EditorActions,
|
||||
HistoryAction,
|
||||
InsertTextAction,
|
||||
Jumpable,
|
||||
PromptAction,
|
||||
Promptable,
|
||||
Scrollable,
|
||||
Searchable,
|
||||
SelectionAction,
|
||||
WindowAction,
|
||||
};
|
||||
use modalkit::editing::{
|
||||
action::{
|
||||
Action,
|
||||
CursorAction,
|
||||
EditAction,
|
||||
EditError,
|
||||
EditInfo,
|
||||
EditResult,
|
||||
Editable,
|
||||
EditorAction,
|
||||
EditorActions,
|
||||
HistoryAction,
|
||||
InsertTextAction,
|
||||
Jumpable,
|
||||
PromptAction,
|
||||
Promptable,
|
||||
Scrollable,
|
||||
Searchable,
|
||||
SelectionAction,
|
||||
UIError,
|
||||
UIResult,
|
||||
},
|
||||
base::{
|
||||
Axis,
|
||||
CloseFlags,
|
||||
CompletionDisplay,
|
||||
CompletionSelection,
|
||||
CompletionType,
|
||||
Count,
|
||||
EditRange,
|
||||
EditTarget,
|
||||
Mark,
|
||||
MoveDir1D,
|
||||
MoveDir2D,
|
||||
MoveDirMod,
|
||||
MovePosition,
|
||||
MoveTerminus,
|
||||
MoveType,
|
||||
PositionList,
|
||||
RangeType,
|
||||
Register,
|
||||
ScrollSize,
|
||||
ScrollStyle,
|
||||
SearchType,
|
||||
TargetShape,
|
||||
ViewportContext,
|
||||
WordStyle,
|
||||
WriteFlags,
|
||||
},
|
||||
completion::CompletionList,
|
||||
context::{EditContext, Resolve},
|
||||
context::Resolve,
|
||||
cursor::{CursorGroup, CursorState},
|
||||
history::HistoryList,
|
||||
rope::EditRope,
|
||||
store::{RegisterCell, RegisterPutFlags},
|
||||
};
|
||||
use modalkit::errors::{EditError, EditResult, UIError, UIResult};
|
||||
use modalkit::prelude::*;
|
||||
|
||||
use crate::{
|
||||
base::{IambBufferId, IambInfo, IambResult, ProgramContext, ProgramStore, RoomFocus, RoomInfo},
|
||||
base::{
|
||||
IambBufferId,
|
||||
IambId,
|
||||
IambInfo,
|
||||
IambResult,
|
||||
Need,
|
||||
ProgramContext,
|
||||
ProgramStore,
|
||||
RoomFetchStatus,
|
||||
RoomFocus,
|
||||
RoomInfo,
|
||||
},
|
||||
config::ApplicationSettings,
|
||||
message::{Message, MessageCursor, MessageKey, Messages},
|
||||
};
|
||||
|
||||
fn nth_key_before(pos: MessageKey, n: usize, info: &RoomInfo) -> MessageKey {
|
||||
fn no_msgs() -> EditError<IambInfo> {
|
||||
let msg = "No messages to select.";
|
||||
EditError::Failure(msg.to_string())
|
||||
}
|
||||
|
||||
fn nth_key_before(pos: MessageKey, n: usize, thread: &Messages) -> MessageKey {
|
||||
let mut end = &pos;
|
||||
let iter = info.messages.range(..=&pos).rev().enumerate();
|
||||
let iter = thread.range(..=&pos).rev().enumerate();
|
||||
|
||||
for (i, (key, _)) in iter {
|
||||
end = key;
|
||||
@@ -91,13 +78,13 @@ fn nth_key_before(pos: MessageKey, n: usize, info: &RoomInfo) -> MessageKey {
|
||||
end.clone()
|
||||
}
|
||||
|
||||
fn nth_before(pos: MessageKey, n: usize, info: &RoomInfo) -> MessageCursor {
|
||||
nth_key_before(pos, n, info).into()
|
||||
fn nth_before(pos: MessageKey, n: usize, thread: &Messages) -> MessageCursor {
|
||||
nth_key_before(pos, n, thread).into()
|
||||
}
|
||||
|
||||
fn nth_key_after(pos: MessageKey, n: usize, info: &RoomInfo) -> MessageKey {
|
||||
fn nth_key_after(pos: MessageKey, n: usize, thread: &Messages) -> MessageKey {
|
||||
let mut end = &pos;
|
||||
let iter = info.messages.range(&pos..).enumerate();
|
||||
let iter = thread.range(&pos..).enumerate();
|
||||
|
||||
for (i, (key, _)) in iter {
|
||||
end = key;
|
||||
@@ -110,12 +97,12 @@ fn nth_key_after(pos: MessageKey, n: usize, info: &RoomInfo) -> MessageKey {
|
||||
end.clone()
|
||||
}
|
||||
|
||||
fn nth_after(pos: MessageKey, n: usize, info: &RoomInfo) -> MessageCursor {
|
||||
nth_key_after(pos, n, info).into()
|
||||
fn nth_after(pos: MessageKey, n: usize, thread: &Messages) -> MessageCursor {
|
||||
nth_key_after(pos, n, thread).into()
|
||||
}
|
||||
|
||||
fn prevmsg<'a>(key: &MessageKey, info: &'a RoomInfo) -> Option<&'a Message> {
|
||||
info.messages.range(..key).next_back().map(|(_, v)| v)
|
||||
fn prevmsg<'a>(key: &MessageKey, thread: &'a Messages) -> Option<&'a Message> {
|
||||
thread.range(..key).next_back().map(|(_, v)| v)
|
||||
}
|
||||
|
||||
pub struct ScrollbackState {
|
||||
@@ -125,6 +112,9 @@ pub struct ScrollbackState {
|
||||
/// The buffer identifier used for saving marks, etc.
|
||||
id: IambBufferId,
|
||||
|
||||
/// The currently focused thread in this room.
|
||||
thread: Option<OwnedEventId>,
|
||||
|
||||
/// The currently selected message in the scrollback.
|
||||
cursor: MessageCursor,
|
||||
|
||||
@@ -142,8 +132,8 @@ pub struct ScrollbackState {
|
||||
}
|
||||
|
||||
impl ScrollbackState {
|
||||
pub fn new(room_id: OwnedRoomId) -> ScrollbackState {
|
||||
let id = IambBufferId::Room(room_id.to_owned(), RoomFocus::Scrollback);
|
||||
pub fn new(room_id: OwnedRoomId, thread: Option<OwnedEventId>) -> ScrollbackState {
|
||||
let id = IambBufferId::Room(room_id.to_owned(), thread.clone(), RoomFocus::Scrollback);
|
||||
let cursor = MessageCursor::default();
|
||||
let viewctx = ViewportContext::default();
|
||||
let jumped = HistoryList::default();
|
||||
@@ -152,6 +142,7 @@ impl ScrollbackState {
|
||||
ScrollbackState {
|
||||
room_id,
|
||||
id,
|
||||
thread,
|
||||
cursor,
|
||||
viewctx,
|
||||
jumped,
|
||||
@@ -172,37 +163,88 @@ impl ScrollbackState {
|
||||
self.cursor
|
||||
.timestamp
|
||||
.clone()
|
||||
.or_else(|| info.messages.last_key_value().map(|kv| kv.0.clone()))
|
||||
.or_else(|| self.get_thread(info)?.last_key_value().map(|kv| kv.0.clone()))
|
||||
}
|
||||
|
||||
pub fn get_mut<'a>(&mut self, messages: &'a mut Messages) -> Option<&'a mut Message> {
|
||||
pub fn get_mut<'a>(&mut self, info: &'a mut RoomInfo) -> Option<&'a mut Message> {
|
||||
let thread = self.get_thread_mut(info);
|
||||
|
||||
if let Some(k) = &self.cursor.timestamp {
|
||||
messages.get_mut(k)
|
||||
thread.get_mut(k)
|
||||
} else {
|
||||
messages.last_entry().map(|o| o.into_mut())
|
||||
thread.last_entry().map(|o| o.into_mut())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn thread(&self) -> Option<&OwnedEventId> {
|
||||
self.thread.as_ref()
|
||||
}
|
||||
|
||||
pub fn get_thread<'a>(&self, info: &'a RoomInfo) -> Option<&'a Messages> {
|
||||
info.get_thread(self.thread.as_deref())
|
||||
}
|
||||
|
||||
pub fn get_thread_mut<'a>(&self, info: &'a mut RoomInfo) -> &'a mut Messages {
|
||||
info.get_thread_mut(self.thread.clone())
|
||||
}
|
||||
|
||||
pub fn messages<'a>(
|
||||
&self,
|
||||
range: EditRange<MessageCursor>,
|
||||
info: &'a RoomInfo,
|
||||
) -> impl Iterator<Item = (&'a MessageKey, &'a Message)> {
|
||||
let start = range.start.to_key(info);
|
||||
let end = range.end.to_key(info);
|
||||
let Some(thread) = self.get_thread(info) else {
|
||||
return Default::default();
|
||||
};
|
||||
|
||||
let start = range.start.to_key(thread);
|
||||
let end = range.end.to_key(thread);
|
||||
|
||||
let (start, end) = if let (Some(start), Some(end)) = (start, end) {
|
||||
(start, end)
|
||||
} else if let Some((last, _)) = info.messages.last_key_value() {
|
||||
} else if let Some((last, _)) = thread.last_key_value() {
|
||||
(last, last)
|
||||
} else {
|
||||
return info.messages.range(..);
|
||||
return thread.range(..);
|
||||
};
|
||||
|
||||
if range.inclusive {
|
||||
info.messages.range(start..=end)
|
||||
thread.range(start..=end)
|
||||
} else {
|
||||
info.messages.range(start..end)
|
||||
thread.range(start..end)
|
||||
}
|
||||
}
|
||||
|
||||
fn need_more_messages(&self, info: &RoomInfo) -> bool {
|
||||
match info.fetch_id {
|
||||
// Don't fetch if we've already hit the end of history.
|
||||
RoomFetchStatus::Done => return false,
|
||||
// Fetch at least once if we're viewing a room.
|
||||
RoomFetchStatus::NotStarted => return true,
|
||||
_ => {},
|
||||
}
|
||||
|
||||
let first_key = self.get_thread(info).and_then(|t| t.first_key_value()).map(|(k, _)| k);
|
||||
let at_top = first_key == self.viewctx.corner.timestamp.as_ref();
|
||||
|
||||
match (at_top, self.thread.as_ref()) {
|
||||
(false, _) => {
|
||||
// Not scrolled to top, don't fetch.
|
||||
false
|
||||
},
|
||||
(true, None) => {
|
||||
// Scrolled to top in non-thread, fetch.
|
||||
true
|
||||
},
|
||||
(true, Some(thread_root)) => {
|
||||
// Scrolled to top in thread, fetch until we have the thread root.
|
||||
//
|
||||
// Typically, if the user has entered a thread view, we should already have fetched
|
||||
// all the way back to the thread root, but it is technically possible via :threads
|
||||
// or when restoring a thread view in the layout at startup to not have the message
|
||||
// yet.
|
||||
!info.keys.contains_key(thread_root)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,7 +255,11 @@ impl ScrollbackState {
|
||||
info: &RoomInfo,
|
||||
settings: &ApplicationSettings,
|
||||
) {
|
||||
let selidx = if let Some(key) = self.cursor.to_key(info) {
|
||||
let Some(thread) = self.get_thread(info) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let selidx = if let Some(key) = self.cursor.to_key(thread) {
|
||||
key
|
||||
} else {
|
||||
return;
|
||||
@@ -227,9 +273,9 @@ impl ScrollbackState {
|
||||
let mut lines = 0;
|
||||
let target = self.viewctx.get_height() / 2;
|
||||
|
||||
for (key, item) in info.messages.range(..=&idx).rev() {
|
||||
for (key, item) in thread.range(..=&idx).rev() {
|
||||
let sel = selidx == key;
|
||||
let prev = prevmsg(key, info);
|
||||
let prev = prevmsg(key, thread);
|
||||
let len = item.show(prev, sel, &self.viewctx, info, settings).lines.len();
|
||||
|
||||
if key == &idx {
|
||||
@@ -250,9 +296,9 @@ impl ScrollbackState {
|
||||
let mut lines = 0;
|
||||
let target = self.viewctx.get_height();
|
||||
|
||||
for (key, item) in info.messages.range(..=&idx).rev() {
|
||||
for (key, item) in thread.range(..=&idx).rev() {
|
||||
let sel = key == selidx;
|
||||
let prev = prevmsg(key, info);
|
||||
let prev = prevmsg(key, thread);
|
||||
let len = item.show(prev, sel, &self.viewctx, info, settings).lines.len();
|
||||
|
||||
lines += len;
|
||||
@@ -268,8 +314,20 @@ impl ScrollbackState {
|
||||
}
|
||||
}
|
||||
|
||||
fn jump_changed(&mut self) -> bool {
|
||||
self.jumped.current() != &self.cursor
|
||||
}
|
||||
|
||||
fn push_jump(&mut self) {
|
||||
self.jumped.push(self.cursor.clone());
|
||||
}
|
||||
|
||||
fn shift_cursor(&mut self, info: &RoomInfo, settings: &ApplicationSettings) {
|
||||
let last_key = if let Some(k) = info.messages.last_key_value() {
|
||||
let Some(thread) = self.get_thread(info) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let last_key = if let Some(k) = thread.last_key_value() {
|
||||
k.0
|
||||
} else {
|
||||
return;
|
||||
@@ -286,9 +344,9 @@ impl ScrollbackState {
|
||||
let mut lines = 0;
|
||||
|
||||
let cursor_key = self.cursor.timestamp.as_ref().unwrap_or(last_key);
|
||||
let mut prev = prevmsg(cursor_key, info);
|
||||
let mut prev = prevmsg(cursor_key, thread);
|
||||
|
||||
for (idx, item) in info.messages.range(corner_key.clone()..) {
|
||||
for (idx, item) in thread.range(corner_key.clone()..) {
|
||||
if idx == cursor_key {
|
||||
// Cursor is already within the viewport.
|
||||
break;
|
||||
@@ -337,7 +395,7 @@ impl ScrollbackState {
|
||||
MoveType::BufferLineOffset => None,
|
||||
MoveType::BufferLinePercent => None,
|
||||
MoveType::BufferPos(MovePosition::Beginning) => {
|
||||
let start = info.messages.first_key_value()?.0.clone();
|
||||
let start = self.get_thread(info)?.first_key_value()?.0.clone();
|
||||
|
||||
Some(start.into())
|
||||
},
|
||||
@@ -350,9 +408,11 @@ impl ScrollbackState {
|
||||
MoveType::ParagraphBegin(dir) |
|
||||
MoveType::SectionBegin(dir) |
|
||||
MoveType::SectionEnd(dir) => {
|
||||
let thread = self.get_thread(info)?;
|
||||
|
||||
match dir {
|
||||
MoveDir1D::Previous => nth_before(pos, count, info).into(),
|
||||
MoveDir1D::Next => nth_after(pos, count, info).into(),
|
||||
MoveDir1D::Previous => nth_before(pos, count, thread).into(),
|
||||
MoveDir1D::Next => nth_after(pos, count, thread).into(),
|
||||
}
|
||||
},
|
||||
MoveType::ViewportPos(MovePosition::Beginning) => {
|
||||
@@ -401,12 +461,14 @@ impl ScrollbackState {
|
||||
RangeType::XmlTag => None,
|
||||
|
||||
RangeType::Buffer => {
|
||||
let start = info.messages.first_key_value()?.0.clone();
|
||||
let end = info.messages.last_key_value()?.0.clone();
|
||||
let thread = self.get_thread(info)?;
|
||||
let start = thread.first_key_value()?.0.clone();
|
||||
let end = thread.last_key_value()?.0.clone();
|
||||
|
||||
Some(EditRange::inclusive(start.into(), end.into(), TargetShape::LineWise))
|
||||
},
|
||||
RangeType::Line | RangeType::Paragraph | RangeType::Sentence => {
|
||||
let thread = self.get_thread(info)?;
|
||||
let count = ctx.resolve(count);
|
||||
|
||||
if count == 0 {
|
||||
@@ -415,7 +477,7 @@ impl ScrollbackState {
|
||||
|
||||
let mut end = &pos;
|
||||
|
||||
for (i, (key, _)) in info.messages.range(&pos..).enumerate() {
|
||||
for (i, (key, _)) in thread.range(&pos..).enumerate() {
|
||||
if i >= count {
|
||||
break;
|
||||
}
|
||||
@@ -440,9 +502,10 @@ impl ScrollbackState {
|
||||
mut count: usize,
|
||||
info: &RoomInfo,
|
||||
) -> Option<MessageCursor> {
|
||||
let thread = self.get_thread(info)?;
|
||||
let mut mc = None;
|
||||
|
||||
for (key, msg) in info.messages.range(&start..) {
|
||||
for (key, msg) in thread.range(&start..) {
|
||||
if count == 0 {
|
||||
break;
|
||||
}
|
||||
@@ -466,11 +529,14 @@ impl ScrollbackState {
|
||||
needle: &Regex,
|
||||
mut count: usize,
|
||||
info: &RoomInfo,
|
||||
need_load: &mut HashSet<OwnedRoomId>,
|
||||
) -> Option<MessageCursor> {
|
||||
) -> (Option<MessageCursor>, bool) {
|
||||
let mut mc = None;
|
||||
|
||||
for (key, msg) in info.messages.range(..&end).rev() {
|
||||
let Some(thread) = self.get_thread(info) else {
|
||||
return (None, false);
|
||||
};
|
||||
|
||||
for (key, msg) in thread.range(..&end).rev() {
|
||||
if count == 0 {
|
||||
break;
|
||||
}
|
||||
@@ -481,11 +547,7 @@ impl ScrollbackState {
|
||||
}
|
||||
}
|
||||
|
||||
if count > 0 {
|
||||
need_load.insert(self.room_id.clone());
|
||||
}
|
||||
|
||||
return mc;
|
||||
return (mc, count > 0);
|
||||
}
|
||||
|
||||
fn find_message(
|
||||
@@ -495,11 +557,10 @@ impl ScrollbackState {
|
||||
needle: &Regex,
|
||||
count: usize,
|
||||
info: &RoomInfo,
|
||||
need_load: &mut HashSet<OwnedRoomId>,
|
||||
) -> Option<MessageCursor> {
|
||||
) -> (Option<MessageCursor>, bool) {
|
||||
match dir {
|
||||
MoveDir1D::Next => self.find_message_next(key, needle, count, info),
|
||||
MoveDir1D::Previous => self.find_message_prev(key, needle, count, info, need_load),
|
||||
MoveDir1D::Next => (self.find_message_next(key, needle, count, info), false),
|
||||
MoveDir1D::Previous => self.find_message_prev(key, needle, count, info),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -513,6 +574,7 @@ impl WindowOps<IambInfo> for ScrollbackState {
|
||||
ScrollbackState {
|
||||
room_id: self.room_id.clone(),
|
||||
id: self.id.clone(),
|
||||
thread: self.thread.clone(),
|
||||
cursor: self.cursor.clone(),
|
||||
viewctx: self.viewctx.clone(),
|
||||
jumped: self.jumped.clone(),
|
||||
@@ -561,18 +623,13 @@ impl EditorActions<ProgramContext, ProgramStore, IambInfo> for ScrollbackState {
|
||||
store: &mut ProgramStore,
|
||||
) -> EditResult<EditInfo, IambInfo> {
|
||||
let info = store.application.rooms.get_or_default(self.room_id.clone());
|
||||
let key = if let Some(k) = self.cursor.to_key(info) {
|
||||
k.clone()
|
||||
} else {
|
||||
let msg = "No messages to select.";
|
||||
let err = EditError::Failure(msg.to_string());
|
||||
return Err(err);
|
||||
};
|
||||
let thread = self.get_thread(info).ok_or_else(no_msgs)?;
|
||||
let key = self.cursor.to_key(thread).ok_or_else(no_msgs)?.clone();
|
||||
|
||||
match operation {
|
||||
EditAction::Motion => {
|
||||
if motion.is_jumping() {
|
||||
self.jumped.push(self.cursor.clone());
|
||||
self.push_jump();
|
||||
}
|
||||
|
||||
let pos = match motion {
|
||||
@@ -591,8 +648,8 @@ impl EditorActions<ProgramContext, ProgramStore, IambInfo> for ScrollbackState {
|
||||
let mark = ctx.resolve(mark);
|
||||
let cursor = store.cursors.get_mark(self.id.clone(), mark)?;
|
||||
|
||||
if let mc @ Some(_) = MessageCursor::from_cursor(&cursor, info) {
|
||||
mc
|
||||
if let Some(mc) = MessageCursor::from_cursor(&cursor, thread) {
|
||||
Some(mc)
|
||||
} else {
|
||||
let msg = "Failed to restore mark";
|
||||
let err = EditError::Failure(msg.into());
|
||||
@@ -616,24 +673,18 @@ impl EditorActions<ProgramContext, ProgramStore, IambInfo> for ScrollbackState {
|
||||
let dir = ctx.get_search_regex_dir();
|
||||
let dir = flip.resolve(&dir);
|
||||
|
||||
let needle = match ctx.get_search_regex() {
|
||||
Some(re) => re,
|
||||
None => {
|
||||
let lsearch = store.registers.get(&Register::LastSearch)?;
|
||||
let lsearch = lsearch.value.to_string();
|
||||
let lsearch = store.registers.get(&Register::LastSearch)?;
|
||||
let lsearch = lsearch.value.to_string();
|
||||
let needle = Regex::new(lsearch.as_ref())?;
|
||||
|
||||
Regex::new(lsearch.as_ref())?
|
||||
},
|
||||
};
|
||||
|
||||
self.find_message(
|
||||
key,
|
||||
dir,
|
||||
&needle,
|
||||
count,
|
||||
info,
|
||||
&mut store.application.need_load,
|
||||
)
|
||||
let (mc, needs_load) = self.find_message(key, dir, &needle, count, info);
|
||||
if needs_load {
|
||||
store
|
||||
.application
|
||||
.need_load
|
||||
.insert(self.room_id.clone(), Need::MESSAGES);
|
||||
}
|
||||
mc
|
||||
},
|
||||
EditTarget::Search(SearchType::Word(_, _), _, _) => {
|
||||
let msg = "Cannot perform word search in a list";
|
||||
@@ -675,7 +726,7 @@ impl EditorActions<ProgramContext, ProgramStore, IambInfo> for ScrollbackState {
|
||||
let mark = ctx.resolve(mark);
|
||||
let cursor = store.cursors.get_mark(self.id.clone(), mark)?;
|
||||
|
||||
if let Some(c) = MessageCursor::from_cursor(&cursor, info) {
|
||||
if let Some(c) = MessageCursor::from_cursor(&cursor, thread) {
|
||||
self._range_to(c).into()
|
||||
} else {
|
||||
let msg = "Failed to restore mark";
|
||||
@@ -702,25 +753,19 @@ impl EditorActions<ProgramContext, ProgramStore, IambInfo> for ScrollbackState {
|
||||
let dir = ctx.get_search_regex_dir();
|
||||
let dir = flip.resolve(&dir);
|
||||
|
||||
let needle = match ctx.get_search_regex() {
|
||||
Some(re) => re,
|
||||
None => {
|
||||
let lsearch = store.registers.get(&Register::LastSearch)?;
|
||||
let lsearch = lsearch.value.to_string();
|
||||
let lsearch = store.registers.get(&Register::LastSearch)?;
|
||||
let lsearch = lsearch.value.to_string();
|
||||
let needle = Regex::new(lsearch.as_ref())?;
|
||||
|
||||
Regex::new(lsearch.as_ref())?
|
||||
},
|
||||
};
|
||||
let (mc, needs_load) = self.find_message(key, dir, &needle, count, info);
|
||||
if needs_load {
|
||||
store
|
||||
.application
|
||||
.need_load
|
||||
.insert(self.room_id.to_owned(), Need::MESSAGES);
|
||||
}
|
||||
|
||||
self.find_message(
|
||||
key,
|
||||
dir,
|
||||
&needle,
|
||||
count,
|
||||
info,
|
||||
&mut store.application.need_load,
|
||||
)
|
||||
.map(|c| self._range_to(c))
|
||||
mc.map(|c| self._range_to(c))
|
||||
},
|
||||
EditTarget::Search(SearchType::Word(_, _), _, _) => {
|
||||
let msg = "Cannot perform word search in a list";
|
||||
@@ -777,17 +822,11 @@ impl EditorActions<ProgramContext, ProgramStore, IambInfo> for ScrollbackState {
|
||||
store: &mut ProgramStore,
|
||||
) -> EditResult<EditInfo, IambInfo> {
|
||||
let info = store.application.get_room_info(self.room_id.clone());
|
||||
let thread = self.get_thread(info).ok_or_else(no_msgs)?;
|
||||
let cursor = self.cursor.to_cursor(thread).ok_or_else(no_msgs)?;
|
||||
store.cursors.set_mark(self.id.clone(), name, cursor);
|
||||
|
||||
if let Some(cursor) = self.cursor.to_cursor(info) {
|
||||
store.cursors.set_mark(self.id.clone(), name, cursor);
|
||||
|
||||
Ok(None)
|
||||
} else {
|
||||
let msg = "Failed to set mark for message";
|
||||
let err = EditError::Failure(msg.into());
|
||||
|
||||
Err(err)
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn complete(
|
||||
@@ -829,7 +868,6 @@ impl EditorActions<ProgramContext, ProgramStore, IambInfo> for ScrollbackState {
|
||||
HistoryAction::Checkpoint => Ok(None),
|
||||
HistoryAction::Undo(_) => Err(EditError::Failure("Nothing to undo".into())),
|
||||
HistoryAction::Redo(_) => Err(EditError::Failure("Nothing to redo".into())),
|
||||
_ => Err(EditError::Unimplemented(format!("Unknown action: {act:?}"))),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -840,6 +878,7 @@ impl EditorActions<ProgramContext, ProgramStore, IambInfo> for ScrollbackState {
|
||||
store: &mut ProgramStore,
|
||||
) -> EditResult<EditInfo, IambInfo> {
|
||||
let info = store.application.get_room_info(self.room_id.clone());
|
||||
let thread = self.get_thread(info).ok_or_else(no_msgs)?;
|
||||
|
||||
match act {
|
||||
CursorAction::Close(_) => Ok(None),
|
||||
@@ -853,11 +892,11 @@ impl EditorActions<ProgramContext, ProgramStore, IambInfo> for ScrollbackState {
|
||||
let ngroup = store.cursors.get_group(self.id.clone(), ®)?;
|
||||
|
||||
// Lists don't have groups; override current position.
|
||||
if self.jumped.current() != &self.cursor {
|
||||
self.jumped.push(self.cursor.clone());
|
||||
if self.jump_changed() {
|
||||
self.push_jump();
|
||||
}
|
||||
|
||||
if let Some(mc) = MessageCursor::from_cursor(ngroup.leader.cursor(), info) {
|
||||
if let Some(mc) = MessageCursor::from_cursor(ngroup.leader.cursor(), thread) {
|
||||
self.cursor = mc;
|
||||
|
||||
Ok(None)
|
||||
@@ -872,7 +911,7 @@ impl EditorActions<ProgramContext, ProgramStore, IambInfo> for ScrollbackState {
|
||||
let reg = ctx.get_register().unwrap_or(Register::UnnamedCursorGroup);
|
||||
|
||||
// Lists don't have groups; override any previously saved group.
|
||||
let cursor = self.cursor.to_cursor(info).ok_or_else(|| {
|
||||
let cursor = self.cursor.to_cursor(thread).ok_or_else(|| {
|
||||
let msg = "Cannot save position in message history";
|
||||
EditError::Failure(msg.into())
|
||||
})?;
|
||||
@@ -931,14 +970,14 @@ impl Jumpable<ProgramContext, IambInfo> for ScrollbackState {
|
||||
let msg = "No changes to jump to within the list";
|
||||
let err = UIError::Failure(msg.into());
|
||||
|
||||
return Err(err);
|
||||
Err(err)
|
||||
},
|
||||
PositionList::JumpList => {
|
||||
let (len, pos) = match dir {
|
||||
MoveDir1D::Previous => {
|
||||
if self.jumped.future_len() == 0 && *self.jumped.current() != self.cursor {
|
||||
if self.jumped.future_len() == 0 && self.jump_changed() {
|
||||
// Push current position if this is the first jump backwards.
|
||||
self.jumped.push(self.cursor.clone());
|
||||
self.push_jump();
|
||||
}
|
||||
|
||||
let plen = self.jumped.past_len();
|
||||
@@ -958,7 +997,7 @@ impl Jumpable<ProgramContext, IambInfo> for ScrollbackState {
|
||||
self.cursor = pos.clone();
|
||||
}
|
||||
|
||||
return Ok(count.saturating_sub(len));
|
||||
Ok(count.saturating_sub(len))
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -968,54 +1007,42 @@ impl Promptable<ProgramContext, ProgramStore, IambInfo> for ScrollbackState {
|
||||
fn prompt(
|
||||
&mut self,
|
||||
act: &PromptAction,
|
||||
_: &ProgramContext,
|
||||
ctx: &ProgramContext,
|
||||
store: &mut ProgramStore,
|
||||
) -> EditResult<Vec<(Action<IambInfo>, ProgramContext)>, IambInfo> {
|
||||
let info = store.application.get_room_info(self.room_id.clone());
|
||||
let thread = self.get_thread(info).ok_or_else(no_msgs)?;
|
||||
|
||||
let _ = if let Some(key) = self.cursor.to_key(info) {
|
||||
key
|
||||
} else {
|
||||
let Some(key) = self.cursor.to_key(thread) else {
|
||||
let msg = "No message currently selected";
|
||||
let err = EditError::Failure(msg.into());
|
||||
|
||||
return Err(err);
|
||||
};
|
||||
|
||||
match act {
|
||||
PromptAction::Submit => {
|
||||
// XXX: I'm not sure exactly what to do here yet. I think I want this to display a
|
||||
// pop-over ListState with actions that can then be submitted:
|
||||
//
|
||||
// - Create a reply
|
||||
// - Edit a message
|
||||
// - Redact a message
|
||||
// - React to a message
|
||||
// - Report a message
|
||||
// - Download an attachment
|
||||
//
|
||||
// Each of these should correspond to a command that a user can run. For example,
|
||||
// running `:reply` when hovering over a message should be equivalent to opening
|
||||
// the pop-up and selecting "Reply To This Message".
|
||||
return Ok(vec![]);
|
||||
if self.thread.is_some() {
|
||||
let msg =
|
||||
"You are already in a thread. Use :reply to reply to a specific message.";
|
||||
let err = EditError::Failure(msg.into());
|
||||
Err(err)
|
||||
} else {
|
||||
let root = key.1.clone();
|
||||
let room_id = self.room_id.clone();
|
||||
let id = IambId::Room(room_id, Some(root));
|
||||
let open = WindowAction::Switch(OpenTarget::Application(id));
|
||||
Ok(vec![(open.into(), ctx.clone())])
|
||||
}
|
||||
},
|
||||
PromptAction::Abort(..) => {
|
||||
let msg = "Cannot abort a message.";
|
||||
let err = EditError::Failure(msg.into());
|
||||
|
||||
return Err(err);
|
||||
Err(err)
|
||||
},
|
||||
PromptAction::Recall(..) => {
|
||||
let msg = "Cannot recall previous messages.";
|
||||
let err = EditError::Failure(msg.into());
|
||||
|
||||
return Err(err);
|
||||
},
|
||||
_ => {
|
||||
let msg = format!("Messages scrollback doesn't support {act:?}");
|
||||
let err = EditError::Unimplemented(msg);
|
||||
|
||||
return Err(err);
|
||||
Err(err)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1033,8 +1060,9 @@ impl ScrollActions<ProgramContext, ProgramStore, IambInfo> for ScrollbackState {
|
||||
let info = store.application.rooms.get_or_default(self.room_id.clone());
|
||||
let settings = &store.application.settings;
|
||||
let mut corner = self.viewctx.corner.clone();
|
||||
let thread = self.get_thread(info).ok_or_else(no_msgs)?;
|
||||
|
||||
let last_key = if let Some(k) = info.messages.last_key_value() {
|
||||
let last_key = if let Some(k) = thread.last_key_value() {
|
||||
k.0
|
||||
} else {
|
||||
return Ok(None);
|
||||
@@ -1053,11 +1081,11 @@ impl ScrollActions<ProgramContext, ProgramStore, IambInfo> for ScrollbackState {
|
||||
|
||||
match dir {
|
||||
MoveDir2D::Up => {
|
||||
let first_key = info.messages.first_key_value().map(|f| f.0.clone());
|
||||
let first_key = thread.first_key_value().map(|f| f.0.clone());
|
||||
|
||||
for (key, item) in info.messages.range(..=&corner_key).rev() {
|
||||
for (key, item) in thread.range(..=&corner_key).rev() {
|
||||
let sel = key == cursor_key;
|
||||
let prev = prevmsg(key, info);
|
||||
let prev = prevmsg(key, thread);
|
||||
let txt = item.show(prev, sel, &self.viewctx, info, settings);
|
||||
let len = txt.height().max(1);
|
||||
let max = len.saturating_sub(1);
|
||||
@@ -1082,9 +1110,9 @@ impl ScrollActions<ProgramContext, ProgramStore, IambInfo> for ScrollbackState {
|
||||
}
|
||||
},
|
||||
MoveDir2D::Down => {
|
||||
let mut prev = prevmsg(&corner_key, info);
|
||||
let mut prev = prevmsg(&corner_key, thread);
|
||||
|
||||
for (key, item) in info.messages.range(&corner_key..) {
|
||||
for (key, item) in thread.range(&corner_key..) {
|
||||
let sel = key == cursor_key;
|
||||
let txt = item.show(prev, sel, &self.viewctx, info, settings);
|
||||
let len = txt.height().max(1);
|
||||
@@ -1146,8 +1174,9 @@ impl ScrollActions<ProgramContext, ProgramStore, IambInfo> for ScrollbackState {
|
||||
Axis::Vertical => {
|
||||
let info = store.application.rooms.get_or_default(self.room_id.clone());
|
||||
let settings = &store.application.settings;
|
||||
let thread = self.get_thread(info).ok_or_else(no_msgs)?;
|
||||
|
||||
if let Some(key) = self.cursor.to_key(info).cloned() {
|
||||
if let Some(key) = self.cursor.to_key(thread).cloned() {
|
||||
self.scrollview(key, pos, info, settings);
|
||||
}
|
||||
|
||||
@@ -1225,7 +1254,7 @@ fn render_jump_to_recent(area: Rect, buf: &mut Buffer, focused: bool) -> Rect {
|
||||
Span::raw(" to jump to latest message"),
|
||||
];
|
||||
|
||||
Paragraph::new(Spans::from(msg))
|
||||
Paragraph::new(Line::from(msg))
|
||||
.alignment(Alignment::Center)
|
||||
.render(bar, buf);
|
||||
|
||||
@@ -1277,15 +1306,24 @@ impl<'a> StatefulWidget for Scrollback<'a> {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(thread) = state.get_thread(info) else {
|
||||
return;
|
||||
};
|
||||
|
||||
if state.cursor.timestamp < state.viewctx.corner.timestamp {
|
||||
state.viewctx.corner = state.cursor.clone();
|
||||
}
|
||||
|
||||
let cursor = &state.cursor;
|
||||
let cursor_key = if let Some(k) = cursor.to_key(info) {
|
||||
let cursor_key = if let Some(k) = cursor.to_key(thread) {
|
||||
k
|
||||
} else {
|
||||
self.store.application.mark_for_load(state.room_id.clone());
|
||||
if state.need_more_messages(info) {
|
||||
self.store
|
||||
.application
|
||||
.need_load
|
||||
.insert(state.room_id.to_owned(), Need::MESSAGES);
|
||||
}
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -1293,20 +1331,19 @@ impl<'a> StatefulWidget for Scrollback<'a> {
|
||||
let corner_key = if let Some(k) = &corner.timestamp {
|
||||
k.clone()
|
||||
} else {
|
||||
nth_key_before(cursor_key.clone(), height, info)
|
||||
nth_key_before(cursor_key.clone(), height, thread)
|
||||
};
|
||||
|
||||
let foc = self.focused || cursor.timestamp.is_some();
|
||||
let full = std::mem::take(&mut state.show_full_on_redraw) || cursor.timestamp.is_none();
|
||||
let mut lines = vec![];
|
||||
let mut sawit = false;
|
||||
let mut prev = prevmsg(&corner_key, info);
|
||||
let mut prev = prevmsg(&corner_key, thread);
|
||||
|
||||
for (key, item) in info.messages.range(&corner_key..) {
|
||||
for (key, item) in thread.range(&corner_key..) {
|
||||
let sel = key == cursor_key;
|
||||
let txt = item.show(prev, foc && sel, &state.viewctx, info, settings);
|
||||
|
||||
prev = Some(item);
|
||||
let (txt, mut msg_preview) =
|
||||
item.show_with_preview(prev, foc && sel, &state.viewctx, info, settings);
|
||||
|
||||
let incomplete_ok = !full || !sel;
|
||||
|
||||
@@ -1322,9 +1359,17 @@ impl<'a> StatefulWidget for Scrollback<'a> {
|
||||
continue;
|
||||
}
|
||||
|
||||
lines.push((key, row, line));
|
||||
let line_preview = match msg_preview {
|
||||
// Only take the preview into the matching row number.
|
||||
Some((_, _, y)) if y as usize == row => msg_preview.take(),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
lines.push((key, row, line, line_preview));
|
||||
sawit |= sel;
|
||||
}
|
||||
|
||||
prev = Some(item);
|
||||
}
|
||||
|
||||
if lines.len() > height {
|
||||
@@ -1332,7 +1377,7 @@ impl<'a> StatefulWidget for Scrollback<'a> {
|
||||
let _ = lines.drain(..n);
|
||||
}
|
||||
|
||||
if let Some(((ts, event_id), row, _)) = lines.first() {
|
||||
if let Some(((ts, event_id), row, _, _)) = lines.first() {
|
||||
state.viewctx.corner.timestamp = Some((*ts, event_id.clone()));
|
||||
state.viewctx.corner.text_row = *row;
|
||||
}
|
||||
@@ -1340,26 +1385,48 @@ impl<'a> StatefulWidget for Scrollback<'a> {
|
||||
let mut y = area.top();
|
||||
let x = area.left();
|
||||
|
||||
for (_, _, txt) in lines.into_iter() {
|
||||
let _ = buf.set_spans(x, y, &txt, area.width);
|
||||
let mut image_previews = vec![];
|
||||
for ((_, _), _, txt, line_preview) in lines.into_iter() {
|
||||
let _ = buf.set_line(x, y, &txt, area.width);
|
||||
if let Some((backend, msg_x, _)) = line_preview {
|
||||
image_previews.push((x + msg_x, y, backend));
|
||||
}
|
||||
|
||||
y += 1;
|
||||
}
|
||||
// Render image previews after all text lines have been drawn, as the render might draw below the current
|
||||
// line.
|
||||
for (x, y, backend) in image_previews {
|
||||
let image_widget = Image::new(backend);
|
||||
let mut rect = backend.rect();
|
||||
rect.x = x;
|
||||
rect.y = y;
|
||||
// Don't render outside of scrollback area
|
||||
if rect.bottom() <= area.bottom() && rect.right() <= area.right() {
|
||||
image_widget.render(rect, buf);
|
||||
}
|
||||
}
|
||||
|
||||
if self.room_focused &&
|
||||
settings.tunables.read_receipt_send &&
|
||||
state.cursor.timestamp.is_none()
|
||||
{
|
||||
// If the cursor is at the last message, then update the read marker.
|
||||
info.read_till = info.messages.last_key_value().map(|(k, _)| k.1.clone());
|
||||
if let Some((k, _)) = thread.last_key_value() {
|
||||
info.set_receipt(settings.profile.user_id.clone(), k.1.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Check whether we should load older messages for this room.
|
||||
let first_key = info.messages.first_key_value().map(|(k, _)| k.clone());
|
||||
if first_key == state.viewctx.corner.timestamp {
|
||||
if state.need_more_messages(info) {
|
||||
// If the top of the screen is the older message, load more.
|
||||
self.store.application.mark_for_load(state.room_id.clone());
|
||||
self.store
|
||||
.application
|
||||
.need_load
|
||||
.insert(state.room_id.to_owned(), Need::MESSAGES);
|
||||
}
|
||||
|
||||
info.draw_last = self.store.application.draw_curr;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1372,7 +1439,7 @@ mod tests {
|
||||
async fn test_search_messages() {
|
||||
let room_id = TEST_ROOM1_ID.clone();
|
||||
let mut store = mock_store().await;
|
||||
let mut scrollback = ScrollbackState::new(room_id.clone());
|
||||
let mut scrollback = ScrollbackState::new(room_id.clone(), None);
|
||||
let ctx = ProgramContext::default();
|
||||
|
||||
let next = MoveDirMod::Exact(MoveDir1D::Next);
|
||||
@@ -1396,12 +1463,23 @@ mod tests {
|
||||
// Search backwards to MSG2.
|
||||
scrollback.search(prev.clone(), 1.into(), &ctx, &mut store).unwrap();
|
||||
assert_eq!(scrollback.cursor, MSG2_KEY.clone().into());
|
||||
assert_eq!(store.application.need_load.contains(&room_id), false);
|
||||
assert_eq!(
|
||||
std::mem::take(&mut store.application.need_load)
|
||||
.into_iter()
|
||||
.collect::<Vec<(OwnedRoomId, Need)>>()
|
||||
.is_empty(),
|
||||
true,
|
||||
);
|
||||
|
||||
// Can't go any further; need_load now contains the room ID.
|
||||
scrollback.search(prev.clone(), 1.into(), &ctx, &mut store).unwrap();
|
||||
assert_eq!(scrollback.cursor, MSG2_KEY.clone().into());
|
||||
assert_eq!(store.application.need_load.contains(&room_id), true);
|
||||
assert_eq!(
|
||||
std::mem::take(&mut store.application.need_load)
|
||||
.into_iter()
|
||||
.collect::<Vec<(OwnedRoomId, Need)>>(),
|
||||
vec![(room_id.clone(), Need::MESSAGES)]
|
||||
);
|
||||
|
||||
// Search forward twice to MSG1.
|
||||
scrollback.search(next.clone(), 2.into(), &ctx, &mut store).unwrap();
|
||||
@@ -1415,7 +1493,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn test_movement() {
|
||||
let mut store = mock_store().await;
|
||||
let mut scrollback = ScrollbackState::new(TEST_ROOM1_ID.clone());
|
||||
let mut scrollback = ScrollbackState::new(TEST_ROOM1_ID.clone(), None);
|
||||
let ctx = ProgramContext::default();
|
||||
|
||||
let prev = |n: usize| EditTarget::Motion(MoveType::Line(MoveDir1D::Previous), n.into());
|
||||
@@ -1449,7 +1527,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn test_dirscroll() {
|
||||
let mut store = mock_store().await;
|
||||
let mut scrollback = ScrollbackState::new(TEST_ROOM1_ID.clone());
|
||||
let mut scrollback = ScrollbackState::new(TEST_ROOM1_ID.clone(), None);
|
||||
let ctx = ProgramContext::default();
|
||||
|
||||
let prev = MoveDir2D::Up;
|
||||
@@ -1600,7 +1678,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn test_cursorpos() {
|
||||
let mut store = mock_store().await;
|
||||
let mut scrollback = ScrollbackState::new(TEST_ROOM1_ID.clone());
|
||||
let mut scrollback = ScrollbackState::new(TEST_ROOM1_ID.clone(), None);
|
||||
let ctx = ProgramContext::default();
|
||||
|
||||
// Skip rendering typing notices.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
//! Window for Matrix spaces
|
||||
use std::ops::{Deref, DerefMut};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
@@ -6,25 +7,28 @@ use matrix_sdk::{
|
||||
ruma::{OwnedRoomId, RoomId},
|
||||
};
|
||||
|
||||
use modalkit::tui::{
|
||||
use ratatui::{
|
||||
buffer::Buffer,
|
||||
layout::Rect,
|
||||
style::{Color, Style},
|
||||
text::{Span, Spans, Text},
|
||||
text::{Line, Span, Text},
|
||||
widgets::StatefulWidget,
|
||||
};
|
||||
|
||||
use modalkit::{
|
||||
widgets::list::{List, ListState},
|
||||
widgets::{TermOffset, TerminalCursor, WindowOps},
|
||||
use modalkit_ratatui::{
|
||||
list::{List, ListState},
|
||||
TermOffset,
|
||||
TerminalCursor,
|
||||
WindowOps,
|
||||
};
|
||||
|
||||
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 {
|
||||
room_id: OwnedRoomId,
|
||||
room: MatrixRoom,
|
||||
@@ -35,7 +39,7 @@ pub struct SpaceState {
|
||||
impl SpaceState {
|
||||
pub fn new(room: MatrixRoom) -> Self {
|
||||
let room_id = room.room_id().to_owned();
|
||||
let content = IambBufferId::Room(room_id.clone(), RoomFocus::Scrollback);
|
||||
let content = IambBufferId::Room(room_id.clone(), None, RoomFocus::Scrollback);
|
||||
let list = ListState::new(content, vec![]);
|
||||
let last_fetch = None;
|
||||
|
||||
@@ -86,6 +90,7 @@ impl DerefMut for SpaceState {
|
||||
}
|
||||
}
|
||||
|
||||
/// [StatefulWidget] for Matrix spaces.
|
||||
pub struct Space<'a> {
|
||||
focused: bool,
|
||||
store: &'a mut ProgramStore,
|
||||
@@ -117,7 +122,7 @@ impl<'a> StatefulWidget for Space<'a> {
|
||||
|
||||
match res {
|
||||
Ok(members) => {
|
||||
let items = members
|
||||
let mut items = members
|
||||
.into_iter()
|
||||
.filter_map(|id| {
|
||||
let (room, _, tags) =
|
||||
@@ -130,14 +135,16 @@ impl<'a> StatefulWidget for Space<'a> {
|
||||
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.last_fetch = Some(Instant::now());
|
||||
},
|
||||
Err(e) => {
|
||||
let lines = vec![
|
||||
Spans::from("Unable to fetch space room hierarchy:"),
|
||||
Line::from("Unable to fetch space room hierarchy:"),
|
||||
Span::styled(e.to_string(), Style::default().fg(Color::Red)).into(),
|
||||
];
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
|
||||
- `:dms` will open a list of direct messages
|
||||
- `:rooms` will open a list of joined rooms
|
||||
- `:chats` will open a list containing both direct messages and rooms
|
||||
- `:members` will open a list of members for the currently focused room or space
|
||||
- `:spaces` will open a list of joined spaces
|
||||
- `:join` can be used to switch to join a new room or start a direct message
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
//! Welcome Window
|
||||
use std::ops::{Deref, DerefMut};
|
||||
|
||||
use modalkit::tui::{buffer::Buffer, layout::Rect};
|
||||
use ratatui::{buffer::Buffer, layout::Rect};
|
||||
|
||||
use modalkit::{
|
||||
widgets::textbox::TextBoxState,
|
||||
widgets::WindowOps,
|
||||
widgets::{TermOffset, TerminalCursor},
|
||||
};
|
||||
use modalkit_ratatui::{textbox::TextBoxState, TermOffset, TerminalCursor, WindowOps};
|
||||
|
||||
use modalkit::editing::action::EditInfo;
|
||||
use modalkit::editing::base::{CloseFlags, WordStyle, WriteFlags};
|
||||
use modalkit::editing::completion::CompletionList;
|
||||
use modalkit::prelude::*;
|
||||
|
||||
use crate::base::{IambBufferId, IambInfo, IambResult, ProgramStore};
|
||||
|
||||
|
||||
657
src/worker.rs
657
src/worker.rs
@@ -1,8 +1,11 @@
|
||||
//! # Async Matrix Client Worker
|
||||
//!
|
||||
//! The worker thread handles asynchronous work, and can receive messages from the main thread that
|
||||
//! block on a reply from the async worker.
|
||||
use std::collections::HashMap;
|
||||
use std::convert::TryFrom;
|
||||
use std::fmt::{Debug, Formatter};
|
||||
use std::fs::File;
|
||||
use std::io::BufWriter;
|
||||
use std::ops::{Deref, DerefMut};
|
||||
use std::str::FromStr;
|
||||
use std::sync::mpsc::{sync_channel, Receiver, SyncSender};
|
||||
use std::sync::Arc;
|
||||
@@ -11,17 +14,22 @@ use std::time::{Duration, Instant};
|
||||
use futures::{stream::FuturesUnordered, StreamExt};
|
||||
use gethostname::gethostname;
|
||||
use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender};
|
||||
use tokio::sync::Semaphore;
|
||||
use tokio::task::JoinHandle;
|
||||
use tracing::{error, warn};
|
||||
use url::Url;
|
||||
|
||||
use matrix_sdk::{
|
||||
config::{RequestConfig, SyncSettings},
|
||||
encryption::verification::{SasVerification, Verification},
|
||||
encryption::{BackupDownloadStrategy, EncryptionSettings},
|
||||
event_handler::Ctx,
|
||||
matrix_auth::MatrixSession,
|
||||
reqwest,
|
||||
room::{Invited, Messages, MessagesOptions, Room as MatrixRoom, RoomMember},
|
||||
room::{Messages, MessagesOptions, Room as MatrixRoom, RoomMember},
|
||||
ruma::{
|
||||
api::client::{
|
||||
filter::{FilterDefinition, LazyLoadOptions, RoomEventFilter, RoomFilter},
|
||||
room::create_room::v3::{CreationContent, Request as CreateRoomRequest, RoomPreset},
|
||||
room::Visibility,
|
||||
space::get_hierarchy::v1::Request as SpaceHierarchyRequest,
|
||||
@@ -37,12 +45,14 @@ use matrix_sdk::{
|
||||
},
|
||||
presence::PresenceEvent,
|
||||
reaction::ReactionEventContent,
|
||||
receipt::ReceiptType,
|
||||
receipt::{ReceiptEventContent, ReceiptThread},
|
||||
room::{
|
||||
encryption::RoomEncryptionEventContent,
|
||||
member::OriginalSyncRoomMemberEvent,
|
||||
message::{MessageType, RoomMessageEventContent},
|
||||
name::RoomNameEventContent,
|
||||
redaction::{OriginalSyncRoomRedactionEvent, SyncRoomRedactionEvent},
|
||||
redaction::OriginalSyncRoomRedactionEvent,
|
||||
},
|
||||
tag::Tags,
|
||||
typing::SyncTypingEvent,
|
||||
@@ -51,12 +61,14 @@ use matrix_sdk::{
|
||||
AnyTimelineEvent,
|
||||
EmptyStateKey,
|
||||
InitialStateEvent,
|
||||
SyncEphemeralRoomEvent,
|
||||
SyncMessageLikeEvent,
|
||||
SyncStateEvent,
|
||||
},
|
||||
room::RoomType,
|
||||
serde::Raw,
|
||||
EventEncryptionAlgorithm,
|
||||
EventId,
|
||||
OwnedEventId,
|
||||
OwnedRoomId,
|
||||
OwnedRoomOrAliasId,
|
||||
@@ -65,40 +77,57 @@ use matrix_sdk::{
|
||||
RoomVersionId,
|
||||
},
|
||||
Client,
|
||||
ClientBuildError,
|
||||
DisplayName,
|
||||
Session,
|
||||
Error as MatrixError,
|
||||
RoomMemberships,
|
||||
};
|
||||
|
||||
use modalkit::editing::action::{EditInfo, InfoMessage, UIError};
|
||||
use modalkit::errors::UIError;
|
||||
use modalkit::prelude::{EditInfo, InfoMessage};
|
||||
|
||||
use crate::base::Need;
|
||||
use crate::notifications::register_notifications;
|
||||
use crate::{
|
||||
base::{
|
||||
AsyncProgramStore,
|
||||
ChatStore,
|
||||
CreateRoomFlags,
|
||||
CreateRoomType,
|
||||
EventLocation,
|
||||
IambError,
|
||||
IambResult,
|
||||
Receipts,
|
||||
ProgramStore,
|
||||
RoomFetchStatus,
|
||||
RoomInfo,
|
||||
VerifyAction,
|
||||
},
|
||||
message::MessageFetchResult,
|
||||
ApplicationSettings,
|
||||
};
|
||||
|
||||
const DEFAULT_ENCRYPTION_SETTINGS: EncryptionSettings = EncryptionSettings {
|
||||
auto_enable_cross_signing: true,
|
||||
auto_enable_backups: true,
|
||||
backup_download_strategy: BackupDownloadStrategy::AfterDecryptionFailure,
|
||||
};
|
||||
|
||||
const IAMB_DEVICE_NAME: &str = "iamb";
|
||||
const IAMB_USER_AGENT: &str = "iamb";
|
||||
const MIN_MSG_LOAD: u32 = 50;
|
||||
|
||||
type MessageFetchResult =
|
||||
IambResult<(Option<String>, Vec<(AnyMessageLikeEvent, Vec<OwnedUserId>)>)>;
|
||||
|
||||
fn initial_devname() -> String {
|
||||
format!("{} on {}", IAMB_DEVICE_NAME, gethostname().to_string_lossy())
|
||||
}
|
||||
|
||||
async fn is_direct(room: &MatrixRoom) -> bool {
|
||||
room.deref().is_direct().await.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub async fn create_room(
|
||||
client: &Client,
|
||||
room_alias_name: Option<&str>,
|
||||
room_alias_name: Option<String>,
|
||||
rt: CreateRoomType,
|
||||
flags: CreateRoomFlags,
|
||||
) -> IambResult<OwnedRoomId> {
|
||||
@@ -144,8 +173,8 @@ pub async fn create_room(
|
||||
let request = assign!(CreateRoomRequest::new(), {
|
||||
room_alias_name,
|
||||
creation_content,
|
||||
initial_state: initial_state.as_slice(),
|
||||
invite: invite.as_slice(),
|
||||
initial_state,
|
||||
invite,
|
||||
is_direct,
|
||||
visibility,
|
||||
preset,
|
||||
@@ -154,49 +183,100 @@ pub async fn create_room(
|
||||
let resp = client.create_room(request).await.map_err(IambError::from)?;
|
||||
|
||||
if is_direct {
|
||||
if let Some(room) = client.get_room(&resp.room_id) {
|
||||
if let Some(room) = client.get_room(resp.room_id()) {
|
||||
room.set_is_direct(true).await.map_err(IambError::from)?;
|
||||
} else {
|
||||
error!(
|
||||
room_id = resp.room_id.as_str(),
|
||||
room_id = resp.room_id().as_str(),
|
||||
"Couldn't set is_direct for new direct message room"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(resp.room_id);
|
||||
return Ok(resp.room_id().to_owned());
|
||||
}
|
||||
|
||||
async fn load_plan(store: &AsyncProgramStore) -> HashMap<OwnedRoomId, Option<String>> {
|
||||
async fn update_event_receipts(info: &mut RoomInfo, room: &MatrixRoom, event_id: &EventId) {
|
||||
let receipts = match room
|
||||
.load_event_receipts(ReceiptType::Read, ReceiptThread::Main, event_id)
|
||||
.await
|
||||
{
|
||||
Ok(receipts) => receipts,
|
||||
Err(e) => {
|
||||
tracing::warn!(?event_id, "failed to get event receipts: {e}");
|
||||
return;
|
||||
},
|
||||
};
|
||||
|
||||
for (user_id, _) in receipts {
|
||||
info.set_receipt(user_id, event_id.to_owned());
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum Plan {
|
||||
Messages(OwnedRoomId, Option<String>),
|
||||
Members(OwnedRoomId),
|
||||
}
|
||||
|
||||
async fn load_plans(store: &AsyncProgramStore) -> Vec<Plan> {
|
||||
let mut locked = store.lock().await;
|
||||
let ChatStore { need_load, rooms, .. } = &mut locked.application;
|
||||
let mut plan = HashMap::new();
|
||||
let mut plan = Vec::with_capacity(need_load.rooms() * 2);
|
||||
|
||||
for room_id in std::mem::take(need_load).into_iter() {
|
||||
let info = rooms.get_or_default(room_id.clone());
|
||||
for (room_id, mut need) in std::mem::take(need_load).into_iter() {
|
||||
if need.contains(Need::MESSAGES) {
|
||||
let info = rooms.get_or_default(room_id.clone());
|
||||
|
||||
if info.recently_fetched() || info.fetching {
|
||||
need_load.insert(room_id);
|
||||
continue;
|
||||
} else {
|
||||
info.fetch_last = Instant::now().into();
|
||||
info.fetching = true;
|
||||
if !info.recently_fetched() && !info.fetching {
|
||||
info.fetch_last = Instant::now().into();
|
||||
info.fetching = true;
|
||||
|
||||
let fetch_id = match &info.fetch_id {
|
||||
RoomFetchStatus::Done => continue,
|
||||
RoomFetchStatus::HaveMore(fetch_id) => Some(fetch_id.clone()),
|
||||
RoomFetchStatus::NotStarted => None,
|
||||
};
|
||||
|
||||
plan.push(Plan::Messages(room_id.to_owned(), fetch_id));
|
||||
need.remove(Need::MESSAGES);
|
||||
}
|
||||
}
|
||||
if need.contains(Need::MEMBERS) {
|
||||
plan.push(Plan::Members(room_id.to_owned()));
|
||||
need.remove(Need::MEMBERS);
|
||||
}
|
||||
if !need.is_empty() {
|
||||
need_load.insert(room_id, need);
|
||||
}
|
||||
|
||||
let fetch_id = match &info.fetch_id {
|
||||
RoomFetchStatus::Done => continue,
|
||||
RoomFetchStatus::HaveMore(fetch_id) => Some(fetch_id.clone()),
|
||||
RoomFetchStatus::NotStarted => None,
|
||||
};
|
||||
|
||||
plan.insert(room_id, fetch_id);
|
||||
}
|
||||
|
||||
return plan;
|
||||
}
|
||||
|
||||
async fn run_plan(client: &Client, store: &AsyncProgramStore, plan: Plan, permits: &Semaphore) {
|
||||
let permit = permits.acquire().await;
|
||||
match plan {
|
||||
Plan::Messages(room_id, fetch_id) => {
|
||||
let limit = MIN_MSG_LOAD;
|
||||
let client = client.clone();
|
||||
let store_clone = store.clone();
|
||||
|
||||
let res = load_older_one(&client, &room_id, fetch_id, limit).await;
|
||||
let mut locked = store.lock().await;
|
||||
load_insert(room_id, res, locked.deref_mut(), store_clone);
|
||||
},
|
||||
Plan::Members(room_id) => {
|
||||
let res = members_load(client, &room_id).await;
|
||||
let mut locked = store.lock().await;
|
||||
members_insert(room_id, res, locked.deref_mut());
|
||||
},
|
||||
}
|
||||
drop(permit);
|
||||
}
|
||||
|
||||
async fn load_older_one(
|
||||
client: Client,
|
||||
client: &Client,
|
||||
room_id: &RoomId,
|
||||
fetch_id: Option<String>,
|
||||
limit: u32,
|
||||
@@ -210,38 +290,70 @@ async fn load_older_one(
|
||||
|
||||
let Messages { end, chunk, .. } = room.messages(opts).await.map_err(IambError::from)?;
|
||||
|
||||
let msgs = chunk.into_iter().filter_map(|ev| {
|
||||
match ev.event.deserialize() {
|
||||
Ok(AnyTimelineEvent::MessageLike(msg)) => Some(msg),
|
||||
Ok(AnyTimelineEvent::State(_)) => None,
|
||||
Err(_) => None,
|
||||
}
|
||||
});
|
||||
let mut msgs = vec![];
|
||||
|
||||
Ok((end, msgs.collect()))
|
||||
for ev in chunk.into_iter() {
|
||||
let msg = match ev.event.deserialize() {
|
||||
Ok(AnyTimelineEvent::MessageLike(msg)) => msg,
|
||||
Ok(AnyTimelineEvent::State(_)) => continue,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
let event_id = msg.event_id();
|
||||
let receipts = match room
|
||||
.load_event_receipts(ReceiptType::Read, ReceiptThread::Main, event_id)
|
||||
.await
|
||||
{
|
||||
Ok(receipts) => receipts.into_iter().map(|(u, _)| u).collect(),
|
||||
Err(e) => {
|
||||
tracing::warn!(?event_id, "failed to get event receipts: {e}");
|
||||
vec![]
|
||||
},
|
||||
};
|
||||
|
||||
msgs.push((msg, receipts));
|
||||
}
|
||||
|
||||
Ok((end, msgs))
|
||||
} else {
|
||||
Err(IambError::UnknownRoom(room_id.to_owned()).into())
|
||||
}
|
||||
}
|
||||
|
||||
async fn load_insert(room_id: OwnedRoomId, res: MessageFetchResult, store: AsyncProgramStore) {
|
||||
let mut locked = store.lock().await;
|
||||
let ChatStore { need_load, presences, rooms, .. } = &mut locked.application;
|
||||
fn load_insert(
|
||||
room_id: OwnedRoomId,
|
||||
res: MessageFetchResult,
|
||||
locked: &mut ProgramStore,
|
||||
store: AsyncProgramStore,
|
||||
) {
|
||||
let ChatStore { presences, rooms, worker, picker, settings, .. } = &mut locked.application;
|
||||
let info = rooms.get_or_default(room_id.clone());
|
||||
info.fetching = false;
|
||||
let client = &worker.client;
|
||||
|
||||
match res {
|
||||
Ok((fetch_id, msgs)) => {
|
||||
for msg in msgs.into_iter() {
|
||||
for (msg, receipts) in msgs.into_iter() {
|
||||
let sender = msg.sender().to_owned();
|
||||
let _ = presences.get_or_default(sender);
|
||||
|
||||
for user_id in receipts {
|
||||
info.set_receipt(user_id, msg.event_id().to_owned());
|
||||
}
|
||||
|
||||
match msg {
|
||||
AnyMessageLikeEvent::RoomEncrypted(msg) => {
|
||||
info.insert_encrypted(msg);
|
||||
},
|
||||
AnyMessageLikeEvent::RoomMessage(msg) => {
|
||||
info.insert(msg);
|
||||
info.insert_with_preview(
|
||||
room_id.clone(),
|
||||
store.clone(),
|
||||
*picker,
|
||||
msg,
|
||||
settings,
|
||||
client.media(),
|
||||
);
|
||||
},
|
||||
AnyMessageLikeEvent::Reaction(ev) => {
|
||||
info.insert_reaction(ev);
|
||||
@@ -256,34 +368,62 @@ async fn load_insert(room_id: OwnedRoomId, res: MessageFetchResult, store: Async
|
||||
warn!(room_id = room_id.as_str(), err = e.to_string(), "Failed to load older messages");
|
||||
|
||||
// Wait and try again.
|
||||
need_load.insert(room_id);
|
||||
locked.application.need_load.insert(room_id, Need::MESSAGES);
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async fn load_older(client: &Client, store: &AsyncProgramStore) -> usize {
|
||||
let limit = MIN_MSG_LOAD;
|
||||
// This is an arbitrary limit on how much work we do in parallel to avoid
|
||||
// spawning too many tasks at startup and overwhelming the client. We
|
||||
// should normally only surpass this limit at startup when doing an initial.
|
||||
// fetch for each room.
|
||||
const LIMIT: usize = 15;
|
||||
|
||||
// Fetch each room separately, so they don't block each other.
|
||||
load_plan(store)
|
||||
.await
|
||||
// Plans are run in parallel. Any room *may* have several plans.
|
||||
let plans = load_plans(store).await;
|
||||
let permits = Semaphore::new(LIMIT);
|
||||
|
||||
plans
|
||||
.into_iter()
|
||||
.map(|(room_id, fetch_id)| {
|
||||
let client = client.clone();
|
||||
let store = store.clone();
|
||||
|
||||
async move {
|
||||
let res = load_older_one(client, room_id.as_ref(), fetch_id, limit).await;
|
||||
load_insert(room_id, res, store).await;
|
||||
}
|
||||
})
|
||||
.map(|plan| run_plan(client, store, plan, &permits))
|
||||
.collect::<FuturesUnordered<_>>()
|
||||
.count()
|
||||
.await
|
||||
}
|
||||
|
||||
async fn members_load(client: &Client, room_id: &RoomId) -> IambResult<Vec<RoomMember>> {
|
||||
if let Some(room) = client.get_room(room_id) {
|
||||
Ok(room
|
||||
.members_no_sync(RoomMemberships::all())
|
||||
.await
|
||||
.map_err(IambError::from)?)
|
||||
} else {
|
||||
Err(IambError::UnknownRoom(room_id.to_owned()).into())
|
||||
}
|
||||
}
|
||||
|
||||
fn members_insert(
|
||||
room_id: OwnedRoomId,
|
||||
res: IambResult<Vec<RoomMember>>,
|
||||
store: &mut ProgramStore,
|
||||
) {
|
||||
if let Ok(members) = res {
|
||||
let ChatStore { rooms, .. } = &mut store.application;
|
||||
let info = rooms.get_or_default(room_id);
|
||||
|
||||
for member in members {
|
||||
let user_id = member.user_id();
|
||||
let display_name =
|
||||
member.display_name().map_or(user_id.to_string(), |str| str.to_string());
|
||||
info.display_names.insert(user_id.to_owned(), display_name);
|
||||
}
|
||||
}
|
||||
// else ???
|
||||
}
|
||||
|
||||
async fn load_older_forever(client: &Client, store: &AsyncProgramStore) {
|
||||
// Load older messages every 2 seconds.
|
||||
// Load any pending older messages or members every 2 seconds.
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(2));
|
||||
|
||||
loop {
|
||||
@@ -301,35 +441,31 @@ async fn refresh_rooms(client: &Client, store: &AsyncProgramStore) {
|
||||
|
||||
for room in client.invited_rooms().into_iter() {
|
||||
let name = room.display_name().await.unwrap_or(DisplayName::Empty).to_string();
|
||||
let tags = room.tags().await.unwrap_or_default();
|
||||
|
||||
names.push((room.room_id().to_owned(), name));
|
||||
|
||||
if room.is_direct() {
|
||||
let tags = room.tags().await.unwrap_or_default();
|
||||
|
||||
dms.push(Arc::new((room.into(), tags)));
|
||||
if is_direct(&room).await {
|
||||
dms.push(Arc::new((room, tags)));
|
||||
} else if room.is_space() {
|
||||
spaces.push(room.into());
|
||||
spaces.push(Arc::new((room, tags)));
|
||||
} else {
|
||||
let tags = room.tags().await.unwrap_or_default();
|
||||
|
||||
rooms.push(Arc::new((room.into(), tags)));
|
||||
rooms.push(Arc::new((room, tags)));
|
||||
}
|
||||
}
|
||||
|
||||
for room in client.joined_rooms().into_iter() {
|
||||
let name = room.display_name().await.unwrap_or(DisplayName::Empty).to_string();
|
||||
let tags = room.tags().await.unwrap_or_default();
|
||||
|
||||
names.push((room.room_id().to_owned(), name));
|
||||
|
||||
if room.is_direct() {
|
||||
let tags = room.tags().await.unwrap_or_default();
|
||||
|
||||
dms.push(Arc::new((room.into(), tags)));
|
||||
if is_direct(&room).await {
|
||||
dms.push(Arc::new((room, tags)));
|
||||
} else if room.is_space() {
|
||||
spaces.push(room.into());
|
||||
spaces.push(Arc::new((room, tags)));
|
||||
} else {
|
||||
let tags = room.tags().await.unwrap_or_default();
|
||||
|
||||
rooms.push(Arc::new((room.into(), tags)));
|
||||
rooms.push(Arc::new((room, tags)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -347,43 +483,101 @@ async fn refresh_rooms_forever(client: &Client, store: &AsyncProgramStore) {
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(5));
|
||||
|
||||
loop {
|
||||
interval.tick().await;
|
||||
|
||||
refresh_rooms(client, store).await;
|
||||
interval.tick().await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn refresh_receipts_forever(client: &Client, store: &AsyncProgramStore) {
|
||||
// Update the displayed read receipts every 5 seconds.
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(5));
|
||||
async fn send_receipts_forever(client: &Client, store: &AsyncProgramStore) {
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(2));
|
||||
let mut sent = HashMap::<OwnedRoomId, OwnedEventId>::default();
|
||||
|
||||
loop {
|
||||
interval.tick().await;
|
||||
let receipts = update_receipts(client).await;
|
||||
let read = store.lock().await.application.set_receipts(receipts).await;
|
||||
|
||||
for (room_id, read_till) in read.into_iter() {
|
||||
if let Some(read_sent) = sent.get(&room_id) {
|
||||
if read_sent == &read_till {
|
||||
// Skip unchanged receipts.
|
||||
continue;
|
||||
let locked = store.lock().await;
|
||||
let user_id = &locked.application.settings.profile.user_id;
|
||||
let updates = client
|
||||
.joined_rooms()
|
||||
.into_iter()
|
||||
.filter_map(|room| {
|
||||
let room_id = room.room_id().to_owned();
|
||||
let info = locked.application.rooms.get(&room_id)?;
|
||||
let new_receipt = info.get_receipt(user_id)?;
|
||||
let old_receipt = sent.get(&room_id);
|
||||
if Some(new_receipt) != old_receipt {
|
||||
Some((room_id, new_receipt.clone()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
drop(locked);
|
||||
|
||||
if let Some(room) = client.get_joined_room(&room_id) {
|
||||
if room.read_receipt(&read_till).await.is_ok() {
|
||||
sent.insert(room_id, read_till);
|
||||
}
|
||||
for (room_id, new_receipt) in updates {
|
||||
use matrix_sdk::ruma::api::client::receipt::create_receipt::v3::ReceiptType;
|
||||
|
||||
let Some(room) = client.get_room(&room_id) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
match room
|
||||
.send_single_receipt(
|
||||
ReceiptType::Read,
|
||||
ReceiptThread::Unthreaded,
|
||||
new_receipt.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
sent.insert(room_id, new_receipt);
|
||||
},
|
||||
Err(e) => tracing::warn!(?room_id, "Failed to set read receipt: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn do_first_sync(client: &Client, store: &AsyncProgramStore) -> Result<(), MatrixError> {
|
||||
// Perform an initial, lazily-loaded sync.
|
||||
let mut room = RoomEventFilter::default();
|
||||
room.lazy_load_options = LazyLoadOptions::Enabled { include_redundant_members: false };
|
||||
|
||||
let mut room_ev = RoomFilter::default();
|
||||
room_ev.state = room;
|
||||
|
||||
let mut filter = FilterDefinition::default();
|
||||
filter.room = room_ev;
|
||||
|
||||
let settings = SyncSettings::new().filter(filter.into());
|
||||
|
||||
client.sync_once(settings).await?;
|
||||
|
||||
// Populate sync_info with our initial set of rooms/dms/spaces.
|
||||
refresh_rooms(client, store).await;
|
||||
|
||||
// Insert Need::Messages to fetch accurate recent timestamps in the background.
|
||||
let mut locked = store.lock().await;
|
||||
let ChatStore { sync_info, need_load, .. } = &mut locked.application;
|
||||
|
||||
for room in sync_info.rooms.iter() {
|
||||
let room_id = room.as_ref().0.room_id().to_owned();
|
||||
need_load.insert(room_id, Need::MESSAGES);
|
||||
}
|
||||
|
||||
for room in sync_info.dms.iter() {
|
||||
let room_id = room.as_ref().0.room_id().to_owned();
|
||||
need_load.insert(room_id, Need::MESSAGES);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum LoginStyle {
|
||||
SessionRestore(Session),
|
||||
SessionRestore(MatrixSession),
|
||||
Password(String),
|
||||
SingleSignOn,
|
||||
}
|
||||
|
||||
pub struct ClientResponse<T>(Receiver<T>);
|
||||
@@ -409,35 +603,13 @@ fn oneshot<T>() -> (ClientReply<T>, ClientResponse<T>) {
|
||||
return (reply, response);
|
||||
}
|
||||
|
||||
async fn update_receipts(client: &Client) -> Vec<(OwnedRoomId, Receipts)> {
|
||||
let mut rooms = vec![];
|
||||
|
||||
for room in client.joined_rooms() {
|
||||
if let Ok(users) = room.active_members_no_sync().await {
|
||||
let mut receipts = Receipts::new();
|
||||
|
||||
for member in users {
|
||||
let res = room.user_read_receipt(member.user_id()).await;
|
||||
|
||||
if let Ok(Some((event_id, _))) = res {
|
||||
let user_id = member.user_id().to_owned();
|
||||
receipts.entry(event_id).or_default().push(user_id);
|
||||
}
|
||||
}
|
||||
|
||||
rooms.push((room.room_id().to_owned(), receipts));
|
||||
}
|
||||
}
|
||||
|
||||
return rooms;
|
||||
}
|
||||
|
||||
pub type FetchedRoom = (MatrixRoom, DisplayName, Option<Tags>);
|
||||
|
||||
pub enum WorkerTask {
|
||||
Init(AsyncProgramStore, ClientReply<()>),
|
||||
Login(LoginStyle, ClientReply<IambResult<EditInfo>>),
|
||||
GetInviter(Invited, ClientReply<IambResult<Option<RoomMember>>>),
|
||||
Logout(String, ClientReply<IambResult<EditInfo>>),
|
||||
GetInviter(MatrixRoom, ClientReply<IambResult<Option<RoomMember>>>),
|
||||
GetRoom(OwnedRoomId, ClientReply<IambResult<FetchedRoom>>),
|
||||
JoinRoom(String, ClientReply<IambResult<OwnedRoomId>>),
|
||||
Members(OwnedRoomId, ClientReply<IambResult<Vec<RoomMember>>>),
|
||||
@@ -462,6 +634,9 @@ impl Debug for WorkerTask {
|
||||
.field(&format_args!("_"))
|
||||
.finish()
|
||||
},
|
||||
WorkerTask::Logout(user_id, _) => {
|
||||
f.debug_tuple("WorkerTask::Logout").field(user_id).finish()
|
||||
},
|
||||
WorkerTask::GetInviter(invite, _) => {
|
||||
f.debug_tuple("WorkerTask::GetInviter").field(invite).finish()
|
||||
},
|
||||
@@ -509,6 +684,57 @@ impl Debug for WorkerTask {
|
||||
}
|
||||
}
|
||||
|
||||
async fn create_client_inner(
|
||||
homeserver: &Option<Url>,
|
||||
settings: &ApplicationSettings,
|
||||
) -> Result<Client, ClientBuildError> {
|
||||
let req_timeout = Duration::from_secs(settings.tunables.request_timeout);
|
||||
|
||||
// Set up the HTTP client.
|
||||
let http = reqwest::Client::builder()
|
||||
.user_agent(IAMB_USER_AGENT)
|
||||
.timeout(req_timeout)
|
||||
.pool_idle_timeout(Duration::from_secs(60))
|
||||
.pool_max_idle_per_host(10)
|
||||
.tcp_keepalive(Duration::from_secs(10))
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let req_config = RequestConfig::new().timeout(req_timeout).retry_timeout(req_timeout);
|
||||
|
||||
// Set up the Matrix client for the selected profile.
|
||||
let builder = Client::builder()
|
||||
.http_client(http)
|
||||
.sqlite_store(settings.sqlite_dir.as_path(), None)
|
||||
.request_config(req_config)
|
||||
.with_encryption_settings(DEFAULT_ENCRYPTION_SETTINGS);
|
||||
|
||||
let builder = if let Some(url) = homeserver {
|
||||
// Use the explicitly specified homeserver.
|
||||
builder.homeserver_url(url.as_str())
|
||||
} else {
|
||||
// Try to discover the homeserver from the user ID.
|
||||
let account = &settings.profile;
|
||||
builder.server_name(account.user_id.server_name())
|
||||
};
|
||||
|
||||
builder.build().await
|
||||
}
|
||||
|
||||
pub async fn create_client(settings: &ApplicationSettings) -> Client {
|
||||
let account = &settings.profile;
|
||||
let res = match create_client_inner(&account.url, settings).await {
|
||||
Err(ClientBuildError::AutoDiscovery(_)) => {
|
||||
let url = format!("https://{}/", account.user_id.server_name().as_str());
|
||||
let url = Url::parse(&url).unwrap();
|
||||
create_client_inner(&Some(url), settings).await
|
||||
},
|
||||
res => res,
|
||||
};
|
||||
|
||||
res.expect("Failed to instantiate client")
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Requester {
|
||||
pub client: Client,
|
||||
@@ -532,7 +758,15 @@ impl Requester {
|
||||
return response.recv();
|
||||
}
|
||||
|
||||
pub fn get_inviter(&self, invite: Invited) -> IambResult<Option<RoomMember>> {
|
||||
pub fn logout(&self, user_id: String) -> IambResult<EditInfo> {
|
||||
let (reply, response) = oneshot();
|
||||
|
||||
self.tx.send(WorkerTask::Logout(user_id, reply)).unwrap();
|
||||
|
||||
return response.recv();
|
||||
}
|
||||
|
||||
pub fn get_inviter(&self, invite: MatrixRoom) -> IambResult<Option<RoomMember>> {
|
||||
let (reply, response) = oneshot();
|
||||
|
||||
self.tx.send(WorkerTask::GetInviter(invite, reply)).unwrap();
|
||||
@@ -602,34 +836,8 @@ pub struct ClientWorker {
|
||||
}
|
||||
|
||||
impl ClientWorker {
|
||||
pub async fn spawn(settings: ApplicationSettings) -> Requester {
|
||||
pub async fn spawn(client: Client, settings: ApplicationSettings) -> Requester {
|
||||
let (tx, rx) = unbounded_channel();
|
||||
let account = &settings.profile;
|
||||
|
||||
let req_timeout = Duration::from_secs(settings.tunables.request_timeout);
|
||||
|
||||
// Set up the HTTP client.
|
||||
let http = reqwest::Client::builder()
|
||||
.user_agent(IAMB_USER_AGENT)
|
||||
.timeout(req_timeout)
|
||||
.pool_idle_timeout(Duration::from_secs(60))
|
||||
.pool_max_idle_per_host(10)
|
||||
.tcp_keepalive(Duration::from_secs(10))
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let req_config = RequestConfig::new().timeout(req_timeout).retry_timeout(req_timeout);
|
||||
|
||||
// Set up the Matrix client for the selected profile.
|
||||
let client = Client::builder()
|
||||
.http_client(Arc::new(http))
|
||||
.homeserver_url(account.url.clone())
|
||||
.sled_store(settings.matrix_dir.as_path(), None)
|
||||
.expect("Failed to setup up sled store for Matrix SDK")
|
||||
.request_config(req_config)
|
||||
.build()
|
||||
.await
|
||||
.expect("Failed to instantiate Matrix client");
|
||||
|
||||
let mut worker = ClientWorker {
|
||||
initialized: false,
|
||||
@@ -686,6 +894,10 @@ impl ClientWorker {
|
||||
assert!(self.initialized);
|
||||
reply.send(self.login_and_sync(style).await);
|
||||
},
|
||||
WorkerTask::Logout(user_id, reply) => {
|
||||
assert!(self.initialized);
|
||||
reply.send(self.logout(user_id).await);
|
||||
},
|
||||
WorkerTask::Members(room_id, reply) => {
|
||||
assert!(self.initialized);
|
||||
reply.send(self.members(room_id).await);
|
||||
@@ -745,13 +957,11 @@ impl ClientWorker {
|
||||
store: Ctx<AsyncProgramStore>| {
|
||||
async move {
|
||||
if let SyncStateEvent::Original(ev) = ev {
|
||||
if let Some(room_name) = ev.content.name {
|
||||
let room_id = room.room_id().to_owned();
|
||||
let room_name = Some(room_name.to_string());
|
||||
let mut locked = store.lock().await;
|
||||
let mut info = locked.application.rooms.get_or_default(room_id.clone());
|
||||
info.name = room_name;
|
||||
}
|
||||
let room_id = room.room_id().to_owned();
|
||||
let room_name = Some(ev.content.name);
|
||||
let mut locked = store.lock().await;
|
||||
let info = locked.application.rooms.get_or_default(room_id.clone());
|
||||
info.name = room_name;
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -782,8 +992,20 @@ impl ClientWorker {
|
||||
let sender = ev.sender().to_owned();
|
||||
let _ = locked.application.presences.get_or_default(sender);
|
||||
|
||||
let info = locked.application.get_room_info(room_id.to_owned());
|
||||
info.insert(ev.into_full_event(room_id.to_owned()));
|
||||
let ChatStore { rooms, picker, settings, .. } = &mut locked.application;
|
||||
let info = rooms.get_or_default(room_id.to_owned());
|
||||
|
||||
update_event_receipts(info, &room, ev.event_id()).await;
|
||||
|
||||
let full_ev = ev.into_full_event(room_id.to_owned());
|
||||
info.insert_with_preview(
|
||||
room_id.to_owned(),
|
||||
store.clone(),
|
||||
*picker,
|
||||
full_ev,
|
||||
settings,
|
||||
client.media(),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -801,11 +1023,34 @@ impl ClientWorker {
|
||||
let _ = locked.application.presences.get_or_default(sender);
|
||||
|
||||
let info = locked.application.get_room_info(room_id.to_owned());
|
||||
update_event_receipts(info, &room, ev.event_id()).await;
|
||||
info.insert_reaction(ev.into_full_event(room_id.to_owned()));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
let _ = self.client.add_event_handler(
|
||||
|ev: SyncEphemeralRoomEvent<ReceiptEventContent>,
|
||||
room: MatrixRoom,
|
||||
store: Ctx<AsyncProgramStore>| {
|
||||
async move {
|
||||
let room_id = room.room_id();
|
||||
|
||||
let mut locked = store.lock().await;
|
||||
|
||||
let info = locked.application.get_room_info(room_id.to_owned());
|
||||
for (event_id, receipts) in ev.content.0.into_iter() {
|
||||
let Some(receipts) = receipts.get(&ReceiptType::Read) else {
|
||||
continue;
|
||||
};
|
||||
for user_id in receipts.keys() {
|
||||
info.set_receipt(user_id.to_owned(), event_id.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
let _ = self.client.add_event_handler(
|
||||
|ev: OriginalSyncRoomRedactionEvent,
|
||||
room: MatrixRoom,
|
||||
@@ -817,23 +1062,7 @@ impl ClientWorker {
|
||||
|
||||
let mut locked = store.lock().await;
|
||||
let info = locked.application.get_room_info(room_id.to_owned());
|
||||
|
||||
match info.keys.get(&ev.redacts) {
|
||||
None => return,
|
||||
Some(EventLocation::Message(key)) => {
|
||||
if let Some(msg) = info.messages.get_mut(key) {
|
||||
let ev = SyncRoomRedactionEvent::Original(ev);
|
||||
msg.redact(ev, room_version);
|
||||
}
|
||||
},
|
||||
Some(EventLocation::Reaction(event_id)) => {
|
||||
if let Some(reactions) = info.reactions.get_mut(event_id) {
|
||||
reactions.remove(&ev.redacts);
|
||||
}
|
||||
|
||||
info.keys.remove(&ev.redacts);
|
||||
},
|
||||
}
|
||||
info.redact(ev, room_version);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -985,12 +1214,18 @@ impl ClientWorker {
|
||||
|
||||
self.load_handle = tokio::spawn({
|
||||
let client = self.client.clone();
|
||||
let settings = self.settings.clone();
|
||||
|
||||
async move {
|
||||
while !client.logged_in() {
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
|
||||
let load = load_older_forever(&client, &store);
|
||||
let rcpt = refresh_receipts_forever(&client, &store);
|
||||
let rcpt = send_receipts_forever(&client, &store);
|
||||
let room = refresh_rooms_forever(&client, &store);
|
||||
let ((), (), ()) = tokio::join!(load, rcpt, room);
|
||||
let notifications = register_notifications(&client, &settings, &store);
|
||||
let ((), (), (), ()) = tokio::join!(load, rcpt, room, notifications);
|
||||
}
|
||||
})
|
||||
.into();
|
||||
@@ -1003,19 +1238,40 @@ impl ClientWorker {
|
||||
|
||||
match style {
|
||||
LoginStyle::SessionRestore(session) => {
|
||||
client.restore_login(session).await.map_err(IambError::from)?;
|
||||
client.restore_session(session).await.map_err(IambError::from)?;
|
||||
},
|
||||
LoginStyle::Password(password) => {
|
||||
let resp = client
|
||||
.matrix_auth()
|
||||
.login_username(&self.settings.profile.user_id, &password)
|
||||
.initial_device_display_name(initial_devname().as_str())
|
||||
.send()
|
||||
.await
|
||||
.map_err(IambError::from)?;
|
||||
let file = File::create(self.settings.session_json.as_path())?;
|
||||
let writer = BufWriter::new(file);
|
||||
let session = Session::from(resp);
|
||||
serde_json::to_writer(writer, &session).map_err(IambError::from)?;
|
||||
let session = MatrixSession::from(&resp);
|
||||
self.settings.write_session(session)?;
|
||||
},
|
||||
LoginStyle::SingleSignOn => {
|
||||
let resp = client
|
||||
.matrix_auth()
|
||||
.login_sso(|url| {
|
||||
let opened = format!(
|
||||
"The following URL should have been opened in your browser:\n {url}"
|
||||
);
|
||||
|
||||
async move {
|
||||
tokio::task::spawn_blocking(move || open::that(url));
|
||||
println!("\n{opened}\n");
|
||||
Ok(())
|
||||
}
|
||||
})
|
||||
.initial_device_display_name(initial_devname().as_str())
|
||||
.send()
|
||||
.await
|
||||
.map_err(IambError::from)?;
|
||||
|
||||
let session = MatrixSession::from(&resp);
|
||||
self.settings.write_session(session)?;
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1028,12 +1284,37 @@ impl ClientWorker {
|
||||
})
|
||||
.into();
|
||||
|
||||
Ok(Some(InfoMessage::from("Successfully logged in!")))
|
||||
Ok(Some(InfoMessage::from("* Successfully logged in!")))
|
||||
}
|
||||
|
||||
async fn logout(&mut self, user_id: String) -> IambResult<EditInfo> {
|
||||
// Verify that the user is logging out of the correct profile.
|
||||
let curr = self.settings.profile.user_id.as_str();
|
||||
|
||||
if user_id != curr {
|
||||
let msg = format!("Incorrect user ID (currently logged in as {curr})");
|
||||
let err = UIError::Failure(msg);
|
||||
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
// Send the logout request.
|
||||
if let Err(e) = self.client.matrix_auth().logout().await {
|
||||
let msg = format!("Failed to logout: {e}");
|
||||
let err = UIError::Failure(msg);
|
||||
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
// Remove the session.json file.
|
||||
std::fs::remove_file(&self.settings.session_json)?;
|
||||
|
||||
Ok(Some(InfoMessage::from("Sucessfully logged out")))
|
||||
}
|
||||
|
||||
async fn direct_message(&mut self, user: OwnedUserId) -> IambResult<OwnedRoomId> {
|
||||
for room in self.client.rooms() {
|
||||
if !room.is_direct() {
|
||||
if !is_direct(&room).await {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1057,7 +1338,7 @@ impl ClientWorker {
|
||||
})
|
||||
}
|
||||
|
||||
async fn get_inviter(&mut self, invited: Invited) -> IambResult<Option<RoomMember>> {
|
||||
async fn get_inviter(&mut self, invited: MatrixRoom) -> IambResult<Option<RoomMember>> {
|
||||
let details = invited.invite_details().await.map_err(IambError::from)?;
|
||||
|
||||
Ok(details.inviter)
|
||||
@@ -1077,7 +1358,7 @@ impl ClientWorker {
|
||||
async fn join_room(&mut self, name: String) -> IambResult<OwnedRoomId> {
|
||||
if let Ok(alias_id) = OwnedRoomOrAliasId::from_str(name.as_str()) {
|
||||
match self.client.join_room_by_id_or_alias(&alias_id, &[]).await {
|
||||
Ok(resp) => Ok(resp.room_id),
|
||||
Ok(resp) => Ok(resp.room_id().to_owned()),
|
||||
Err(e) => {
|
||||
let msg = e.to_string();
|
||||
let err = UIError::Failure(msg);
|
||||
@@ -1097,14 +1378,14 @@ impl ClientWorker {
|
||||
|
||||
async fn members(&mut self, room_id: OwnedRoomId) -> IambResult<Vec<RoomMember>> {
|
||||
if let Some(room) = self.client.get_room(room_id.as_ref()) {
|
||||
Ok(room.active_members().await.map_err(IambError::from)?)
|
||||
Ok(room.members(RoomMemberships::ACTIVE).await.map_err(IambError::from)?)
|
||||
} else {
|
||||
Err(IambError::UnknownRoom(room_id).into())
|
||||
}
|
||||
}
|
||||
|
||||
async fn space_members(&mut self, space: OwnedRoomId) -> IambResult<Vec<OwnedRoomId>> {
|
||||
let mut req = SpaceHierarchyRequest::new(&space);
|
||||
let mut req = SpaceHierarchyRequest::new(space);
|
||||
req.limit = Some(1000u32.into());
|
||||
req.max_depth = Some(1u32.into());
|
||||
|
||||
@@ -1116,7 +1397,7 @@ impl ClientWorker {
|
||||
}
|
||||
|
||||
async fn typing_notice(&mut self, room_id: OwnedRoomId) {
|
||||
if let Some(room) = self.client.get_joined_room(room_id.as_ref()) {
|
||||
if let Some(room) = self.client.get_room(room_id.as_ref()) {
|
||||
let _ = room.typing_notice(true).await;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user