95 Commits

Author SHA1 Message Date
Ulyssa
82645c8828 Release v0.0.9 (#236) 2024-03-29 04:35:38 +00:00
Ulyssa
5a2a7b028d Wait to log in before starting background tasks (#234) 2024-03-29 04:14:37 +00:00
Ulyssa
2327658e8c Add commands for importing and exporting room keys (#233) 2024-03-28 20:58:34 -07:00
Ulyssa
b4e9c213e6 Add an icon for iamb (#232) 2024-03-28 16:20:27 +00:00
Ulyssa
79f6b5b75c Reset message bar when ! is passed with :cancel (#231) 2024-03-27 19:35:15 -07:00
Ulyssa
6600685dd5 Update manual pages to use mdoc(7) and list commands (#230) 2024-03-26 15:55:22 +00:00
Ulyssa
ed1b88c197 Support loading a TOML configuration (#229) 2024-03-25 21:30:35 -07:00
Ulyssa
99996e275b Support notifications via terminal bell (#227)
Co-authored-by: Benjamin Grosse <ste3ls@gmail.com>
2024-03-24 17:19:34 +00:00
Ulyssa
db9cb92737 Enable autolinking when rendering Markdown (#226) 2024-03-24 03:06:33 +00:00
Ulyssa
d3b717d1be Fix image previews in replies (#225) 2024-03-24 02:41:05 +00:00
Ulyssa
2ac71da9a6 Fix entering thread view when there's no messages yet (#224) 2024-03-24 02:20:06 +00:00
Ulyssa
1e9b6cc271 Provide better error message for M_UNKNOWN_TOKEN (#101) 2024-03-23 19:09:11 -07:00
mordquist
46e081b1e4 Support configuring user gutter width (#223) 2024-03-23 18:54:26 -07:00
Bernhard Bliem
23a729e565 Support displaying shortcodes instead of Emojis in messages (#222) 2024-03-23 16:35:10 -07:00
Benjamin Grosse
0c52375e06 Add support for desktop notifications (#192) 2024-03-21 17:46:46 -07:00
Ulyssa
c63f8d98d5 Fix odd Windows-only compile error (#221) 2024-03-20 22:30:14 -07:00
Ulyssa
013214899a Ignore key releases on platforms that support it (#220) 2024-03-21 05:13:47 +00:00
Ulyssa
8a5049fb25 GitHub workflow should use --locked to avoid broken Cargo.lock (#219) 2024-03-20 15:29:04 +00:00
Ulyssa
9c6ff58b96 Support linking against system OpenSSL (#218) 2024-03-19 21:55:14 -07:00
Thomas Vodrazka
b41faff9b7 Add example of mapping "V" to toggle message selection mode (#195) 2024-03-09 22:57:35 -08:00
Ulyssa
e7f158ffcd Add support for custom key macros (#217) 2024-03-10 06:49:40 +00:00
Ulyssa
ef868175cb Add support for threads (#216) 2024-03-09 00:47:05 -08:00
Benjamin Grosse
8ee203c9a9 Update to ratatui-image@0.8.1 (#215) 2024-03-08 20:04:52 -08:00
Ryan
95f2c7af30 Nix flake updates (#214) 2024-03-08 20:03:55 -08:00
Ali Elnwegy
c71cec1f54 Fix Nix flake hashes (#206) 2024-03-08 06:06:02 +00:00
Ulyssa
ec81b72f2c Load receipts for room before acquiring lock (#213) 2024-03-07 07:49:35 +00:00
Ulyssa
dd001af365 Download rooms keys from backups if they exist (#211) 2024-03-02 23:55:27 +00:00
Ulyssa
9732971fc2 Update to matrix-sdk@0.7.1 (#200) 2024-03-02 23:00:29 +00:00
Alan Pope
1948d80ec8 Add snap install instructions (#210) 2024-03-02 21:48:46 +00:00
Ulyssa
84bc6be822 Support following the .well-known entries for a username's domain (#209) 2024-02-29 07:21:31 +00:00
Ulyssa
c5999bffc8 Pull in modalkit repository with a Cargo.lock (#208) 2024-02-29 07:00:25 +00:00
Ulyssa
aa878f7569 Move LTO into its own "release-lto" profile (#207) 2024-02-29 06:31:00 +00:00
Ulyssa
a2a708f1ae Indicate and sort on rooms with unread messages (#205)
Fixes #83
2024-02-28 09:03:28 -08:00
Benjamin Grosse
3ed87aae05 Support coloring entire message with the user color (#193) 2024-02-28 06:52:24 +00:00
Ulyssa
1325295d2b Update modalkit dependencies (#204) 2024-02-28 05:21:05 +00:00
Benjamin Lee
1cb280df8b Fix truncation/padding for non-ASCII sender names (#182) 2024-02-27 21:09:37 -08:00
Rerum02
5be886301b Update README.md to add openSUSE Tumbleweed (#191) 2024-02-28 03:43:03 +00:00
O. C. Taskin
3e3b771b2e Rename Nix flake build input from pkgconfig to pkg-config (#203) 2024-02-28 03:23:17 +00:00
FormindVER
b7ae01499b Add a new :chats window that lists both DMs and Rooms (#184)
Fixes #172
2024-02-27 18:37:10 -08:00
Benjamin Grosse
88af9bfec3 Fix crash on small image preview (#198) 2024-01-27 23:35:07 -08:00
Benjamin Grosse
999399a70f Fix not showing display names in already synced rooms (#171)
Fixes #149
2023-12-18 20:55:04 -08:00
sem pruijs
b33759cbc3 Enable direnv for Nix flakes (#183) 2023-12-19 00:53:17 +00:00
Benjamin Grosse
4236d9f53e Update to ratatui-image@0.4.3 to use native sixel lib (#181) 2023-11-24 15:22:39 -08:00
Benjamin Grosse
1ae22086f6 Fix image preview offset (#179) 2023-11-20 13:22:15 -08:00
Benjamin Grosse
221faa828d Add support for previewing images in room scrollback (#108) 2023-11-16 08:36:22 -08:00
Ron Waldon-Howe
974775b29b feat: desktop file for GUI environment launchers (#178) 2023-11-14 12:19:54 -08:00
chloe
25eef55eb7 Add support for logging in with SSO (#160) 2023-11-04 21:39:17 +00:00
Ulyssa
8943909f06 Support custom sorting for room and user lists (#170) 2023-10-21 02:32:33 +00:00
Ulyssa
443ad241b4 Use mozilla-actions/sccache-action for caching builds (#169) 2023-10-21 01:48:06 +00:00
Aaditya Dhruv
3b86be0545 Add new command for logging out of iamb session (#162) 2023-10-19 21:40:22 -07:00
Benjamin Lee
b2b47ed7a0 Reduce CPU usage by instead fetching read receipts after related sync events (#168) 2023-10-16 01:12:39 +00:00
Ulyssa
df3148b9f5 Links should be "openable" (#43) 2023-10-07 18:25:25 -07:00
Benjamin Große
95af00ba93 Update modalkit for newer ratatui and crossterm 2023-10-07 17:21:48 -07:00
Ulyssa
9197864c5c Add more documentation (#166) 2023-10-06 22:35:27 -07:00
Ulyssa
2673cfaeb9 Fix CI workflow (#164) 2023-10-05 18:37:31 -07:00
Ulyssa
c7864cb869 Enable sending strikethrough text (#141) 2023-09-12 17:27:04 -07:00
Ulyssa
7fdb5f98e3 Update Cargo.lock file (#157) 2023-09-12 17:17:29 -07:00
Leonid Dyachkov
0565b6eb05 Support composing messages in an external editor (#155) 2023-09-12 17:07:56 -07:00
balejk
47e650c2be Fix example config (#140) 2023-09-12 16:50:37 -07:00
Ulyssa
89bb107c87 Release v0.0.8 (fix cargo publish issues) (#134) 2023-07-07 23:21:47 -07:00
Ulyssa
ca4c0034d9 Release v0.0.8 (#134) 2023-07-07 22:46:08 -07:00
Ulyssa
bb30cecc63 Handle sync failure after successful password entry (#133) 2023-07-07 22:35:33 -07:00
Ulyssa
7b050f82aa Indicate when there are new messages below scrollback viewport (#131) 2023-07-07 22:16:57 -07:00
Ulyssa
b1ccec6732 Code blocks get rendered without line breaks (#122) 2023-07-07 21:46:13 -07:00
Ulyssa
6e8e12b579 Need fallback behaviour when dirs::download_dir returns None (#118) 2023-07-07 20:35:01 -07:00
Ulyssa
3da9835a17 Profile session token should only be readable by the user (#130) 2023-07-07 20:34:52 -07:00
Ulyssa
64891ec68f Support hiding server part of username in message scrollback (#71) 2023-07-06 23:15:58 -07:00
Ulyssa
61aba80be1 Reduce number of Tokio workers (#129) 2023-07-05 15:25:42 -07:00
Ulyssa
8d4539831f Remove trailing newlines in body (#125) 2023-06-30 21:18:08 -07:00
Ulyssa
7c39e88ba2 Restore opened tabs and windows upon restart (#72) 2023-06-28 23:42:31 -07:00
satoqz
758397eb5a Fix Nix flake build on Darwin (#117) 2023-06-22 23:05:50 -07:00
u2on
1a0af6df37 Link to AUR pkg in README (#121) 2023-06-22 23:01:07 -07:00
Ulyssa
885b56038f Use terminal window focus to determine when a message has actually been seen (#94) 2023-06-14 22:42:23 -07:00
Benjamin Große
430c601ff2 Support configuring which program :open runs (#95) 2023-06-14 21:36:23 -07:00
Moritz Poldrack
0ddefcd7b3 Add manual pages (#88) 2023-06-14 21:14:23 -07:00
Ulyssa
2a573b6056 Show Git SHA information when printing version information (#120) 2023-06-14 20:28:01 -07:00
mikoto
a020b860dd Indicate number of members in room (#110) 2023-06-14 19:42:53 -07:00
jasalltime
6c031f589e Mention Minimum Supported Rust Version in README (#115) 2023-06-14 19:28:23 -07:00
Ulyssa
b0256d7120 Replace GitHub actions using deprecated features (#114) 2023-05-28 21:46:43 -07:00
Ulyssa
0f870367b3 Show errors fetching space hierarchy when list is empty (#113) 2023-05-28 13:16:37 -07:00
Ulyssa
8d22b83d85 Support sending and completing Emoji shortcodes in the message bar (#100) 2023-05-24 21:14:13 -07:00
Pavlo Rudy
529073f4d4 Upload artifacts built in GitHub Actions (#105) 2023-05-22 17:20:17 -07:00
Ulyssa
17c87a617e Cache build directory in GitHub Actions (#107) 2023-05-19 18:25:09 -07:00
Benjamin Große
2899d4f45a Support uploading image attachments from clipboard (#36) 2023-05-19 17:38:23 -07:00
Ulyssa
ad8b4a60d2 ChatStore::set_receipts locks up app for bad connections (#99) 2023-05-12 17:42:25 -07:00
Ulyssa
4935899aed Indicate when you're editing a message (#75) 2023-05-01 22:14:08 -07:00
Ulyssa
cc1d2f3bf8 Gracefully handle verification events that are unknown locally (#90) 2023-05-01 21:33:12 -07:00
Ulyssa
5df9fe7960 Tab completion panics for unrecognized commands (#81) 2023-05-01 21:14:19 -07:00
Ulyssa
a5c25f2487 Support leaving rooms (#45) 2023-04-28 16:52:33 -07:00
Benjamin Große
50023bad40 Append suffix to download filenames to avoid overwrites (#35) 2023-04-28 15:56:14 -07:00
Moritz Poldrack
b6a318dfe3 Fix error message for undefined download directory (#87) 2023-04-25 13:57:03 -07:00
Ulyssa
ad3b40d538 Interpret newlines as line breaks when converting Markdown to HTML (#74) 2023-04-06 16:10:48 -07:00
Ulyssa
953be6a195 Add FUNDING.yml to project (#77) 2023-03-31 18:38:13 -07:00
Benjamin Große
463d46b8ab Add Nix flake (#73) 2023-03-31 11:43:22 -07:00
Ulyssa
274234ce4c Update locked Cargo dependencies (#70) 2023-03-23 13:39:57 -07:00
41 changed files with 10696 additions and 2886 deletions

1
.envrc Normal file
View File

@@ -0,0 +1 @@
use flake

4
.gitattributes vendored
View File

@@ -1 +1,3 @@
* text eol=lf
*.rs text eol=lf
*.toml text eol=lf
*.md text eol=lf

1
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1 @@
ko_fi: ulyssam

View File

@@ -9,52 +9,48 @@ on:
name: CI
jobs:
clippy_check:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
with:
submodules: true
- name: Install Rust
uses: actions-rs/toolchain@v1
with:
toolchain: stable
components: clippy
override: true
- name: Check Clippy
uses: actions-rs/clippy-check@v1
with:
token: ${{ secrets.GITHUB_TOKEN }}
toolchain: stable
args:
test:
strategy:
matrix:
platform: [ubuntu-latest, windows-latest, macos-latest]
runs-on: ${{ matrix.platform }}
env:
SCCACHE_GHA_ENABLED: "true"
RUSTC_WRAPPER: "sccache"
steps:
- name: Checkout code
uses: actions/checkout@v3
with:
submodules: true
- name: Install Rust
uses: actions-rs/toolchain@v1
- name: Install Rust (1.70 w/ clippy)
uses: dtolnay/rust-toolchain@1.70
with:
toolchain: nightly
override: true
components: rustfmt, clippy
components: clippy
- name: Install Rust (nightly w/ rustfmt)
run: rustup toolchain install nightly --component rustfmt
- name: Cache cargo registry
uses: actions/cache@v3
with:
path: ~/.cargo/registry
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
- name: Run sccache-cache
uses: mozilla-actions/sccache-action@v0.0.3
- name: Check formatting
uses: actions-rs/cargo@v1
run: cargo +nightly fmt --all -- --check
- name: Check Clippy
if: matrix.platform == 'ubuntu-latest'
uses: giraffate/clippy-action@v1
with:
command: fmt
args: --all -- --check
github_token: ${{ secrets.GITHUB_TOKEN }}
reporter: 'github-check'
- name: Run tests
uses: actions-rs/cargo@v1
run: cargo test --locked
- name: Build artifacts
run: cargo build --release --locked
- name: Upload artifacts
uses: actions/upload-artifact@master
with:
command: test
name: iamb-${{ matrix.platform }}
path: |
./target/release/iamb
./target/release/iamb.exe

2
.gitignore vendored
View File

@@ -1,2 +1,4 @@
/target
/result
/TODO
.direnv

3993
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package]
name = "iamb"
version = "0.0.7"
version = "0.0.9"
edition = "2018"
authors = ["Ulyssa <git@ulyssa.dev>"]
repository = "https://github.com/ulyssa/iamb"
@@ -11,40 +11,72 @@ 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"
[features]
default = ["bundled"]
bundled = ["matrix-sdk/bundled-sqlite", "rustls-tls"]
native-tls = ["matrix-sdk/native-tls"]
rustls-tls = ["matrix-sdk/rustls-tls"]
[build-dependencies.vergen]
version = "8"
default-features = false
features = ["build", "git", "gitcl",]
[dependencies]
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"
emojis = "~0.5.2"
futures = "0.3"
gethostname = "0.4.1"
html5ever = "0.26.0"
image = "0.24.5"
libc = "0.2"
markup5ever_rcdom = "0.2.0"
mime = "^0.3.16"
mime_guess = "^2.0.4"
notify-rust = { version = "4.10.0", default-features = false, features = ["zbus", "serde"] }
open = "3.2.0"
rand = "0.8.5"
ratatui = "0.23"
ratatui-image = { version = "0.8.1", features = ["serde"] }
regex = "^1.5"
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.14"
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", "markdown", "sled", "rustls-tls"]
features = ["e2e-encryption", "sqlite", "sso-login"]
[dependencies.tokio]
version = "1.24.1"
@@ -52,7 +84,9 @@ features = ["macros", "net", "rt-multi-thread", "sync", "time"]
[dev-dependencies]
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
View 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

123
README.md
View File

@@ -1,18 +1,31 @@
# iamb
<div align="center">
<h1><img width="200" height="200" src="docs/iamb.svg"></h1>
[![Build Status](https://github.com/ulyssa/iamb/workflows/CI/badge.svg)](https://github.com/ulyssa/iamb/actions?query=workflow%3ACI+)
[![License: Apache 2.0](https://img.shields.io/crates/l/iamb.svg?logo=apache)](https://crates.io/crates/iamb)
[![Build Status](https://github.com/ulyssa/iamb/actions/workflows/ci.yml/badge.svg)](https://github.com/ulyssa/iamb/actions?query=workflow%3ACI+)
[![License: Apache 2.0](https://img.shields.io/crates/l/iamb.svg?logo=apache)][crates-io-iamb]
[![#iamb:0x.badd.cafe](https://img.shields.io/badge/matrix-%23iamb:0x.badd.cafe-blue)](https://matrix.to/#/#iamb:0x.badd.cafe)
[![Latest Version](https://img.shields.io/crates/v/iamb.svg?logo=rust)](https://crates.io/crates/iamb)
[![Latest Version](https://img.shields.io/crates/v/iamb.svg?logo=rust)][crates-io-iamb]
[![iamb](https://snapcraft.io/iamb/badge.svg)](https://snapcraft.io/iamb)
![Example Usage](https://iamb.chat/static/images/iamb-demo.gif)
</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
![Example Usage](https://iamb.chat/static/images/iamb-demo.gif)
_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 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:
@@ -37,66 +52,52 @@ pkgin install iamb
### Arch Linux
On Arch Linux a package is available in the Arch User Repositories (AUR). To install it simply run with your favorite AUR helper:
On Arch Linux a [package](https://aur.archlinux.org/packages/iamb-git) is available in the
Arch User Repositories (AUR). To install it simply run with your favorite AUR helper:
```
paru iamb-git
```
### openSUSE Tumbleweed
On openSUSE Tumbleweed a [package](https://build.opensuse.org/package/show/home%3Asmolsheep/iamb) is available from openSUSE Build Service (OBS). To install just use OBS Package Installer:
```
opi iamb
```
### Nix / NixOS (flake)
```
nix profile install "github:ulyssa/iamb"
```
### Snap
A snap for Linux distributions which [support](https://snapcraft.io/docs/installing-snapd) the packaging system.
```
snap install iamb
```
## Configuration
You can create a basic configuration in `$CONFIG_DIR/iamb/config.json` that looks like:
You can create a basic configuration in `$CONFIG_DIR/iamb/config.toml` that looks like:
```json
{
"profiles": {
"example.com": {
"url": "https://example.com",
"user_id": "@user:example.com"
}
}
}
```toml
[profiles."example.com"]
user_id = "@user:example.com"
```
## Comparison With Other Clients
If you homeserver is located on a different domain than the server part of the
`user_id` and you don't have a [`/.well-known`][well_known_entry] entry, then
you can explicitly specify the homeserver URL to use:
To get an idea of what is and isn't yet implemented, here is a subset of the
Matrix website's [features comparison table][client-comparison-matrix], showing
two other TUI clients and Element Web:
| | 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 | ❌ | ✔️ | ✔️ | ✔️ |
```toml
[profiles."example.com"]
url = "https://example.com"
user_id = "@user:example.com"
```
## License
@@ -104,10 +105,8 @@ iamb is released under the [Apache License, Version 2.0].
[Apache License, Version 2.0]: https://github.com/ulyssa/iamb/blob/master/LICENSE
[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

9
build.rs Normal file
View File

@@ -0,0 +1,9 @@
use std::error::Error;
use vergen::EmitBuilder;
fn main() -> Result<(), Box<dyn Error>> {
EmitBuilder::builder().git_sha(true).emit()?;
Ok(())
}

57
config.example.toml Normal file
View File

@@ -0,0 +1,57 @@
default_profile = "default"
[profiles.default]
user_id = "@user:matrix.org"
url = "https://matrix.org"
[settings]
default_room = "#iamb-users:0x.badd.cafe"
log_level = "warn"
message_shortcode_display = false
open_command = ["my-open", "--file"]
reaction_display = true
reaction_shortcode_display = false
read_receipt_display = true
read_receipt_send = true
request_timeout = 10000
typing_notice_display = true
typing_notice_send = true
user_gutter_width = 30
username_display = "username"
[settings.image_preview]
protocol.type = "sixel"
size = { "width" = 66, "height" = 10 }
[settings.sort]
rooms = ["favorite", "lowpriority", "unread", "name"]
members = ["power", "id"]
[settings.users]
"@user:matrix.org" = { "name" = "John Doe", "color" = "magenta" }
[layout]
style = "config"
[[layout.tabs]]
window = "iamb://dms"
[[layout.tabs]]
window = "iamb://rooms"
[[layout.tabs]]
split = [
{ "window" = "#iamb-users:0x.badd.cafe" },
{ "window" = "#iamb-dev:0x.badd.cafe" }
]
[macros.insert]
"jj" = "<Esc>"
[macros."normal|visual"]
"V" = "<C-W>m"
[dirs]
cache = "/home/user/.cache/iamb/"
logs = "/home/user/.local/share/iamb/logs/"
downloads = "/home/user/Downloads/"

BIN
docs/iamb-256x256.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

BIN
docs/iamb-512x512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

206
docs/iamb.1 Normal file
View File

@@ -0,0 +1,206 @@
.\" iamb(1) manual page
.\"
.\" This manual page is written using the mdoc(7) macros. For more
.\" information, see <https://manpages.bsd.lv/mdoc.html>.
.\"
.\" You can preview this file with:
.\" $ man ./docs/iamb.1
.Dd Mar 24, 2024
.Dt IAMB 1
.Os
.Sh NAME
.Nm iamb
.Nd a terminal-based client for Matrix for the Vim addict
.Sh SYNOPSIS
.Nm
.Op Fl hV
.Op Fl P Ar profile
.Op Fl C Ar dir
.Sh DESCRIPTION
.Nm
is a client for the Matrix communication protocol.
It provides a terminal user interface with familiar Vim keybindings, and
includes support for multiple profiles, threads, spaces, notifications,
reactions, custom keybindings, and more.
.Pp
This manual page includes a quick rundown of the available commands in
.Nm .
For example usage and a full description of each one and its arguments, please
refer to the full documentation online.
.Sh OPTIONS
.Bl -tag -width Ds
.It Fl P , Fl Fl profile
The profile to start
.Nm
with.
If this flag is not specified,
then it defaults to using
.Sy default_profile
(see
.Xr iamb 5 ) .
.It Fl C , Fl Fl config-directory
Path to the directory the configuration file is located in.
.It Fl h , Fl Fl help
Show the help text and quit.
.It Fl V , Fl Fl version
Show the current
.Nm
version and quit.
.El
.Sh "GENERAL COMMANDS"
.Bl -tag -width Ds
.It Sy ":chats"
View a list of joined rooms and direct messages.
.It Sy ":dms"
View a list of direct messages.
.It Sy ":logout"
Log out of
.Nm .
.It Sy ":rooms"
View a list of joined rooms.
.It Sy ":spaces"
View a list of joined spaces.
.It Sy ":welcome"
View the startup Welcome window.
.El
.Sh "E2EE COMMANDS"
.Bl -tag -width Ds
.It Sy ":keys export [path] [passphrase]"
Export and encrypt keys to
.Pa path .
.It Sy ":keys import [path] [passphrase]"
Import and decrypt keys from
.Pa path .
.It Sy ":verify"
View a list of ongoing E2EE verifications.
.El
.Sh "MESSAGE COMMANDS"
.Bl -tag -width Ds
.It Sy ":download"
Download an attachment from the selected message.
.It Sy ":edit"
Edit the selected message.
.It Sy ":editor"
Open an external
.Ev $EDITOR
to compose a message.
.It Sy ":open"
Download and then open an attachment, or open a link in a message.
.It Sy ":react [shortcode]"
React to the selected message with an Emoji.
.It Sy ":redact [reason]"
Redact the selected message.
.It Sy ":reply"
Reply to the selected message.
.It Sy ":unreact [shortcode]"
Remove your reaction from the selected message.
When no arguments are given, remove all of your reactions from the message.
.It Sy ":upload"
Upload an attachment and send it to the currently selected room.
.El
.Sh "ROOM COMMANDS"
.Bl -tag -width Ds
.It Sy ":create"
Create a new room.
.It Sy ":invite accept"
Accept an invitation to the currently focused room.
.It Sy ":invite reject"
Reject an invitation to the currently focused room.
.It Sy ":invite send [user]"
Send an invitation to a user to join the currently focused room.
.It Sy ":join [room]"
Join a room.
.It Sy ":leave"
Leave the currently focused room.
.It Sy ":members"
View a list of members of the currently focused room.
.It Sy ":room name set [name]"
Set the name of the currently focused room.
.It Sy ":room name unset"
Unset the name of the currently focused room.
.It Sy ":room tag set [tag]"
Add a tag to the currently focused room.
.It Sy ":room tag unset [tag]"
Remove a tag from the currently focused room.
.It Sy ":room topic set [topic]"
Set the topic of the currently focused room.
.It Sy ":room topic unset"
Unset the topic of the currently focused room.
.El
.Sh "WINDOW COMMANDS"
.Bl -tag -width Ds
.It Sy ":horizontal [cmd]"
Change the behaviour of the given command to be horizontal.
.It Sy ":leftabove [cmd]"
Change the behaviour of the given command to open before the current window.
.It Sy ":only" , Sy ":on"
Quit all but one window in the current tab.
.It Sy ":quit" , Sy ":q"
Quit a window.
.It Sy ":quitall" , Sy ":qa"
Quit all windows in the current tab.
.It Sy ":resize"
Resize a window.
.It Sy ":rightbelow [cmd]"
Change the behaviour of the given command to open after the current window.
.It Sy ":split" , Sy ":sp"
Horizontally split a window.
.It Sy ":vertical [cmd]"
Change the layout of the following command to be vertical.
.It Sy ":vsplit" , Sy ":vsp"
Vertically split a window.
.El
.Sh "TAB COMMANDS"
.Bl -tag -width Ds
.It Sy ":tab [cmd]"
Run a command that opens a window in a new tab.
.It Sy ":tabclose" , Sy ":tabc"
Close a tab.
.It Sy ":tabedit [room]" , Sy ":tabe"
Open a room in a new tab.
.It Sy ":tabrewind" , Sy ":tabr"
Go to the first tab.
.It Sy ":tablast" , Sy ":tabl"
Go to the last tab.
.It Sy ":tabnext" , Sy ":tabn"
Go to the next tab.
.It Sy ":tabonly" , Sy ":tabo"
Close all but one tab.
.It Sy ":tabprevious" , Sy ":tabp"
Go to the preview tab.
.El
.Sh EXAMPLES
.Ss Example 1: Starting with a specific profile
To start with a profile named
.Sy personal
instead of the
.Sy default_profile
value:
.Bd -literal -offset indent
$ iamb -P personal
.Ed
.Ss Example 2: Using an alternate configuration directory
By default,
.Nm
will use the XDG directories, but you may sometimes want to store
your configuration elsewhere.
.Bd -literal -offset indent
$ iamb -C ~/src/iamb-dev/dev-config/
.Ed
.Sh "REPORTING BUGS"
Please report bugs in
.Nm
or its manual pages at
.Lk https://github.com/ulyssa/iamb/issues
.Sh "SEE ALSO"
.Xr iamb 5
.Pp
Extended documentation is available online at
.Lk https://iamb.chat

555
docs/iamb.5 Normal file
View File

@@ -0,0 +1,555 @@
.\" iamb(7) manual page
.\"
.\" This manual page is written using the mdoc(7) macros. For more
.\" information, see <https://manpages.bsd.lv/mdoc.html>.
.\"
.\" You can preview this file with:
.\" $ man ./docs/iamb.1
.Dd Mar 24, 2024
.Dt IAMB 5
.Os
.Sh NAME
.Nm config.toml
.Nd configuration file for
.Sy iamb
.Sh DESCRIPTION
Configuration must be placed under
.Pa ~/.config/iamb/
and named
.Nm .
(If
.Ev $XDG_CONFIG_HOME
is set, then
.Sy iamb
will look for a directory named
.Pa iamb
there instead.)
.Pp
Example configuration usually comes bundled with your installation and can
typically be found in
.Pa /usr/share/iamb .
.Pp
As implied by the filename, the configuration is formatted in TOML.
It's structure and fields are described below.
.Sh CONFIGURATION
These options are sections at the top-level of the file.
.Bl -tag -width Ds
.It Sy profiles
A map of profile names containing per-account information.
See
.Sx PROFILES .
.It Sy default_profile
The name of the default profile to connect to, unless overwritten by a
commandline switch.
It should be one of the names defined in the
.Sy profiles
section.
.It Sy settings
Overwrite general settings for
.Sy iamb .
See
.Sx SETTINGS
for a description of possible values.
.It Sy layout
Configure the default window layout to use when starting
.Sy iamb .
See
.Sx "STARTUP LAYOUT"
for more information on how to configure this object.
.It Sy macros
Map keybindings to other keybindings.
See
.Sx "CUSTOM KEYBINDINGS"
for how to configure this object.
.It Sy dirs
Configure the directories to use for data, logs, and more.
See
.Sx DIRECTORIES
for the possible values you can set in this object.
.El
.Sh PROFILES
These options are configured as fields in the
.Sy profiles
object.
.Bl -tag -width Ds
.It Sy user_id
The user ID to use when connecting to the server.
For example "user" in "@user:matrix.org".
.It Sy url
The URL of the user's server.
(For example "https://matrix.org" for "@user:matrix.org".)
This is only needed when the server does not have a
.Pa /.well-known/matrix/client
entry.
.El
.Pp
In addition to the above fields, you can also reuse the following fields to set
per-profile overrides of their global values:
.Bl -bullet -offset indent -width 1m
.It
.Sy dirs
.It
.Sy layout
.It
.Sy macros
.It
.Sy settings
.El
.Ss Example 1: A single profile
.Bd -literal -offset indent
[profiles.personal]
user_id = "@user:matrix.org"
.Ed
.Ss Example 2: Two profiles with a default
In the following example, there are two profiles,
.Dq personal
(set to be the default) and
.Dq work .
The
.Dq work
profile has an explicit URL set for its homeserver.
.Bd -literal -offset indent
default_profile = "personal"
[profiles.personal]
user_id = "@user:matrix.org"
[profiles.work]
user_id = "@user:example.com"
url = "https://matrix.example.com"
.Ed
.Sh SETTINGS
These options are configured as an object under the
.Sy settings
key and can be overridden as described in
.Sx PROFILES .
.Bl -tag -width Ds
.It Sy default_room
The room to show by default instead of the
.Sy :welcome
window.
.It Sy image_preview
Enable image previews and configure it.
An empty object will enable the feature with default settings, omitting it will disable the feature.
The available fields in this object are:
.Bl -tag -width Ds
.It Sy size
An optional object with
.Sy width
and
.Sy height
fields to specify the preview size in cells.
Defaults to 66 and 10.
.It Sy protocol
An optional object to override settings that will normally be guessed automatically:
.Bl -tag -width Ds
.It Sy type
An optional string set to one of the protocol types:
.Dq Sy sixel ,
.Dq Sy kitty , and
.Dq Sy halfblocks .
.It Sy font_size
An optional list of two numbers representing font width and height in pixels.
.El
.El
.It Sy log_level
Specifies the lowest log level that should be shown.
Possible values are:
.Dq Sy trace ,
.Dq Sy debug ,
.Dq Sy info ,
.Dq Sy warn , and
.Dq Sy error .
.It Sy message_shortcode_display
Defines whether or not Emoji characters in messages should be replaced by their
respective shortcodes.
.It Sy message_user_color
Defines whether or not the message body is colored like the username.
.It Sy notifications
When this subsection is present, you can enable and configure push notifications.
See
.Sx NOTIFICATIONS
for more details.
.It Sy open_command
Defines a custom command and its arguments to run when opening downloads instead of the default.
(For example,
.Sy ["my-open",\ "--file"] . )
.It Sy reaction_display
Defines whether or not reactions should be shown.
.It Sy reaction_shortcode_display
Defines whether or not reactions should be shown as their respective shortcode.
.It Sy read_receipt_send
Defines whether or not read confirmations are sent.
.It Sy read_receipt_display
Defines whether or not read confirmations are displayed.
.It Sy request_timeout
Defines the maximum time per request in seconds.
.It Sy sort
Configures how to sort the lists shown in windows like
.Sy :rooms
or
.Sy :members .
See
.Sx "SORTING LISTS"
for more details.
.It Sy typing_notice_send
Defines whether or not the typing state is sent.
.It Sy typing_notice_display
Defines whether or not the typing state is displayed.
.It Sy user
Overrides values for the specified user.
See
.Sx "USER OVERRIDES"
for details on the format.
.It Sy username_display
Defines how usernames are shown for message senders.
Possible values are
.Dq Sy username ,
.Dq Sy localpart , or
.Dq Sy displayname .
.It Sy user_gutter_width
Specify the width of the column where usernames are displayed in a room.
Usernames that are too long are truncated.
Defaults to 30.
.El
.Ss Example 1: Avoid showing Emojis (useful for terminals w/o support)
.Bd -literal -offset indent
[settings]
username = "username"
message_shortcode_display = true
reaction_shortcode_display = true
.Ed
.Ss Example 2: Increase request timeout to 2 minutes for a slow homeserver
.Bd -literal -offset indent
[settings]
request_timeout = 120
.Ed
.Sh NOTIFICATIONS
The
.Sy settings.notifications
subsection allows configuring how notifications for new messages behave.
The available fields in this subsection are:
.Bl -tag -width Ds
.It Sy enabled
Defaults to
.Sy false .
Setting this field to
.Sy true
enables notifications.
.It Sy via
Defaults to
.Dq Sy desktop
to use the desktop mechanism (default).
Setting this field to
.Dq Sy bell
will use the terminal bell instead.
.It Sy show_message
controls whether to show the message in the desktop notification, and defaults to
.Sy true .
Messages are truncated beyond a small length.
The notification rules are stored server side, loaded once at startup, and are currently not configurable in iamb.
In other words, you can simply change the rules with another client.
.El
.Ss Example 1: Enable notifications with default options
.Bd -literal -offset indent
[settings]
notifications = {}
.Ed
.Ss Example 2: Enable notifications using terminal bell
.Bd -literal -offset indent
[settings.notifications]
via = "bell"
show_message = false
.Ed
.Sh "SORTING LISTS"
The
.Sy settings.sort
subsection allows configuring how different windows have their contents sorted.
Fields available within this subsection are:
.Bl -tag -width Ds
.It Sy rooms
How to sort the
.Sy :rooms
window.
Defaults to
.Sy ["favorite",\ "lowpriority",\ "unread",\ "name"] .
.It Sy chats
How to sort the
.Sy :chats
window.
Defaults to the
.Sy rooms
value.
.It Sy dms
How to sort the
.Sy :dms
window.
Defaults to the
.Sy rooms
value.
.It Sy spaces
How to sort the
.Sy :spaces
window.
Defaults to the
.Sy rooms
value.
.It Sy members
How to sort the
.Sy :members
window.
Defaults to
.Sy ["power",\ "id"] .
.El
.El
.Ss Example 1: Group room members by ther server first
.Bd -literal -offset indent
[settings.sort]
members = ["server", "localpart"]
.Ed
.Sh "USER OVERRIDES"
The
.Sy settings.users
subsections allows overriding how specific senders are displayed.
Overrides are mapped onto Matrix User IDs such as
.Sy @user:matrix.org ,
and are typically written as inline tables containing the following keys:
.Bl -tag -width Ds
.It Sy name
Change the display name of the user.
.It Sy color
Change the color the user is shown as.
Possible values are:
.Dq Sy black ,
.Dq Sy blue ,
.Dq Sy cyan ,
.Dq Sy dark-gray ,
.Dq Sy gray ,
.Dq Sy green ,
.Dq Sy light-blue ,
.Dq Sy light-cyan ,
.Dq Sy light-green ,
.Dq Sy light-magenta ,
.Dq Sy light-red ,
.Dq Sy light-yellow ,
.Dq Sy magenta ,
.Dq Sy none ,
.Dq Sy red ,
.Dq Sy white ,
and
.Dq Sy yellow .
.El
.Ss Example 1: Override how @ada:example.com appears in chat
.Bd -literal -offset indent
[settings.users]
"@ada:example.com" = { name = "Ada Lovelace", color = "light-red" }
.Ed
.Sh STARTUP LAYOUT
The
.Sy layout
section allows configuring the initial set of tabs and windows to show when
starting the client.
.Bl -tag -width Ds
.It Sy style
Specifies what window layout to load when starting.
Valid values are
.Dq Sy restore
to restore the layout from the last time the client was exited,
.Dq Sy new
to open a single window (uses the value of
.Sy default_room
if set), or
.Dq Sy config
to open the layout described under
.Sy tabs .
.It Sy tabs
If
.Sy style
is set to
.Sy config ,
then this value will be used to open a set of tabs and windows at startup.
Each object can contain either a
.Sy window
key specifying a username, room identifier or room alias to show, or a
.Sy split
key specifying an array of window objects.
.El
.Ss Example 1: Show a single room every startup
.Bd -literal -offset indent
[settings]
default_room = "#iamb-users:0x.badd.cafe"
[layout]
style = "new"
.Ed
.Ss Example 2: Show a specific layout every startup
.Bd -literal -offset indent
[layout]
style = "config"
[[layout.tabs]]
window = "iamb://dms"
[[layout.tabs]]
window = "iamb://rooms"
[[layout.tabs]]
split = [
{ "window" = "#iamb-users:0x.badd.cafe" },
{ "window" = "#iamb-dev:0x.badd.cafe" }
]
.Ed
.Sh "CUSTOM KEYBINDINGS"
The
.Sy macros
subsections allow configuring custom keybindings.
Available subsections are:
.Bl -tag -width Ds
.It Sy insert , Sy i
Map the key sequences in this section in
.Sy Insert
mode.
.It Sy normal , Sy n
Map the key sequences in this section in
.Sy Normal
mode.
.It Sy visual , Sy v
Map the key sequences in this section in
.Sy Visual
mode.
.It Sy select
Map the key sequences in this section in
.Sy Select
mode.
.It Sy command , Sy c
Map the key sequences in this section in
.Sy Visual
mode.
.It Sy operator-pending
Map the key sequences in this section in
.Sy "Operator Pending"
mode.
.El
Multiple modes can be given together by separating their names with
.Dq Sy | .
.Ss Example 1: Use "jj" to exit Insert mode
.Bd -literal -offset indent
[macros.insert]
"jj" = "<Esc>"
.Ed
.Ss Example 2: Use "V" for switching between message bar and room history
.Bd -literal -offset indent
[macros."normal|visual"]
"V" = "<C-W>m"
.Ed
.Sh DIRECTORIES
Specifies the directories to save data in.
Configured as an object under the key
.Sy dirs .
.Bl -tag -width Ds
.It Sy cache
Specifies where to store assets and temporary data in.
(For example,
.Sy image_preview
and
.Sy logs
will also go in here by default.)
Defaults to
.Ev $XDG_CACHE_HOME/iamb .
.It Sy data
Specifies where to store persistent data in, such as E2EE room keys.
Defaults to
.Ev $XDG_DATA_HOME/iamb .
.It Sy downloads
Specifies where to store downloaded files.
Defaults to
.Ev $XDG_DOWNLOAD_DIR .
.It Sy image_previews
Specifies where to store automatically downloaded image previews.
Defaults to
.Ev ${cache}/image_preview_downloads .
.It Sy logs
Specifies where to store log files.
Defaults to
.Ev ${cache}/logs .
.El
.Sh FILES
.Bl -tag -width Ds
.It Pa ~/.config/iamb/config.toml
The TOML configuration file that
.Sy iamb
loads by default.
.It Pa ~/.config/iamb/config.json
A JSON configuration file that
.Sy iamb
will load if the TOML one is not found.
.It Pa /usr/share/iamb/config.example.toml
A sample configuration file with examples of how to set different values.
.El
.Sh "REPORTING BUGS"
Please report bugs in
.Sy iamb
or its manual pages at
.Lk https://github.com/ulyssa/iamb/issues
.Sh SEE ALSO
.Xr iamb 1
.Pp
Extended documentation is available online at
.Lk https://iamb.chat

BIN
docs/iamb.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

128
docs/iamb.svg Normal file
View File

@@ -0,0 +1,128 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="120"
height="120"
viewBox="0 0 120 120"
version="1.1"
id="svg5"
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
sodipodi:docname="iamb.svg"
inkscape:export-filename="iamb.png"
inkscape:export-xdpi="288"
inkscape:export-ydpi="288"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview7"
pagecolor="#505050"
bordercolor="#eeeeee"
borderopacity="1"
inkscape:showpageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#505050"
inkscape:document-units="px"
showgrid="false"
inkscape:zoom="4.3724198"
inkscape:cx="2.5157694"
inkscape:cy="43.11114"
inkscape:window-width="1850"
inkscape:window-height="1016"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs2">
<rect
x="69.359197"
y="2.6803692"
width="66.742953"
height="18.624167"
id="rect15628" />
<rect
x="2.8780095"
y="32.203989"
width="116.94288"
height="87.251209"
id="rect14838" />
</defs>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<rect
id="rect111"
width="119.99836"
height="119.79127"
x="0.0058150524"
y="0.21117544"
ry="18.295183"
style="fill:#201127;fill-opacity:1;stroke-width:0.997728" />
<path
id="rect111-3"
style="fill:#1b1e34;fill-opacity:1;stroke-width:0.997728"
d="m 18.321605,-0.01480561 c -10.1355215,0 -18.29492247,8.15940011 -18.29492247,18.29492161 v 4.564453 H 119.99738 V 17.733241 C 119.70734,7.8552235 111.68056,-0.01480561 101.7298,-0.01480561 Z" />
<ellipse
style="fill:#c24b6e;fill-opacity:1"
id="path4855"
cx="105.25824"
cy="12.000000"
rx="5.9108677"
ry="5.9019933" />
<ellipse
style="fill:#ffeb99;fill-opacity:1"
id="path4855-6"
cx="91.251190"
cy="12.000000"
rx="5.9108677"
ry="5.9019933" />
<ellipse
style="fill:#6aaf9d;fill-opacity:1"
id="path4855-7"
cx="77.244141"
cy="12.000000"
rx="5.9108677"
ry="5.9019933" />
<g
aria-label="◡–"
transform="translate(-0.25103084,-17.617149)"
id="text14836"
style="font-size:77.3333px;line-height:1.25;font-family:monospace;-inkscape-font-specification:'monospace, Normal';text-align:center;letter-spacing:0px;word-spacing:0px;white-space:pre;shape-inside:url(#rect14838);shape-padding:0.114105;display:inline">
<path
d="m 38.072257,96.572677 q -4.485331,0 -8.351996,-2.319999 -3.866665,-2.397332 -6.263997,-6.263997 -2.319999,-3.866665 -2.319999,-8.506662 h 3.247999 q 0,3.711998 1.855999,6.882663 1.933332,3.093332 5.026664,5.026664 3.170665,1.933333 6.80533,1.933333 3.711999,0 6.882664,-1.933333 3.170665,-1.933332 5.026664,-5.026664 1.933333,-3.170665 1.933333,-6.882663 h 3.247998 q 0,4.485331 -2.319999,8.429329 -2.319999,3.866665 -6.263997,6.263997 -3.866665,2.397332 -8.506663,2.397332 z"
style="display:inline;fill:#ec9a6d"
id="path809" />
<path
d="m 69.08294,84.895349 v -6.186663 h 30.93332 v 6.186663 z"
style="display:inline;fill:#ec9a6d"
id="path811" />
</g>
<g
aria-label="iamb"
transform="translate(-55.871719,2.2068568)"
id="text15626"
style="font-size:13.3333px;line-height:1.25;letter-spacing:0px;word-spacing:0px;white-space:pre;shape-inside:url(#rect15628);display:inline">
<path
d="m 71.026037,7.5196777 h 3.066399 v 6.3606613 h 2.376296 v 0.930987 h -5.950506 v -0.930987 h 2.376296 V 8.4506649 h -1.868485 z m 1.868485,-2.8385345 h 1.197914 v 1.5169233 h -1.197914 z"
style="font-family:'Bitstream Vera Sans Mono';-inkscape-font-specification:'Bitstream Vera Sans Mono, Normal';fill:#ec9a6d"
id="path800" />
<path
d="m 81.957,11.145971 h -0.397135 q -1.048174,0 -1.582027,0.371092 -0.527342,0.364583 -0.527342,1.093748 0,0.65755 0.397134,1.022133 0.397134,0.364582 1.100258,0.364582 0.98958,0 1.555985,-0.683592 0.566405,-0.690103 0.572916,-1.901037 v -0.266926 z m 2.324213,-0.494791 v 4.160146 H 83.076789 V 13.7306 q -0.384114,0.65104 -0.97005,0.963539 -0.579426,0.305989 -1.412757,0.305989 -1.113278,0 -1.777339,-0.624999 -0.664061,-0.631509 -0.664061,-1.686194 0,-1.217444 0.8138,-1.848953 0.82031,-0.631509 2.402338,-0.631509 h 1.608069 v -0.188802 q -0.0065,-0.8723932 -0.442708,-1.2630172 -0.436197,-0.3971345 -1.393225,-0.3971345 -0.611978,0 -1.236976,0.1757808 -0.624999,0.1757809 -1.217445,0.5143217 V 7.8517081 q 0.664061,-0.2539056 1.269528,-0.3776032 0.611977,-0.130208 1.184893,-0.130208 0.904945,0 1.542964,0.2669264 0.64453,0.2669264 1.041665,0.8007792 0.247395,0.3255201 0.351561,0.8072897 0.104167,0.4752592 0.104167,1.4322878 z"
style="font-family:'Bitstream Vera Sans Mono';-inkscape-font-specification:'Bitstream Vera Sans Mono, Normal';fill:#ec9a6d"
id="path802" />
<path
d="m 89.815053,8.2618633 q 0.221354,-0.4687488 0.559894,-0.6901024 0.345052,-0.227864 0.826821,-0.227864 0.878904,0 1.236976,0.683592 0.364583,0.6770817 0.364583,2.5585871 v 4.22525 h -1.093748 v -4.173167 q 0,-1.5429644 -0.17578,-1.9140572 -0.169271,-0.3776033 -0.624999,-0.3776033 -0.520832,0 -0.716144,0.4036449 -0.188801,0.3971344 -0.188801,1.8880156 v 4.173167 h -1.093748 v -4.173167 q 0,-1.5624956 -0.188801,-1.927078 -0.182291,-0.3645825 -0.664061,-0.3645825 -0.475259,0 -0.664061,0.4036449 -0.182291,0.3971344 -0.182291,1.8880156 v 4.173167 H 86.123656 V 7.5196777 h 1.087237 v 0.6249984 q 0.214843,-0.390624 0.533853,-0.5924464 0.32552,-0.2083328 0.735675,-0.2083328 0.49479,0 0.82031,0.227864 0.332031,0.227864 0.514322,0.6901024 z"
style="font-family:'Bitstream Vera Sans Mono';-inkscape-font-specification:'Bitstream Vera Sans Mono, Normal';fill:#ec9a6d"
id="path804" />
<path
d="m 99.417893,11.172012 q 0,-1.3932254 -0.442708,-2.102859 -0.442707,-0.7096337 -1.30859,-0.7096337 -0.872394,0 -1.321611,0.7161441 -0.449218,0.7096336 -0.449218,2.0963486 0,1.380205 0.449218,2.096349 0.449217,0.716144 1.321611,0.716144 0.865883,0 1.30859,-0.709633 0.442708,-0.709634 0.442708,-2.10286 z M 95.895766,8.4506649 q 0.286458,-0.5338528 0.787759,-0.8203104 0.507811,-0.2864576 1.171872,-0.2864576 1.3151,0 2.070307,1.0156224 0.755206,1.0091121 0.755206,2.7864517 0,1.80338 -0.761717,2.832024 -0.755206,1.022133 -2.076817,1.022133 -0.65104,0 -1.152341,-0.279948 -0.49479,-0.286457 -0.794269,-0.82682 v 0.917966 H 94.697852 V 4.6811432 h 1.197914 z"
style="font-family:'Bitstream Vera Sans Mono';-inkscape-font-specification:'Bitstream Vera Sans Mono, Normal';fill:#ec9a6d"
id="path806" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 7.1 KiB

130
flake.lock generated Normal file
View File

@@ -0,0 +1,130 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1709126324,
"narHash": "sha256-q6EQdSeUZOG26WelxqkmR7kArjgWCdw5sfJVHPH/7j8=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "d465f4819400de7c8d874d50b982301f28a84605",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"flake-utils_2": {
"inputs": {
"systems": "systems_2"
},
"locked": {
"lastModified": 1705309234,
"narHash": "sha256-uNRRNRKmJyCRC/8y1RqBkqWBLM034y4qN7EprSdmgyA=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "1ef2e671c3b0c19053962c07dbda38332dcebf26",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1709703039,
"narHash": "sha256-6hqgQ8OK6gsMu1VtcGKBxKQInRLHtzulDo9Z5jxHEFY=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "9df3e30ce24fd28c7b3e2de0d986769db5d6225d",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1706487304,
"narHash": "sha256-LE8lVX28MV2jWJsidW13D2qrHU/RUUONendL2Q/WlJg=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "90f456026d284c22b3e3497be980b2e47d0b28ac",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs",
"rust-overlay": "rust-overlay"
}
},
"rust-overlay": {
"inputs": {
"flake-utils": "flake-utils_2",
"nixpkgs": "nixpkgs_2"
},
"locked": {
"lastModified": 1709863839,
"narHash": "sha256-QpEL5FmZNi2By3sKZY55wGniFXc4wEn9PQczlE8TG0o=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "e5ab9ee98f479081ad971473d2bc13c59e9fbc0a",
"type": "github"
},
"original": {
"owner": "oxalica",
"repo": "rust-overlay",
"type": "github"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
},
"systems_2": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

44
flake.nix Normal file
View File

@@ -0,0 +1,44 @@
{
description = "iamb";
nixConfig.bash-prompt = "\[nix-develop\]$ ";
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
rust-overlay.url = "github:oxalica/rust-overlay";
};
outputs = { self, nixpkgs, flake-utils, rust-overlay, ... }:
flake-utils.lib.eachDefaultSystem (system:
let
# We only need the nightly overlay in the devShell because .rs files are formatted with nightly.
overlays = [ (import rust-overlay) ];
pkgs = import nixpkgs { inherit system overlays; };
rustNightly = pkgs.rust-bin.nightly."2024-03-08".default;
in
with pkgs;
{
packages.default = rustPlatform.buildRustPackage {
pname = "iamb";
version = self.shortRev or self.dirtyShortRev;
src = ./.;
cargoLock = {
lockFile = ./Cargo.lock;
};
nativeBuildInputs = [ pkg-config ];
buildInputs = [ openssl ] ++ lib.optionals stdenv.isDarwin
(with darwin.apple_sdk.frameworks; [ AppKit Security ]);
};
devShell = mkShell {
buildInputs = [
(rustNightly.override {
extensions = [ "rust-src" "rust-analyzer-preview" "rustfmt" "clippy" ];
})
pkg-config
cargo-tarpaulin
cargo-watch
];
};
});
}

12
iamb.desktop Normal file
View 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

File diff suppressed because it is too large Load Diff

View File

@@ -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,18 @@ 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);
}
fn iamb_leave(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
if !desc.arg.text.is_empty() {
return Result::Err(CommandError::InvalidArgument);
}
let leave = IambAction::Room(RoomAction::Leave(desc.bang));
let step = CommandStep::Continue(leave.into(), ctx.context.clone());
return Ok(step);
}
@@ -166,8 +203,8 @@ fn iamb_cancel(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
return Result::Err(CommandError::InvalidArgument);
}
let mact = IambAction::from(MessageAction::Cancel);
let step = CommandStep::Continue(mact.into(), ctx.context.take());
let mact = IambAction::from(MessageAction::Cancel(desc.bang));
let step = CommandStep::Continue(mact.into(), ctx.context.clone());
return Ok(step);
}
@@ -178,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);
}
@@ -194,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 {
@@ -225,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);
}
@@ -237,8 +274,9 @@ fn iamb_redact(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
return Result::Err(CommandError::InvalidArgument);
}
let ract = IambAction::from(MessageAction::Redact(args.into_iter().next()));
let step = CommandStep::Continue(ract.into(), ctx.context.take());
let reason = args.into_iter().next();
let ract = IambAction::from(MessageAction::Redact(reason, desc.bang));
let step = CommandStep::Continue(ract.into(), ctx.context.clone());
return Ok(step);
}
@@ -249,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);
}
@@ -260,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);
}
@@ -271,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);
}
@@ -282,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);
}
@@ -295,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);
}
@@ -342,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);
}
@@ -389,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);
}
@@ -403,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);
}
@@ -421,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);
}
@@ -439,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);
}
@@ -455,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(),
@@ -469,6 +547,12 @@ 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![],
f: iamb_leave,
});
cmds.add_command(ProgramCommand {
name: "members".into(),
aliases: vec![],
@@ -520,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();
@@ -534,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));
@@ -586,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()));
@@ -606,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));
@@ -621,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())
@@ -652,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));
@@ -664,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());
@@ -683,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);
@@ -696,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());
@@ -765,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));
@@ -830,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);
@@ -867,21 +963,52 @@ 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));
let act = IambAction::Message(MessageAction::Redact(None, false));
assert_eq!(res, vec![(act.into(), ctx.clone())]);
let res = cmds.input_cmd("redact!", ctx.clone()).unwrap();
let act = IambAction::Message(MessageAction::Redact(None, true));
assert_eq!(res, vec![(act.into(), ctx.clone())]);
let res = cmds.input_cmd("redact Removed", ctx.clone()).unwrap();
let act = IambAction::Message(MessageAction::Redact(Some("Removed".into())));
let act = IambAction::Message(MessageAction::Redact(Some("Removed".into()), false));
assert_eq!(res, vec![(act.into(), ctx.clone())]);
let res = cmds.input_cmd("redact \"Removed\"", ctx.clone()).unwrap();
let act = IambAction::Message(MessageAction::Redact(Some("Removed".into())));
let act = IambAction::Message(MessageAction::Redact(Some("Removed".into()), false));
assert_eq!(res, vec![(act.into(), ctx.clone())]);
let res = cmds.input_cmd("redact Removed Removed", ctx.clone());
assert_eq!(res, Err(CommandError::InvalidArgument));
}
#[test]
fn test_cmd_keys() {
let mut cmds = setup_commands();
let ctx = EditContext::default();
let res = cmds.input_cmd("keys import /a/b/c pword", ctx.clone()).unwrap();
let act = IambAction::Keys(KeysAction::Import("/a/b/c".into(), "pword".into()));
assert_eq!(res, vec![(act.into(), ctx.clone())]);
let res = cmds.input_cmd("keys export /a/b/c pword", ctx.clone()).unwrap();
let act = IambAction::Keys(KeysAction::Export("/a/b/c".into(), "pword".into()));
assert_eq!(res, vec![(act.into(), ctx.clone())]);
// Invalid invocations.
let res = cmds.input_cmd("keys", ctx.clone());
assert_eq!(res, Err(CommandError::InvalidArgument));
let res = cmds.input_cmd("keys import", ctx.clone());
assert_eq!(res, Err(CommandError::InvalidArgument));
let res = cmds.input_cmd("keys import foo", ctx.clone());
assert_eq!(res, Err(CommandError::InvalidArgument));
let res = cmds.input_cmd("keys import foo bar baz", ctx.clone());
assert_eq!(res, Err(CommandError::InvalidArgument));
}
}

View File

@@ -1,24 +1,38 @@
//! # 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::{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,
};
type Macros = HashMap<VimModes, HashMap<Keys, Keys>>;
macro_rules! usage {
( $($args: tt)* ) => {
println!($($args)*);
@@ -26,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] = [
@@ -60,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;
@@ -89,8 +119,13 @@ fn validate_profile_names(names: &HashMap<String, ProfileConfig>) {
}
}
const VERSION: &str = match option_env!("VERGEN_GIT_SHA") {
None => env!("CARGO_PKG_VERSION"),
Some(_) => concat!(env!("CARGO_PKG_VERSION"), " (", env!("VERGEN_GIT_SHA"), ")"),
};
#[derive(Parser)]
#[clap(version, about, long_about = None)]
#[clap(version = VERSION, about, long_about = None)]
#[clap(propagate_version = true)]
pub struct Iamb {
#[clap(short = 'P', long, value_parser)]
@@ -106,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)]
@@ -197,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>,
@@ -205,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),
@@ -220,38 +380,165 @@ fn merge_users(a: Option<UserOverrides>, b: Option<UserOverrides>) -> Option<Use
}
}
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum UserDisplayStyle {
// The Matrix username for the sender (e.g., "@user:example.com").
#[default]
Username,
// The localpart of the Matrix username (e.g., "@user").
LocalPart,
// The display name for the Matrix user, calculated according to the rules from the spec.
//
// This is usually something like "Ada Lovelace" if the user has configured a display name, but
// it can wind up being the Matrix username if there are display name collisions in the room,
// in order to avoid any confusion.
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
@@ -259,25 +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),
}
}
}
@@ -285,23 +587,48 @@ impl Tunables {
#[derive(Clone)]
pub struct DirectoryValues {
pub cache: PathBuf,
pub data: PathBuf,
pub logs: PathBuf,
pub downloads: 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),
}
}
@@ -315,27 +642,71 @@ 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");
dir
});
let downloads = self
.downloads
.or_else(dirs::download_dir)
.expect("no dirs.download value configured!");
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 }
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
#[serde(untagged)]
pub enum WindowPath {
AliasId(OwnedRoomAliasId),
RoomId(OwnedRoomId),
UserId(OwnedUserId),
Window(IambId),
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
#[serde(untagged, deny_unknown_fields)]
pub enum WindowLayout {
Window { window: WindowPath },
Split { split: Vec<WindowLayout> },
}
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "lowercase", tag = "style")]
pub enum Layout {
/// Restore the layout from the previous session.
#[default]
Restore,
/// Open a single window using the `default_room` value.
New,
/// Open the window layouts described under `tabs`.
Config { tabs: Vec<WindowLayout> },
}
#[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)]
@@ -344,21 +715,21 @@ pub struct IambConfig {
pub default_profile: Option<String>,
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)?;
Ok(config)
}
let file = File::open(config_json)?;
let reader = BufReader::new(file);
let config = serde_json::from_reader(reader)?;
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)
}
@@ -366,12 +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 {
@@ -383,16 +759,31 @@ 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,
default_profile,
dirs,
settings: global,
} = IambConfig::load(config_json.as_path())?;
layout,
macros,
} = config;
validate_profile_names(&profiles);
@@ -409,42 +800,86 @@ 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();
let tunables = profile.settings.take().unwrap_or_default().merge(tunables);
let tunables = tunables.values();
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 session_json = profile_dir;
session_json.push("session.json");
let dirs = dirs.unwrap_or_default();
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 profile_data_dir = dirs.data.clone();
profile_data_dir.push("profiles");
profile_data_dir.push(profile_name.as_str());
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");
cache_dir.push(profile_name.as_str());
let mut layout_json = cache_dir.clone();
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
@@ -466,18 +901,45 @@ impl ApplicationSettings {
Span::styled(String::from(c), style)
}
pub fn get_user_span<'a>(&self, user_id: &'a UserId) -> Span<'a> {
let (color, name) = self
.tunables
pub fn get_user_overrides(
&self,
user_id: &UserId,
) -> (Option<Color>, Option<Cow<'static, str>>) {
self.tunables
.users
.get(user_id)
.map(|user| (user.color.as_ref().map(|c| c.0), user.name.clone().map(Cow::Owned)))
.unwrap_or_default();
.unwrap_or_default()
}
let user_id = user_id.as_str();
let color = color.unwrap_or_else(|| user_color(user_id));
pub fn get_user_style(&self, user_id: &UserId) -> Style {
let color = self
.tunables
.users
.get(user_id)
.and_then(|user| user.color.as_ref().map(|c| c.0))
.unwrap_or_else(|| user_color(user_id.as_str()));
user_style_from_color(color)
}
pub fn get_user_span<'a>(&self, user_id: &'a UserId, info: &'a RoomInfo) -> Span<'a> {
let (color, name) = self.get_user_overrides(user_id);
let color = color.unwrap_or_else(|| user_color(user_id.as_str()));
let style = user_style_from_color(color);
let name = name.unwrap_or(Cow::Borrowed(user_id));
let name = match (name, &self.tunables.username_display) {
(Some(name), _) => name,
(None, UserDisplayStyle::Username) => Cow::Borrowed(user_id.as_str()),
(None, UserDisplayStyle::LocalPart) => Cow::Borrowed(user_id.localpart()),
(None, UserDisplayStyle::DisplayName) => {
if let Some(display) = info.display_names.get(user_id) {
Cow::Borrowed(display.as_str())
} else {
Cow::Borrowed(user_id.as_str())
}
},
};
Span::styled(name, style)
}
@@ -487,6 +949,7 @@ impl ApplicationSettings {
mod tests {
use super::*;
use matrix_sdk::ruma::user_id;
use std::convert::TryFrom;
#[test]
fn test_profile_name_invalid() {
@@ -527,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()));
}
@@ -580,4 +1043,153 @@ mod tests {
})];
assert_eq!(res.users, Some(users.into_iter().collect()));
}
#[test]
fn test_parse_tunables_username_display() {
let res: Tunables = serde_json::from_str("{\"username_display\": \"username\"}").unwrap();
assert_eq!(res.username_display, Some(UserDisplayStyle::Username));
let res: Tunables = serde_json::from_str("{\"username_display\": \"localpart\"}").unwrap();
assert_eq!(res.username_display, Some(UserDisplayStyle::LocalPart));
let res: Tunables =
serde_json::from_str("{\"username_display\": \"displayname\"}").unwrap();
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());
let alias = WindowPath::AliasId(OwnedRoomAliasId::try_from("#room:example.com").unwrap());
let room = WindowPath::RoomId(OwnedRoomId::try_from("!room:example.com").unwrap());
let dms = WindowPath::Window(IambId::DirectList);
let welcome = WindowPath::Window(IambId::Welcome);
let res: Layout = serde_json::from_str("{\"style\": \"restore\"}").unwrap();
assert_eq!(res, Layout::Restore);
let res: Layout = serde_json::from_str("{\"style\": \"new\"}").unwrap();
assert_eq!(res, Layout::New);
let res: Layout = serde_json::from_str(
"{\"style\": \"config\", \"tabs\": [{\"window\":\"@user:example.com\"}]}",
)
.unwrap();
assert_eq!(res, Layout::Config {
tabs: vec![WindowLayout::Window { window: user.clone() }]
});
let res: Layout = serde_json::from_str(
"{\
\"style\": \"config\",\
\"tabs\": [\
{\"split\":[\
{\"window\":\"@user:example.com\"},\
{\"window\":\"#room:example.com\"}\
]},\
{\"split\":[\
{\"window\":\"!room:example.com\"},\
{\"split\":[\
{\"window\":\"iamb://dms\"},\
{\"window\":\"iamb://welcome\"}\
]}\
]}\
]}",
)
.unwrap();
let split1 = WindowLayout::Split {
split: vec![
WindowLayout::Window { window: user.clone() },
WindowLayout::Window { window: alias },
],
};
let split2 = WindowLayout::Split {
split: vec![WindowLayout::Window { window: dms }, WindowLayout::Window {
window: welcome,
}],
};
let split3 = WindowLayout::Split {
split: vec![WindowLayout::Window { window: room }, split2],
};
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());
}
}

View File

@@ -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);
}
}
}
}
}

View File

@@ -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,28 +19,41 @@ use std::collections::VecDeque;
use std::convert::TryFrom;
use std::fmt::Display;
use std::fs::{create_dir_all, File};
use std::io::{stdout, BufReader, Stdout};
use std::io::{stdout, BufWriter, Stdout, Write};
use std::ops::DerefMut;
use std::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::ruma::OwnedUserId;
use modalkit::crossterm::{
self,
cursor::Show as CursorShow,
event::{poll, read, DisableBracketedPaste, EnableBracketedPaste, Event},
event::{
poll,
read,
DisableBracketedPaste,
DisableFocusChange,
EnableBracketedPaste,
EnableFocusChange,
Event,
KeyEventKind,
},
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, SetTitle},
};
use modalkit::tui::{
use ratatui::{
backend::CrosstermBackend,
layout::Rect,
style::{Color, Style},
@@ -41,6 +67,9 @@ mod commands;
mod config;
mod keybindings;
mod message;
mod notifications;
mod preview;
mod sled_export;
mod util;
mod windows;
mod worker;
@@ -58,6 +87,7 @@ use crate::{
IambId,
IambInfo,
IambResult,
KeysAction,
ProgramAction,
ProgramContext,
ProgramStore,
@@ -68,45 +98,151 @@ use crate::{
};
use modalkit::{
editing::{
action::{
actions::{
Action,
Commandable,
EditError,
EditInfo,
Editable,
EditorAction,
InsertTextAction,
Jumpable,
Promptable,
Scrollable,
TabAction,
TabContainer,
TabCount,
WindowAction,
WindowContainer,
},
base::{MoveDir1D, OpenTarget, RepeatType},
context::Resolve,
key::KeyManager,
store::Store,
editing::{context::Resolve, key::KeyManager, store::Store},
errors::{EditError, UIError},
key::TerminalKey,
keybindings::{
dialog::{Pager, PromptYesNo},
BindingMachine,
},
input::{bindings::BindingMachine, key::TerminalKey},
widgets::{
prelude::*,
ui::FocusList,
};
use modalkit_ratatui::{
cmdbar::CommandBarState,
screen::{Screen, ScreenState},
screen::{Screen, ScreenState, TabLayoutDescription},
windows::WindowLayoutDescription,
TerminalCursor,
TerminalExtOps,
Window,
};
fn config_tab_to_desc(
layout: config::WindowLayout,
store: &mut ProgramStore,
) -> IambResult<WindowLayoutDescription<IambInfo>> {
let desc = match layout {
config::WindowLayout::Window { window } => {
let ChatStore { names, worker, .. } = &mut store.application;
let window = match window {
config::WindowPath::UserId(user_id) => {
let name = user_id.to_string();
let room_id = worker.join_room(name.clone())?;
names.insert(name, room_id.clone());
IambId::Room(room_id, None)
},
config::WindowPath::RoomId(room_id) => IambId::Room(room_id, None),
config::WindowPath::AliasId(alias) => {
let name = alias.to_string();
let room_id = worker.join_room(name.clone())?;
names.insert(name, room_id.clone());
IambId::Room(room_id, None)
},
config::WindowPath::Window(id) => id,
};
WindowLayoutDescription::Window { window, length: None }
},
config::WindowLayout::Split { split } => {
let children = split
.into_iter()
.map(|child| config_tab_to_desc(child, store))
.collect::<IambResult<Vec<_>>>()?;
WindowLayoutDescription::Split { children, length: None }
},
};
Ok(desc)
}
fn setup_screen(
settings: ApplicationSettings,
store: &mut ProgramStore,
) -> IambResult<ScreenState<IambWindow, IambInfo>> {
let cmd = CommandBarState::new(store);
let dims = crossterm::terminal::size()?;
let area = Rect::new(0, 0, dims.0, dims.1);
match settings.layout {
config::Layout::Restore => {
if let Ok(layout) = std::fs::read(&settings.layout_json) {
let tabs: TabLayoutDescription<IambInfo> =
serde_json::from_slice(&layout).map_err(IambError::from)?;
let tabs = tabs.to_layout(area.into(), store)?;
return Ok(ScreenState::from_list(tabs, cmd));
}
},
config::Layout::New => {},
config::Layout::Config { tabs } => {
let mut list = FocusList::default();
for tab in tabs.into_iter() {
let tab = config_tab_to_desc(tab, store)?;
let tab = tab.to_layout(area.into(), store)?;
list.push(tab);
}
return Ok(ScreenState::from_list(list, cmd));
},
}
let win = settings
.tunables
.default_room
.and_then(|room| IambWindow::find(room, store).ok())
.or_else(|| IambWindow::open(IambId::Welcome, store).ok())
.unwrap();
return Ok(ScreenState::new(win, cmd));
}
/// The main application state and event loop.
struct Application {
store: AsyncProgramStore,
worker: Requester,
/// Terminal backend.
terminal: Terminal<CrosstermBackend<Stdout>>,
bindings: KeyManager<TerminalKey, ProgramAction, RepeatType, ProgramContext>,
actstack: VecDeque<(ProgramAction, ProgramContext)>,
/// State for the Matrix client, editing, etc.
store: AsyncProgramStore,
/// UI state (open tabs, command bar, etc.) to use when rendering.
screen: ScreenState<IambWindow, IambInfo>,
/// Handle to communicate synchronously with the Matrix worker task.
worker: Requester,
/// Mapped keybindings.
bindings: KeyManager<TerminalKey, ProgramAction, RepeatType>,
/// Pending actions to run.
actstack: VecDeque<(ProgramAction, ProgramContext)>,
/// Whether or not the terminal is currently focused.
focused: bool,
/// The tab layout before the last executed [TabAction].
last_layout: Option<TabLayoutDescription<IambInfo>>,
/// Whether we need to do a full redraw (e.g., after running a subprocess).
dirty: bool,
}
impl Application {
@@ -118,6 +254,7 @@ impl Application {
crossterm::terminal::enable_raw_mode()?;
crossterm::execute!(stdout, EnterAlternateScreen)?;
crossterm::execute!(stdout, EnableBracketedPaste)?;
crossterm::execute!(stdout, EnableFocusChange)?;
let title = format!("iamb ({})", settings.profile.user_id);
crossterm::execute!(stdout, SetTitle(title))?;
@@ -125,22 +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 win = settings
.tunables
.default_room
.and_then(|room| IambWindow::find(room, locked.deref_mut()).ok())
.or_else(|| IambWindow::open(IambId::Welcome, locked.deref_mut()).ok())
.unwrap();
let cmd = CommandBarState::new(locked.deref_mut());
let screen = ScreenState::new(win, cmd);
let screen = setup_screen(settings, locked.deref_mut())?;
let worker = locked.application.worker.clone();
drop(locked);
let actstack = VecDeque::new();
@@ -152,15 +282,22 @@ impl Application {
bindings,
actstack,
screen,
focused: true,
last_layout: None,
dirty: true,
})
}
fn redraw(&mut self, full: bool, store: &mut ProgramStore) -> Result<(), std::io::Error> {
let modestr = self.bindings.showmode();
let cursor = self.bindings.get_cursor_indicator();
let bindings = &mut self.bindings;
let focused = self.focused;
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()?;
}
@@ -168,9 +305,25 @@ impl Application {
term.draw(|f| {
let area = f.size();
let screen = Screen::new(store).showmode(modestr).borders(true);
let modestr = bindings.show_mode();
let cursor = bindings.get_cursor_indicator();
let dialogstr = bindings.show_dialog(area.height as usize, area.width as usize);
// Don't show terminal cursor when we show a dialog.
let hide_cursor = !dialogstr.is_empty();
store.application.draw_curr = Some(Instant::now());
let screen = Screen::new(store)
.show_dialog(dialogstr)
.show_mode(modestr)
.borders(true)
.focus(focused);
f.render_stateful_widget(screen, area, sstate);
if hide_cursor {
return;
}
if let Some((cx, cy)) = sstate.get_term_cursor() {
if let Some(c) = cursor {
let style = Style::default().fg(Color::Green);
@@ -188,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.
@@ -196,12 +350,21 @@ 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.
},
Event::FocusGained | Event::FocusLost => {
// Do nothing for now.
Event::FocusGained => {
self.focused = true;
},
Event::FocusLost => {
self.focused = false;
},
Event::Resize(_, _) => {
// We'll redraw for the new size next time step() is called.
@@ -215,7 +378,8 @@ impl Application {
match self.screen.editor_command(&act, &ctx, store.deref_mut()) {
Ok(None) => {},
Ok(Some(info)) => {
self.screen.push_info(info);
drop(store);
self.handle_info(info);
},
Err(e) => {
self.screen.push_error(e);
@@ -279,8 +443,7 @@ impl Application {
Action::CommandBar(act) => self.screen.command_bar(&act, &ctx)?,
Action::Macro(act) => self.bindings.macro_command(&act, &ctx, store)?,
Action::Scroll(style) => self.screen.scroll(&style, &ctx, store)?,
Action::Suspend => self.terminal.program_suspend()?,
Action::Tab(cmd) => self.screen.tab_command(&cmd, &ctx, store)?,
Action::ShowInfoMessage(info) => Some(info),
Action::Window(cmd) => self.screen.window_command(&cmd, &ctx, store)?,
Action::Jump(l, dir, count) => {
@@ -289,8 +452,20 @@ impl Application {
None
},
Action::Suspend => {
self.terminal.program_suspend()?;
None
},
// UI actions.
Action::Tab(cmd) => {
if let TabAction::Close(_, _) = &cmd {
self.last_layout = self.screen.as_description().into();
}
self.screen.tab_command(&cmd, &ctx, store)?
},
Action::RedrawScreen => {
self.screen.clear_message();
self.redraw(true, store)?;
@@ -338,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();
@@ -351,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?
},
@@ -364,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())?
@@ -392,13 +580,70 @@ 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()))
},
}
}
fn handle_info(&mut self, info: InfoMessage) {
match info {
InfoMessage::Message(info) => {
self.screen.push_info(info);
},
InfoMessage::Pager(text) => {
let pager = Box::new(Pager::new(text, vec![]));
self.bindings.run_dialog(pager);
},
}
}
@@ -422,11 +667,18 @@ impl Application {
continue;
},
Ok(Some(info)) => {
self.screen.push_info(info);
self.handle_info(info);
// Continue processing; we'll redraw later.
continue;
},
Err(
UIError::NeedConfirm(dialog) |
UIError::EditingFailure(EditError::NeedConfirm(dialog)),
) => {
self.bindings.run_dialog(dialog);
continue;
},
Err(e) => {
self.screen.push_error(e);
@@ -438,6 +690,19 @@ impl Application {
}
}
if let Some(ref layout) = self.last_layout {
let locked = self.store.lock().await;
let path = locked.application.settings.layout_json.as_path();
path.parent().map(create_dir_all).transpose()?;
let file = File::create(path)?;
let writer = BufWriter::new(file);
if let Err(e) = serde_json::to_writer(writer, layout) {
tracing::error!("Failed to save window layout while exiting: {}", e);
}
}
crossterm::terminal::disable_raw_mode()?;
execute!(self.terminal.backend_mut(), LeaveAlternateScreen)?;
self.terminal.show_cursor()?;
@@ -446,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 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}");
@@ -481,27 +782,174 @@ async fn login(worker: Requester, settings: &ApplicationSettings) -> IambResult<
}
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();
let _ = crossterm::execute!(stdout(), DisableBracketedPaste);
let _ = crossterm::execute!(stdout(), DisableFocusChange);
let _ = crossterm::execute!(stdout(), LeaveAlternateScreen);
let _ = crossterm::execute!(stdout(), CursorShow);
}
// Make sure panics clean up the terminal properly.
let orig_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |panic_info| {
let _ = crossterm::terminal::disable_raw_mode();
let _ = crossterm::execute!(stdout(), DisableBracketedPaste);
let _ = crossterm::execute!(stdout(), LeaveAlternateScreen);
let _ = crossterm::execute!(stdout(), CursorShow);
restore_tty();
orig_hook(panic_info);
process::exit(1);
}));
@@ -510,6 +958,7 @@ async fn run(settings: ApplicationSettings) -> IambResult<()> {
// We can now run the application.
application.run().await?;
restore_tty();
Ok(())
}
@@ -521,13 +970,16 @@ fn main() -> IambResult<()> {
// Load configuration and set up the Matrix SDK.
let settings = ApplicationSettings::load(iamb).unwrap_or_else(print_exit);
// Set umask on Unix platforms so that tokens, keys, etc. are only readable by the user.
#[cfg(unix)]
unsafe {
libc::umask(0o077);
};
// Set up the tracing subscriber so we can log client messages.
let log_prefix = format!("iamb-log-{}", settings.profile_name);
let log_dir = settings.dirs.logs.as_path();
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);
@@ -539,6 +991,7 @@ fn main() -> IambResult<()> {
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.worker_threads(2)
.thread_name_fn(|| {
static ATOMIC_ID: AtomicUsize = AtomicUsize::new(0);
let id = ATOMIC_ID.fetch_add(1, Ordering::SeqCst);

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,61 +1,95 @@
//! # 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>>,
curr_width: usize,
literal: bool,
}
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![],
curr_width: 0,
literal: false,
}
}
/// Configure the alignment for each line.
pub fn align(mut self, alignment: Alignment) -> Self {
self.alignment = alignment;
self
}
/// Set whether newlines should be treated literally, or turned into spaces.
pub fn literal(mut self, literal: bool) -> Self {
self.literal = literal;
self
}
/// Indicates whether replies should be pushed to the printer.
pub fn hide_reply(&self) -> bool {
self.hide_reply
}
/// Indicates whether emojis should be replaced by shortcodes
pub fn emoji_shortcodes(&self) -> bool {
self.emoji_shortcodes
}
/// Indicates the current printer's width.
pub fn width(&self) -> usize {
self.width
}
/// Create a new printer with a smaller width.
pub fn sub(&self, indent: usize) -> Self {
TextPrinter {
text: Text::default(),
width: self.width.saturating_sub(indent),
base_style: self.base_style,
hide_reply: self.hide_reply,
emoji_shortcodes: self.emoji_shortcodes,
alignment: self.alignment,
curr_spans: vec![],
curr_width: 0,
literal: self.literal,
}
}
@@ -63,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();
@@ -71,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.
@@ -141,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 {
@@ -153,19 +193,39 @@ 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);
for word in UnicodeSegmentation::split_word_bounds(s) {
if self.width == 0 && word.chars().all(char::is_whitespace) {
if self.width == 0 {
return;
}
for mut word in UnicodeSegmentation::split_word_bounds(s) {
if let "\n" | "\r\n" = word {
if self.literal {
self.commit();
continue;
}
// Render embedded newlines as spaces.
word = " ";
}
if !self.literal && self.curr_width == 0 && word.chars().all(char::is_whitespace) {
// Drop leading whitespace.
continue;
}
let 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;
}
@@ -173,13 +233,13 @@ impl<'a> TextPrinter<'a> {
// Word doesn't fit on this line, so start a new one.
self.commit();
if 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;
}
@@ -190,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
View 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
View 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
View 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)
}

View File

@@ -1,4 +1,4 @@
use std::collections::{BTreeMap, HashMap};
use std::collections::HashMap;
use std::path::PathBuf;
use matrix_sdk::ruma::{
@@ -15,21 +15,25 @@ 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,
UserDisplayTunables,
},
message::{
@@ -42,6 +46,8 @@ use crate::{
worker::Requester,
};
const TEST_ROOM1_ALIAS: &str = "#room1:example.com";
lazy_static! {
pub static ref TEST_ROOM1_ID: OwnedRoomId = RoomId::new(server_name!("example.com")).to_owned();
pub static ref TEST_USER1: OwnedUserId = user_id!("@user1:example.com").to_owned();
@@ -121,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());
@@ -143,29 +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,
}
let mut room = RoomInfo::default();
room.name = Some("Watercooler Discussion".into());
room.keys = mock_keys();
*room.get_thread_mut(None) = mock_messages();
room
}
pub fn mock_dirs() -> DirectoryValues {
DirectoryValues {
cache: PathBuf::new(),
data: PathBuf::new(),
logs: PathBuf::new(),
downloads: PathBuf::new(),
downloads: None,
image_previews: PathBuf::new(),
}
}
@@ -173,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 {
@@ -186,22 +185,40 @@ pub fn mock_tunables() -> TunableValues {
})]
.into_iter()
.collect::<HashMap<_, _>>(),
open_command: None,
username_display: UserDisplayStyle::Username,
message_user_color: false,
notifications: Notifications {
enabled: false,
via: NotifyVia::Desktop,
show_message: true,
},
image_preview: None,
user_gutter_width: 30,
}
}
pub fn mock_settings() -> ApplicationSettings {
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(),
}
}
@@ -223,7 +240,8 @@ pub async fn mock_store() -> ProgramStore {
let room_id = TEST_ROOM1_ID.clone();
let info = mock_room();
store.rooms.insert(room_id, info);
store.rooms.insert(room_id.clone(), info);
store.names.insert(TEST_ROOM1_ALIAS.to_string(), room_id);
ProgramStore::new(store)
}

View File

@@ -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 idx = UnicodeSegmentation::grapheme_indices(s.as_ref(), true)
.find_map(|(i, g)| {
let gw = UnicodeWidthStr::width(g);
idx = i;
if w + gw > width {
break;
}
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

View File

@@ -1,63 +1,73 @@
//! Window for Matrix rooms
use std::borrow::Cow;
use std::ffi::OsStr;
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::{
tui::{
use ratatui::{
buffer::Buffer,
layout::Rect,
text::{Span, Spans},
text::{Line, Span},
widgets::{Paragraph, StatefulWidget, Widget},
},
widgets::textbox::{TextBox, TextBoxState},
widgets::TerminalCursor,
widgets::{PromptActions, WindowOps},
};
use modalkit::editing::{
action::{
EditError,
EditInfo,
EditResult,
use modalkit::keybindings::dialog::{MultiChoice, MultiChoiceItem, PromptYesNo};
use modalkit_ratatui::{
textbox::{TextBox, TextBoxState},
PromptActions,
TerminalCursor,
WindowOps,
};
use modalkit::actions::{
Action,
Editable,
EditorAction,
InfoMessage,
Jumpable,
PromptAction,
Promptable,
Scrollable,
UIError,
},
base::{CloseFlags, Count, MoveDir1D, PositionList, ScrollStyle, WordStyle, WriteFlags},
};
use modalkit::editing::{
completion::CompletionList,
context::Resolve,
history::{self, HistoryList},
rope::EditRope,
};
use modalkit::errors::{EditError, EditResult, UIError};
use modalkit::prelude::*;
use crate::base::{
DownloadFlags,
@@ -75,11 +85,12 @@ use crate::base::{
SendAction,
};
use crate::message::{Message, MessageEvent, MessageKey, MessageTimeStamp};
use crate::message::{text_to_message, Message, MessageEvent, MessageKey, MessageTimeStamp};
use crate::worker::Requester;
use super::scrollback::{Scrollback, ScrollbackState};
/// State needed for rendering [Chat].
pub struct ChatState {
room_id: OwnedRoomId,
room: MatrixRoom,
@@ -96,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);
@@ -119,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)
@@ -157,65 +181,99 @@ 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 => {
MessageAction::Cancel(skip_confirm) => {
if skip_confirm {
self.reset();
return Ok(None);
}
self.reply_to = None;
self.editing = None;
Ok(None)
let msg = "Would you like to clear the message bar?";
let act = PromptAction::Abort(false);
let prompt = PromptYesNo::new(msg, vec![Action::from(act)]);
let prompt = Box::new(prompt);
Err(UIError::NeedConfirm(prompt))
},
MessageAction::Download(filename, flags) => {
if let MessageEvent::Original(ev) = &msg.event {
let media = client.media();
let mut filename = match filename {
Some(f) => PathBuf::from(f),
None => settings.dirs.downloads.clone(),
let mut filename = match (filename, &settings.dirs.downloads) {
(Some(f), _) => PathBuf::from(f),
(None, Some(downloads)) => downloads.clone(),
(None, None) => return Err(IambError::NoDownloadDir.into()),
};
let source = match &ev.content.msgtype {
MessageType::Audio(c) => {
if filename.is_dir() {
filename.push(c.body.as_str());
}
c.source.clone()
},
let (source, msg_filename) = match &ev.content.msgtype {
MessageType::Audio(c) => (c.source.clone(), c.body.as_str()),
MessageType::File(c) => {
if filename.is_dir() {
if let Some(name) = &c.filename {
filename.push(name);
} else {
filename.push(c.body.as_str());
}
}
c.source.clone()
},
MessageType::Image(c) => {
if filename.is_dir() {
filename.push(c.body.as_str());
}
c.source.clone()
},
MessageType::Video(c) => {
if filename.is_dir() {
filename.push(c.body.as_str());
}
c.source.clone()
(c.source.clone(), c.filename.as_deref().unwrap_or(c.body.as_str()))
},
MessageType::Image(c) => (c.source.clone(), c.body.as_str()),
MessageType::Video(c) => (c.source.clone(), c.body.as_str()),
_ => {
if !flags.contains(DownloadFlags::OPEN) {
return Err(IambError::NoAttachment.into());
}
let links = if let Some(html) = &msg.html {
html.get_links()
} else if let Ok(url) = Url::parse(&msg.event.body()) {
vec![('0', url)]
} else {
vec![]
};
if links.is_empty() {
return Err(IambError::NoAttachment.into());
}
let choices = links
.into_iter()
.map(|l| {
let url = l.1.to_string();
let act = IambAction::OpenLink(url.clone()).into();
MultiChoiceItem::new(l.0, url, vec![act])
})
.collect();
let dialog = MultiChoice::new(choices);
let err = UIError::NeedConfirm(Box::new(dialog));
return Err(err);
},
};
if filename.is_dir() {
filename.push(msg_filename);
}
if filename.exists() && !flags.contains(DownloadFlags::FORCE) {
// Find an incrementally suffixed filename, e.g. image-2.jpg -> image-3.jpg
if let Some(stem) = filename.file_stem().and_then(OsStr::to_str) {
let ext = filename.extension();
let mut filename_incr = filename.clone();
for n in 1..=1000 {
if let Some(ext) = ext.and_then(OsStr::to_str) {
filename_incr.set_file_name(format!("{}-{}.{}", stem, n, ext));
} else {
filename_incr.set_file_name(format!("{}-{}", stem, n));
}
if !filename_incr.exists() {
filename = filename_incr;
break;
}
}
}
}
if !filename.exists() || flags.contains(DownloadFlags::FORCE) {
let req = MediaRequest { source, format: MediaFormat::File };
@@ -236,14 +294,21 @@ impl ChatState {
}
let info = if flags.contains(DownloadFlags::OPEN) {
// open::that may not return until the spawned program closes.
let target = filename.clone().into_os_string();
tokio::task::spawn_blocking(move || open::that(target));
match open_command(
store.application.settings.tunables.open_command.as_ref(),
target,
) {
Ok(_) => {
InfoMessage::from(format!(
"Attachment downloaded to {} and opened",
filename.display()
))
},
Err(err) => {
return Err(err);
},
}
} else {
InfoMessage::from(format!(
"Attachment downloaded to {}",
@@ -286,6 +351,7 @@ impl ChatState {
};
self.tbox.set_text(text);
self.reply_to = msg.reply_to().and_then(|id| info.get_message_key(&id)).cloned();
self.editing = self.scrollback.get_key(info);
self.focus = RoomFocus::MessageBar;
@@ -306,13 +372,22 @@ 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)
},
MessageAction::Redact(reason) => {
MessageAction::Redact(reason, skip_confirm) => {
if !skip_confirm {
let msg = "Are you sure you want to redact this message?";
let act = IambAction::Message(MessageAction::Redact(reason, true));
let prompt = PromptYesNo::new(msg, vec![Action::from(act)]);
let prompt = Box::new(prompt);
return Err(UIError::NeedConfirm(prompt));
}
let room = self.get_joined(&store.application.worker)?;
let event_id = match &msg.event {
MessageEvent::EncryptedOriginal(ev) => ev.event_id.clone(),
@@ -341,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());
@@ -354,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),
};
@@ -390,43 +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 msg = TextMessageEventContent::markdown(msg.to_string());
let msg = MessageType::Text(msg);
let mut msg = RoomMessageEventContent::new(msg);
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.
@@ -446,7 +524,37 @@ 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)?;
// Mock up the local echo message for the scrollback.
let msg = TextMessageEventContent::plain(format!("[Attached File: {name}]"));
let msg = MessageType::Text(msg);
let msg = RoomMessageEventContent::new(msg);
(resp.event_id, msg)
},
SendAction::UploadImage(width, height, bytes) => {
// Convert to png because arboard does not give us the mime type.
let bytes =
image::ImageBuffer::from_raw(width as _, height as _, bytes.into_owned())
.ok_or(IambError::Clipboard)
.and_then(|imagebuf| {
let dynimage = image::DynamicImage::ImageRgba8(imagebuf);
let bytes = Vec::<u8>::new();
let mut buff = std::io::Cursor::new(bytes);
dynimage.write_to(&mut buff, image::ImageOutputFormat::Png)?;
Ok(buff.into_inner())
})
.map_err(IambError::from)?;
let mime = mime::IMAGE_PNG;
let name = "Clipboard.png";
let config = AttachmentConfig::new();
let resp = room
.send_attachment(name.as_ref(), &mime, bytes, config)
.await
.map_err(IambError::from)?;
@@ -464,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.
@@ -531,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,
@@ -592,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;
@@ -601,6 +714,15 @@ impl Editable<ProgramContext, ProgramStore, IambInfo> for ChatState {
// Run command again.
delegate!(self, w => w.editor_command(act, ctx, store))
},
Err(EditError::Register(RegisterError::ClipboardImage(data))) => {
let msg = "Do you really want to upload the image from your system clipboard?";
let send =
IambAction::Send(SendAction::UploadImage(data.width, data.height, data.bytes));
let prompt = PromptYesNo::new(msg, vec![Action::from(send)]);
let prompt = Box::new(prompt);
Err(EditError::NeedConfirm(prompt))
},
res @ Err(_) => res,
}
}
@@ -677,13 +799,14 @@ impl PromptActions<ProgramContext, ProgramStore, IambInfo> for ChatState {
&mut self,
dir: &MoveDir1D,
count: &Count,
prefixed: bool,
ctx: &ProgramContext,
_: &mut ProgramStore,
) -> EditResult<Vec<(ProgramAction, ProgramContext)>, IambInfo> {
let count = ctx.resolve(count);
let rope = self.tbox.get();
let text = self.sent.recall(&rope, &mut self.sent_scrollback, *dir, count);
let text = self.sent.recall(&rope, &mut self.sent_scrollback, *dir, prefixed, count);
if let Some(text) = text {
self.tbox.set_text(text);
@@ -701,18 +824,20 @@ 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 {
PromptAction::Submit => self.submit(ctx, store),
PromptAction::Abort(empty) => self.abort(*empty, ctx, store),
PromptAction::Recall(dir, count) => self.recall(dir, count, ctx, store),
_ => Err(EditError::Unimplemented("unknown prompt action".to_string())),
PromptAction::Recall(dir, count, prefixed) => {
self.recall(dir, count, *prefixed, ctx, store)
},
}
}
}
/// [StatefulWidget] for Matrix rooms.
pub struct Chat<'a> {
store: &'a mut ProgramStore,
focused: bool,
@@ -733,10 +858,35 @@ impl<'a> StatefulWidget for Chat<'a> {
type State = ChatState;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
// Determine whether we have a description to show for the message bar.
let desc_spans = match (&state.editing, &state.reply_to, state.thread()) {
(None, None, None) => None,
(None, None, Some(_)) => Some(Line::from("Replying in thread")),
(Some(_), None, None) => Some(Line::from("Editing message")),
(Some(_), None, Some(_)) => Some(Line::from("Editing message in thread")),
(editing, Some(_), thread) => {
self.store.application.rooms.get(state.id()).and_then(|room| {
let msg = state.get_reply_to(room)?;
let user =
self.store.application.settings.get_user_span(msg.sender.as_ref(), room);
let prefix = match (editing.is_some(), thread.is_some()) {
(true, false) => Span::from("Editing reply to "),
(true, true) => Span::from("Editing reply in thread to "),
(false, false) => Span::from("Replying to "),
(false, true) => Span::from("Replying in thread to "),
};
let spans = Line::from(vec![prefix, user]);
spans.into()
})
},
};
// Determine the region to show each UI element.
let lines = state.tbox.has_lines(5).max(1) as u16;
let drawh = area.height;
let texth = lines.min(drawh).clamp(1, 5);
let desch = if state.reply_to.is_some() {
let desch = if desc_spans.is_some() {
drawh.saturating_sub(texth).min(1)
} else {
0
@@ -747,25 +897,7 @@ impl<'a> StatefulWidget for Chat<'a> {
let descarea = Rect::new(area.x, scrollarea.y + scrollh, area.width, desch);
let textarea = Rect::new(area.x, descarea.y + desch, area.width, texth);
let scrollback_focused = state.focus.is_scrollback() && self.focused;
let scrollback = Scrollback::new(self.store).focus(scrollback_focused);
scrollback.render(scrollarea, buf, &mut state.scrollback);
let desc_spans = match (&state.editing, &state.reply_to) {
(None, None) => None,
(Some(_), _) => Some(Spans::from("Editing message")),
(_, 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 user = self.store.application.settings.get_user_span(msg.sender.as_ref());
let spans = Spans(vec![Span::from("Replying to "), user]);
spans.into()
})
},
};
// Render the message bar and any description for it.
if let Some(desc_spans) = desc_spans {
Paragraph::new(desc_spans).render(descarea, buf);
}
@@ -774,5 +906,35 @@ impl<'a> StatefulWidget for Chat<'a> {
let tbox = TextBox::new().prompt(prompt);
tbox.render(textarea, buf, &mut state.tbox);
// Render the message scrollback.
let scrollback_focused = state.focus.is_scrollback() && self.focused;
let scrollback = Scrollback::new(self.store)
.focus(scrollback_focused)
.room_focus(self.focused);
scrollback.render(scrollarea, buf, &mut state.scrollback);
}
}
fn open_command(open_command: Option<&Vec<String>>, target: OsString) -> IambResult<()> {
if let Some(mut cmd) = open_command.and_then(cmd) {
cmd.arg(target);
cmd.spawn()?;
return Ok(());
} else {
// open::that may not return until the spawned program closes.
tokio::task::spawn_blocking(move || {
return open::that(target);
});
return Ok(());
}
}
fn cmd(open_command: &Vec<String>) -> Option<Command> {
if let [program, args @ ..] = open_command.as_slice() {
let mut cmd = Command::new(program);
cmd.args(args);
return Some(cmd);
}
None
}

View File

@@ -1,53 +1,42 @@
//! # 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::{
use modalkit::actions::{
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::InputContext,
widgets::{TermOffset, TerminalCursor, WindowOps},
};
use modalkit::errors::{EditResult, UIError};
use modalkit::prelude::*;
use modalkit::{editing::completion::CompletionList, keybindings::dialog::PromptYesNo};
use modalkit_ratatui::{TermOffset, TerminalCursor, WindowOps};
use crate::base::{
IambAction,
IambError,
IambId,
IambInfo,
@@ -77,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),
@@ -97,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,
@@ -109,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,
}
}
@@ -122,7 +124,7 @@ impl RoomState {
fn draw_invite(
&self,
invited: Invited,
invited: MatrixRoom,
area: Rect,
buf: &mut Buffer,
store: &mut ProgramStore,
@@ -137,12 +139,13 @@ impl RoomState {
let mut invited = vec![Span::from(format!("You have been invited to join {name}"))];
if let Ok(Some(inviter)) = &inviter {
let info = store.application.rooms.get_or_default(self.id().to_owned());
invited.push(Span::from(" by "));
invited.push(store.application.settings.get_user_span(inviter.user_id()));
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] };
@@ -184,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)?;
@@ -201,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 {
@@ -210,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![])
@@ -218,6 +221,24 @@ impl RoomState {
Err(IambError::NotJoined.into())
}
},
RoomAction::Leave(skip_confirm) => {
if let Some(room) = store.application.worker.client.get_room(self.id()) {
if skip_confirm {
room.leave().await.map_err(IambError::from)?;
Ok(vec![])
} else {
let msg = "Do you really want to leave this room?";
let leave = IambAction::Room(RoomAction::Leave(true));
let prompt = PromptYesNo::new(msg, vec![Action::from(leave)]);
let prompt = Box::new(prompt);
Err(UIError::NeedConfirm(prompt))
}
} else {
Err(IambError::NotJoined.into())
}
},
RoomAction::Members(mut cmd) => {
let width = Count::Exact(30);
let act =
@@ -226,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
@@ -236,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) => {
@@ -261,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) => {
@@ -278,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() => {
@@ -292,7 +321,7 @@ impl RoomState {
_ => {},
}
Spans(spans)
Line::from(spans)
}
pub fn focus_toggle(&mut self) {
@@ -370,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 {

View File

@@ -1,20 +1,22 @@
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::{buffer::Buffer, layout::Rect, widgets::StatefulWidget};
use modalkit::widgets::{ScrollActions, TerminalCursor, WindowOps};
use modalkit_ratatui::{ScrollActions, TerminalCursor, WindowOps};
use ratatui::{
buffer::Buffer,
layout::{Alignment, Rect},
style::{Modifier as StyleModifier, Style},
text::{Line, Span},
widgets::{Paragraph, StatefulWidget, Widget},
};
use modalkit::editing::{
action::{
use modalkit::actions::{
Action,
CursorAction,
EditAction,
EditError,
EditInfo,
EditResult,
Editable,
EditorAction,
EditorActions,
@@ -26,53 +28,44 @@ use modalkit::editing::{
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,
},
WindowAction,
};
use modalkit::editing::{
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;
@@ -85,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;
@@ -104,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 {
@@ -119,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,
@@ -136,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();
@@ -146,6 +142,7 @@ impl ScrollbackState {
ScrollbackState {
room_id,
id,
thread,
cursor,
viewctx,
jumped,
@@ -166,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)
},
}
}
@@ -207,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;
@@ -221,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 {
@@ -244,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;
@@ -262,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;
@@ -280,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;
@@ -331,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())
},
@@ -344,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) => {
@@ -395,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 {
@@ -409,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;
}
@@ -434,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;
}
@@ -460,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;
}
@@ -475,11 +547,7 @@ impl ScrollbackState {
}
}
if count > 0 {
need_load.insert(self.room_id.clone());
}
return mc;
return (mc, count > 0);
}
fn find_message(
@@ -489,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),
}
}
}
@@ -507,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(),
@@ -555,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 {
@@ -585,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());
@@ -610,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 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";
@@ -669,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";
@@ -696,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 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";
@@ -771,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());
if let Some(cursor) = self.cursor.to_cursor(info) {
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);
Ok(None)
} else {
let msg = "Failed to set mark for message";
let err = EditError::Failure(msg.into());
Err(err)
}
}
fn complete(
@@ -823,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:?}"))),
}
}
@@ -834,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),
@@ -847,11 +892,11 @@ impl EditorActions<ProgramContext, ProgramStore, IambInfo> for ScrollbackState {
let ngroup = store.cursors.get_group(self.id.clone(), &reg)?;
// 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)
@@ -866,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())
})?;
@@ -925,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();
@@ -952,7 +997,7 @@ impl Jumpable<ProgramContext, IambInfo> for ScrollbackState {
self.cursor = pos.clone();
}
return Ok(count.saturating_sub(len));
Ok(count.saturating_sub(len))
},
}
}
@@ -962,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)
},
}
}
@@ -1027,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);
@@ -1047,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);
@@ -1076,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);
@@ -1140,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);
}
@@ -1205,14 +1240,43 @@ impl TerminalCursor for ScrollbackState {
}
}
fn render_jump_to_recent(area: Rect, buf: &mut Buffer, focused: bool) -> Rect {
if area.height <= 5 || area.width <= 20 {
return area;
}
let top = Rect::new(area.x, area.y, area.width, area.height - 1);
let bar = Rect::new(area.x, area.y + top.height, area.width, 1);
let msg = vec![
Span::raw("Use "),
Span::styled("G", Style::default().add_modifier(StyleModifier::BOLD)),
Span::raw(if focused { "" } else { " in scrollback" }),
Span::raw(" to jump to latest message"),
];
Paragraph::new(Line::from(msg))
.alignment(Alignment::Center)
.render(bar, buf);
return top;
}
pub struct Scrollback<'a> {
room_focused: bool,
focused: bool,
store: &'a mut ProgramStore,
}
impl<'a> Scrollback<'a> {
pub fn new(store: &'a mut ProgramStore) -> Self {
Scrollback { focused: false, store }
Scrollback { room_focused: false, focused: false, store }
}
/// Indicate whether the room window is currently focused, regardless of whether the scrollback
/// also is.
pub fn room_focus(mut self, focused: bool) -> Self {
self.room_focused = focused;
self
}
/// Indicate whether the scrollback is currently focused.
@@ -1228,7 +1292,11 @@ impl<'a> StatefulWidget for Scrollback<'a> {
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
let info = self.store.application.rooms.get_or_default(state.room_id.clone());
let settings = &self.store.application.settings;
let area = info.render_typing(area, buf, &self.store.application.settings);
let area = if state.cursor.timestamp.is_some() {
render_jump_to_recent(area, buf, self.focused)
} else {
info.render_typing(area, buf, &self.store.application.settings)
};
state.set_term_info(area);
@@ -1238,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;
};
@@ -1254,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;
@@ -1283,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 {
@@ -1293,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;
}
@@ -1301,23 +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 settings.tunables.read_receipt_send && state.cursor.timestamp.is_none() {
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;
}
}
@@ -1330,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);
@@ -1354,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();
@@ -1373,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());
@@ -1407,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;
@@ -1558,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.

View File

@@ -1,34 +1,49 @@
//! Window for Matrix spaces
use std::ops::{Deref, DerefMut};
use std::time::{Duration, Instant};
use matrix_sdk::{
room::Room as MatrixRoom,
ruma::{OwnedRoomId, RoomId},
};
use modalkit::tui::{buffer::Buffer, layout::Rect, widgets::StatefulWidget};
use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Color, Style},
text::{Line, Span, Text},
widgets::StatefulWidget,
};
use modalkit::{
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,
list: ListState<RoomItem, IambInfo>,
last_fetch: Option<Instant>,
}
impl SpaceState {
pub fn new(room: MatrixRoom) -> Self {
let room_id = room.room_id().to_owned();
let content = IambBufferId::Room(room_id.clone(), RoomFocus::Scrollback);
let content = IambBufferId::Room(room_id.clone(), None, RoomFocus::Scrollback);
let list = ListState::new(content, vec![]);
let last_fetch = None;
SpaceState { room_id, room, list }
SpaceState { room_id, room, list, last_fetch }
}
pub fn refresh_room(&mut self, store: &mut ProgramStore) {
@@ -50,6 +65,7 @@ impl SpaceState {
room_id: self.room_id.clone(),
room: self.room.clone(),
list: self.list.dup(store),
last_fetch: self.last_fetch,
}
}
}
@@ -74,6 +90,7 @@ impl DerefMut for SpaceState {
}
}
/// [StatefulWidget] for Matrix spaces.
pub struct Space<'a> {
focused: bool,
store: &'a mut ProgramStore,
@@ -94,30 +111,54 @@ impl<'a> StatefulWidget for Space<'a> {
type State = SpaceState;
fn render(self, area: Rect, buffer: &mut Buffer, state: &mut Self::State) {
let members =
if let Ok(m) = self.store.application.worker.space_members(state.room_id.clone()) {
m
} else {
return;
let mut empty_message = None;
let need_fetch = match state.last_fetch {
Some(i) => i.elapsed() >= SPACE_HIERARCHY_DEBOUNCE,
None => true,
};
let items = members
if need_fetch {
let res = self.store.application.worker.space_members(state.room_id.clone());
match res {
Ok(members) => {
let mut items = members
.into_iter()
.filter_map(|id| {
let (room, name, tags) = self.store.application.worker.get_room(id.clone()).ok()?;
let (room, _, tags) =
self.store.application.worker.get_room(id.clone()).ok()?;
let room_info = std::sync::Arc::new((room, tags));
if id != state.room_id {
Some(RoomItem::new(room, name, tags, self.store))
Some(RoomItem::new(room_info, self.store))
} else {
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![
Line::from("Unable to fetch space room hierarchy:"),
Span::styled(e.to_string(), Style::default().fg(Color::Red)).into(),
];
List::new(self.store)
.focus(self.focused)
.render(area, buffer, &mut state.list)
empty_message = Text { lines }.into();
},
}
}
let mut list = List::new(self.store).focus(self.focused);
if let Some(text) = empty_message {
list = list.empty_message(text);
}
list.render(area, buffer, &mut state.list)
}
}

View File

@@ -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

View File

@@ -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};

File diff suppressed because it is too large Load Diff