110 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
Ulyssa
a2590b6bbb Release v0.0.7 (#68) 2023-03-22 21:28:34 -07:00
Ulyssa
725ebb9fd6 Redacted messages should have their HTML removed (#67) 2023-03-22 21:25:37 -07:00
jahway603
ca395097e1 Update README.md to include Arch Linux package (#66) 2023-03-22 20:13:25 -07:00
Ulyssa
e98d58a8cc Emote messages should always show sender (#65) 2023-03-21 14:02:42 -07:00
Ulyssa
e6cdd02f22 HTML self-closing tags are getting parsed incorrectly (#63) 2023-03-20 17:53:55 -07:00
Ulyssa
0bc4ff07b0 Lazy load room state events on initial sync (#62) 2023-03-20 16:17:59 -07:00
Ulyssa
14fe916d94 Allow log level to be configured (#58) 2023-03-13 16:43:08 -07:00
Ulyssa
db35581d07 Indicate when an encrypted room event has been redacted (#59) 2023-03-13 16:43:04 -07:00
Ulyssa
7c1c62897a Show events that couldn't be decrypted (#57) 2023-03-13 15:18:53 -07:00
Ulyssa
61897ea6f2 Fetch scrollback history independently of main loop (#39) 2023-03-13 10:46:26 -07:00
Ulyssa
6a0722795a Fix empty message check when sending (#56) 2023-03-13 09:26:49 -07:00
Ulyssa
f3bbc6ad9f Support configuring client request timeout (#54) 2023-03-12 15:43:13 -07:00
Ulyssa
2dd8c0fddf Link to iamb space in README (#55) 2023-03-10 18:08:42 -08:00
Pavlo Rudy
a786369b14 Create release profile with LTO (#52) 2023-03-10 16:41:32 -08:00
pin
066f60ad32 Add NetBSD install instructions (#51) 2023-03-09 09:27:40 -08:00
41 changed files with 10965 additions and 2960 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 name: CI
jobs: 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: test:
strategy: strategy:
matrix: matrix:
platform: [ubuntu-latest, windows-latest, macos-latest] platform: [ubuntu-latest, windows-latest, macos-latest]
runs-on: ${{ matrix.platform }} runs-on: ${{ matrix.platform }}
env:
SCCACHE_GHA_ENABLED: "true"
RUSTC_WRAPPER: "sccache"
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v3 uses: actions/checkout@v3
with: with:
submodules: true submodules: true
- name: Install Rust - name: Install Rust (1.70 w/ clippy)
uses: actions-rs/toolchain@v1 uses: dtolnay/rust-toolchain@1.70
with: with:
toolchain: nightly components: clippy
override: true - name: Install Rust (nightly w/ rustfmt)
components: rustfmt, clippy run: rustup toolchain install nightly --component rustfmt
- name: Cache cargo registry - name: Cache cargo registry
uses: actions/cache@v3 uses: actions/cache@v3
with: with:
path: ~/.cargo/registry path: ~/.cargo/registry
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
- name: Run sccache-cache
uses: mozilla-actions/sccache-action@v0.0.3
- name: Check formatting - name: Check formatting
uses: actions-rs/cargo@v1 run: cargo +nightly fmt --all -- --check
- name: Check Clippy
if: matrix.platform == 'ubuntu-latest'
uses: giraffate/clippy-action@v1
with: with:
command: fmt github_token: ${{ secrets.GITHUB_TOKEN }}
args: --all -- --check reporter: 'github-check'
- name: Run tests - name: Run tests
uses: actions-rs/cargo@v1 run: cargo test --locked
- name: Build artifacts
run: cargo build --release --locked
- name: Upload artifacts
uses: actions/upload-artifact@master
with: with:
command: test name: iamb-${{ matrix.platform }}
path: |
./target/release/iamb
./target/release/iamb.exe

2
.gitignore vendored
View File

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

3993
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "iamb" name = "iamb"
version = "0.0.6" version = "0.0.9"
edition = "2018" edition = "2018"
authors = ["Ulyssa <git@ulyssa.dev>"] authors = ["Ulyssa <git@ulyssa.dev>"]
repository = "https://github.com/ulyssa/iamb" repository = "https://github.com/ulyssa/iamb"
@@ -11,40 +11,72 @@ license = "Apache-2.0"
exclude = [".github", "CONTRIBUTING.md"] exclude = [".github", "CONTRIBUTING.md"]
keywords = ["matrix", "chat", "tui", "vim"] keywords = ["matrix", "chat", "tui", "vim"]
categories = ["command-line-utilities"] categories = ["command-line-utilities"]
rust-version = "1.66" rust-version = "1.70"
build = "build.rs"
[features]
default = ["bundled"]
bundled = ["matrix-sdk/bundled-sqlite", "rustls-tls"]
native-tls = ["matrix-sdk/native-tls"]
rustls-tls = ["matrix-sdk/rustls-tls"]
[build-dependencies.vergen]
version = "8"
default-features = false
features = ["build", "git", "gitcl",]
[dependencies] [dependencies]
bitflags = "1.3.2" arboard = "3.3.0"
bitflags = "^2.3"
chrono = "0.4" chrono = "0.4"
clap = {version = "4.0", features = ["derive"]} clap = {version = "~4.3", features = ["derive"]}
comrak = {version = "0.18.0", features = ["shortcodes"]}
css-color-parser = "0.1.2" css-color-parser = "0.1.2"
dirs = "4.0.0" dirs = "4.0.0"
emojis = "~0.5.2" emojis = "~0.5.2"
futures = "0.3"
gethostname = "0.4.1" gethostname = "0.4.1"
html5ever = "0.26.0" html5ever = "0.26.0"
image = "0.24.5"
libc = "0.2"
markup5ever_rcdom = "0.2.0" markup5ever_rcdom = "0.2.0"
mime = "^0.3.16" mime = "^0.3.16"
mime_guess = "^2.0.4" mime_guess = "^2.0.4"
notify-rust = { version = "4.10.0", default-features = false, features = ["zbus", "serde"] }
open = "3.2.0" open = "3.2.0"
rand = "0.8.5"
ratatui = "0.23"
ratatui-image = { version = "0.8.1", features = ["serde"] }
regex = "^1.5" regex = "^1.5"
rpassword = "^7.2" rpassword = "^7.2"
serde = "^1.0" serde = "^1.0"
serde_json = "^1.0" serde_json = "^1.0"
sled = "0.34.7"
temp-dir = "0.1.12"
thiserror = "^1.0.37" thiserror = "^1.0.37"
toml = "^0.8.12"
tracing = "~0.1.36" tracing = "~0.1.36"
tracing-appender = "~0.2.2" tracing-appender = "~0.2.2"
tracing-subscriber = "0.3.16" tracing-subscriber = "0.3.16"
unicode-segmentation = "^1.7" unicode-segmentation = "^1.7"
unicode-width = "0.1.10" unicode-width = "0.1.10"
url = {version = "^2.2.2", features = ["serde"]} url = {version = "^2.2.2", features = ["serde"]}
edit = "0.1.4"
[dependencies.modalkit] [dependencies.modalkit]
version = "0.0.13" 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] [dependencies.matrix-sdk]
version = "0.6" version = "0.7.1"
default-features = false default-features = false
features = ["e2e-encryption", "markdown", "sled", "rustls-tls"] features = ["e2e-encryption", "sqlite", "sso-login"]
[dependencies.tokio] [dependencies.tokio]
version = "1.24.1" version = "1.24.1"
@@ -52,3 +84,9 @@ features = ["macros", "net", "rt-multi-thread", "sync", "time"]
[dev-dependencies] [dev-dependencies]
lazy_static = "1.4.0" lazy_static = "1.4.0"
pretty_assertions = "1.4.0"
[profile.release-lto]
inherits = "release"
incremental = false
lto = true

42
PACKAGING.md Normal file
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

138
README.md
View File

@@ -1,17 +1,31 @@
# iamb <div align="center">
<h1><img width="200" height="200" src="docs/iamb.svg"></h1>
[![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)][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>
[![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)
[![Latest Version](https://img.shields.io/crates/v/iamb.svg?logo=rust)](https://crates.io/crates/iamb)
## About ## About
`iamb` is a Matrix client for the terminal that uses Vim keybindings. `iamb` is a Matrix client for the terminal that uses Vim keybindings. It includes support for:
This project is a work-in-progress, and there's still a lot to be implemented, - Threads, spaces, E2EE, and read receipts
but much of the basic client functionality is already present. - Image previews in terminals that support it (sixels, Kitty, and iTerm2), or using pixelated blocks for those that don't
- Notifications via terminal bell or desktop environment
- Creating, joining, and leaving rooms
- Sending and accepting room invitations
- Editing, redacting, and reacting to messages
- Custom keybindings
- Multiple profiles
![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 ## Documentation
@@ -20,66 +34,70 @@ website, [iamb.chat].
## Installation ## Installation
Install Rust and Cargo, and then run: Install Rust (1.70.0 or above) and Cargo, and then run:
``` ```
cargo install --locked iamb cargo install --locked iamb
``` ```
## Configuration See [Configuration](#configuration) for getting a profile set up.
You can create a basic configuration in `$CONFIG_DIR/iamb/config.json` that looks like: ### NetBSD
```json On NetBSD a package is available from the official repositories. To install it simply run:
{
"profiles": { ```
"example.com": { pkgin install iamb
"url": "https://example.com",
"user_id": "@user:example.com"
}
}
}
``` ```
## Comparison With Other Clients ### Arch Linux
To get an idea of what is and isn't yet implemented, here is a subset of the On Arch Linux a [package](https://aur.archlinux.org/packages/iamb-git) is available in the
Matrix website's [features comparison table][client-comparison-matrix], showing Arch User Repositories (AUR). To install it simply run with your favorite AUR helper:
two other TUI clients and Element Web:
| | iamb | [gomuks] | [weechat-matrix] | Element Web/Desktop | ```
| --------------------------------------- | :---------- | :------: | :--------------: | :-----------------: | paru iamb-git
| Room directory | ❌ ([#14]) | ❌ | ✔️ | ✔️ | ```
| Room tag showing | ✔️ | ✔️ | ❌ | ✔️ | ### openSUSE Tumbleweed
| Room tag editing | ✔️ | ✔️ | ❌ | ✔️ |
| Search joined rooms | ❌ ([#16]) | ✔️ | ❌ | ✔️ | 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:
| Room user list | ✔️ | ✔️ | ✔️ | ✔️ |
| Display Room Description | ✔️ | ✔️ | ✔️ | ✔️ | ```
| Edit Room Description | ✔️ | ❌ | ✔️ | ✔️ | opi iamb
| Highlights | ❌ ([#8]) | ✔️ | ✔️ | ✔️ | ```
| Pushrules | ❌ | ✔️ | ❌ | ✔️ |
| Send read markers | ✔️ | ✔️ | ✔️ | ✔️ | ### Nix / NixOS (flake)
| Display read markers | ✔️ | ❌ | ❌ | ✔️ |
| Sending Invites | ✔️ | ✔️ | ✔️ | ✔️ | ```
| Accepting Invites | ✔️ | ✔️ | ✔️ | ✔️ | nix profile install "github:ulyssa/iamb"
| Typing Notification | ✔️ | ✔️ | ✔️ | ✔️ | ```
| E2E | ✔️ | ✔️ | ✔️ | ✔️ |
| Replies | ✔️ | ✔️ | ❌ | ✔️ | ### Snap
| Attachment uploading | ✔️ | ❌ | ✔️ | ✔️ |
| Attachment downloading | ✔️ | ✔️ | ✔️ | ✔️ | A snap for Linux distributions which [support](https://snapcraft.io/docs/installing-snapd) the packaging system.
| Send stickers | ❌ | ❌ | ❌ | ✔️ |
| Send formatted messages (markdown) | ✔️ | ✔️ | ✔️ | ✔️ | ```
| Rich Text Editor for formatted messages | ❌ | ❌ | ❌ | ✔️ | snap install iamb
| Display formatted messages | ✔️ | ✔️ | ✔️ | ✔️ | ```
| Redacting | ✔️ | ✔️ | ✔️ | ✔️ |
| Multiple Matrix Accounts | ✔️ | ❌ | ✔️ | ❌ | ## Configuration
| New user registration | ❌ | ❌ | ❌ | ✔️ |
| VOIP | ❌ | ❌ | ❌ | ✔️ | You can create a basic configuration in `$CONFIG_DIR/iamb/config.toml` that looks like:
| Reactions | ✔️ | ✔️ | ❌ | ✔️ |
| Message editing | ✔️ | ✔️ | ❌ | ✔️ | ```toml
| Room upgrades | ❌ ([#41]) | ✔️ | ❌ | ✔️ | [profiles."example.com"]
| Localisations | ❌ | 1 | ❌ | 44 | user_id = "@user:example.com"
| SSO Support | ❌ | ✔️ | ✔️ | ✔️ | ```
If you homeserver is located on a different domain than the server part of the
`user_id` and you don't have a [`/.well-known`][well_known_entry] entry, then
you can explicitly specify the homeserver URL to use:
```toml
[profiles."example.com"]
url = "https://example.com"
user_id = "@user:example.com"
```
## License ## License
@@ -87,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 [Apache License, Version 2.0]: https://github.com/ulyssa/iamb/blob/master/LICENSE
[client-comparison-matrix]: https://matrix.org/clients-matrix/ [client-comparison-matrix]: https://matrix.org/clients-matrix/
[crates-io-iamb]: https://crates.io/crates/iamb
[iamb.chat]: https://iamb.chat [iamb.chat]: https://iamb.chat
[gomuks]: https://github.com/tulir/gomuks [gomuks]: https://github.com/tulir/gomuks
[weechat-matrix]: https://github.com/poljar/weechat-matrix [weechat-matrix]: https://github.com/poljar/weechat-matrix
[#8]: https://github.com/ulyssa/iamb/issues/8 [well_known_entry]: https://spec.matrix.org/latest/client-server-api/#getwell-knownmatrixclient
[#14]: https://github.com/ulyssa/iamb/issues/14
[#16]: https://github.com/ulyssa/iamb/issues/16
[#41]: https://github.com/ulyssa/iamb/issues/41

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 std::convert::TryFrom;
use matrix_sdk::ruma::{events::tag::TagName, OwnedUserId}; use matrix_sdk::ruma::{events::tag::TagName, OwnedUserId};
use modalkit::{ use modalkit::{
editing::base::OpenTarget, commands::{CommandError, CommandResult, CommandStep},
env::vim::command::{CommandContext, CommandDescription, OptionType}, env::vim::command::{CommandContext, CommandDescription, OptionType},
input::commands::{CommandError, CommandResult, CommandStep}, prelude::OpenTarget,
input::InputContext,
}; };
use crate::base::{ use crate::base::{
@@ -16,17 +19,17 @@ use crate::base::{
HomeserverAction, HomeserverAction,
IambAction, IambAction,
IambId, IambId,
KeysAction,
MessageAction, MessageAction,
ProgramCommand, ProgramCommand,
ProgramCommands, ProgramCommands,
ProgramContext,
RoomAction, RoomAction,
RoomField, RoomField,
SendAction, SendAction,
VerifyAction, VerifyAction,
}; };
type ProgContext = CommandContext<ProgramContext>; type ProgContext = CommandContext;
type ProgResult = CommandResult<ProgramCommand>; type ProgResult = CommandResult<ProgramCommand>;
/// Convert strings the user types into a tag name. /// 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 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); return Ok(step);
} }
@@ -106,7 +132,7 @@ fn iamb_verify(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
match args.len() { match args.len() {
0 => { 0 => {
let open = ctx.switch(OpenTarget::Application(IambId::VerifyList)); let open = ctx.switch(OpenTarget::Application(IambId::VerifyList));
let step = CommandStep::Continue(open, ctx.context.take()); let step = CommandStep::Continue(open, ctx.context.clone());
return Ok(step); return Ok(step);
}, },
@@ -121,7 +147,7 @@ fn iamb_verify(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
"mismatch" => VerifyAction::Mismatch, "mismatch" => VerifyAction::Mismatch,
"request" => { "request" => {
let iact = IambAction::VerifyRequest(args.remove(1)); let iact = IambAction::VerifyRequest(args.remove(1));
let step = CommandStep::Continue(iact.into(), ctx.context.take()); let step = CommandStep::Continue(iact.into(), ctx.context.clone());
return Ok(step); return Ok(step);
}, },
@@ -129,7 +155,7 @@ fn iamb_verify(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
}; };
let vact = IambAction::Verify(act, args.remove(1)); let vact = IambAction::Verify(act, args.remove(1));
let step = CommandStep::Continue(vact.into(), ctx.context.take()); let step = CommandStep::Continue(vact.into(), ctx.context.clone());
return Ok(step); return Ok(step);
}, },
@@ -145,7 +171,7 @@ fn iamb_dms(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
} }
let open = ctx.switch(OpenTarget::Application(IambId::DirectList)); let open = ctx.switch(OpenTarget::Application(IambId::DirectList));
let step = CommandStep::Continue(open, ctx.context.take()); let step = CommandStep::Continue(open, ctx.context.clone());
return Ok(step); return Ok(step);
} }
@@ -156,7 +182,18 @@ fn iamb_members(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
} }
let open = IambAction::Room(RoomAction::Members(ctx.clone().into())); let open = IambAction::Room(RoomAction::Members(ctx.clone().into()));
let step = CommandStep::Continue(open.into(), ctx.context.take()); let step = CommandStep::Continue(open.into(), ctx.context.clone());
return Ok(step);
}
fn iamb_leave(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
if !desc.arg.text.is_empty() {
return Result::Err(CommandError::InvalidArgument);
}
let leave = IambAction::Room(RoomAction::Leave(desc.bang));
let step = CommandStep::Continue(leave.into(), ctx.context.clone());
return Ok(step); return Ok(step);
} }
@@ -166,8 +203,8 @@ fn iamb_cancel(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
return Result::Err(CommandError::InvalidArgument); return Result::Err(CommandError::InvalidArgument);
} }
let mact = IambAction::from(MessageAction::Cancel); let mact = IambAction::from(MessageAction::Cancel(desc.bang));
let step = CommandStep::Continue(mact.into(), ctx.context.take()); let step = CommandStep::Continue(mact.into(), ctx.context.clone());
return Ok(step); return Ok(step);
} }
@@ -178,7 +215,7 @@ fn iamb_edit(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
} }
let mact = IambAction::from(MessageAction::Edit); 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); 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)) { if let Some(emoji) = emojis::get(k).or_else(|| emojis::get_by_shortcode(k)) {
let mact = IambAction::from(MessageAction::React(emoji.to_string())); 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); return Ok(step);
} else { } else {
@@ -225,7 +262,7 @@ fn iamb_unreact(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
IambAction::from(MessageAction::Unreact(None)) 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); return Ok(step);
} }
@@ -237,8 +274,9 @@ fn iamb_redact(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
return Result::Err(CommandError::InvalidArgument); return Result::Err(CommandError::InvalidArgument);
} }
let ract = IambAction::from(MessageAction::Redact(args.into_iter().next())); let reason = args.into_iter().next();
let step = CommandStep::Continue(ract.into(), ctx.context.take()); let ract = IambAction::from(MessageAction::Redact(reason, desc.bang));
let step = CommandStep::Continue(ract.into(), ctx.context.clone());
return Ok(step); return Ok(step);
} }
@@ -249,7 +287,18 @@ fn iamb_reply(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
} }
let ract = IambAction::from(MessageAction::Reply); 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); 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 open = ctx.switch(OpenTarget::Application(IambId::RoomList));
let step = CommandStep::Continue(open, ctx.context.take()); let step = CommandStep::Continue(open, ctx.context.clone());
return Ok(step);
}
fn iamb_chats(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
if !desc.arg.text.is_empty() {
return Result::Err(CommandError::InvalidArgument);
}
let open = ctx.switch(OpenTarget::Application(IambId::ChatList));
let step = CommandStep::Continue(open, ctx.context.clone());
return Ok(step); return Ok(step);
} }
@@ -271,7 +331,7 @@ fn iamb_spaces(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
} }
let open = ctx.switch(OpenTarget::Application(IambId::SpaceList)); let open = ctx.switch(OpenTarget::Application(IambId::SpaceList));
let step = CommandStep::Continue(open, ctx.context.take()); let step = CommandStep::Continue(open, ctx.context.clone());
return Ok(step); return Ok(step);
} }
@@ -282,7 +342,7 @@ fn iamb_welcome(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
} }
let open = ctx.switch(OpenTarget::Application(IambId::Welcome)); let open = ctx.switch(OpenTarget::Application(IambId::Welcome));
let step = CommandStep::Continue(open, ctx.context.take()); let step = CommandStep::Continue(open, ctx.context.clone());
return Ok(step); return Ok(step);
} }
@@ -295,7 +355,7 @@ fn iamb_join(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
} }
let open = ctx.switch(args.remove(0)); let open = ctx.switch(args.remove(0));
let step = CommandStep::Continue(open, ctx.context.take()); let step = CommandStep::Continue(open, ctx.context.clone());
return Ok(step); return Ok(step);
} }
@@ -342,7 +402,7 @@ fn iamb_create(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
let hact = HomeserverAction::CreateRoom(alias, ct, flags); let hact = HomeserverAction::CreateRoom(alias, ct, flags);
let iact = IambAction::from(hact); 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); return Ok(step);
} }
@@ -389,7 +449,7 @@ fn iamb_room(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
_ => return Result::Err(CommandError::InvalidArgument), _ => return Result::Err(CommandError::InvalidArgument),
}; };
let step = CommandStep::Continue(act.into(), ctx.context.take()); let step = CommandStep::Continue(act.into(), ctx.context.clone());
return Ok(step); return Ok(step);
} }
@@ -403,7 +463,7 @@ fn iamb_upload(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
let sact = SendAction::Upload(args.remove(0)); let sact = SendAction::Upload(args.remove(0));
let iact = IambAction::from(sact); 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); return Ok(step);
} }
@@ -421,7 +481,7 @@ fn iamb_download(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult
}; };
let mact = MessageAction::Download(args.pop(), flags); let mact = MessageAction::Download(args.pop(), flags);
let iact = IambAction::from(mact); 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); return Ok(step);
} }
@@ -439,7 +499,20 @@ fn iamb_open(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
}; };
let mact = MessageAction::Download(args.pop(), flags); let mact = MessageAction::Download(args.pop(), flags);
let iact = IambAction::from(mact); 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); return Ok(step);
} }
@@ -455,6 +528,11 @@ fn add_iamb_commands(cmds: &mut ProgramCommands) {
aliases: vec![], aliases: vec![],
f: iamb_create, 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: "dms".into(), aliases: vec![], f: iamb_dms });
cmds.add_command(ProgramCommand { cmds.add_command(ProgramCommand {
name: "download".into(), name: "download".into(),
@@ -469,6 +547,12 @@ fn add_iamb_commands(cmds: &mut ProgramCommands) {
f: iamb_invite, f: iamb_invite,
}); });
cmds.add_command(ProgramCommand { name: "join".into(), aliases: vec![], f: iamb_join }); 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 { cmds.add_command(ProgramCommand {
name: "members".into(), name: "members".into(),
aliases: vec![], aliases: vec![],
@@ -520,8 +604,19 @@ fn add_iamb_commands(cmds: &mut ProgramCommands) {
aliases: vec![], aliases: vec![],
f: iamb_welcome, f: iamb_welcome,
}); });
cmds.add_command(ProgramCommand {
name: "editor".into(),
aliases: vec![],
f: iamb_editor,
});
cmds.add_command(ProgramCommand {
name: "logout".into(),
aliases: vec![],
f: iamb_logout,
});
} }
/// Initialize the default command state.
pub fn setup_commands() -> ProgramCommands { pub fn setup_commands() -> ProgramCommands {
let mut cmds = ProgramCommands::default(); let mut cmds = ProgramCommands::default();
@@ -534,12 +629,13 @@ pub fn setup_commands() -> ProgramCommands {
mod tests { mod tests {
use super::*; use super::*;
use matrix_sdk::ruma::user_id; use matrix_sdk::ruma::user_id;
use modalkit::editing::action::WindowAction; use modalkit::actions::WindowAction;
use modalkit::editing::context::EditContext;
#[test] #[test]
fn test_cmd_verify() { fn test_cmd_verify() {
let mut cmds = setup_commands(); let mut cmds = setup_commands();
let ctx = ProgramContext::default(); let ctx = EditContext::default();
let res = cmds.input_cmd(":verify", ctx.clone()).unwrap(); let res = cmds.input_cmd(":verify", ctx.clone()).unwrap();
let act = WindowAction::Switch(OpenTarget::Application(IambId::VerifyList)); let act = WindowAction::Switch(OpenTarget::Application(IambId::VerifyList));
@@ -586,7 +682,7 @@ mod tests {
#[test] #[test]
fn test_cmd_join() { fn test_cmd_join() {
let mut cmds = setup_commands(); let mut cmds = setup_commands();
let ctx = ProgramContext::default(); let ctx = EditContext::default();
let res = cmds.input_cmd("join #foobar:example.com", ctx.clone()).unwrap(); let res = cmds.input_cmd("join #foobar:example.com", ctx.clone()).unwrap();
let act = WindowAction::Switch(OpenTarget::Name("#foobar:example.com".into())); let act = WindowAction::Switch(OpenTarget::Name("#foobar:example.com".into()));
@@ -606,7 +702,7 @@ mod tests {
#[test] #[test]
fn test_cmd_room_invalid() { fn test_cmd_room_invalid() {
let mut cmds = setup_commands(); let mut cmds = setup_commands();
let ctx = ProgramContext::default(); let ctx = EditContext::default();
let res = cmds.input_cmd("room", ctx.clone()); let res = cmds.input_cmd("room", ctx.clone());
assert_eq!(res, Err(CommandError::InvalidArgument)); assert_eq!(res, Err(CommandError::InvalidArgument));
@@ -621,7 +717,7 @@ mod tests {
#[test] #[test]
fn test_cmd_room_topic_set() { fn test_cmd_room_topic_set() {
let mut cmds = setup_commands(); let mut cmds = setup_commands();
let ctx = ProgramContext::default(); let ctx = EditContext::default();
let res = cmds let res = cmds
.input_cmd("room topic set \"Lots of fun discussion!\"", ctx.clone()) .input_cmd("room topic set \"Lots of fun discussion!\"", ctx.clone())
@@ -652,7 +748,7 @@ mod tests {
#[test] #[test]
fn test_cmd_room_name_invalid() { fn test_cmd_room_name_invalid() {
let mut cmds = setup_commands(); let mut cmds = setup_commands();
let ctx = ProgramContext::default(); let ctx = EditContext::default();
let res = cmds.input_cmd("room name", ctx.clone()); let res = cmds.input_cmd("room name", ctx.clone());
assert_eq!(res, Err(CommandError::InvalidArgument)); assert_eq!(res, Err(CommandError::InvalidArgument));
@@ -664,7 +760,7 @@ mod tests {
#[test] #[test]
fn test_cmd_room_name_set() { fn test_cmd_room_name_set() {
let mut cmds = setup_commands(); 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 res = cmds.input_cmd("room name set Development", ctx.clone()).unwrap();
let act = RoomAction::Set(RoomField::Name, "Development".into()); let act = RoomAction::Set(RoomField::Name, "Development".into());
@@ -683,7 +779,7 @@ mod tests {
#[test] #[test]
fn test_cmd_room_name_unset() { fn test_cmd_room_name_unset() {
let mut cmds = setup_commands(); 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 res = cmds.input_cmd("room name unset", ctx.clone()).unwrap();
let act = RoomAction::Unset(RoomField::Name); let act = RoomAction::Unset(RoomField::Name);
@@ -696,7 +792,7 @@ mod tests {
#[test] #[test]
fn test_cmd_room_tag_set() { fn test_cmd_room_tag_set() {
let mut cmds = setup_commands(); 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 res = cmds.input_cmd("room tag set favourite", ctx.clone()).unwrap();
let act = RoomAction::Set(RoomField::Tag(TagName::Favorite), "".into()); let act = RoomAction::Set(RoomField::Tag(TagName::Favorite), "".into());
@@ -765,7 +861,7 @@ mod tests {
#[test] #[test]
fn test_cmd_room_tag_unset() { fn test_cmd_room_tag_unset() {
let mut cmds = setup_commands(); 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 res = cmds.input_cmd("room tag unset favourite", ctx.clone()).unwrap();
let act = RoomAction::Unset(RoomField::Tag(TagName::Favorite)); let act = RoomAction::Unset(RoomField::Tag(TagName::Favorite));
@@ -830,7 +926,7 @@ mod tests {
#[test] #[test]
fn test_cmd_invite() { fn test_cmd_invite() {
let mut cmds = setup_commands(); let mut cmds = setup_commands();
let ctx = ProgramContext::default(); let ctx = EditContext::default();
let res = cmds.input_cmd("invite accept", ctx.clone()).unwrap(); let res = cmds.input_cmd("invite accept", ctx.clone()).unwrap();
let act = IambAction::Room(RoomAction::InviteAccept); let act = IambAction::Room(RoomAction::InviteAccept);
@@ -867,21 +963,52 @@ mod tests {
#[test] #[test]
fn test_cmd_redact() { fn test_cmd_redact() {
let mut cmds = setup_commands(); let mut cmds = setup_commands();
let ctx = ProgramContext::default(); let ctx = EditContext::default();
let res = cmds.input_cmd("redact", ctx.clone()).unwrap(); 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())]); assert_eq!(res, vec![(act.into(), ctx.clone())]);
let res = cmds.input_cmd("redact Removed", ctx.clone()).unwrap(); 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())]); assert_eq!(res, vec![(act.into(), ctx.clone())]);
let res = cmds.input_cmd("redact \"Removed\"", ctx.clone()).unwrap(); 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())]); assert_eq!(res, vec![(act.into(), ctx.clone())]);
let res = cmds.input_cmd("redact Removed Removed", ctx.clone()); let res = cmds.input_cmd("redact Removed Removed", ctx.clone());
assert_eq!(res, Err(CommandError::InvalidArgument)); 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));
}
} }

File diff suppressed because it is too large Load Diff

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::{ use modalkit::{
editing::action::WindowAction, actions::{MacroAction, WindowAction},
env::vim::keybindings::{InputStep, VimBindings}, env::vim::keybindings::{InputStep, VimBindings},
env::vim::VimMode, env::vim::VimMode,
input::bindings::{EdgeEvent, EdgeRepeat, InputBindings}, env::CommonKeyClass,
input::key::TerminalKey, key::TerminalKey,
keybindings::{EdgeEvent, EdgeRepeat, InputBindings},
prelude::Count,
}; };
use crate::base::{IambAction, IambInfo, Keybindings, MATRIX_ID_WORD}; 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 { pub fn setup_keybindings() -> Keybindings {
let mut ism = Keybindings::empty(); let mut ism = Keybindings::empty();
@@ -19,20 +31,14 @@ pub fn setup_keybindings() -> Keybindings {
vim.setup(&mut ism); vim.setup(&mut ism);
let ctrl_w = EdgeEvent::Key("<C-W>".parse::<TerminalKey>().unwrap()); let ctrl_w = "<C-W>".parse::<TerminalKey>().unwrap();
let ctrl_m = EdgeEvent::Key("<C-M>".parse::<TerminalKey>().unwrap()); let ctrl_m = "<C-M>".parse::<TerminalKey>().unwrap();
let ctrl_z = EdgeEvent::Key("<C-Z>".parse::<TerminalKey>().unwrap()); let ctrl_z = "<C-Z>".parse::<TerminalKey>().unwrap();
let key_m_lc = EdgeEvent::Key("m".parse::<TerminalKey>().unwrap()); let key_m_lc = "m".parse::<TerminalKey>().unwrap();
let key_z_lc = EdgeEvent::Key("z".parse::<TerminalKey>().unwrap()); let key_z_lc = "z".parse::<TerminalKey>().unwrap();
let cwz = vec![ let cwz = vec![once(&ctrl_w), once(&key_z_lc)];
(EdgeRepeat::Once, ctrl_w.clone()), let cwcz = vec![once(&ctrl_w), once(&ctrl_z)];
(EdgeRepeat::Once, key_z_lc),
];
let cwcz = vec![
(EdgeRepeat::Once, ctrl_w.clone()),
(EdgeRepeat::Once, ctrl_z),
];
let zoom = IambStep::new() let zoom = IambStep::new()
.actions(vec![WindowAction::ZoomToggle.into()]) .actions(vec![WindowAction::ZoomToggle.into()])
.goto(VimMode::Normal); .goto(VimMode::Normal);
@@ -42,11 +48,8 @@ pub fn setup_keybindings() -> Keybindings {
ism.add_mapping(VimMode::Normal, &cwcz, &zoom); ism.add_mapping(VimMode::Normal, &cwcz, &zoom);
ism.add_mapping(VimMode::Visual, &cwcz, &zoom); ism.add_mapping(VimMode::Visual, &cwcz, &zoom);
let cwm = vec![ let cwm = vec![once(&ctrl_w), once(&key_m_lc)];
(EdgeRepeat::Once, ctrl_w.clone()), let cwcm = vec![once(&ctrl_w), once(&ctrl_m)];
(EdgeRepeat::Once, key_m_lc),
];
let cwcm = vec![(EdgeRepeat::Once, ctrl_w), (EdgeRepeat::Once, ctrl_m)];
let stoggle = IambStep::new() let stoggle = IambStep::new()
.actions(vec![IambAction::ToggleScrollbackFocus.into()]) .actions(vec![IambAction::ToggleScrollbackFocus.into()])
.goto(VimMode::Normal); .goto(VimMode::Normal);
@@ -54,6 +57,21 @@ pub fn setup_keybindings() -> Keybindings {
ism.add_mapping(VimMode::Visual, &cwm, &stoggle); ism.add_mapping(VimMode::Visual, &cwm, &stoggle);
ism.add_mapping(VimMode::Normal, &cwcm, &stoggle); ism.add_mapping(VimMode::Normal, &cwcm, &stoggle);
ism.add_mapping(VimMode::Visual, &cwcm, &stoggle); ism.add_mapping(VimMode::Visual, &cwcm, &stoggle);
ism
return 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::manual_range_contains)]
#![allow(clippy::needless_return)] #![allow(clippy::needless_return)]
#![allow(clippy::result_large_err)] #![allow(clippy::result_large_err)]
@@ -6,29 +19,41 @@ use std::collections::VecDeque;
use std::convert::TryFrom; use std::convert::TryFrom;
use std::fmt::Display; use std::fmt::Display;
use std::fs::{create_dir_all, File}; use std::fs::{create_dir_all, File};
use std::io::{stdout, BufReader, Stdout}; use std::io::{stdout, BufWriter, Stdout, Write};
use std::ops::DerefMut; use std::ops::DerefMut;
use std::process; use std::process;
use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::{Duration, Instant};
use clap::Parser; use clap::Parser;
use tokio::sync::Mutex as AsyncMutex; use matrix_sdk::crypto::encrypt_room_key_export;
use tracing::{self, Level}; use matrix_sdk::ruma::api::client::error::ErrorKind;
use tracing_subscriber::FmtSubscriber;
use matrix_sdk::ruma::OwnedUserId; use matrix_sdk::ruma::OwnedUserId;
use modalkit::keybindings::InputBindings;
use rand::{distributions::Alphanumeric, Rng};
use temp_dir::TempDir;
use tokio::sync::Mutex as AsyncMutex;
use tracing_subscriber::FmtSubscriber;
use modalkit::crossterm::{ use modalkit::crossterm::{
self, self,
cursor::Show as CursorShow, cursor::Show as CursorShow,
event::{poll, read, DisableBracketedPaste, EnableBracketedPaste, Event}, event::{
poll,
read,
DisableBracketedPaste,
DisableFocusChange,
EnableBracketedPaste,
EnableFocusChange,
Event,
KeyEventKind,
},
execute, execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, SetTitle}, terminal::{EnterAlternateScreen, LeaveAlternateScreen, SetTitle},
}; };
use modalkit::tui::{ use ratatui::{
backend::CrosstermBackend, backend::CrosstermBackend,
layout::Rect, layout::Rect,
style::{Color, Style}, style::{Color, Style},
@@ -42,6 +67,9 @@ mod commands;
mod config; mod config;
mod keybindings; mod keybindings;
mod message; mod message;
mod notifications;
mod preview;
mod sled_export;
mod util; mod util;
mod windows; mod windows;
mod worker; mod worker;
@@ -59,6 +87,7 @@ use crate::{
IambId, IambId,
IambInfo, IambInfo,
IambResult, IambResult,
KeysAction,
ProgramAction, ProgramAction,
ProgramContext, ProgramContext,
ProgramStore, ProgramStore,
@@ -69,53 +98,151 @@ use crate::{
}; };
use modalkit::{ use modalkit::{
editing::{ actions::{
action::{
Action, Action,
Commandable, Commandable,
EditError,
EditInfo,
Editable, Editable,
EditorAction, EditorAction,
InsertTextAction, InsertTextAction,
Jumpable, Jumpable,
Promptable, Promptable,
Scrollable, Scrollable,
TabAction,
TabContainer, TabContainer,
TabCount, TabCount,
WindowAction, WindowAction,
WindowContainer, WindowContainer,
}, },
base::{MoveDir1D, OpenTarget, RepeatType}, editing::{context::Resolve, key::KeyManager, store::Store},
context::Resolve, errors::{EditError, UIError},
key::KeyManager, key::TerminalKey,
store::Store, keybindings::{
dialog::{Pager, PromptYesNo},
BindingMachine,
}, },
input::{bindings::BindingMachine, key::TerminalKey}, prelude::*,
widgets::{ ui::FocusList,
};
use modalkit_ratatui::{
cmdbar::CommandBarState, cmdbar::CommandBarState,
screen::{Screen, ScreenState}, screen::{Screen, ScreenState, TabLayoutDescription},
windows::WindowLayoutDescription,
TerminalCursor, TerminalCursor,
TerminalExtOps, TerminalExtOps,
Window, Window,
},
}; };
const MIN_MSG_LOAD: u32 = 50; 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;
fn msg_load_req(area: Rect) -> u32 { let window = match window {
let n = area.height as u32; 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,
};
n.max(MIN_MSG_LOAD) WindowLayoutDescription::Window { window, length: None }
},
config::WindowLayout::Split { split } => {
let children = split
.into_iter()
.map(|child| config_tab_to_desc(child, store))
.collect::<IambResult<Vec<_>>>()?;
WindowLayoutDescription::Split { children, length: None }
},
};
Ok(desc)
} }
fn setup_screen(
settings: ApplicationSettings,
store: &mut ProgramStore,
) -> IambResult<ScreenState<IambWindow, IambInfo>> {
let cmd = CommandBarState::new(store);
let dims = crossterm::terminal::size()?;
let area = Rect::new(0, 0, dims.0, dims.1);
match settings.layout {
config::Layout::Restore => {
if let Ok(layout) = std::fs::read(&settings.layout_json) {
let tabs: TabLayoutDescription<IambInfo> =
serde_json::from_slice(&layout).map_err(IambError::from)?;
let tabs = tabs.to_layout(area.into(), store)?;
return Ok(ScreenState::from_list(tabs, cmd));
}
},
config::Layout::New => {},
config::Layout::Config { tabs } => {
let mut list = FocusList::default();
for tab in tabs.into_iter() {
let tab = config_tab_to_desc(tab, store)?;
let tab = tab.to_layout(area.into(), store)?;
list.push(tab);
}
return Ok(ScreenState::from_list(list, cmd));
},
}
let win = settings
.tunables
.default_room
.and_then(|room| IambWindow::find(room, store).ok())
.or_else(|| IambWindow::open(IambId::Welcome, store).ok())
.unwrap();
return Ok(ScreenState::new(win, cmd));
}
/// The main application state and event loop.
struct Application { struct Application {
store: AsyncProgramStore, /// Terminal backend.
worker: Requester,
terminal: Terminal<CrosstermBackend<Stdout>>, terminal: Terminal<CrosstermBackend<Stdout>>,
bindings: KeyManager<TerminalKey, ProgramAction, RepeatType, ProgramContext>,
actstack: VecDeque<(ProgramAction, ProgramContext)>, /// State for the Matrix client, editing, etc.
store: AsyncProgramStore,
/// UI state (open tabs, command bar, etc.) to use when rendering.
screen: ScreenState<IambWindow, IambInfo>, screen: ScreenState<IambWindow, IambInfo>,
/// Handle to communicate synchronously with the Matrix worker task.
worker: Requester,
/// Mapped keybindings.
bindings: KeyManager<TerminalKey, ProgramAction, RepeatType>,
/// Pending actions to run.
actstack: VecDeque<(ProgramAction, ProgramContext)>,
/// Whether or not the terminal is currently focused.
focused: bool,
/// The tab layout before the last executed [TabAction].
last_layout: Option<TabLayoutDescription<IambInfo>>,
/// Whether we need to do a full redraw (e.g., after running a subprocess).
dirty: bool,
} }
impl Application { impl Application {
@@ -127,6 +254,7 @@ impl Application {
crossterm::terminal::enable_raw_mode()?; crossterm::terminal::enable_raw_mode()?;
crossterm::execute!(stdout, EnterAlternateScreen)?; crossterm::execute!(stdout, EnterAlternateScreen)?;
crossterm::execute!(stdout, EnableBracketedPaste)?; crossterm::execute!(stdout, EnableBracketedPaste)?;
crossterm::execute!(stdout, EnableFocusChange)?;
let title = format!("iamb ({})", settings.profile.user_id); let title = format!("iamb ({})", settings.profile.user_id);
crossterm::execute!(stdout, SetTitle(title))?; crossterm::execute!(stdout, SetTitle(title))?;
@@ -134,22 +262,15 @@ impl Application {
let backend = CrosstermBackend::new(stdout); let backend = CrosstermBackend::new(stdout);
let terminal = Terminal::new(backend)?; let terminal = Terminal::new(backend)?;
let bindings = crate::keybindings::setup_keybindings(); let mut bindings = crate::keybindings::setup_keybindings();
settings.setup(&mut bindings);
let bindings = KeyManager::new(bindings); let bindings = KeyManager::new(bindings);
let mut locked = store.lock().await; let mut locked = store.lock().await;
let screen = setup_screen(settings, locked.deref_mut())?;
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 worker = locked.application.worker.clone(); let worker = locked.application.worker.clone();
drop(locked); drop(locked);
let actstack = VecDeque::new(); let actstack = VecDeque::new();
@@ -161,15 +282,22 @@ impl Application {
bindings, bindings,
actstack, actstack,
screen, screen,
focused: true,
last_layout: None,
dirty: true,
}) })
} }
fn redraw(&mut self, full: bool, store: &mut ProgramStore) -> Result<(), std::io::Error> { fn redraw(&mut self, full: bool, store: &mut ProgramStore) -> Result<(), std::io::Error> {
let modestr = self.bindings.showmode(); let bindings = &mut self.bindings;
let cursor = self.bindings.get_cursor_indicator(); let focused = self.focused;
let sstate = &mut self.screen; let sstate = &mut self.screen;
let term = &mut self.terminal; let term = &mut self.terminal;
if store.application.ring_bell {
store.application.ring_bell = term.backend_mut().write_all(&[7]).is_err();
}
if full { if full {
term.clear()?; term.clear()?;
} }
@@ -177,9 +305,25 @@ impl Application {
term.draw(|f| { term.draw(|f| {
let area = f.size(); let area = f.size();
let screen = Screen::new(store).showmode(modestr).borders(true); let modestr = bindings.show_mode();
let cursor = bindings.get_cursor_indicator();
let dialogstr = bindings.show_dialog(area.height as usize, area.width as usize);
// Don't show terminal cursor when we show a dialog.
let hide_cursor = !dialogstr.is_empty();
store.application.draw_curr = Some(Instant::now());
let screen = Screen::new(store)
.show_dialog(dialogstr)
.show_mode(modestr)
.borders(true)
.focus(focused);
f.render_stateful_widget(screen, area, sstate); f.render_stateful_widget(screen, area, sstate);
if hide_cursor {
return;
}
if let Some((cx, cy)) = sstate.get_term_cursor() { if let Some((cx, cy)) = sstate.get_term_cursor() {
if let Some(c) = cursor { if let Some(c) = cursor {
let style = Style::default().fg(Color::Green); let style = Style::default().fg(Color::Green);
@@ -190,8 +334,6 @@ impl Application {
} }
f.set_cursor(cx, cy); f.set_cursor(cx, cy);
} }
store.application.load_older(msg_load_req(area));
})?; })?;
Ok(()) Ok(())
@@ -199,7 +341,8 @@ impl Application {
async fn step(&mut self) -> Result<TerminalKey, std::io::Error> { async fn step(&mut self) -> Result<TerminalKey, std::io::Error> {
loop { loop {
self.redraw(false, self.store.clone().lock().await.deref_mut())?; self.redraw(self.dirty, self.store.clone().lock().await.deref_mut())?;
self.dirty = false;
if !poll(Duration::from_secs(1))? { if !poll(Duration::from_secs(1))? {
// Redraw in case there's new messages to show. // Redraw in case there's new messages to show.
@@ -207,12 +350,21 @@ impl Application {
} }
match read()? { match read()? {
Event::Key(ke) => return Ok(ke.into()), Event::Key(ke) => {
if ke.kind == KeyEventKind::Release {
continue;
}
return Ok(ke.into());
},
Event::Mouse(_) => { Event::Mouse(_) => {
// Do nothing for now. // Do nothing for now.
}, },
Event::FocusGained | Event::FocusLost => { Event::FocusGained => {
// Do nothing for now. self.focused = true;
},
Event::FocusLost => {
self.focused = false;
}, },
Event::Resize(_, _) => { Event::Resize(_, _) => {
// We'll redraw for the new size next time step() is called. // We'll redraw for the new size next time step() is called.
@@ -226,7 +378,8 @@ impl Application {
match self.screen.editor_command(&act, &ctx, store.deref_mut()) { match self.screen.editor_command(&act, &ctx, store.deref_mut()) {
Ok(None) => {}, Ok(None) => {},
Ok(Some(info)) => { Ok(Some(info)) => {
self.screen.push_info(info); drop(store);
self.handle_info(info);
}, },
Err(e) => { Err(e) => {
self.screen.push_error(e); self.screen.push_error(e);
@@ -290,8 +443,7 @@ impl Application {
Action::CommandBar(act) => self.screen.command_bar(&act, &ctx)?, Action::CommandBar(act) => self.screen.command_bar(&act, &ctx)?,
Action::Macro(act) => self.bindings.macro_command(&act, &ctx, store)?, Action::Macro(act) => self.bindings.macro_command(&act, &ctx, store)?,
Action::Scroll(style) => self.screen.scroll(&style, &ctx, store)?, Action::Scroll(style) => self.screen.scroll(&style, &ctx, store)?,
Action::Suspend => self.terminal.program_suspend()?, Action::ShowInfoMessage(info) => Some(info),
Action::Tab(cmd) => self.screen.tab_command(&cmd, &ctx, store)?,
Action::Window(cmd) => self.screen.window_command(&cmd, &ctx, store)?, Action::Window(cmd) => self.screen.window_command(&cmd, &ctx, store)?,
Action::Jump(l, dir, count) => { Action::Jump(l, dir, count) => {
@@ -300,8 +452,20 @@ impl Application {
None None
}, },
Action::Suspend => {
self.terminal.program_suspend()?;
None
},
// UI actions. // UI actions.
Action::Tab(cmd) => {
if let TabAction::Close(_, _) = &cmd {
self.last_layout = self.screen.as_description().into();
}
self.screen.tab_command(&cmd, &ctx, store)?
},
Action::RedrawScreen => { Action::RedrawScreen => {
self.screen.clear_message(); self.screen.clear_message();
self.redraw(true, store)?; self.redraw(true, store)?;
@@ -349,6 +513,10 @@ impl Application {
ctx: ProgramContext, ctx: ProgramContext,
store: &mut ProgramStore, store: &mut ProgramStore,
) -> IambResult<EditInfo> { ) -> IambResult<EditInfo> {
if action.scribbles() {
self.dirty = true;
}
let info = match action { let info = match action {
IambAction::ToggleScrollbackFocus => { IambAction::ToggleScrollbackFocus => {
self.screen.current_window_mut()?.focus_toggle(); self.screen.current_window_mut()?.focus_toggle();
@@ -362,6 +530,7 @@ impl Application {
None None
}, },
IambAction::Keys(act) => self.keys_command(act, ctx, store).await?,
IambAction::Message(act) => { IambAction::Message(act) => {
self.screen.current_window_mut()?.message_command(act, ctx, store).await? self.screen.current_window_mut()?.message_command(act, ctx, store).await?
}, },
@@ -375,6 +544,14 @@ impl Application {
self.screen.current_window_mut()?.send_command(act, ctx, store).await? self.screen.current_window_mut()?.send_command(act, ctx, store).await?
}, },
IambAction::OpenLink(url) => {
tokio::task::spawn_blocking(move || {
return open::that(url);
});
None
},
IambAction::Verify(act, user_dev) => { IambAction::Verify(act, user_dev) => {
if let Some(sas) = store.application.verifications.get(&user_dev) { if let Some(sas) = store.application.verifications.get(&user_dev) {
self.worker.verify(act, sas.clone())? self.worker.verify(act, sas.clone())?
@@ -403,13 +580,70 @@ impl Application {
match action { match action {
HomeserverAction::CreateRoom(alias, vis, flags) => { HomeserverAction::CreateRoom(alias, vis, flags) => {
let client = &store.application.worker.client; let client = &store.application.worker.client;
let room_id = create_room(client, alias.as_deref(), vis, flags).await?; let room_id = create_room(client, alias, vis, flags).await?;
let room = IambId::Room(room_id); let room = IambId::Room(room_id, None);
let target = OpenTarget::Application(room); let target = OpenTarget::Application(room);
let action = WindowAction::Switch(target); let action = WindowAction::Switch(target);
Ok(vec![(action.into(), ctx)]) 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);
},
} }
} }
@@ -433,11 +667,18 @@ impl Application {
continue; continue;
}, },
Ok(Some(info)) => { Ok(Some(info)) => {
self.screen.push_info(info); self.handle_info(info);
// Continue processing; we'll redraw later. // Continue processing; we'll redraw later.
continue; continue;
}, },
Err(
UIError::NeedConfirm(dialog) |
UIError::EditingFailure(EditError::NeedConfirm(dialog)),
) => {
self.bindings.run_dialog(dialog);
continue;
},
Err(e) => { Err(e) => {
self.screen.push_error(e); self.screen.push_error(e);
@@ -449,6 +690,19 @@ impl Application {
} }
} }
if let Some(ref layout) = self.last_layout {
let locked = self.store.lock().await;
let path = locked.application.settings.layout_json.as_path();
path.parent().map(create_dir_all).transpose()?;
let file = File::create(path)?;
let writer = BufWriter::new(file);
if let Err(e) = serde_json::to_writer(writer, layout) {
tracing::error!("Failed to save window layout while exiting: {}", e);
}
}
crossterm::terminal::disable_raw_mode()?; crossterm::terminal::disable_raw_mode()?;
execute!(self.terminal.backend_mut(), LeaveAlternateScreen)?; execute!(self.terminal.backend_mut(), LeaveAlternateScreen)?;
self.terminal.show_cursor()?; self.terminal.show_cursor()?;
@@ -457,23 +711,59 @@ impl Application {
} }
} }
async fn login(worker: Requester, settings: &ApplicationSettings) -> IambResult<()> { fn gen_passphrase() -> String {
println!("Logging in for {}...", settings.profile.user_id); rand::thread_rng()
.sample_iter(&Alphanumeric)
.take(20)
.map(char::from)
.collect()
}
fn read_response(question: &str) -> String {
println!("{question}");
let mut input = String::new();
let _ = std::io::stdin().read_line(&mut input);
input
}
fn read_yesno(question: &str) -> Option<char> {
read_response(question).chars().next().map(|c| c.to_ascii_lowercase())
}
async fn login(worker: &Requester, settings: &ApplicationSettings) -> IambResult<()> {
if settings.session_json.is_file() { if settings.session_json.is_file() {
let file = File::open(settings.session_json.as_path())?; let session = settings.read_session(&settings.session_json)?;
let reader = BufReader::new(file); worker.login(LoginStyle::SessionRestore(session.into()))?;
let session = serde_json::from_reader(reader).map_err(IambError::from)?;
worker.login(LoginStyle::SessionRestore(session))?; return Ok(());
}
if settings.session_json_old.is_file() && !settings.sled_dir.is_dir() {
let session = settings.read_session(&settings.session_json_old)?;
worker.login(LoginStyle::SessionRestore(session.into()))?;
return Ok(()); return Ok(());
} }
loop { loop {
let login_style =
match read_response("Please select login type: [p]assword / [s]ingle sign on")
.chars()
.next()
.map(|c| c.to_ascii_lowercase())
{
None | Some('p') => {
let password = rpassword::prompt_password("Password: ")?; let password = rpassword::prompt_password("Password: ")?;
LoginStyle::Password(password)
},
Some('s') => LoginStyle::SingleSignOn,
Some(_) => {
println!("Failed to login. Please enter 'p' or 's'");
continue;
},
};
match worker.login(LoginStyle::Password(password)) { match worker.login(login_style) {
Ok(info) => { Ok(info) => {
if let Some(msg) = info { if let Some(msg) = info {
println!("{msg}"); println!("{msg}");
@@ -492,27 +782,174 @@ async fn login(worker: Requester, settings: &ApplicationSettings) -> IambResult<
} }
fn print_exit<T: Display, N>(v: T) -> N { fn print_exit<T: Display, N>(v: T) -> N {
println!("{v}"); eprintln!("{v}");
process::exit(2); process::exit(2);
} }
// 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<()> { async fn run(settings: ApplicationSettings) -> IambResult<()> {
// Get old keys the first time we run w/ the upgraded SDK.
let import_keys = check_import_keys(&settings).await?;
// Set up client state.
create_dir_all(settings.sqlite_dir.as_path())?;
let client = worker::create_client(&settings).await;
// Set up the async worker thread and global store. // Set up the async worker thread and global store.
let worker = ClientWorker::spawn(settings.clone()).await; let worker = ClientWorker::spawn(client.clone(), settings.clone()).await;
let store = ChatStore::new(worker.clone(), settings.clone()); let store = ChatStore::new(worker.clone(), settings.clone());
let store = Store::new(store); let store = Store::new(store);
let store = Arc::new(AsyncMutex::new(store)); let store = Arc::new(AsyncMutex::new(store));
worker.init(store.clone()); worker.init(store.clone());
login(worker, &settings).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. // Make sure panics clean up the terminal properly.
let orig_hook = std::panic::take_hook(); let orig_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |panic_info| { std::panic::set_hook(Box::new(move |panic_info| {
let _ = crossterm::terminal::disable_raw_mode(); restore_tty();
let _ = crossterm::execute!(stdout(), DisableBracketedPaste);
let _ = crossterm::execute!(stdout(), LeaveAlternateScreen);
let _ = crossterm::execute!(stdout(), CursorShow);
orig_hook(panic_info); orig_hook(panic_info);
process::exit(1); process::exit(1);
})); }));
@@ -521,6 +958,7 @@ async fn run(settings: ApplicationSettings) -> IambResult<()> {
// We can now run the application. // We can now run the application.
application.run().await?; application.run().await?;
restore_tty();
Ok(()) Ok(())
} }
@@ -532,24 +970,28 @@ fn main() -> IambResult<()> {
// Load configuration and set up the Matrix SDK. // Load configuration and set up the Matrix SDK.
let settings = ApplicationSettings::load(iamb).unwrap_or_else(print_exit); 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. // Set up the tracing subscriber so we can log client messages.
let log_prefix = format!("iamb-log-{}", settings.profile_name); let log_prefix = format!("iamb-log-{}", settings.profile_name);
let log_dir = settings.dirs.logs.as_path(); 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 = tracing_appender::rolling::daily(log_dir, log_prefix);
let (appender, guard) = tracing_appender::non_blocking(appender); let (appender, guard) = tracing_appender::non_blocking(appender);
let subscriber = FmtSubscriber::builder() let subscriber = FmtSubscriber::builder()
.with_writer(appender) .with_writer(appender)
.with_max_level(Level::INFO) .with_max_level(settings.tunables.log_level)
.finish(); .finish();
tracing::subscriber::set_global_default(subscriber).expect("setting default subscriber failed"); tracing::subscriber::set_global_default(subscriber).expect("setting default subscriber failed");
let rt = tokio::runtime::Builder::new_multi_thread() let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all() .enable_all()
.worker_threads(2)
.thread_name_fn(|| { .thread_name_fn(|| {
static ATOMIC_ID: AtomicUsize = AtomicUsize::new(0); static ATOMIC_ID: AtomicUsize = AtomicUsize::new(0);
let id = ATOMIC_ID.fetch_add(1, Ordering::SeqCst); 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 std::borrow::Cow;
use modalkit::tui::layout::Alignment; use ratatui::layout::Alignment;
use modalkit::tui::style::Style; use ratatui::style::Style;
use modalkit::tui::text::{Span, Spans, Text}; use ratatui::text::{Line, Span, Text};
use unicode_segmentation::UnicodeSegmentation; use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr; 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> { pub struct TextPrinter<'a> {
text: Text<'a>, text: Text<'a>,
width: usize, width: usize,
base_style: Style, base_style: Style,
hide_reply: bool, hide_reply: bool,
emoji_shortcodes: bool,
alignment: Alignment, alignment: Alignment,
curr_spans: Vec<Span<'a>>, curr_spans: Vec<Span<'a>>,
curr_width: usize, curr_width: usize,
literal: bool,
} }
impl<'a> TextPrinter<'a> { impl<'a> TextPrinter<'a> {
pub fn new(width: usize, base_style: Style, hide_reply: bool) -> Self { /// Create a new printer.
pub fn new(width: usize, base_style: Style, hide_reply: bool, emoji_shortcodes: bool) -> Self {
TextPrinter { TextPrinter {
text: Text::default(), text: Text::default(),
width, width,
base_style, base_style,
hide_reply, hide_reply,
emoji_shortcodes,
alignment: Alignment::Left, alignment: Alignment::Left,
curr_spans: vec![], curr_spans: vec![],
curr_width: 0, curr_width: 0,
literal: false,
} }
} }
/// Configure the alignment for each line.
pub fn align(mut self, alignment: Alignment) -> Self { pub fn align(mut self, alignment: Alignment) -> Self {
self.alignment = alignment; self.alignment = alignment;
self self
} }
/// Set whether newlines should be treated literally, or turned into spaces.
pub fn literal(mut self, literal: bool) -> Self {
self.literal = literal;
self
}
/// Indicates whether replies should be pushed to the printer.
pub fn hide_reply(&self) -> bool { pub fn hide_reply(&self) -> bool {
self.hide_reply self.hide_reply
} }
/// Indicates whether emojis should be replaced by shortcodes
pub fn emoji_shortcodes(&self) -> bool {
self.emoji_shortcodes
}
/// Indicates the current printer's width.
pub fn width(&self) -> usize { pub fn width(&self) -> usize {
self.width self.width
} }
/// Create a new printer with a smaller width.
pub fn sub(&self, indent: usize) -> Self { pub fn sub(&self, indent: usize) -> Self {
TextPrinter { TextPrinter {
text: Text::default(), text: Text::default(),
width: self.width.saturating_sub(indent), width: self.width.saturating_sub(indent),
base_style: self.base_style, base_style: self.base_style,
hide_reply: self.hide_reply, hide_reply: self.hide_reply,
emoji_shortcodes: self.emoji_shortcodes,
alignment: self.alignment, alignment: self.alignment,
curr_spans: vec![], curr_spans: vec![],
curr_width: 0, curr_width: 0,
literal: self.literal,
} }
} }
@@ -63,6 +97,7 @@ impl<'a> TextPrinter<'a> {
self.width - self.curr_width self.width - self.curr_width
} }
/// If there is any text on the current line, start a new one.
pub fn commit(&mut self) { pub fn commit(&mut self) {
if self.curr_width > 0 { if self.curr_width > 0 {
self.push_break(); self.push_break();
@@ -71,9 +106,10 @@ impl<'a> TextPrinter<'a> {
fn push(&mut self) { fn push(&mut self) {
self.curr_width = 0; self.curr_width = 0;
self.text.lines.push(Spans(std::mem::take(&mut self.curr_spans))); self.text.lines.push(Line::from(std::mem::take(&mut self.curr_spans)));
} }
/// Start a new line.
pub fn push_break(&mut self) { pub fn push_break(&mut self) {
if self.curr_width == 0 && self.text.lines.is_empty() { if self.curr_width == 0 && self.text.lines.is_empty() {
// Disallow leading breaks. // Disallow leading breaks.
@@ -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()); let sw = UnicodeWidthStr::width(span.content.as_ref());
if self.curr_width + sw > self.width { if self.curr_width + sw > self.width {
@@ -153,19 +193,39 @@ impl<'a> TextPrinter<'a> {
self.curr_width += sw; self.curr_width += sw;
} }
/// Push text with a [Style].
pub fn push_str(&mut self, s: &'a str, style: Style) { pub fn push_str(&mut self, s: &'a str, style: Style) {
let style = self.base_style.patch(style); let style = self.base_style.patch(style);
for word in UnicodeSegmentation::split_word_bounds(s) { if self.width == 0 {
if self.width == 0 && word.chars().all(char::is_whitespace) { 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. // Drop leading whitespace.
continue; 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 { if sw > self.width {
self.push_str_wrapped(word, style); self.push_str_wrapped(cow, style);
continue; continue;
} }
@@ -173,13 +233,13 @@ impl<'a> TextPrinter<'a> {
// Word doesn't fit on this line, so start a new one. // Word doesn't fit on this line, so start a new one.
self.commit(); self.commit();
if word.chars().all(char::is_whitespace) { if !self.literal && cow.chars().all(char::is_whitespace) {
// Drop leading whitespace. // Drop leading whitespace.
continue; continue;
} }
} }
let span = Span::styled(word, style); let span = Span::styled(cow, style);
self.curr_spans.push(span); self.curr_spans.push(span);
self.curr_width += sw; 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.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(); self.commit();
if self.emoji_shortcodes {
for line in &mut text.lines {
replace_emojis_in_line(line);
}
}
self.text.lines.extend(text.lines); self.text.lines.extend(text.lines);
} }
/// Render the contents of this printer as [Text].
pub fn finish(mut self) -> Text<'a> { pub fn finish(mut self) -> Text<'a> {
self.commit(); self.commit();
self.text self.text

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 std::path::PathBuf;
use matrix_sdk::ruma::{ use matrix_sdk::ruma::{
@@ -15,20 +15,25 @@ use matrix_sdk::ruma::{
}; };
use lazy_static::lazy_static; use lazy_static::lazy_static;
use modalkit::tui::style::{Color, Style}; use ratatui::style::{Color, Style};
use tokio::sync::mpsc::unbounded_channel; use tokio::sync::mpsc::unbounded_channel;
use tracing::Level;
use url::Url; use url::Url;
use crate::{ use crate::{
base::{ChatStore, EventLocation, ProgramStore, RoomFetchStatus, RoomInfo}, base::{ChatStore, EventLocation, ProgramStore, RoomInfo},
config::{ config::{
user_color, user_color,
user_style_from_color, user_style_from_color,
ApplicationSettings, ApplicationSettings,
DirectoryValues, DirectoryValues,
Notifications,
NotifyVia,
ProfileConfig, ProfileConfig,
SortOverrides,
TunableValues, TunableValues,
UserColor, UserColor,
UserDisplayStyle,
UserDisplayTunables, UserDisplayTunables,
}, },
message::{ message::{
@@ -41,6 +46,8 @@ use crate::{
worker::Requester, worker::Requester,
}; };
const TEST_ROOM1_ALIAS: &str = "#room1:example.com";
lazy_static! { lazy_static! {
pub static ref TEST_ROOM1_ID: OwnedRoomId = RoomId::new(server_name!("example.com")).to_owned(); pub static ref TEST_ROOM1_ID: OwnedRoomId = RoomId::new(server_name!("example.com")).to_owned();
pub static ref TEST_USER1: OwnedUserId = user_id!("@user1:example.com").to_owned(); pub static ref TEST_USER1: OwnedUserId = user_id!("@user1:example.com").to_owned();
@@ -120,17 +127,17 @@ pub fn mock_message5() -> Message {
pub fn mock_keys() -> HashMap<OwnedEventId, EventLocation> { pub fn mock_keys() -> HashMap<OwnedEventId, EventLocation> {
let mut keys = HashMap::new(); let mut keys = HashMap::new();
keys.insert(MSG1_EVID.clone(), EventLocation::Message(MSG1_KEY.clone())); keys.insert(MSG1_EVID.clone(), EventLocation::Message(None, MSG1_KEY.clone()));
keys.insert(MSG2_EVID.clone(), EventLocation::Message(MSG2_KEY.clone())); keys.insert(MSG2_EVID.clone(), EventLocation::Message(None, MSG2_KEY.clone()));
keys.insert(MSG3_EVID.clone(), EventLocation::Message(MSG3_KEY.clone())); keys.insert(MSG3_EVID.clone(), EventLocation::Message(None, MSG3_KEY.clone()));
keys.insert(MSG4_EVID.clone(), EventLocation::Message(MSG4_KEY.clone())); keys.insert(MSG4_EVID.clone(), EventLocation::Message(None, MSG4_KEY.clone()));
keys.insert(MSG5_EVID.clone(), EventLocation::Message(MSG5_KEY.clone())); keys.insert(MSG5_EVID.clone(), EventLocation::Message(None, MSG5_KEY.clone()));
keys keys
} }
pub fn mock_messages() -> Messages { pub fn mock_messages() -> Messages {
let mut messages = BTreeMap::new(); let mut messages = Messages::default();
messages.insert(MSG1_KEY.clone(), mock_message1()); messages.insert(MSG1_KEY.clone(), mock_message1());
messages.insert(MSG2_KEY.clone(), mock_message2()); messages.insert(MSG2_KEY.clone(), mock_message2());
@@ -142,38 +149,34 @@ pub fn mock_messages() -> Messages {
} }
pub fn mock_room() -> RoomInfo { pub fn mock_room() -> RoomInfo {
RoomInfo { let mut room = RoomInfo::default();
name: Some("Watercooler Discussion".into()), room.name = Some("Watercooler Discussion".into());
tags: None, room.keys = mock_keys();
*room.get_thread_mut(None) = mock_messages();
keys: mock_keys(), room
messages: mock_messages(),
receipts: HashMap::new(),
read_till: None,
reactions: HashMap::new(),
fetch_id: RoomFetchStatus::NotStarted,
fetch_last: None,
users_typing: None,
}
} }
pub fn mock_dirs() -> DirectoryValues { pub fn mock_dirs() -> DirectoryValues {
DirectoryValues { DirectoryValues {
cache: PathBuf::new(), cache: PathBuf::new(),
data: PathBuf::new(),
logs: PathBuf::new(), logs: PathBuf::new(),
downloads: PathBuf::new(), downloads: None,
image_previews: PathBuf::new(),
} }
} }
pub fn mock_tunables() -> TunableValues { pub fn mock_tunables() -> TunableValues {
TunableValues { TunableValues {
default_room: None, default_room: None,
log_level: Level::INFO,
message_shortcode_display: false,
reaction_display: true, reaction_display: true,
reaction_shortcode_display: false, reaction_shortcode_display: false,
read_receipt_send: true, read_receipt_send: true,
read_receipt_display: true, read_receipt_display: true,
request_timeout: 120,
sort: SortOverrides::default().values(),
typing_notice_send: true, typing_notice_send: true,
typing_notice_display: true, typing_notice_display: true,
users: vec![(TEST_USER5.clone(), UserDisplayTunables { users: vec![(TEST_USER5.clone(), UserDisplayTunables {
@@ -182,22 +185,40 @@ pub fn mock_tunables() -> TunableValues {
})] })]
.into_iter() .into_iter()
.collect::<HashMap<_, _>>(), .collect::<HashMap<_, _>>(),
open_command: None,
username_display: UserDisplayStyle::Username,
message_user_color: false,
notifications: Notifications {
enabled: false,
via: NotifyVia::Desktop,
show_message: true,
},
image_preview: None,
user_gutter_width: 30,
} }
} }
pub fn mock_settings() -> ApplicationSettings { pub fn mock_settings() -> ApplicationSettings {
ApplicationSettings { ApplicationSettings {
matrix_dir: PathBuf::new(), layout_json: PathBuf::new(),
session_json: PathBuf::new(), session_json: PathBuf::new(),
session_json_old: PathBuf::new(),
sled_dir: PathBuf::new(),
sqlite_dir: PathBuf::new(),
profile_name: "test".into(), profile_name: "test".into(),
profile: ProfileConfig { profile: ProfileConfig {
user_id: user_id!("@user:example.com").to_owned(), user_id: user_id!("@user:example.com").to_owned(),
url: Url::parse("https://example.com").unwrap(), url: None,
settings: None, settings: None,
dirs: None, dirs: None,
layout: None,
macros: None,
}, },
tunables: mock_tunables(), tunables: mock_tunables(),
dirs: mock_dirs(), dirs: mock_dirs(),
layout: Default::default(),
macros: HashMap::default(),
} }
} }
@@ -219,7 +240,8 @@ pub async fn mock_store() -> ProgramStore {
let room_id = TEST_ROOM1_ID.clone(); let room_id = TEST_ROOM1_ID.clone();
let info = mock_room(); let info = mock_room();
store.rooms.insert(room_id, info); store.rooms.insert(room_id.clone(), info);
store.names.insert(TEST_ROOM1_ALIAS.to_string(), room_id);
ProgramStore::new(store) ProgramStore::new(store)
} }

View File

@@ -1,10 +1,11 @@
//! # Utility functions
use std::borrow::Cow; use std::borrow::Cow;
use unicode_segmentation::UnicodeSegmentation; use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr; use unicode_width::UnicodeWidthStr;
use modalkit::tui::style::Style; use ratatui::style::Style;
use modalkit::tui::text::{Span, Spans, Text}; use ratatui::text::{Line, Span, Text};
pub fn split_cow(cow: Cow<'_, str>, idx: usize) -> (Cow<'_, str>, Cow<'_, str>) { pub fn split_cow(cow: Cow<'_, str>, idx: usize) -> (Cow<'_, str>, Cow<'_, str>) {
match cow { match cow {
@@ -25,19 +26,19 @@ pub fn split_cow(cow: Cow<'_, str>, idx: usize) -> (Cow<'_, str>, Cow<'_, str>)
pub fn take_width(s: Cow<'_, str>, width: usize) -> ((Cow<'_, str>, usize), Cow<'_, str>) { pub fn take_width(s: Cow<'_, str>, width: usize) -> ((Cow<'_, str>, usize), Cow<'_, str>) {
// Find where to split the line. // Find where to split the line.
let mut idx = 0;
let mut w = 0; let mut w = 0;
for (i, g) in UnicodeSegmentation::grapheme_indices(s.as_ref(), true) { let idx = UnicodeSegmentation::grapheme_indices(s.as_ref(), true)
.find_map(|(i, g)| {
let gw = UnicodeWidthStr::width(g); let gw = UnicodeWidthStr::width(g);
idx = i;
if w + gw > width { if w + gw > width {
break; Some(i)
} } else {
w += gw; w += gw;
None
} }
})
.unwrap_or(s.len());
let (s0, s1) = split_cow(s, idx); let (s0, s1) = split_cow(s, idx);
@@ -105,7 +106,7 @@ where
for (line, w) in wrap(s, width) { for (line, w) in wrap(s, width) {
let space = space_span(width.saturating_sub(w), style); let space = space_span(width.saturating_sub(w), style);
let spans = Spans(vec![Span::styled(line, style), space]); let spans = Line::from(vec![Span::styled(line, style), space]);
text.lines.push(spans); text.lines.push(spans);
} }
@@ -127,23 +128,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> { pub fn join_cell_text<'a>(texts: Vec<(Text<'a>, usize)>, join: Span<'a>, style: Style) -> Text<'a> {
let height = texts.iter().map(|t| t.0.height()).max().unwrap_or(0); let height = texts.iter().map(|t| t.0.height()).max().unwrap_or(0);
let mut text = Text { lines: vec![Spans(vec![join.clone()]); height] }; let mut text = Text {
lines: vec![Line::from(vec![join.clone()]); height],
};
for (mut t, w) in texts.into_iter() { for (mut t, w) in texts.into_iter() {
for i in 0..height { for i in 0..height {
if let Some(spans) = t.lines.get_mut(i) { if let Some(line) = t.lines.get_mut(i) {
text.lines[i].0.append(&mut spans.0); text.lines[i].spans.append(&mut line.spans);
} else { } else {
text.lines[i].0.push(space_span(w, style)); text.lines[i].spans.push(space_span(w, style));
} }
text.lines[i].0.push(join.clone()); text.lines[i].spans.push(join.clone());
} }
} }
text text
} }
fn replace_emoji_in_grapheme(grapheme: &str) -> String {
emojis::get(grapheme)
.and_then(|emoji| emoji.shortcode())
.map(|shortcode| format!(":{shortcode}:"))
.unwrap_or_else(|| grapheme.to_owned())
}
pub fn replace_emojis_in_str(s: &str) -> String {
let graphemes = s.graphemes(true);
graphemes.map(replace_emoji_in_grapheme).collect()
}
pub fn replace_emojis_in_span(span: &mut Span) {
span.content = Cow::Owned(replace_emojis_in_str(span.content.as_ref()))
}
pub fn replace_emojis_in_line(line: &mut Line) {
for span in &mut line.spans {
replace_emojis_in_span(span);
}
}
#[cfg(test)] #[cfg(test)]
pub mod tests { pub mod tests {
use super::*; use super::*;

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::borrow::Cow;
use std::ffi::OsStr; use std::ffi::{OsStr, OsString};
use std::fs; use std::fs;
use std::ops::Deref; use std::ops::Deref;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use edit::edit as external_edit;
use modalkit::editing::store::RegisterError;
use std::process::Command;
use tokio; use tokio;
use url::Url;
use matrix_sdk::{ use matrix_sdk::{
attachment::AttachmentConfig, attachment::AttachmentConfig,
media::{MediaFormat, MediaRequest}, media::{MediaFormat, MediaRequest},
room::{Joined, Room as MatrixRoom}, room::Room as MatrixRoom,
ruma::{ ruma::{
events::reaction::{ReactionEventContent, Relation as Reaction}, events::reaction::ReactionEventContent,
events::relation::{Annotation, Replacement},
events::room::message::{ events::room::message::{
AddMentions,
ForwardThread,
MessageType, MessageType,
OriginalRoomMessageEvent, OriginalRoomMessageEvent,
Relation, Relation,
Replacement, ReplyWithinThread,
RoomMessageEventContent, RoomMessageEventContent,
TextMessageEventContent, TextMessageEventContent,
}, },
EventId, OwnedEventId,
OwnedRoomId, OwnedRoomId,
RoomId, RoomId,
}, },
RoomState,
}; };
use modalkit::{ use ratatui::{
tui::{
buffer::Buffer, buffer::Buffer,
layout::Rect, layout::Rect,
text::{Span, Spans}, text::{Line, Span},
widgets::{Paragraph, StatefulWidget, Widget}, widgets::{Paragraph, StatefulWidget, Widget},
},
widgets::textbox::{TextBox, TextBoxState},
widgets::TerminalCursor,
widgets::{PromptActions, WindowOps},
}; };
use modalkit::editing::{ use modalkit::keybindings::dialog::{MultiChoice, MultiChoiceItem, PromptYesNo};
action::{
EditError, use modalkit_ratatui::{
EditInfo, textbox::{TextBox, TextBoxState},
EditResult, PromptActions,
TerminalCursor,
WindowOps,
};
use modalkit::actions::{
Action,
Editable, Editable,
EditorAction, EditorAction,
InfoMessage,
Jumpable, Jumpable,
PromptAction, PromptAction,
Promptable, Promptable,
Scrollable, Scrollable,
UIError, };
}, use modalkit::editing::{
base::{CloseFlags, Count, MoveDir1D, PositionList, ScrollStyle, WordStyle, WriteFlags},
completion::CompletionList, completion::CompletionList,
context::Resolve, context::Resolve,
history::{self, HistoryList}, history::{self, HistoryList},
rope::EditRope, rope::EditRope,
}; };
use modalkit::errors::{EditError, EditResult, UIError};
use modalkit::prelude::*;
use crate::base::{ use crate::base::{
DownloadFlags, DownloadFlags,
@@ -75,11 +85,12 @@ use crate::base::{
SendAction, SendAction,
}; };
use crate::message::{Message, MessageEvent, MessageKey, MessageTimeStamp}; use crate::message::{text_to_message, Message, MessageEvent, MessageKey, MessageTimeStamp};
use crate::worker::Requester; use crate::worker::Requester;
use super::scrollback::{Scrollback, ScrollbackState}; use super::scrollback::{Scrollback, ScrollbackState};
/// State needed for rendering [Chat].
pub struct ChatState { pub struct ChatState {
room_id: OwnedRoomId, room_id: OwnedRoomId,
room: MatrixRoom, room: MatrixRoom,
@@ -96,10 +107,10 @@ pub struct ChatState {
} }
impl ChatState { impl ChatState {
pub fn new(room: MatrixRoom, store: &mut ProgramStore) -> Self { pub fn new(room: MatrixRoom, thread: Option<OwnedEventId>, store: &mut ProgramStore) -> Self {
let room_id = room.room_id().to_owned(); let room_id = room.room_id().to_owned();
let scrollback = ScrollbackState::new(room_id.clone()); let scrollback = ScrollbackState::new(room_id.clone(), thread.clone());
let id = IambBufferId::Room(room_id.clone(), RoomFocus::MessageBar); let id = IambBufferId::Room(room_id.clone(), thread, RoomFocus::MessageBar);
let ebuf = store.load_buffer(id); let ebuf = store.load_buffer(id);
let tbox = TextBoxState::new(ebuf); let tbox = TextBoxState::new(ebuf);
@@ -119,13 +130,26 @@ impl ChatState {
} }
} }
fn get_joined(&self, worker: &Requester) -> Result<Joined, IambError> { pub fn thread(&self) -> Option<&OwnedEventId> {
worker.client.get_joined_room(self.id()).ok_or(IambError::NotJoined) 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> { 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 key = self.reply_to.as_ref()?;
let msg = info.messages.get(key)?; let msg = thread.get(key)?;
if let MessageEvent::Original(ev) = &msg.event { if let MessageEvent::Original(ev) = &msg.event {
Some(ev) Some(ev)
@@ -157,65 +181,99 @@ impl ChatState {
let settings = &store.application.settings; let settings = &store.application.settings;
let info = store.application.rooms.get_or_default(self.room_id.clone()); let info = store.application.rooms.get_or_default(self.room_id.clone());
let msg = self let msg = self.scrollback.get_mut(info).ok_or(IambError::NoSelectedMessage)?;
.scrollback
.get_mut(&mut info.messages)
.ok_or(IambError::NoSelectedMessage)?;
match act { match act {
MessageAction::Cancel => { MessageAction::Cancel(skip_confirm) => {
if skip_confirm {
self.reset();
return Ok(None);
}
self.reply_to = None; self.reply_to = None;
self.editing = 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) => { MessageAction::Download(filename, flags) => {
if let MessageEvent::Original(ev) = &msg.event { if let MessageEvent::Original(ev) = &msg.event {
let media = client.media(); let media = client.media();
let mut filename = match filename { let mut filename = match (filename, &settings.dirs.downloads) {
Some(f) => PathBuf::from(f), (Some(f), _) => PathBuf::from(f),
None => settings.dirs.downloads.clone(), (None, Some(downloads)) => downloads.clone(),
(None, None) => return Err(IambError::NoDownloadDir.into()),
}; };
let source = match &ev.content.msgtype { let (source, msg_filename) = match &ev.content.msgtype {
MessageType::Audio(c) => { MessageType::Audio(c) => (c.source.clone(), c.body.as_str()),
if filename.is_dir() {
filename.push(c.body.as_str());
}
c.source.clone()
},
MessageType::File(c) => { MessageType::File(c) => {
if filename.is_dir() { (c.source.clone(), c.filename.as_deref().unwrap_or(c.body.as_str()))
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()
}, },
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()); 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) { if !filename.exists() || flags.contains(DownloadFlags::FORCE) {
let req = MediaRequest { source, format: MediaFormat::File }; let req = MediaRequest { source, format: MediaFormat::File };
@@ -236,14 +294,21 @@ impl ChatState {
} }
let info = if flags.contains(DownloadFlags::OPEN) { let info = if flags.contains(DownloadFlags::OPEN) {
// open::that may not return until the spawned program closes.
let target = filename.clone().into_os_string(); 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!( InfoMessage::from(format!(
"Attachment downloaded to {} and opened", "Attachment downloaded to {} and opened",
filename.display() filename.display()
)) ))
},
Err(err) => {
return Err(err);
},
}
} else { } else {
InfoMessage::from(format!( InfoMessage::from(format!(
"Attachment downloaded to {}", "Attachment downloaded to {}",
@@ -286,6 +351,7 @@ impl ChatState {
}; };
self.tbox.set_text(text); 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.editing = self.scrollback.get_key(info);
self.focus = RoomFocus::MessageBar; self.focus = RoomFocus::MessageBar;
@@ -294,6 +360,8 @@ impl ChatState {
MessageAction::React(emoji) => { MessageAction::React(emoji) => {
let room = self.get_joined(&store.application.worker)?; let room = self.get_joined(&store.application.worker)?;
let event_id = match &msg.event { 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::Original(ev) => ev.event_id.clone(),
MessageEvent::Local(event_id, _) => event_id.clone(), MessageEvent::Local(event_id, _) => event_id.clone(),
MessageEvent::Redacted(_) => { MessageEvent::Redacted(_) => {
@@ -304,15 +372,26 @@ impl ChatState {
}, },
}; };
let reaction = Reaction::new(event_id, emoji); let reaction = Annotation::new(event_id, emoji);
let msg = ReactionEventContent::new(reaction); 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) 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 room = self.get_joined(&store.application.worker)?;
let event_id = match &msg.event { 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::Original(ev) => ev.event_id.clone(),
MessageEvent::Local(event_id, _) => event_id.clone(), MessageEvent::Local(event_id, _) => event_id.clone(),
MessageEvent::Redacted(_) => { MessageEvent::Redacted(_) => {
@@ -337,9 +416,11 @@ impl ChatState {
}, },
MessageAction::Unreact(emoji) => { MessageAction::Unreact(emoji) => {
let room = self.get_joined(&store.application.worker)?; let room = self.get_joined(&store.application.worker)?;
let event_id: &EventId = match &msg.event { let event_id = match &msg.event {
MessageEvent::Original(ev) => ev.event_id.as_ref(), MessageEvent::EncryptedOriginal(ev) => ev.event_id.clone(),
MessageEvent::Local(event_id, _) => event_id.as_ref(), MessageEvent::EncryptedRedacted(ev) => ev.event_id.clone(),
MessageEvent::Original(ev) => ev.event_id.clone(),
MessageEvent::Local(event_id, _) => event_id.clone(),
MessageEvent::Redacted(_) => { MessageEvent::Redacted(_) => {
let msg = "Cannot unreact to a redacted message"; let msg = "Cannot unreact to a redacted message";
let err = UIError::Failure(msg.into()); let err = UIError::Failure(msg.into());
@@ -348,7 +429,7 @@ impl ChatState {
}, },
}; };
let reactions = match info.reactions.get(event_id) { let reactions = match info.reactions.get(&event_id) {
Some(r) => r, Some(r) => r,
None => return Ok(None), None => return Ok(None),
}; };
@@ -384,43 +465,46 @@ impl ChatState {
_: ProgramContext, _: ProgramContext,
store: &mut ProgramStore, store: &mut ProgramStore,
) -> IambResult<EditInfo> { ) -> IambResult<EditInfo> {
let room = store let room = self.get_joined(&store.application.worker)?;
.application
.worker
.client
.get_joined_room(self.id())
.ok_or(IambError::NotJoined)?;
let info = store.application.rooms.get_or_default(self.id().to_owned()); let info = store.application.rooms.get_or_default(self.id().to_owned());
let mut show_echo = true; let mut show_echo = true;
let (event_id, msg) = match act { let (event_id, msg) = match act {
SendAction::Submit => { SendAction::Submit | SendAction::SubmitFromEditor => {
let msg = self.tbox.get_text(); let msg = self.tbox.get();
if msg.is_empty() { let msg = if let SendAction::SubmitFromEditor = act {
external_edit(msg.trim_end().to_string())?
} else if msg.is_blank() {
return Ok(None); return Ok(None);
} } else {
msg.trim_end().to_string()
};
let msg = TextMessageEventContent::markdown(msg); let mut msg = text_to_message(msg);
let msg = MessageType::Text(msg);
let mut msg = RoomMessageEventContent::new(msg);
if let Some((_, event_id)) = &self.editing { if let Some((_, event_id)) = &self.editing {
msg.relates_to = Some(Relation::Replacement(Replacement::new( msg.relates_to = Some(Relation::Replacement(Replacement::new(
event_id.clone(), event_id.clone(),
Box::new(msg.clone()), msg.msgtype.clone().into(),
))); )));
show_echo = false; 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) { } 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, ForwardThread::Yes, AddMentions::No);
msg = msg.make_reply_to(m);
} }
// XXX: second parameter can be a locally unique transaction id. // XXX: second parameter can be a locally unique transaction id.
// Useful for doing retries. // 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; let event_id = resp.event_id;
// Reset message bar state now that it's been sent. // Reset message bar state now that it's been sent.
@@ -440,7 +524,37 @@ impl ChatState {
let config = AttachmentConfig::new(); let config = AttachmentConfig::new();
let resp = room 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 .await
.map_err(IambError::from)?; .map_err(IambError::from)?;
@@ -458,7 +572,8 @@ impl ChatState {
let key = (MessageTimeStamp::LocalEcho, event_id.clone()); let key = (MessageTimeStamp::LocalEcho, event_id.clone());
let msg = MessageEvent::Local(event_id, msg.into()); let msg = MessageEvent::Local(event_id, msg.into());
let msg = Message::new(msg, user, MessageTimeStamp::LocalEcho); 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. // Jump to the end of the scrollback to show the message.
@@ -525,12 +640,14 @@ impl WindowOps<IambInfo> for ChatState {
fn dup(&self, store: &mut ProgramStore) -> Self { fn dup(&self, store: &mut ProgramStore) -> Self {
// XXX: I want each WindowSlot to have its own shared buffer, instead of each Room; need to // XXX: I want each WindowSlot to have its own shared buffer, instead of each Room; need to
// find a good way to pass that info here so that it can be part of the content id. // find a good way to pass that info here so that it can be part of the content id.
let id = IambBufferId::Room(self.room_id.clone(), RoomFocus::MessageBar); let room_id = self.room_id.clone();
let thread = self.thread().cloned();
let id = IambBufferId::Room(room_id.clone(), thread, RoomFocus::MessageBar);
let ebuf = store.load_buffer(id); let ebuf = store.load_buffer(id);
let tbox = TextBoxState::new(ebuf); let tbox = TextBoxState::new(ebuf);
ChatState { ChatState {
room_id: self.room_id.clone(), room_id,
room: self.room.clone(), room: self.room.clone(),
tbox, tbox,
@@ -586,8 +703,10 @@ impl Editable<ProgramContext, ProgramStore, IambInfo> for ChatState {
match delegate!(self, w => w.editor_command(act, ctx, store)) { match delegate!(self, w => w.editor_command(act, ctx, store)) {
res @ Ok(_) => res, res @ Ok(_) => res,
Err(EditError::WrongBuffer(IambBufferId::Room(room_id, focus))) Err(EditError::WrongBuffer(IambBufferId::Room(room_id, thread, focus)))
if room_id == self.room_id && act.is_switchable(ctx) => if room_id == self.room_id &&
thread.as_ref() == self.thread() &&
act.is_switchable(ctx) =>
{ {
// Switch focus. // Switch focus.
self.focus = focus; self.focus = focus;
@@ -595,6 +714,15 @@ impl Editable<ProgramContext, ProgramStore, IambInfo> for ChatState {
// Run command again. // Run command again.
delegate!(self, w => w.editor_command(act, ctx, store)) delegate!(self, w => w.editor_command(act, ctx, store))
}, },
Err(EditError::Register(RegisterError::ClipboardImage(data))) => {
let msg = "Do you really want to upload the image from your system clipboard?";
let send =
IambAction::Send(SendAction::UploadImage(data.width, data.height, data.bytes));
let prompt = PromptYesNo::new(msg, vec![Action::from(send)]);
let prompt = Box::new(prompt);
Err(EditError::NeedConfirm(prompt))
},
res @ Err(_) => res, res @ Err(_) => res,
} }
} }
@@ -671,13 +799,14 @@ impl PromptActions<ProgramContext, ProgramStore, IambInfo> for ChatState {
&mut self, &mut self,
dir: &MoveDir1D, dir: &MoveDir1D,
count: &Count, count: &Count,
prefixed: bool,
ctx: &ProgramContext, ctx: &ProgramContext,
_: &mut ProgramStore, _: &mut ProgramStore,
) -> EditResult<Vec<(ProgramAction, ProgramContext)>, IambInfo> { ) -> EditResult<Vec<(ProgramAction, ProgramContext)>, IambInfo> {
let count = ctx.resolve(count); let count = ctx.resolve(count);
let rope = self.tbox.get(); let rope = self.tbox.get();
let text = self.sent.recall(&rope, &mut self.sent_scrollback, *dir, count); let text = self.sent.recall(&rope, &mut self.sent_scrollback, *dir, prefixed, count);
if let Some(text) = text { if let Some(text) = text {
self.tbox.set_text(text); self.tbox.set_text(text);
@@ -695,18 +824,20 @@ impl Promptable<ProgramContext, ProgramStore, IambInfo> for ChatState {
store: &mut ProgramStore, store: &mut ProgramStore,
) -> EditResult<Vec<(ProgramAction, ProgramContext)>, IambInfo> { ) -> EditResult<Vec<(ProgramAction, ProgramContext)>, IambInfo> {
if let RoomFocus::Scrollback = self.focus { if let RoomFocus::Scrollback = self.focus {
return Ok(vec![]); return self.scrollback.prompt(act, ctx, store);
} }
match act { match act {
PromptAction::Submit => self.submit(ctx, store), PromptAction::Submit => self.submit(ctx, store),
PromptAction::Abort(empty) => self.abort(*empty, ctx, store), PromptAction::Abort(empty) => self.abort(*empty, ctx, store),
PromptAction::Recall(dir, count) => self.recall(dir, count, ctx, store), PromptAction::Recall(dir, count, prefixed) => {
_ => Err(EditError::Unimplemented("unknown prompt action".to_string())), self.recall(dir, count, *prefixed, ctx, store)
},
} }
} }
} }
/// [StatefulWidget] for Matrix rooms.
pub struct Chat<'a> { pub struct Chat<'a> {
store: &'a mut ProgramStore, store: &'a mut ProgramStore,
focused: bool, focused: bool,
@@ -727,10 +858,35 @@ impl<'a> StatefulWidget for Chat<'a> {
type State = ChatState; type State = ChatState;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
// Determine whether we have a description to show for the message bar.
let desc_spans = match (&state.editing, &state.reply_to, state.thread()) {
(None, None, None) => None,
(None, None, Some(_)) => Some(Line::from("Replying in thread")),
(Some(_), None, None) => Some(Line::from("Editing message")),
(Some(_), None, Some(_)) => Some(Line::from("Editing message in thread")),
(editing, Some(_), thread) => {
self.store.application.rooms.get(state.id()).and_then(|room| {
let msg = state.get_reply_to(room)?;
let user =
self.store.application.settings.get_user_span(msg.sender.as_ref(), room);
let prefix = match (editing.is_some(), thread.is_some()) {
(true, false) => Span::from("Editing reply to "),
(true, true) => Span::from("Editing reply in thread to "),
(false, false) => Span::from("Replying to "),
(false, true) => Span::from("Replying in thread to "),
};
let spans = Line::from(vec![prefix, user]);
spans.into()
})
},
};
// Determine the region to show each UI element.
let lines = state.tbox.has_lines(5).max(1) as u16; let lines = state.tbox.has_lines(5).max(1) as u16;
let drawh = area.height; let drawh = area.height;
let texth = lines.min(drawh).clamp(1, 5); let texth = lines.min(drawh).clamp(1, 5);
let desch = if state.reply_to.is_some() { let desch = if desc_spans.is_some() {
drawh.saturating_sub(texth).min(1) drawh.saturating_sub(texth).min(1)
} else { } else {
0 0
@@ -741,25 +897,7 @@ impl<'a> StatefulWidget for Chat<'a> {
let descarea = Rect::new(area.x, scrollarea.y + scrollh, area.width, desch); 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 textarea = Rect::new(area.x, descarea.y + desch, area.width, texth);
let scrollback_focused = state.focus.is_scrollback() && self.focused; // Render the message bar and any description for it.
let scrollback = Scrollback::new(self.store).focus(scrollback_focused);
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()
})
},
};
if let Some(desc_spans) = desc_spans { if let Some(desc_spans) = desc_spans {
Paragraph::new(desc_spans).render(descarea, buf); Paragraph::new(desc_spans).render(descarea, buf);
} }
@@ -768,5 +906,35 @@ impl<'a> StatefulWidget for Chat<'a> {
let tbox = TextBox::new().prompt(prompt); let tbox = TextBox::new().prompt(prompt);
tbox.render(textarea, buf, &mut state.tbox); tbox.render(textarea, buf, &mut state.tbox);
// Render the message scrollback.
let scrollback_focused = state.focus.is_scrollback() && self.focused;
let scrollback = Scrollback::new(self.store)
.focus(scrollback_focused)
.room_focus(self.focused);
scrollback.render(scrollarea, buf, &mut state.scrollback);
} }
} }
fn open_command(open_command: Option<&Vec<String>>, target: OsString) -> IambResult<()> {
if let Some(mut cmd) = open_command.and_then(cmd) {
cmd.arg(target);
cmd.spawn()?;
return Ok(());
} else {
// open::that may not return until the spawned program closes.
tokio::task::spawn_blocking(move || {
return open::that(target);
});
return Ok(());
}
}
fn cmd(open_command: &Vec<String>) -> Option<Command> {
if let [program, args @ ..] = open_command.as_slice() {
let mut cmd = Command::new(program);
cmd.args(args);
return Some(cmd);
}
None
}

View File

@@ -1,53 +1,42 @@
//! # Windows for Matrix rooms and spaces
use matrix_sdk::{ use matrix_sdk::{
room::{Invited, Room as MatrixRoom}, room::Room as MatrixRoom,
ruma::{ ruma::{
events::{ events::{
room::{name::RoomNameEventContent, topic::RoomTopicEventContent}, room::{name::RoomNameEventContent, topic::RoomTopicEventContent},
tag::{TagInfo, Tags}, tag::{TagInfo, Tags},
}, },
OwnedEventId,
RoomId, RoomId,
}, },
DisplayName, DisplayName,
RoomState as MatrixRoomState,
}; };
use modalkit::tui::{ use ratatui::{
buffer::Buffer, buffer::Buffer,
layout::{Alignment, Rect}, layout::{Alignment, Rect},
style::{Modifier as StyleModifier, Style}, style::{Modifier as StyleModifier, Style},
text::{Span, Spans, Text}, text::{Line, Span, Text},
widgets::{Paragraph, StatefulWidget, Widget}, widgets::{Paragraph, StatefulWidget, Widget},
}; };
use modalkit::{ use modalkit::actions::{
editing::action::{
Action, Action,
EditInfo,
EditResult,
Editable, Editable,
EditorAction, EditorAction,
Jumpable, Jumpable,
PromptAction, PromptAction,
Promptable, Promptable,
Scrollable, 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::{ use crate::base::{
IambAction,
IambError, IambError,
IambId, IambId,
IambInfo, 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 { pub enum RoomState {
Chat(ChatState), Chat(ChatState),
Space(SpaceState), Space(SpaceState),
@@ -97,6 +91,7 @@ impl From<SpaceState> for RoomState {
impl RoomState { impl RoomState {
pub fn new( pub fn new(
room: MatrixRoom, room: MatrixRoom,
thread: Option<OwnedEventId>,
name: DisplayName, name: DisplayName,
tags: Option<Tags>, tags: Option<Tags>,
store: &mut ProgramStore, store: &mut ProgramStore,
@@ -109,7 +104,14 @@ impl RoomState {
if room.is_space() { if room.is_space() {
SpaceState::new(room).into() SpaceState::new(room).into()
} else { } else {
ChatState::new(room, store).into() ChatState::new(room, thread, store).into()
}
}
pub fn thread(&self) -> Option<&OwnedEventId> {
match self {
RoomState::Chat(chat) => chat.thread(),
RoomState::Space(_) => None,
} }
} }
@@ -122,7 +124,7 @@ impl RoomState {
fn draw_invite( fn draw_invite(
&self, &self,
invited: Invited, invited: MatrixRoom,
area: Rect, area: Rect,
buf: &mut Buffer, buf: &mut Buffer,
store: &mut ProgramStore, store: &mut ProgramStore,
@@ -137,12 +139,13 @@ impl RoomState {
let mut invited = vec![Span::from(format!("You have been invited to join {name}"))]; let mut invited = vec![Span::from(format!("You have been invited to join {name}"))];
if let Ok(Some(inviter)) = &inviter { if let Ok(Some(inviter)) = &inviter {
let info = store.application.rooms.get_or_default(self.id().to_owned());
invited.push(Span::from(" by ")); invited.push(Span::from(" by "));
invited.push(store.application.settings.get_user_span(inviter.user_id())); invited.push(store.application.settings.get_user_span(inviter.user_id(), info));
} }
let l1 = Spans(invited); let l1 = Line::from(invited);
let l2 = Spans::from( let l2 = Line::from(
"You can run `:invite accept` or `:invite reject` to accept or reject this invitation.", "You can run `:invite accept` or `:invite reject` to accept or reject this invitation.",
); );
let text = Text { lines: vec![l1, l2] }; let text = Text { lines: vec![l1, l2] };
@@ -184,12 +187,12 @@ impl RoomState {
) -> IambResult<Vec<(Action<IambInfo>, ProgramContext)>> { ) -> IambResult<Vec<(Action<IambInfo>, ProgramContext)>> {
match act { match act {
RoomAction::InviteAccept => { RoomAction::InviteAccept => {
if let Some(room) = store.application.worker.client.get_invited_room(self.id()) { if let Some(room) = store.application.worker.client.get_room(self.id()) {
let details = room.invite_details().await.map_err(IambError::from)?; let details = room.invite_details().await.map_err(IambError::from)?;
let details = details.invitee.event().original_content(); let details = details.invitee.event().original_content();
let is_direct = details.and_then(|ev| ev.is_direct).unwrap_or_default(); 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 { if is_direct {
room.set_is_direct(true).await.map_err(IambError::from)?; room.set_is_direct(true).await.map_err(IambError::from)?;
@@ -201,8 +204,8 @@ impl RoomState {
} }
}, },
RoomAction::InviteReject => { RoomAction::InviteReject => {
if let Some(room) = store.application.worker.client.get_invited_room(self.id()) { if let Some(room) = store.application.worker.client.get_room(self.id()) {
room.reject_invitation().await.map_err(IambError::from)?; room.leave().await.map_err(IambError::from)?;
Ok(vec![]) Ok(vec![])
} else { } else {
@@ -210,7 +213,7 @@ impl RoomState {
} }
}, },
RoomAction::InviteSend(user) => { RoomAction::InviteSend(user) => {
if let Some(room) = store.application.worker.client.get_joined_room(self.id()) { if let Some(room) = store.application.worker.client.get_room(self.id()) {
room.invite_user_by_id(user.as_ref()).await.map_err(IambError::from)?; room.invite_user_by_id(user.as_ref()).await.map_err(IambError::from)?;
Ok(vec![]) Ok(vec![])
@@ -218,6 +221,24 @@ impl RoomState {
Err(IambError::NotJoined.into()) Err(IambError::NotJoined.into())
} }
}, },
RoomAction::Leave(skip_confirm) => {
if let Some(room) = store.application.worker.client.get_room(self.id()) {
if skip_confirm {
room.leave().await.map_err(IambError::from)?;
Ok(vec![])
} else {
let msg = "Do you really want to leave this room?";
let leave = IambAction::Room(RoomAction::Leave(true));
let prompt = PromptYesNo::new(msg, vec![Action::from(leave)]);
let prompt = Box::new(prompt);
Err(UIError::NeedConfirm(prompt))
}
} else {
Err(IambError::NotJoined.into())
}
},
RoomAction::Members(mut cmd) => { RoomAction::Members(mut cmd) => {
let width = Count::Exact(30); let width = Count::Exact(30);
let act = let act =
@@ -226,7 +247,7 @@ impl RoomState {
width.into(), width.into(),
); );
Ok(vec![(act, cmd.context.take())]) Ok(vec![(act, cmd.context.clone())])
}, },
RoomAction::Set(field, value) => { RoomAction::Set(field, value) => {
let room = store let room = store
@@ -236,7 +257,7 @@ impl RoomState {
match field { match field {
RoomField::Name => { RoomField::Name => {
let ev = RoomNameEventContent::new(value.into()); let ev = RoomNameEventContent::new(value);
let _ = room.send_state_event(ev).await.map_err(IambError::from)?; let _ = room.send_state_event(ev).await.map_err(IambError::from)?;
}, },
RoomField::Tag(tag) => { RoomField::Tag(tag) => {
@@ -261,7 +282,7 @@ impl RoomState {
match field { match field {
RoomField::Name => { RoomField::Name => {
let ev = RoomNameEventContent::new(None); let ev = RoomNameEventContent::new("".into());
let _ = room.send_state_event(ev).await.map_err(IambError::from)?; let _ = room.send_state_event(ev).await.map_err(IambError::from)?;
}, },
RoomField::Tag(tag) => { RoomField::Tag(tag) => {
@@ -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 title = store.application.get_room_title(self.id());
let style = Style::default().add_modifier(StyleModifier::BOLD); let style = Style::default().add_modifier(StyleModifier::BOLD);
let mut spans = vec![Span::styled(title, style)]; let mut spans = vec![];
if let RoomState::Chat(chat) = self {
if chat.thread().is_some() {
spans.push("Thread in ".into());
}
}
spans.push(Span::styled(title, style));
match self.room().topic() { match self.room().topic() {
Some(desc) if !desc.is_empty() => { Some(desc) if !desc.is_empty() => {
@@ -292,7 +321,7 @@ impl RoomState {
_ => {}, _ => {},
} }
Spans(spans) Line::from(spans)
} }
pub fn focus_toggle(&mut self) { pub fn focus_toggle(&mut self) {
@@ -370,12 +399,12 @@ impl TerminalCursor for RoomState {
impl WindowOps<IambInfo> for RoomState { impl WindowOps<IambInfo> for RoomState {
fn draw(&mut self, area: Rect, buf: &mut Buffer, focused: bool, store: &mut ProgramStore) { fn draw(&mut self, area: Rect, buf: &mut Buffer, focused: bool, store: &mut ProgramStore) {
if let MatrixRoom::Invited(_) = self.room() { if self.room().state() == MatrixRoomState::Invited {
self.refresh_room(store); self.refresh_room(store);
} }
if let MatrixRoom::Invited(invited) = self.room() { if self.room().state() == MatrixRoomState::Invited {
self.draw_invite(invited.clone(), area, buf, store); self.draw_invite(self.room().clone(), area, buf, store);
} }
match self { match self {

View File

@@ -1,20 +1,22 @@
use std::collections::HashSet; //! Message scrollback
use ratatui_image::Image;
use regex::Regex; 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_ratatui::{ScrollActions, TerminalCursor, WindowOps};
use modalkit::widgets::{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::{ use modalkit::actions::{
action::{
Action, Action,
CursorAction, CursorAction,
EditAction, EditAction,
EditError,
EditInfo,
EditResult,
Editable, Editable,
EditorAction, EditorAction,
EditorActions, EditorActions,
@@ -26,53 +28,44 @@ use modalkit::editing::{
Scrollable, Scrollable,
Searchable, Searchable,
SelectionAction, SelectionAction,
UIError, WindowAction,
UIResult, };
}, use modalkit::editing::{
base::{
Axis,
CloseFlags,
CompletionDisplay,
CompletionSelection,
CompletionType,
Count,
EditRange,
EditTarget,
Mark,
MoveDir1D,
MoveDir2D,
MoveDirMod,
MovePosition,
MoveTerminus,
MoveType,
PositionList,
RangeType,
Register,
ScrollSize,
ScrollStyle,
SearchType,
TargetShape,
ViewportContext,
WordStyle,
WriteFlags,
},
completion::CompletionList, completion::CompletionList,
context::{EditContext, Resolve}, context::Resolve,
cursor::{CursorGroup, CursorState}, cursor::{CursorGroup, CursorState},
history::HistoryList, history::HistoryList,
rope::EditRope, rope::EditRope,
store::{RegisterCell, RegisterPutFlags}, store::{RegisterCell, RegisterPutFlags},
}; };
use modalkit::errors::{EditError, EditResult, UIError, UIResult};
use modalkit::prelude::*;
use crate::{ use crate::{
base::{IambBufferId, IambInfo, IambResult, ProgramContext, ProgramStore, RoomFocus, RoomInfo}, base::{
IambBufferId,
IambId,
IambInfo,
IambResult,
Need,
ProgramContext,
ProgramStore,
RoomFetchStatus,
RoomFocus,
RoomInfo,
},
config::ApplicationSettings, config::ApplicationSettings,
message::{Message, MessageCursor, MessageKey, Messages}, 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 mut end = &pos;
let iter = info.messages.range(..=&pos).rev().enumerate(); let iter = thread.range(..=&pos).rev().enumerate();
for (i, (key, _)) in iter { for (i, (key, _)) in iter {
end = key; end = key;
@@ -85,13 +78,13 @@ fn nth_key_before(pos: MessageKey, n: usize, info: &RoomInfo) -> MessageKey {
end.clone() end.clone()
} }
fn nth_before(pos: MessageKey, n: usize, info: &RoomInfo) -> MessageCursor { fn nth_before(pos: MessageKey, n: usize, thread: &Messages) -> MessageCursor {
nth_key_before(pos, n, info).into() 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 mut end = &pos;
let iter = info.messages.range(&pos..).enumerate(); let iter = thread.range(&pos..).enumerate();
for (i, (key, _)) in iter { for (i, (key, _)) in iter {
end = key; end = key;
@@ -104,12 +97,12 @@ fn nth_key_after(pos: MessageKey, n: usize, info: &RoomInfo) -> MessageKey {
end.clone() end.clone()
} }
fn nth_after(pos: MessageKey, n: usize, info: &RoomInfo) -> MessageCursor { fn nth_after(pos: MessageKey, n: usize, thread: &Messages) -> MessageCursor {
nth_key_after(pos, n, info).into() nth_key_after(pos, n, thread).into()
} }
fn prevmsg<'a>(key: &MessageKey, info: &'a RoomInfo) -> Option<&'a Message> { fn prevmsg<'a>(key: &MessageKey, thread: &'a Messages) -> Option<&'a Message> {
info.messages.range(..key).next_back().map(|(_, v)| v) thread.range(..key).next_back().map(|(_, v)| v)
} }
pub struct ScrollbackState { pub struct ScrollbackState {
@@ -119,6 +112,9 @@ pub struct ScrollbackState {
/// The buffer identifier used for saving marks, etc. /// The buffer identifier used for saving marks, etc.
id: IambBufferId, id: IambBufferId,
/// The currently focused thread in this room.
thread: Option<OwnedEventId>,
/// The currently selected message in the scrollback. /// The currently selected message in the scrollback.
cursor: MessageCursor, cursor: MessageCursor,
@@ -136,8 +132,8 @@ pub struct ScrollbackState {
} }
impl ScrollbackState { impl ScrollbackState {
pub fn new(room_id: OwnedRoomId) -> ScrollbackState { pub fn new(room_id: OwnedRoomId, thread: Option<OwnedEventId>) -> ScrollbackState {
let id = IambBufferId::Room(room_id.to_owned(), RoomFocus::Scrollback); let id = IambBufferId::Room(room_id.to_owned(), thread.clone(), RoomFocus::Scrollback);
let cursor = MessageCursor::default(); let cursor = MessageCursor::default();
let viewctx = ViewportContext::default(); let viewctx = ViewportContext::default();
let jumped = HistoryList::default(); let jumped = HistoryList::default();
@@ -146,6 +142,7 @@ impl ScrollbackState {
ScrollbackState { ScrollbackState {
room_id, room_id,
id, id,
thread,
cursor, cursor,
viewctx, viewctx,
jumped, jumped,
@@ -166,37 +163,88 @@ impl ScrollbackState {
self.cursor self.cursor
.timestamp .timestamp
.clone() .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 { if let Some(k) = &self.cursor.timestamp {
messages.get_mut(k) thread.get_mut(k)
} else { } 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>( pub fn messages<'a>(
&self, &self,
range: EditRange<MessageCursor>, range: EditRange<MessageCursor>,
info: &'a RoomInfo, info: &'a RoomInfo,
) -> impl Iterator<Item = (&'a MessageKey, &'a Message)> { ) -> impl Iterator<Item = (&'a MessageKey, &'a Message)> {
let start = range.start.to_key(info); let Some(thread) = self.get_thread(info) else {
let end = range.end.to_key(info); 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) { let (start, end) = if let (Some(start), Some(end)) = (start, 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) (last, last)
} else { } else {
return info.messages.range(..); return thread.range(..);
}; };
if range.inclusive { if range.inclusive {
info.messages.range(start..=end) thread.range(start..=end)
} else { } 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, info: &RoomInfo,
settings: &ApplicationSettings, 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 key
} else { } else {
return; return;
@@ -221,9 +273,9 @@ impl ScrollbackState {
let mut lines = 0; let mut lines = 0;
let target = self.viewctx.get_height() / 2; 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 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(); let len = item.show(prev, sel, &self.viewctx, info, settings).lines.len();
if key == &idx { if key == &idx {
@@ -244,9 +296,9 @@ impl ScrollbackState {
let mut lines = 0; let mut lines = 0;
let target = self.viewctx.get_height(); 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 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(); let len = item.show(prev, sel, &self.viewctx, info, settings).lines.len();
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) { 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 k.0
} else { } else {
return; return;
@@ -280,9 +344,9 @@ impl ScrollbackState {
let mut lines = 0; let mut lines = 0;
let cursor_key = self.cursor.timestamp.as_ref().unwrap_or(last_key); 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 { if idx == cursor_key {
// Cursor is already within the viewport. // Cursor is already within the viewport.
break; break;
@@ -331,7 +395,7 @@ impl ScrollbackState {
MoveType::BufferLineOffset => None, MoveType::BufferLineOffset => None,
MoveType::BufferLinePercent => None, MoveType::BufferLinePercent => None,
MoveType::BufferPos(MovePosition::Beginning) => { 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()) Some(start.into())
}, },
@@ -344,9 +408,11 @@ impl ScrollbackState {
MoveType::ParagraphBegin(dir) | MoveType::ParagraphBegin(dir) |
MoveType::SectionBegin(dir) | MoveType::SectionBegin(dir) |
MoveType::SectionEnd(dir) => { MoveType::SectionEnd(dir) => {
let thread = self.get_thread(info)?;
match dir { match dir {
MoveDir1D::Previous => nth_before(pos, count, info).into(), MoveDir1D::Previous => nth_before(pos, count, thread).into(),
MoveDir1D::Next => nth_after(pos, count, info).into(), MoveDir1D::Next => nth_after(pos, count, thread).into(),
} }
}, },
MoveType::ViewportPos(MovePosition::Beginning) => { MoveType::ViewportPos(MovePosition::Beginning) => {
@@ -395,12 +461,14 @@ impl ScrollbackState {
RangeType::XmlTag => None, RangeType::XmlTag => None,
RangeType::Buffer => { RangeType::Buffer => {
let start = info.messages.first_key_value()?.0.clone(); let thread = self.get_thread(info)?;
let end = info.messages.last_key_value()?.0.clone(); 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)) Some(EditRange::inclusive(start.into(), end.into(), TargetShape::LineWise))
}, },
RangeType::Line | RangeType::Paragraph | RangeType::Sentence => { RangeType::Line | RangeType::Paragraph | RangeType::Sentence => {
let thread = self.get_thread(info)?;
let count = ctx.resolve(count); let count = ctx.resolve(count);
if count == 0 { if count == 0 {
@@ -409,7 +477,7 @@ impl ScrollbackState {
let mut end = &pos; let mut end = &pos;
for (i, (key, _)) in info.messages.range(&pos..).enumerate() { for (i, (key, _)) in thread.range(&pos..).enumerate() {
if i >= count { if i >= count {
break; break;
} }
@@ -434,9 +502,10 @@ impl ScrollbackState {
mut count: usize, mut count: usize,
info: &RoomInfo, info: &RoomInfo,
) -> Option<MessageCursor> { ) -> Option<MessageCursor> {
let thread = self.get_thread(info)?;
let mut mc = None; let mut mc = None;
for (key, msg) in info.messages.range(&start..) { for (key, msg) in thread.range(&start..) {
if count == 0 { if count == 0 {
break; break;
} }
@@ -460,11 +529,14 @@ impl ScrollbackState {
needle: &Regex, needle: &Regex,
mut count: usize, mut count: usize,
info: &RoomInfo, info: &RoomInfo,
need_load: &mut HashSet<OwnedRoomId>, ) -> (Option<MessageCursor>, bool) {
) -> Option<MessageCursor> {
let mut mc = None; 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 { if count == 0 {
break; break;
} }
@@ -475,11 +547,7 @@ impl ScrollbackState {
} }
} }
if count > 0 { return (mc, count > 0);
need_load.insert(self.room_id.clone());
}
return mc;
} }
fn find_message( fn find_message(
@@ -489,11 +557,10 @@ impl ScrollbackState {
needle: &Regex, needle: &Regex,
count: usize, count: usize,
info: &RoomInfo, info: &RoomInfo,
need_load: &mut HashSet<OwnedRoomId>, ) -> (Option<MessageCursor>, bool) {
) -> Option<MessageCursor> {
match dir { match dir {
MoveDir1D::Next => self.find_message_next(key, needle, count, info), MoveDir1D::Next => (self.find_message_next(key, needle, count, info), false),
MoveDir1D::Previous => self.find_message_prev(key, needle, count, info, need_load), MoveDir1D::Previous => self.find_message_prev(key, needle, count, info),
} }
} }
} }
@@ -507,6 +574,7 @@ impl WindowOps<IambInfo> for ScrollbackState {
ScrollbackState { ScrollbackState {
room_id: self.room_id.clone(), room_id: self.room_id.clone(),
id: self.id.clone(), id: self.id.clone(),
thread: self.thread.clone(),
cursor: self.cursor.clone(), cursor: self.cursor.clone(),
viewctx: self.viewctx.clone(), viewctx: self.viewctx.clone(),
jumped: self.jumped.clone(), jumped: self.jumped.clone(),
@@ -555,18 +623,13 @@ impl EditorActions<ProgramContext, ProgramStore, IambInfo> for ScrollbackState {
store: &mut ProgramStore, store: &mut ProgramStore,
) -> EditResult<EditInfo, IambInfo> { ) -> EditResult<EditInfo, IambInfo> {
let info = store.application.rooms.get_or_default(self.room_id.clone()); let info = store.application.rooms.get_or_default(self.room_id.clone());
let key = if let Some(k) = self.cursor.to_key(info) { let thread = self.get_thread(info).ok_or_else(no_msgs)?;
k.clone() let key = self.cursor.to_key(thread).ok_or_else(no_msgs)?.clone();
} else {
let msg = "No messages to select.";
let err = EditError::Failure(msg.to_string());
return Err(err);
};
match operation { match operation {
EditAction::Motion => { EditAction::Motion => {
if motion.is_jumping() { if motion.is_jumping() {
self.jumped.push(self.cursor.clone()); self.push_jump();
} }
let pos = match motion { let pos = match motion {
@@ -585,8 +648,8 @@ impl EditorActions<ProgramContext, ProgramStore, IambInfo> for ScrollbackState {
let mark = ctx.resolve(mark); let mark = ctx.resolve(mark);
let cursor = store.cursors.get_mark(self.id.clone(), mark)?; let cursor = store.cursors.get_mark(self.id.clone(), mark)?;
if let mc @ Some(_) = MessageCursor::from_cursor(&cursor, info) { if let Some(mc) = MessageCursor::from_cursor(&cursor, thread) {
mc Some(mc)
} else { } else {
let msg = "Failed to restore mark"; let msg = "Failed to restore mark";
let err = EditError::Failure(msg.into()); 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 = ctx.get_search_regex_dir();
let dir = flip.resolve(&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 = store.registers.get(&Register::LastSearch)?;
let lsearch = lsearch.value.to_string(); 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
self.find_message( .need_load
key, .insert(self.room_id.clone(), Need::MESSAGES);
dir, }
&needle, mc
count,
info,
&mut store.application.need_load,
)
}, },
EditTarget::Search(SearchType::Word(_, _), _, _) => { EditTarget::Search(SearchType::Word(_, _), _, _) => {
let msg = "Cannot perform word search in a list"; 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 mark = ctx.resolve(mark);
let cursor = store.cursors.get_mark(self.id.clone(), 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() self._range_to(c).into()
} else { } else {
let msg = "Failed to restore mark"; 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 = ctx.get_search_regex_dir();
let dir = flip.resolve(&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 = store.registers.get(&Register::LastSearch)?;
let lsearch = lsearch.value.to_string(); 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( mc.map(|c| self._range_to(c))
key,
dir,
&needle,
count,
info,
&mut store.application.need_load,
)
.map(|c| self._range_to(c))
}, },
EditTarget::Search(SearchType::Word(_, _), _, _) => { EditTarget::Search(SearchType::Word(_, _), _, _) => {
let msg = "Cannot perform word search in a list"; let msg = "Cannot perform word search in a list";
@@ -771,17 +822,11 @@ impl EditorActions<ProgramContext, ProgramStore, IambInfo> for ScrollbackState {
store: &mut ProgramStore, store: &mut ProgramStore,
) -> EditResult<EditInfo, IambInfo> { ) -> EditResult<EditInfo, IambInfo> {
let info = store.application.get_room_info(self.room_id.clone()); let info = store.application.get_room_info(self.room_id.clone());
let thread = self.get_thread(info).ok_or_else(no_msgs)?;
if let Some(cursor) = self.cursor.to_cursor(info) { let cursor = self.cursor.to_cursor(thread).ok_or_else(no_msgs)?;
store.cursors.set_mark(self.id.clone(), name, cursor); store.cursors.set_mark(self.id.clone(), name, cursor);
Ok(None) Ok(None)
} else {
let msg = "Failed to set mark for message";
let err = EditError::Failure(msg.into());
Err(err)
}
} }
fn complete( fn complete(
@@ -823,7 +868,6 @@ impl EditorActions<ProgramContext, ProgramStore, IambInfo> for ScrollbackState {
HistoryAction::Checkpoint => Ok(None), HistoryAction::Checkpoint => Ok(None),
HistoryAction::Undo(_) => Err(EditError::Failure("Nothing to undo".into())), HistoryAction::Undo(_) => Err(EditError::Failure("Nothing to undo".into())),
HistoryAction::Redo(_) => Err(EditError::Failure("Nothing to redo".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, store: &mut ProgramStore,
) -> EditResult<EditInfo, IambInfo> { ) -> EditResult<EditInfo, IambInfo> {
let info = store.application.get_room_info(self.room_id.clone()); let info = store.application.get_room_info(self.room_id.clone());
let thread = self.get_thread(info).ok_or_else(no_msgs)?;
match act { match act {
CursorAction::Close(_) => Ok(None), 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)?; let ngroup = store.cursors.get_group(self.id.clone(), &reg)?;
// Lists don't have groups; override current position. // Lists don't have groups; override current position.
if self.jumped.current() != &self.cursor { if self.jump_changed() {
self.jumped.push(self.cursor.clone()); 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; self.cursor = mc;
Ok(None) Ok(None)
@@ -866,7 +911,7 @@ impl EditorActions<ProgramContext, ProgramStore, IambInfo> for ScrollbackState {
let reg = ctx.get_register().unwrap_or(Register::UnnamedCursorGroup); let reg = ctx.get_register().unwrap_or(Register::UnnamedCursorGroup);
// Lists don't have groups; override any previously saved group. // 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"; let msg = "Cannot save position in message history";
EditError::Failure(msg.into()) 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 msg = "No changes to jump to within the list";
let err = UIError::Failure(msg.into()); let err = UIError::Failure(msg.into());
return Err(err); Err(err)
}, },
PositionList::JumpList => { PositionList::JumpList => {
let (len, pos) = match dir { let (len, pos) = match dir {
MoveDir1D::Previous => { 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. // 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(); let plen = self.jumped.past_len();
@@ -952,7 +997,7 @@ impl Jumpable<ProgramContext, IambInfo> for ScrollbackState {
self.cursor = pos.clone(); 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( fn prompt(
&mut self, &mut self,
act: &PromptAction, act: &PromptAction,
_: &ProgramContext, ctx: &ProgramContext,
store: &mut ProgramStore, store: &mut ProgramStore,
) -> EditResult<Vec<(Action<IambInfo>, ProgramContext)>, IambInfo> { ) -> EditResult<Vec<(Action<IambInfo>, ProgramContext)>, IambInfo> {
let info = store.application.get_room_info(self.room_id.clone()); 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) { let Some(key) = self.cursor.to_key(thread) else {
key
} else {
let msg = "No message currently selected"; let msg = "No message currently selected";
let err = EditError::Failure(msg.into()); let err = EditError::Failure(msg.into());
return Err(err); return Err(err);
}; };
match act { match act {
PromptAction::Submit => { PromptAction::Submit => {
// XXX: I'm not sure exactly what to do here yet. I think I want this to display a if self.thread.is_some() {
// pop-over ListState with actions that can then be submitted: let msg =
// "You are already in a thread. Use :reply to reply to a specific message.";
// - Create a reply let err = EditError::Failure(msg.into());
// - Edit a message Err(err)
// - Redact a message } else {
// - React to a message let root = key.1.clone();
// - Report a message let room_id = self.room_id.clone();
// - Download an attachment let id = IambId::Room(room_id, Some(root));
// let open = WindowAction::Switch(OpenTarget::Application(id));
// Each of these should correspond to a command that a user can run. For example, Ok(vec![(open.into(), ctx.clone())])
// running `:reply` when hovering over a message should be equivalent to opening }
// the pop-up and selecting "Reply To This Message".
return Ok(vec![]);
}, },
PromptAction::Abort(..) => { PromptAction::Abort(..) => {
let msg = "Cannot abort a message."; let msg = "Cannot abort a message.";
let err = EditError::Failure(msg.into()); let err = EditError::Failure(msg.into());
Err(err)
return Err(err);
}, },
PromptAction::Recall(..) => { PromptAction::Recall(..) => {
let msg = "Cannot recall previous messages."; let msg = "Cannot recall previous messages.";
let err = EditError::Failure(msg.into()); let err = EditError::Failure(msg.into());
Err(err)
return Err(err);
},
_ => {
let msg = format!("Messages scrollback doesn't support {act:?}");
let err = EditError::Unimplemented(msg);
return 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 info = store.application.rooms.get_or_default(self.room_id.clone());
let settings = &store.application.settings; let settings = &store.application.settings;
let mut corner = self.viewctx.corner.clone(); 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 k.0
} else { } else {
return Ok(None); return Ok(None);
@@ -1047,11 +1081,11 @@ impl ScrollActions<ProgramContext, ProgramStore, IambInfo> for ScrollbackState {
match dir { match dir {
MoveDir2D::Up => { 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 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 txt = item.show(prev, sel, &self.viewctx, info, settings);
let len = txt.height().max(1); let len = txt.height().max(1);
let max = len.saturating_sub(1); let max = len.saturating_sub(1);
@@ -1076,9 +1110,9 @@ impl ScrollActions<ProgramContext, ProgramStore, IambInfo> for ScrollbackState {
} }
}, },
MoveDir2D::Down => { 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 sel = key == cursor_key;
let txt = item.show(prev, sel, &self.viewctx, info, settings); let txt = item.show(prev, sel, &self.viewctx, info, settings);
let len = txt.height().max(1); let len = txt.height().max(1);
@@ -1140,8 +1174,9 @@ impl ScrollActions<ProgramContext, ProgramStore, IambInfo> for ScrollbackState {
Axis::Vertical => { Axis::Vertical => {
let info = store.application.rooms.get_or_default(self.room_id.clone()); let info = store.application.rooms.get_or_default(self.room_id.clone());
let settings = &store.application.settings; 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); 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> { pub struct Scrollback<'a> {
room_focused: bool,
focused: bool, focused: bool,
store: &'a mut ProgramStore, store: &'a mut ProgramStore,
} }
impl<'a> Scrollback<'a> { impl<'a> Scrollback<'a> {
pub fn new(store: &'a mut ProgramStore) -> Self { 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. /// 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) { 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 info = self.store.application.rooms.get_or_default(state.room_id.clone());
let settings = &self.store.application.settings; 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); state.set_term_info(area);
@@ -1238,15 +1306,24 @@ impl<'a> StatefulWidget for Scrollback<'a> {
return; return;
} }
let Some(thread) = state.get_thread(info) else {
return;
};
if state.cursor.timestamp < state.viewctx.corner.timestamp { if state.cursor.timestamp < state.viewctx.corner.timestamp {
state.viewctx.corner = state.cursor.clone(); state.viewctx.corner = state.cursor.clone();
} }
let cursor = &state.cursor; 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 k
} else { } 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; return;
}; };
@@ -1254,20 +1331,19 @@ impl<'a> StatefulWidget for Scrollback<'a> {
let corner_key = if let Some(k) = &corner.timestamp { let corner_key = if let Some(k) = &corner.timestamp {
k.clone() k.clone()
} else { } 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 foc = self.focused || cursor.timestamp.is_some();
let full = std::mem::take(&mut state.show_full_on_redraw) || cursor.timestamp.is_none(); let full = std::mem::take(&mut state.show_full_on_redraw) || cursor.timestamp.is_none();
let mut lines = vec![]; let mut lines = vec![];
let mut sawit = false; 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 sel = key == cursor_key;
let txt = item.show(prev, foc && sel, &state.viewctx, info, settings); let (txt, mut msg_preview) =
item.show_with_preview(prev, foc && sel, &state.viewctx, info, settings);
prev = Some(item);
let incomplete_ok = !full || !sel; let incomplete_ok = !full || !sel;
@@ -1283,9 +1359,17 @@ impl<'a> StatefulWidget for Scrollback<'a> {
continue; 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; sawit |= sel;
} }
prev = Some(item);
} }
if lines.len() > height { if lines.len() > height {
@@ -1293,7 +1377,7 @@ impl<'a> StatefulWidget for Scrollback<'a> {
let _ = lines.drain(..n); 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.timestamp = Some((*ts, event_id.clone()));
state.viewctx.corner.text_row = *row; state.viewctx.corner.text_row = *row;
} }
@@ -1301,23 +1385,48 @@ impl<'a> StatefulWidget for Scrollback<'a> {
let mut y = area.top(); let mut y = area.top();
let x = area.left(); let x = area.left();
for (_, _, txt) in lines.into_iter() { let mut image_previews = vec![];
let _ = buf.set_spans(x, y, &txt, area.width); 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; 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. // 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. // Check whether we should load older messages for this room.
let first_key = info.messages.first_key_value().map(|(k, _)| k.clone()); if state.need_more_messages(info) {
if first_key == state.viewctx.corner.timestamp {
// If the top of the screen is the older message, load more. // 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() { async fn test_search_messages() {
let room_id = TEST_ROOM1_ID.clone(); let room_id = TEST_ROOM1_ID.clone();
let mut store = mock_store().await; 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 ctx = ProgramContext::default();
let next = MoveDirMod::Exact(MoveDir1D::Next); let next = MoveDirMod::Exact(MoveDir1D::Next);
@@ -1354,12 +1463,23 @@ mod tests {
// Search backwards to MSG2. // Search backwards to MSG2.
scrollback.search(prev.clone(), 1.into(), &ctx, &mut store).unwrap(); scrollback.search(prev.clone(), 1.into(), &ctx, &mut store).unwrap();
assert_eq!(scrollback.cursor, MSG2_KEY.clone().into()); 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. // Can't go any further; need_load now contains the room ID.
scrollback.search(prev.clone(), 1.into(), &ctx, &mut store).unwrap(); scrollback.search(prev.clone(), 1.into(), &ctx, &mut store).unwrap();
assert_eq!(scrollback.cursor, MSG2_KEY.clone().into()); 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. // Search forward twice to MSG1.
scrollback.search(next.clone(), 2.into(), &ctx, &mut store).unwrap(); scrollback.search(next.clone(), 2.into(), &ctx, &mut store).unwrap();
@@ -1373,7 +1493,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn test_movement() { async fn test_movement() {
let mut store = mock_store().await; 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 ctx = ProgramContext::default();
let prev = |n: usize| EditTarget::Motion(MoveType::Line(MoveDir1D::Previous), n.into()); let prev = |n: usize| EditTarget::Motion(MoveType::Line(MoveDir1D::Previous), n.into());
@@ -1407,7 +1527,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn test_dirscroll() { async fn test_dirscroll() {
let mut store = mock_store().await; 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 ctx = ProgramContext::default();
let prev = MoveDir2D::Up; let prev = MoveDir2D::Up;
@@ -1558,7 +1678,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn test_cursorpos() { async fn test_cursorpos() {
let mut store = mock_store().await; 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 ctx = ProgramContext::default();
// Skip rendering typing notices. // Skip rendering typing notices.

View File

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

View File

@@ -12,6 +12,7 @@
- `:dms` will open a list of direct messages - `:dms` will open a list of direct messages
- `:rooms` will open a list of joined rooms - `:rooms` will open a list of joined rooms
- `:chats` will open a list containing both direct messages and rooms
- `:members` will open a list of members for the currently focused room or space - `:members` will open a list of members for the currently focused room or space
- `:spaces` will open a list of joined spaces - `:spaces` will open a list of joined spaces
- `:join` can be used to switch to join a new room or start a direct message - `:join` can be used to switch to join a new room or start a direct message

View File

@@ -1,16 +1,12 @@
//! Welcome Window
use std::ops::{Deref, DerefMut}; use std::ops::{Deref, DerefMut};
use modalkit::tui::{buffer::Buffer, layout::Rect}; use ratatui::{buffer::Buffer, layout::Rect};
use modalkit::{ use modalkit_ratatui::{textbox::TextBoxState, TermOffset, TerminalCursor, WindowOps};
widgets::textbox::TextBoxState,
widgets::WindowOps,
widgets::{TermOffset, TerminalCursor},
};
use modalkit::editing::action::EditInfo;
use modalkit::editing::base::{CloseFlags, WordStyle, WriteFlags};
use modalkit::editing::completion::CompletionList; use modalkit::editing::completion::CompletionList;
use modalkit::prelude::*;
use crate::base::{IambBufferId, IambInfo, IambResult, ProgramStore}; use crate::base::{IambBufferId, IambInfo, IambResult, ProgramStore};

File diff suppressed because it is too large Load Diff