41 Commits

Author SHA1 Message Date
Ulyssa
ffc528e56a Do not include icon tag in MetaInfo 2024-08-21 18:57:48 -07:00
Ulyssa
7b6c5df268 Update MetaInfo for v0.0.10 release (#335) 2024-08-21 16:10:56 +00:00
Ulyssa
2e6376ff86 Release v0.0.10 (#333) 2024-08-20 22:26:52 -07:00
Ulyssa
480888a1fc Add commands for viewing and clearing unreads (#332) 2024-08-20 19:33:46 -07:00
Ulyssa
4fc05c7b40 Handle message marks on non-64-bit platforms (#329) 2024-08-18 08:31:42 +00:00
Ulyssa
3003f0a528 Add command for setting room history visibility (#328) 2024-08-18 07:33:45 +00:00
Ulyssa
df3896df9c Add ban/unban/kick room commands (#327) 2024-08-18 01:50:48 +00:00
Tony
2a66496913 Add command to set per-room notification levels (#305) 2024-08-17 14:43:19 -07:00
Ulyssa
b4fc574163 Include room name in desktop notifications (#326) 2024-08-17 01:03:13 -07:00
Ulyssa
e63341fe32 Avoid treating simple messages as Markdown (#325) 2024-08-16 23:56:30 -07:00
Ulyssa
657e61fe2e Remove modifyOtherKeys enablement (#324) 2024-08-17 03:06:35 +00:00
Ulyssa
94999dc4c0 Build cross-platform binaries and packages of main (#323) 2024-08-16 10:06:26 -07:00
Ulyssa
54cb7991be Fix underflow panics when using TextPrinter::push_span_nobreak (#322) 2024-08-15 03:48:04 +00:00
Ulyssa
c94d7d0ad7 Add metadata for cargo-deb and cargo-generate-rpm (#321) 2024-08-15 03:37:56 +00:00
Ulyssa
d44961c461 Support reacting literally with non-Emojis (#320) 2024-08-13 06:21:11 +00:00
Ulyssa
6d80b516f8 Update to modalkit{,-ratatui}@0.0.20 (#319) 2024-08-12 04:59:32 +00:00
Ulyssa
04480eda1b Add message slash commands (#317) 2024-08-08 05:49:54 +00:00
Ulyssa
653287478e Add FreeDesktop MetaInfo file (#315) 2024-08-01 07:45:50 +00:00
lymkwi
4571788678 Implement set/unset/show for alternative and canonical aliases (#279) 2024-08-01 06:51:01 +00:00
Andrew Collins
9a1adfb287 Allow notifications on open room if terminal not focused (#281) 2024-08-01 03:37:21 +00:00
Backroom8816
cb4455655f Add Hombrew as install method on MacOS (#303) 2024-08-01 03:10:31 +00:00
Jarkko Sakkinen
4fc71c9291 Fix newer Clippy warnings for 1.80 (#301) 2024-08-01 03:02:42 +00:00
Veronika
d8d8e91295 Remove timeout for desktop notifications (#314) 2024-08-01 02:56:33 +00:00
Aurore Poirier
497be7f099 Display file sizes for attachments (#278) 2024-05-25 16:16:04 -07:00
mordquist
64e4f67e43 Add error for missing username on :logout (#277) 2024-05-25 15:53:52 -07:00
Andrew Collins
a18d0f54eb Trim :editor output and check if it's empty (#275) 2024-05-25 15:52:41 -07:00
Lars E
59e1862e9c Add FreeBSD installation instructions (#280) 2024-05-25 20:42:46 +00:00
Joshua Smith
14415a30fc Fix openSUSE link and installation command in README (#283) 2024-05-25 20:40:25 +00:00
Gabor Pihaj
6c0d126f4b Add missing darwin build dependency (#286) 2024-05-25 20:38:01 +00:00
Ulyssa
c6982c9737 Fix LICENSE file (#274) 2024-04-24 06:59:00 +00:00
Ulyssa
46f6d37f76 Update to modalkit{,-ratatui}@0.0.19 (#273) 2024-04-24 06:30:01 +00:00
Ulyssa
3971801aa3 Allow typing newline with <S-Enter> and enable keyboard enhancement protocol (#272) 2024-04-21 18:19:53 -07:00
Ulyssa
7bc34c8145 Update Cargo.toml to v0.0.10-alpha.1 and update dependencies (#269) 2024-04-17 08:06:08 +00:00
Ethan Reynolds
91ca50aecb Fix image preview placement when messages are preceded by a date in the timeline (#257) 2024-04-13 15:47:08 -07:00
Ulyssa
949100bdc7 Update Welcome window to reference TOML instead of JSON (#254) 2024-04-12 06:20:05 +00:00
Ethan Reynolds
b995906c79 Add external_edit_file_suffix to config (#253) 2024-04-11 20:50:26 -07:00
Ulyssa
e5b284ed19 Use color overrides for users when message_user_color is enabled (#245) 2024-04-02 15:42:27 +00:00
Matthias Ahouansou
0f17bbfa17 Fix reaction count when there are duplicate reaction events from a user (#239) 2024-04-02 15:40:25 +00:00
Matthias Ahouansou
aba72aa64d Prevent sending duplicate reaction events (#240) 2024-04-02 15:21:24 +00:00
Benjamin Grosse
72d35431de Update to ratatui-image@1.0.0 (#241) 2024-04-02 08:01:00 -07:00
Ulyssa
a98bbd97be Support marking a room as a direct message room (#92) 2024-03-31 00:12:57 -07:00
31 changed files with 2913 additions and 1088 deletions

94
.github/workflows/binaries.yml vendored Normal file
View File

@@ -0,0 +1,94 @@
on:
push:
branches:
- main
name: Binaries
jobs:
package:
strategy:
matrix:
platform: [ubuntu-latest, windows-latest, macos-latest]
arch: [x86_64, aarch64]
exclude:
- platform: windows-latest
arch: aarch64
include:
- platform: ubuntu-latest
arch: x86_64
triple: unknown-linux-musl
- platform: ubuntu-latest
arch: aarch64
triple: unknown-linux-gnu
- platform: macos-latest
triple: apple-darwin
- platform: windows-latest
triple: pc-windows-msvc
runs-on: ${{ matrix.platform }}
env:
TARGET: ${{ matrix.arch }}-${{ matrix.triple }}
SCCACHE_GHA_ENABLED: "true"
RUSTC_WRAPPER: "sccache"
steps:
- name: Checkout code
uses: actions/checkout@v3
with:
submodules: true
- name: Install Rust (stable)
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ env.TARGET }}
- name: Install C cross-compilation toolchain
if: matrix.platform == 'ubuntu-latest'
run: |
sudo apt-get update
sudo apt install -f -y build-essential crossbuild-essential-arm64 musl-dev
# Cross-compilation env vars for x86_64-unknown-linux-musl
echo CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER=x86_64-linux-musl-gcc >> $GITHUB_ENV
echo AR_x86_64_unknown_linux_musl=x86_64-linux-gnu-ar >> $GITHUB_ENV
echo CC_x86_64_unknown_linux_musl=x86_64-linux-musl-gcc >> $GITHUB_ENV
echo CXX_x86_64_unknown_linux_musl=x86_64-linux-gnu-g++ >> $GITHUB_ENV
# Cross-compilation env vars for aarch64-unknown-linux-gnu
echo CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc >> $GITHUB_ENV
echo AR_aarch64_unknown_linux_gnu=aarch64-linux-gnu-ar >> $GITHUB_ENV
echo CC_aarch64_unknown_linux_gnu=aarch64-linux-gnu-gcc >> $GITHUB_ENV
echo CXX_aarch64_unknown_linux_gnu=aarch64-linux-gnu-g++ >> $GITHUB_ENV
- name: Cache cargo registry
uses: actions/cache@v3
with:
path: ~/.cargo/registry
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
- name: Run sccache-cache
uses: mozilla-actions/sccache-action@v0.0.3
- name: 'Build: binary'
run: cargo build --release --locked --target ${{ env.TARGET }}
- name: 'Upload: binary'
uses: actions/upload-artifact@v4
with:
name: iamb-${{ env.TARGET }}-binary
path: |
./target/${{ env.TARGET }}/release/iamb
./target/${{ env.TARGET }}/release/iamb.exe
- name: 'Package: deb'
if: matrix.platform == 'ubuntu-latest'
run: |
cargo install --locked cargo-deb
cargo deb --no-strip --target ${{ env.TARGET }}
- name: 'Upload: deb'
if: matrix.platform == 'ubuntu-latest'
uses: actions/upload-artifact@v4
with:
name: iamb-${{ env.TARGET }}-deb
path: ./target/${{ env.TARGET }}/debian/iamb*.deb
- name: 'Package: rpm'
if: matrix.platform == 'ubuntu-latest'
run: |
cargo install --locked cargo-generate-rpm
cargo generate-rpm --target ${{ env.TARGET }}
- name: 'Upload: rpm'
if: matrix.platform == 'ubuntu-latest'
uses: actions/upload-artifact@v4
with:
name: iamb-${{ env.TARGET }}-rpm
path: ./target/${{ env.TARGET }}/generate-rpm/iamb*.rpm

View File

@@ -45,12 +45,3 @@ jobs:
reporter: 'github-check' reporter: 'github-check'
- name: Run tests - name: Run tests
run: cargo test --locked run: cargo test --locked
- name: Build artifacts
run: cargo build --release --locked
- name: Upload artifacts
uses: actions/upload-artifact@master
with:
name: iamb-${{ matrix.platform }}
path: |
./target/release/iamb
./target/release/iamb.exe

1627
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.9" version = "0.0.10"
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"
@@ -15,8 +15,9 @@ rust-version = "1.70"
build = "build.rs" build = "build.rs"
[features] [features]
default = ["bundled"] default = ["bundled", "desktop"]
bundled = ["matrix-sdk/bundled-sqlite", "rustls-tls"] bundled = ["matrix-sdk/bundled-sqlite", "rustls-tls"]
desktop = ["dep:notify-rust", "modalkit/clipboard"]
native-tls = ["matrix-sdk/native-tls"] native-tls = ["matrix-sdk/native-tls"]
rustls-tls = ["matrix-sdk/rustls-tls"] rustls-tls = ["matrix-sdk/rustls-tls"]
@@ -26,14 +27,13 @@ default-features = false
features = ["build", "git", "gitcl",] features = ["build", "git", "gitcl",]
[dependencies] [dependencies]
arboard = "3.3.0" anyhow = "1.0"
bitflags = "^2.3" bitflags = "^2.3"
chrono = "0.4" chrono = "0.4"
clap = {version = "~4.3", 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"
futures = "0.3" futures = "0.3"
gethostname = "0.4.1" gethostname = "0.4.1"
html5ever = "0.26.0" html5ever = "0.26.0"
@@ -42,11 +42,11 @@ 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"] } nom = "7.0.0"
open = "3.2.0" open = "3.2.0"
rand = "0.8.5" rand = "0.8.5"
ratatui = "0.23" ratatui = "0.26"
ratatui-image = { version = "0.8.1", features = ["serde"] } ratatui-image = { version = "1.0.0", features = ["serde"] }
regex = "^1.5" regex = "^1.5"
rpassword = "^7.2" rpassword = "^7.2"
serde = "^1.0" serde = "^1.0"
@@ -62,16 +62,29 @@ 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" edit = "0.1.4"
humansize = "2.0.0"
[dependencies.comrak]
version = "0.22.0"
default-features = false
features = ["shortcodes"]
[dependencies.notify-rust]
version = "4.10.0"
default-features = false
features = ["zbus", "serde"]
optional = true
[dependencies.modalkit] [dependencies.modalkit]
version = "0.0.18" version = "0.0.20"
default-features = false
#git = "https://github.com/ulyssa/modalkit" #git = "https://github.com/ulyssa/modalkit"
#rev = "cb8c8aeb9a499b9b16615ce144f9014d78036e01" #rev = "24f3ec11c7f634005a27b26878d0fbbdcc08f272"
[dependencies.modalkit-ratatui] [dependencies.modalkit-ratatui]
version = "0.0.18" version = "0.0.20"
#git = "https://github.com/ulyssa/modalkit" #git = "https://github.com/ulyssa/modalkit"
#rev = "cb8c8aeb9a499b9b16615ce144f9014d78036e01" #rev = "24f3ec11c7f634005a27b26878d0fbbdcc08f272"
[dependencies.matrix-sdk] [dependencies.matrix-sdk]
version = "0.7.1" version = "0.7.1"
@@ -90,3 +103,33 @@ pretty_assertions = "1.4.0"
inherits = "release" inherits = "release"
incremental = false incremental = false
lto = true lto = true
[package.metadata.deb]
section = "net"
license-file = ["LICENSE", "0"]
assets = [
# Binary:
["target/release/iamb", "usr/bin/iamb", "755"],
# Manual pages:
["docs/iamb.1", "usr/share/man/man1/iamb.1", "644"],
["docs/iamb.5", "usr/share/man/man5/iamb.5", "644"],
# Other assets:
["iamb.desktop", "usr/share/applications/iamb.desktop", "644"],
["config.example.toml", "usr/share/iamb/config.example.toml", "644"],
["docs/iamb.svg", "usr/share/icons/hicolor/scalable/apps/iamb.svg", "644"],
["docs/iamb.metainfo.xml", "usr/share/metainfo/iamb.metainfo.xml", "644"],
]
[package.metadata.generate-rpm]
assets = [
# Binary:
{ source = "target/release/iamb", dest = "/usr/bin/iamb", mode = "755" },
# Manual pages:
{ source = "docs/iamb.1", dest = "/usr/share/man/man1/iamb.1", mode = "644" },
{ source = "docs/iamb.5", dest = "/usr/share/man/man5/iamb.5", mode = "644" },
# Other assets:
{ source = "iamb.desktop", dest = "/usr/share/applications/iamb.desktop", mode = "644" },
{ source = "config.example.toml", dest = "/usr/share/iamb/config.example.toml", mode = "644"},
{ source = "docs/iamb.svg", dest = "/usr/share/icons/hicolor/scalable/apps/iamb.svg", mode = "644"},
{ source = "docs/iamb.metainfo.xml", dest = "/usr/share/metainfo/iamb.metainfo.xml", mode = "644"},
]

View File

@@ -186,7 +186,7 @@
same "printed page" as the copyright notice for easier same "printed page" as the copyright notice for easier
identification within third-party archives. identification within third-party archives.
Copyright [yyyy] [name of copyright owner] Copyright 2024 Ulyssa Mello
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.

View File

@@ -28,15 +28,17 @@ and GCC are present.
In addition to the compiled binary, there are other files in the repo that In addition to the compiled binary, there are other files in the repo that
you'll want to install as part of a package: you'll want to install as part of a package:
| Repository Path | Installed Path (may vary per OS) | <!-- Please keep in sync w/ the `deb`/`generate-rpm` sections of `Cargo.toml` -->
| -------------------- | ----------------------------------------------- | | Repository Path | Installed Path (may vary per OS) |
| /iamb.desktop | /usr/share/applications/iamb.desktop | | ----------------------- | ----------------------------------------------- |
| /config.example.toml | /usr/share/iamb/config.example.toml | | /iamb.desktop | /usr/share/applications/iamb.desktop |
| /docs/iamb-256x256.png | /usr/share/icons/hicolor/256x256/apps/iamb.png | | /config.example.toml | /usr/share/iamb/config.example.toml |
| /docs/iamb-512x512.png | /usr/share/icons/hicolor/512x512/apps/iamb.png | | /docs/iamb-256x256.png | /usr/share/icons/hicolor/256x256/apps/iamb.png |
| /docs/iamb.svg | /usr/share/icons/hicolor/scalable/apps/iamb.svg | | /docs/iamb-512x512.png | /usr/share/icons/hicolor/512x512/apps/iamb.png |
| /docs/iamb.1 | /usr/share/man/man1/iamb.1 | | /docs/iamb.svg | /usr/share/icons/hicolor/scalable/apps/iamb.svg |
| /docs/iamb.5 | /usr/share/man/man5/iamb.5 | | /docs/iamb.1 | /usr/share/man/man1/iamb.1 |
| /docs/iamb.5 | /usr/share/man/man5/iamb.5 |
| /docs/iamb.metainfo.xml | /usr/share/metainfo/iamb.metainfo.xml |
[ring-lto]: https://github.com/briansmith/ring/issues/1444 [ring-lto]: https://github.com/briansmith/ring/issues/1444
[rustls]: https://crates.io/crates/rustls [rustls]: https://crates.io/crates/rustls

121
README.md
View File

@@ -11,7 +11,6 @@
</div> </div>
## About ## About
`iamb` is a Matrix client for the terminal that uses Vim keybindings. It includes support for: `iamb` is a Matrix client for the terminal that uses Vim keybindings. It includes support for:
@@ -19,6 +18,7 @@
- Threads, spaces, E2EE, and read receipts - Threads, spaces, E2EE, and read receipts
- Image previews in terminals that support it (sixels, Kitty, and iTerm2), or using pixelated blocks for those that don't - 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 - Notifications via terminal bell or desktop environment
- Send Markdown, HTML or plaintext messages
- Creating, joining, and leaving rooms - Creating, joining, and leaving rooms
- Sending and accepting room invitations - Sending and accepting room invitations
- Editing, redacting, and reacting to messages - Editing, redacting, and reacting to messages
@@ -32,54 +32,6 @@ _You may want to [see this page as it was when the latest version was published]
You can find documentation for installing, configuring, and using iamb on its You can find documentation for installing, configuring, and using iamb on its
website, [iamb.chat]. website, [iamb.chat].
## Installation
Install Rust (1.70.0 or above) and Cargo, and then run:
```
cargo install --locked iamb
```
See [Configuration](#configuration) for getting a profile set up.
### NetBSD
On NetBSD a package is available from the official repositories. To install it simply run:
```
pkgin install iamb
```
### Arch Linux
On Arch Linux a [package](https://aur.archlinux.org/packages/iamb-git) is available in the
Arch User Repositories (AUR). To install it simply run with your favorite AUR helper:
```
paru iamb-git
```
### openSUSE Tumbleweed
On openSUSE Tumbleweed a [package](https://build.opensuse.org/package/show/home%3Asmolsheep/iamb) is available from openSUSE Build Service (OBS). To install just use OBS Package Installer:
```
opi iamb
```
### Nix / NixOS (flake)
```
nix profile install "github:ulyssa/iamb"
```
### Snap
A snap for Linux distributions which [support](https://snapcraft.io/docs/installing-snapd) the packaging system.
```
snap install iamb
```
## Configuration ## Configuration
You can create a basic configuration in `$CONFIG_DIR/iamb/config.toml` that looks like: You can create a basic configuration in `$CONFIG_DIR/iamb/config.toml` that looks like:
@@ -99,14 +51,79 @@ url = "https://example.com"
user_id = "@user:example.com" user_id = "@user:example.com"
``` ```
## Installation (via `crates.io`)
Install Rust (1.70.0 or above) and Cargo, and then run:
```
cargo install --locked iamb
```
See [Configuration](#configuration) for getting a profile set up.
## Installation (via package managers)
### Arch Linux
On Arch Linux a [package](https://aur.archlinux.org/packages/iamb-git) is available in the
Arch User Repositories (AUR). To install it simply run with your favorite AUR helper:
```
paru iamb-git
```
### FreeBSD
On FreeBSD a package is available from the official repositories. To install it simply run:
```
pkg install iamb
```
### macOS
On macOS a [package](https://formulae.brew.sh/formula/iamb#default) is availabe in Homebrew's
repository. To install it simply run:
```
brew install iamb
```
### NetBSD
On NetBSD a package is available from the official repositories. To install it simply run:
```
pkgin install iamb
```
### Nix / NixOS (flake)
```
nix profile install "github:ulyssa/iamb"
```
### openSUSE Tumbleweed
On openSUSE Tumbleweed a [package](https://build.opensuse.org/package/show/openSUSE:Factory/iamb) is available from the official repositories. To install it simply run:
```
zypper install iamb
```
### Snap
A snap for Linux distributions which [support](https://snapcraft.io/docs/installing-snapd) the packaging system.
```
snap install iamb
```
## License ## License
iamb is released under the [Apache License, Version 2.0]. 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/
[crates-io-iamb]: https://crates.io/crates/iamb [crates-io-iamb]: https://crates.io/crates/iamb
[iamb.chat]: https://iamb.chat [iamb.chat]: https://iamb.chat
[gomuks]: https://github.com/tulir/gomuks
[weechat-matrix]: https://github.com/poljar/weechat-matrix
[well_known_entry]: https://spec.matrix.org/latest/client-server-api/#getwell-knownmatrixclient [well_known_entry]: https://spec.matrix.org/latest/client-server-api/#getwell-knownmatrixclient

View File

@@ -6,6 +6,7 @@ url = "https://matrix.org"
[settings] [settings]
default_room = "#iamb-users:0x.badd.cafe" default_room = "#iamb-users:0x.badd.cafe"
external_edit_file_suffix = ".md"
log_level = "warn" log_level = "warn"
message_shortcode_display = false message_shortcode_display = false
open_command = ["my-open", "--file"] open_command = ["my-open", "--file"]

View File

@@ -61,6 +61,8 @@ Log out of
View a list of joined rooms. View a list of joined rooms.
.It Sy ":spaces" .It Sy ":spaces"
View a list of joined spaces. View a list of joined spaces.
.It Sy ":unreads"
View a list of unread rooms.
.It Sy ":welcome" .It Sy ":welcome"
View the startup Welcome window. View the startup Welcome window.
.El .El
@@ -95,6 +97,8 @@ React to the selected message with an Emoji.
Redact the selected message. Redact the selected message.
.It Sy ":reply" .It Sy ":reply"
Reply to the selected message. Reply to the selected message.
.It Sy ":unreads clear"
Mark all unread rooms as read.
.It Sy ":unreact [shortcode]" .It Sy ":unreact [shortcode]"
Remove your reaction from the selected message. Remove your reaction from the selected message.
When no arguments are given, remove all of your reactions from the message. When no arguments are given, remove all of your reactions from the message.
@@ -122,6 +126,25 @@ View a list of members of the currently focused room.
Set the name of the currently focused room. Set the name of the currently focused room.
.It Sy ":room name unset" .It Sy ":room name unset"
Unset the name of the currently focused room. Unset the name of the currently focused room.
.It Sy ":room notify set [level]"
Set a notification level for the currently focused room.
Valid levels are
.Dq mute ,
.Dq mentions ,
.Dq keywords ,
and
.Dq all .
Note that
.Dq mentions
and
.Dq keywords
are aliases for the same behaviour.
.It Sy ":room notify unset"
Unset any room-level notification configuration.
.It Sy ":room notify show"
Show the current room-level notification configuration.
If the room is using the account-level default, then this will print
.Dq default .
.It Sy ":room tag set [tag]" .It Sy ":room tag set [tag]"
Add a tag to the currently focused room. Add a tag to the currently focused room.
.It Sy ":room tag unset [tag]" .It Sy ":room tag unset [tag]"
@@ -130,6 +153,24 @@ Remove a tag from the currently focused room.
Set the topic of the currently focused room. Set the topic of the currently focused room.
.It Sy ":room topic unset" .It Sy ":room topic unset"
Unset the topic of the currently focused room. Unset the topic of the currently focused room.
.It Sy ":room alias set [alias]"
Create and point the given alias to the room.
.It Sy ":room alias unset [alias]"
Delete the provided alias from the room's alternative alias list.
.It Sy ":room alias show"
Show alternative aliases to the room, if any are set.
.It Sy ":room canon set [alias]"
Set the room's canonical alias to the one provided, and make the previous one an alternative alias.
.It Sy ":room canon unset [alias]"
Delete the room's canonical alias.
.It Sy ":room canon show"
Show the room's canonical alias, if any is set.
.It Sy ":room ban [user] [reason]"
Ban a user from this room with an optional reason.
.It Sy ":room unban [user] [reason]"
Unban a user from this room with an optional reason.
.It Sy ":room kick [user] [reason]"
Kick a user from this room with an optional reason.
.El .El
.Sh "WINDOW COMMANDS" .Sh "WINDOW COMMANDS"
@@ -176,6 +217,43 @@ Close all but one tab.
Go to the preview tab. Go to the preview tab.
.El .El
.Sh "SLASH COMMANDS"
.Bl -tag -width Ds
.It Sy "/markdown" , Sy "/md"
Interpret the message body as Markdown markup.
This is the default behaviour.
.It Sy "/html" , Sy "/h"
Send the message body as literal HTML.
.It Sy "/plaintext" , Sy "/plain" , Sy "/p"
Do not interpret any markup in the message body and send it as it is.
.It Sy "/me"
Send an emote message.
.It Sy "/confetti"
Produces no effect in
.Nm ,
but will display confetti in Matrix clients that support doing so.
.It Sy "/fireworks"
Produces no effect in
.Nm ,
but will display fireworks in Matrix clients that support doing so.
.It Sy "/hearts"
Produces no effect in
.Nm ,
but will display floating hearts in Matrix clients that support doing so.
.It Sy "/rainfall"
Produces no effect in
.Nm ,
but will display rainfall in Matrix clients that support doing so.
.It Sy "/snowfall"
Produces no effect in
.Nm ,
but will display snowfall in Matrix clients that support doing so.
.It Sy "/spaceinvaders"
Produces no effect in
.Nm ,
but will display aliens from Space Invaders in Matrix clients that support doing so.
.El
.Sh EXAMPLES .Sh EXAMPLES
.Ss Example 1: Starting with a specific profile .Ss Example 1: Starting with a specific profile
To start with a profile named To start with a profile named

View File

@@ -125,6 +125,9 @@ key and can be overridden as described in
.Sx PROFILES . .Sx PROFILES .
.Bl -tag -width Ds .Bl -tag -width Ds
.It Sy external_edit_file_suffix
Suffix to append to temporary file names when using the :editor command. Defaults to .md.
.It Sy default_room .It Sy default_room
The room to show by default instead of the The room to show by default instead of the
.Sy :welcome .Sy :welcome

51
docs/iamb.metainfo.xml Normal file
View File

@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="UTF-8"?>
<component type="console-application">
<id>chat.iamb.iamb</id>
<name>iamb</name>
<summary>A terminal Matrix client for Vim addicts</summary>
<url type="homepage">https://iamb.chat</url>
<releases>
<release version="0.0.10" date="2024-08-20"/>
<release version="0.0.9" date="2024-03-28"/>
</releases>
<developer id="dev.ulyssa">
<name>Ulyssa</name>
</developer>
<metadata_license>CC-BY-SA-4.0</metadata_license>
<project_license>Apache-2.0</project_license>
<content_rating type="oars-1.1">
<content_attribute id="social-chat">intense</content_attribute>
</content_rating>
<screenshots>
<screenshot type="default">
<image>https://iamb.chat/static/images/metainfo-screenshot.png</image>
<caption>Example screenshot of room and lists of rooms, spaces and members within iamb</caption>
</screenshot>
</screenshots>
<description>
<p>
iamb is a client for the Matrix communication protocol. It provides a
terminal user interface with familiar Vim keybindings, and includes
support for multiple profiles, threads, spaces, notifications,
reactions, custom keybindings, and more.
</p>
</description>
<launchable type="desktop-id">iamb.desktop</launchable>
<categories>
<category>Network</category>
<category>Chat</category>
</categories>
<provides>
<binary>iamb</binary>
</provides>
</component>

View File

@@ -27,7 +27,7 @@
}; };
nativeBuildInputs = [ pkg-config ]; nativeBuildInputs = [ pkg-config ];
buildInputs = [ openssl ] ++ lib.optionals stdenv.isDarwin buildInputs = [ openssl ] ++ lib.optionals stdenv.isDarwin
(with darwin.apple_sdk.frameworks; [ AppKit Security ]); (with darwin.apple_sdk.frameworks; [ AppKit Security Cocoa]);
}; };
devShell = mkShell { devShell = mkShell {

View File

@@ -3,7 +3,7 @@
//! The types defined here get used throughout iamb. //! The types defined here get used throughout iamb.
use std::borrow::Cow; use std::borrow::Cow;
use std::collections::hash_map::IntoIter; use std::collections::hash_map::IntoIter;
use std::collections::{HashMap, HashSet}; use std::collections::{BTreeSet, HashMap, HashSet};
use std::convert::TryFrom; use std::convert::TryFrom;
use std::fmt::{self, Display}; use std::fmt::{self, Display};
use std::hash::Hash; use std::hash::Hash;
@@ -152,7 +152,11 @@ pub enum MessageAction {
Edit, Edit,
/// React to a message with an Emoji. /// React to a message with an Emoji.
React(String), ///
/// `:react` will by default try to convert the [String] argument to an Emoji, and error when
/// it doesn't recognize it. The second [bool] argument forces it to be interpreted literally
/// when it is `true`.
React(String, bool),
/// Redact a message, with an optional reason. /// Redact a message, with an optional reason.
/// ///
@@ -166,7 +170,11 @@ pub enum MessageAction {
/// ///
/// If no specific Emoji to remove to is specified, then all reactions from the user on the /// If no specific Emoji to remove to is specified, then all reactions from the user on the
/// message are removed. /// message are removed.
Unreact(Option<String>), ///
/// Like `:react`, `:unreact` will by default try to convert the [String] argument to an Emoji,
/// and error when it doesn't recognize it. The second [bool] argument forces it to be
/// interpreted literally when it is `true`.
Unreact(Option<String>, bool),
} }
/// The type of room being created. /// The type of room being created.
@@ -361,6 +369,9 @@ impl<'de> Visitor<'de> for SortUserVisitor {
/// A room property. /// A room property.
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Debug, Eq, PartialEq)]
pub enum RoomField { pub enum RoomField {
/// The room's history visibility.
History,
/// The room name. /// The room name.
Name, Name,
@@ -369,6 +380,36 @@ pub enum RoomField {
/// The room topic. /// The room topic.
Topic, Topic,
/// Notification level.
NotificationMode,
/// The room's entire list of alternative aliases.
Aliases,
/// A specific alternative alias to the room.
Alias(String),
/// The room's canonical alias.
CanonicalAlias,
}
/// An action that operates on a room member.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum MemberUpdateAction {
Ban,
Kick,
Unban,
}
impl Display for MemberUpdateAction {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
MemberUpdateAction::Ban => write!(f, "ban"),
MemberUpdateAction::Kick => write!(f, "kick"),
MemberUpdateAction::Unban => write!(f, "unban"),
}
}
} }
/// An action that operates on a focused room. /// An action that operates on a focused room.
@@ -386,14 +427,23 @@ pub enum RoomAction {
/// Leave this room. /// Leave this room.
Leave(bool), Leave(bool),
/// Update a user's membership in this room.
MemberUpdate(MemberUpdateAction, String, Option<String>, bool),
/// Open the members window. /// Open the members window.
Members(Box<CommandContext>), Members(Box<CommandContext>),
/// Set whether a room is a direct message.
SetDirect(bool),
/// Set a room property. /// Set a room property.
Set(RoomField, String), Set(RoomField, String),
/// Unset a room property. /// Unset a room property.
Unset(RoomField), Unset(RoomField),
/// List the values in a list room property.
Show(RoomField),
} }
/// An action that sends a message to a room. /// An action that sends a message to a room.
@@ -460,6 +510,9 @@ pub enum IambAction {
/// Toggle the focus within the focused room. /// Toggle the focus within the focused room.
ToggleScrollbackFocus, ToggleScrollbackFocus,
/// Clear all unread messages.
ClearUnreads,
} }
impl IambAction { impl IambAction {
@@ -496,6 +549,7 @@ impl From<SendAction> for IambAction {
impl ApplicationAction for IambAction { impl ApplicationAction for IambAction {
fn is_edit_sequence(&self, _: &EditContext) -> SequenceStatus { fn is_edit_sequence(&self, _: &EditContext) -> SequenceStatus {
match self { match self {
IambAction::ClearUnreads => SequenceStatus::Break,
IambAction::Homeserver(..) => SequenceStatus::Break, IambAction::Homeserver(..) => SequenceStatus::Break,
IambAction::Keys(..) => SequenceStatus::Break, IambAction::Keys(..) => SequenceStatus::Break,
IambAction::Message(..) => SequenceStatus::Break, IambAction::Message(..) => SequenceStatus::Break,
@@ -510,6 +564,7 @@ impl ApplicationAction for IambAction {
fn is_last_action(&self, _: &EditContext) -> SequenceStatus { fn is_last_action(&self, _: &EditContext) -> SequenceStatus {
match self { match self {
IambAction::ClearUnreads => SequenceStatus::Atom,
IambAction::Homeserver(..) => SequenceStatus::Atom, IambAction::Homeserver(..) => SequenceStatus::Atom,
IambAction::Keys(..) => SequenceStatus::Atom, IambAction::Keys(..) => SequenceStatus::Atom,
IambAction::Message(..) => SequenceStatus::Atom, IambAction::Message(..) => SequenceStatus::Atom,
@@ -524,6 +579,7 @@ impl ApplicationAction for IambAction {
fn is_last_selection(&self, _: &EditContext) -> SequenceStatus { fn is_last_selection(&self, _: &EditContext) -> SequenceStatus {
match self { match self {
IambAction::ClearUnreads => SequenceStatus::Ignore,
IambAction::Homeserver(..) => SequenceStatus::Ignore, IambAction::Homeserver(..) => SequenceStatus::Ignore,
IambAction::Keys(..) => SequenceStatus::Ignore, IambAction::Keys(..) => SequenceStatus::Ignore,
IambAction::Message(..) => SequenceStatus::Ignore, IambAction::Message(..) => SequenceStatus::Ignore,
@@ -538,6 +594,7 @@ impl ApplicationAction for IambAction {
fn is_switchable(&self, _: &EditContext) -> bool { fn is_switchable(&self, _: &EditContext) -> bool {
match self { match self {
IambAction::ClearUnreads => false,
IambAction::Homeserver(..) => false, IambAction::Homeserver(..) => false,
IambAction::Message(..) => false, IambAction::Message(..) => false,
IambAction::Room(..) => false, IambAction::Room(..) => false,
@@ -589,10 +646,22 @@ pub type MessageReactions = HashMap<OwnedEventId, (String, OwnedUserId)>;
/// Errors encountered during application use. /// Errors encountered during application use.
#[derive(thiserror::Error, Debug)] #[derive(thiserror::Error, Debug)]
pub enum IambError { pub enum IambError {
/// An invalid history visibility was specified.
#[error("Invalid history visibility setting: {0}")]
InvalidHistoryVisibility(String),
/// An invalid notification level was specified.
#[error("Invalid notification level: {0}")]
InvalidNotificationLevel(String),
/// An invalid user identifier was specified. /// An invalid user identifier was specified.
#[error("Invalid user identifier: {0}")] #[error("Invalid user identifier: {0}")]
InvalidUserId(String), InvalidUserId(String),
/// An invalid user identifier was specified.
#[error("Invalid room alias: {0}")]
InvalidRoomAlias(String),
/// An invalid verification identifier was specified. /// An invalid verification identifier was specified.
#[error("Invalid verification user/device pair: {0}")] #[error("Invalid verification user/device pair: {0}")]
InvalidVerificationId(String), InvalidVerificationId(String),
@@ -656,10 +725,17 @@ pub enum IambError {
#[error("Unknown room identifier: {0}")] #[error("Unknown room identifier: {0}")]
UnknownRoom(OwnedRoomId), UnknownRoom(OwnedRoomId),
/// An invalid room alias id was specified.
#[error("Invalid room alias id: {0}")]
InvalidRoomAliasId(#[from] matrix_sdk::ruma::IdParseError),
/// A failure occurred during verification. /// A failure occurred during verification.
#[error("Verification request error: {0}")] #[error("Verification request error: {0}")]
VerificationRequestError(#[from] matrix_sdk::encryption::identities::RequestVerificationError), VerificationRequestError(#[from] matrix_sdk::encryption::identities::RequestVerificationError),
#[error("Notification setting error: {0}")]
NotificationSettingError(#[from] matrix_sdk::NotificationSettingsError),
/// A failure related to images. /// A failure related to images.
#[error("Image error: {0}")] #[error("Image error: {0}")]
Image(#[from] image::ImageError), Image(#[from] image::ImageError),
@@ -835,9 +911,14 @@ impl RoomInfo {
if let Some(reacts) = self.reactions.get(event_id) { if let Some(reacts) = self.reactions.get(event_id) {
let mut counts = HashMap::new(); let mut counts = HashMap::new();
for (key, _) in reacts.values() { let mut seen_user_reactions = BTreeSet::new();
let count = counts.entry(key.as_str()).or_default();
*count += 1; for (key, user) in reacts.values() {
if !seen_user_reactions.contains(&(key, user)) {
seen_user_reactions.insert((key, user));
let count = counts.entry(key.as_str()).or_default();
*count += 1;
}
} }
let mut reactions = counts.into_iter().collect::<Vec<_>>(); let mut reactions = counts.into_iter().collect::<Vec<_>>();
@@ -1074,6 +1155,14 @@ impl RoomInfo {
self.user_receipts.insert(user_id, event_id); self.user_receipts.insert(user_id, event_id);
} }
pub fn fully_read(&mut self, user_id: OwnedUserId) {
let Some(((_, event_id), _)) = self.messages.last_key_value() else {
return;
};
self.set_receipt(user_id, event_id.clone());
}
pub fn get_receipt(&self, user_id: &UserId) -> Option<&OwnedEventId> { pub fn get_receipt(&self, user_id: &UserId) -> Option<&OwnedEventId> {
self.user_receipts.get(user_id) self.user_receipts.get(user_id)
} }
@@ -1146,6 +1235,22 @@ impl RoomInfo {
return top; return top;
} }
/// Checks if a given user has reacted with the given emoji on the given event
pub fn user_reactions_contains(
&mut self,
user_id: &UserId,
event_id: &EventId,
emoji: &str,
) -> bool {
if let Some(reactions) = self.reactions.get(event_id) {
reactions
.values()
.any(|(annotation, user)| annotation == emoji && user == user_id)
} else {
false
}
}
} }
/// Generate a [CompletionMap] for Emoji shortcodes. /// Generate a [CompletionMap] for Emoji shortcodes.
@@ -1219,6 +1324,20 @@ pub struct SyncInfo {
pub dms: Vec<Arc<(MatrixRoom, Option<Tags>)>>, pub dms: Vec<Arc<(MatrixRoom, Option<Tags>)>>,
} }
impl SyncInfo {
pub fn rooms(&self) -> impl Iterator<Item = &RoomId> {
self.rooms.iter().map(|r| r.0.room_id())
}
pub fn dms(&self) -> impl Iterator<Item = &RoomId> {
self.dms.iter().map(|r| r.0.room_id())
}
pub fn chats(&self) -> impl Iterator<Item = &RoomId> {
self.rooms().chain(self.dms())
}
}
bitflags::bitflags! { bitflags::bitflags! {
/// Load-needs /// Load-needs
#[derive(Debug, Default, PartialEq)] #[derive(Debug, Default, PartialEq)]
@@ -1295,6 +1414,9 @@ pub struct ChatStore {
/// Whether to ring the terminal bell on the next redraw. /// Whether to ring the terminal bell on the next redraw.
pub ring_bell: bool, pub ring_bell: bool,
/// Whether the application is currently focused
pub focused: bool,
} }
impl ChatStore { impl ChatStore {
@@ -1317,14 +1439,13 @@ impl ChatStore {
sync_info: Default::default(), sync_info: Default::default(),
draw_curr: None, draw_curr: None,
ring_bell: false, ring_bell: false,
focused: true,
} }
} }
/// Get a joined room. /// Get a joined room.
pub fn get_joined_room(&self, room_id: &RoomId) -> Option<MatrixRoom> { pub fn get_joined_room(&self, room_id: &RoomId) -> Option<MatrixRoom> {
let Some(room) = self.worker.client.get_room(room_id) else { let room = self.worker.client.get_room(room_id)?;
return None;
};
if room.state() == MatrixRoomState::Joined { if room.state() == MatrixRoomState::Joined {
Some(room) Some(room)
@@ -1388,6 +1509,9 @@ pub enum IambId {
/// The `:chats` window. /// The `:chats` window.
ChatList, ChatList,
/// The `:unreads` window.
UnreadList,
} }
impl Display for IambId { impl Display for IambId {
@@ -1408,6 +1532,7 @@ impl Display for IambId {
IambId::VerifyList => f.write_str("iamb://verify"), IambId::VerifyList => f.write_str("iamb://verify"),
IambId::Welcome => f.write_str("iamb://welcome"), IambId::Welcome => f.write_str("iamb://welcome"),
IambId::ChatList => f.write_str("iamb://chats"), IambId::ChatList => f.write_str("iamb://chats"),
IambId::UnreadList => f.write_str("iamb://unreads"),
} }
} }
} }
@@ -1539,6 +1664,13 @@ impl<'de> Visitor<'de> for IambIdVisitor {
Ok(IambId::ChatList) Ok(IambId::ChatList)
}, },
Some("unreads") => {
if url.path() != "" {
return Err(E::custom("iamb://unreads takes no path"));
}
Ok(IambId::UnreadList)
},
Some(s) => Err(E::custom(format!("{s:?} is not a valid window"))), Some(s) => Err(E::custom(format!("{s:?} is not a valid window"))),
None => Err(E::custom("Invalid iamb window URL")), None => Err(E::custom("Invalid iamb window URL")),
} }
@@ -1599,6 +1731,9 @@ pub enum IambBufferId {
/// The `:chats` window. /// The `:chats` window.
ChatList, ChatList,
/// The `:unreads` window.
UnreadList,
} }
impl IambBufferId { impl IambBufferId {
@@ -1614,6 +1749,7 @@ impl IambBufferId {
IambBufferId::VerifyList => IambId::VerifyList, IambBufferId::VerifyList => IambId::VerifyList,
IambBufferId::Welcome => IambId::Welcome, IambBufferId::Welcome => IambId::Welcome,
IambBufferId::ChatList => IambId::ChatList, IambBufferId::ChatList => IambId::ChatList,
IambBufferId::UnreadList => IambId::UnreadList,
}; };
Some(id) Some(id)
@@ -1648,6 +1784,7 @@ impl ApplicationInfo for IambInfo {
IambBufferId::VerifyList => vec![], IambBufferId::VerifyList => vec![],
IambBufferId::Welcome => vec![], IambBufferId::Welcome => vec![],
IambBufferId::ChatList => vec![], IambBufferId::ChatList => vec![],
IambBufferId::UnreadList => vec![],
} }
} }
@@ -1836,8 +1973,78 @@ pub mod tests {
use super::*; use super::*;
use crate::config::user_style_from_color; use crate::config::user_style_from_color;
use crate::tests::*; use crate::tests::*;
use matrix_sdk::ruma::{
events::{reaction::ReactionEventContent, relation::Annotation, MessageLikeUnsigned},
owned_event_id,
owned_room_id,
owned_user_id,
MilliSecondsSinceUnixEpoch,
};
use pretty_assertions::assert_eq;
use ratatui::style::Color; use ratatui::style::Color;
#[test]
fn multiple_identical_reactions() {
let mut info = RoomInfo::default();
let content = ReactionEventContent::new(Annotation::new(
owned_event_id!("$my_reaction"),
"🏠".to_owned(),
));
for i in 0..3 {
let event_id = format!("$house_{}", i);
info.insert_reaction(MessageLikeEvent::Original(
matrix_sdk::ruma::events::OriginalMessageLikeEvent {
content: content.clone(),
event_id: OwnedEventId::from_str(&event_id).unwrap(),
sender: owned_user_id!("@foo:example.org"),
origin_server_ts: MilliSecondsSinceUnixEpoch::now(),
room_id: owned_room_id!("!foo:example.org"),
unsigned: MessageLikeUnsigned::new(),
},
));
}
let content = ReactionEventContent::new(Annotation::new(
owned_event_id!("$my_reaction"),
"🙂".to_owned(),
));
for i in 0..2 {
let event_id = format!("$smile_{}", i);
info.insert_reaction(MessageLikeEvent::Original(
matrix_sdk::ruma::events::OriginalMessageLikeEvent {
content: content.clone(),
event_id: OwnedEventId::from_str(&event_id).unwrap(),
sender: owned_user_id!("@foo:example.org"),
origin_server_ts: MilliSecondsSinceUnixEpoch::now(),
room_id: owned_room_id!("!foo:example.org"),
unsigned: MessageLikeUnsigned::new(),
},
));
}
for i in 2..4 {
let event_id = format!("$smile_{}", i);
info.insert_reaction(MessageLikeEvent::Original(
matrix_sdk::ruma::events::OriginalMessageLikeEvent {
content: content.clone(),
event_id: OwnedEventId::from_str(&event_id).unwrap(),
sender: owned_user_id!("@bar:example.org"),
origin_server_ts: MilliSecondsSinceUnixEpoch::now(),
room_id: owned_room_id!("!foo:example.org"),
unsigned: MessageLikeUnsigned::new(),
},
));
}
assert_eq!(info.get_reactions(&owned_event_id!("$my_reaction")), vec![
("🏠", 1),
("🙂", 2)
]);
}
#[test] #[test]
fn test_typing_spans() { fn test_typing_spans() {
let mut info = RoomInfo::default(); let mut info = RoomInfo::default();

View File

@@ -20,6 +20,7 @@ use crate::base::{
IambAction, IambAction,
IambId, IambId,
KeysAction, KeysAction,
MemberUpdateAction,
MessageAction, MessageAction,
ProgramCommand, ProgramCommand,
ProgramCommands, ProgramCommands,
@@ -34,7 +35,7 @@ type ProgResult = CommandResult<ProgramCommand>;
/// Convert strings the user types into a tag name. /// Convert strings the user types into a tag name.
fn tag_name(name: String) -> Result<TagName, CommandError> { fn tag_name(name: String) -> Result<TagName, CommandError> {
let tag = match name.as_str() { let tag = match name.to_lowercase().as_str() {
"fav" | "favorite" | "favourite" | "m.favourite" => TagName::Favorite, "fav" | "favorite" | "favourite" | "m.favourite" => TagName::Favorite,
"low" | "lowpriority" | "low_priority" | "low-priority" | "m.lowpriority" => { "low" | "lowpriority" | "low_priority" | "low-priority" | "m.lowpriority" => {
TagName::LowPriority TagName::LowPriority
@@ -221,24 +222,17 @@ fn iamb_edit(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
} }
fn iamb_react(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult { fn iamb_react(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
let args = desc.arg.strings()?; let mut args = desc.arg.strings()?;
if args.len() != 1 { if args.len() != 1 {
return Result::Err(CommandError::InvalidArgument); return Result::Err(CommandError::InvalidArgument);
} }
let k = args[0].as_str(); let react = args.remove(0);
let mact = IambAction::from(MessageAction::React(react, desc.bang));
let step = CommandStep::Continue(mact.into(), ctx.context.clone());
if let Some(emoji) = emojis::get(k).or_else(|| emojis::get_by_shortcode(k)) { return Ok(step);
let mact = IambAction::from(MessageAction::React(emoji.to_string()));
let step = CommandStep::Continue(mact.into(), ctx.context.clone());
return Ok(step);
} else {
let msg = format!("Invalid Emoji or shortcode: {k}");
return Result::Err(CommandError::Error(msg));
}
} }
fn iamb_unreact(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult { fn iamb_unreact(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
@@ -248,20 +242,8 @@ fn iamb_unreact(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
return Result::Err(CommandError::InvalidArgument); return Result::Err(CommandError::InvalidArgument);
} }
let mact = if let Some(k) = args.pop() { let reaction = args.pop();
let k = k.as_str(); let mact = IambAction::from(MessageAction::Unreact(reaction, desc.bang));
if let Some(emoji) = emojis::get(k).or_else(|| emojis::get_by_shortcode(k)) {
IambAction::from(MessageAction::Unreact(Some(emoji.to_string())))
} else {
let msg = format!("Invalid Emoji or shortcode: {k}");
return Result::Err(CommandError::Error(msg));
}
} else {
IambAction::from(MessageAction::Unreact(None))
};
let step = CommandStep::Continue(mact.into(), ctx.context.clone()); let step = CommandStep::Continue(mact.into(), ctx.context.clone());
return Ok(step); return Ok(step);
@@ -325,6 +307,30 @@ fn iamb_chats(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
return Ok(step); return Ok(step);
} }
fn iamb_unreads(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
let mut args = desc.arg.strings()?;
if args.len() > 1 {
return Result::Err(CommandError::InvalidArgument);
}
match args.pop().as_deref() {
Some("clear") => {
let clear = IambAction::ClearUnreads;
let step = CommandStep::Continue(clear.into(), ctx.context.clone());
return Ok(step);
},
Some(_) => return Result::Err(CommandError::InvalidArgument),
None => {
let open = ctx.switch(OpenTarget::Application(IambId::UnreadList));
let step = CommandStep::Continue(open, ctx.context.clone());
return Ok(step);
},
}
}
fn iamb_spaces(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult { fn iamb_spaces(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
if !desc.arg.text.is_empty() { if !desc.arg.text.is_empty() {
return Result::Err(CommandError::InvalidArgument); return Result::Err(CommandError::InvalidArgument);
@@ -422,6 +428,37 @@ fn iamb_room(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
} }
let act: IambAction = match (field.as_str(), action.as_str(), args.pop()) { let act: IambAction = match (field.as_str(), action.as_str(), args.pop()) {
// :room dm set
("dm", "set", None) => RoomAction::SetDirect(true).into(),
("dm", "set", Some(_)) => return Result::Err(CommandError::InvalidArgument),
// :room dm set
("dm", "unset", None) => RoomAction::SetDirect(false).into(),
("dm", "unset", Some(_)) => return Result::Err(CommandError::InvalidArgument),
// :room [kick|ban|unban] <user>
("kick", u, r) => {
RoomAction::MemberUpdate(MemberUpdateAction::Kick, u.into(), r, desc.bang).into()
},
("ban", u, r) => {
RoomAction::MemberUpdate(MemberUpdateAction::Ban, u.into(), r, desc.bang).into()
},
("unban", u, r) => {
RoomAction::MemberUpdate(MemberUpdateAction::Unban, u.into(), r, desc.bang).into()
},
// :room history set <visibility>
("history", "set", Some(s)) => RoomAction::Set(RoomField::History, s).into(),
("history", "set", None) => return Result::Err(CommandError::InvalidArgument),
// :room history unset
("history", "unset", None) => RoomAction::Unset(RoomField::History).into(),
("history", "unset", Some(_)) => return Result::Err(CommandError::InvalidArgument),
// :room history show
("history", "show", None) => RoomAction::Show(RoomField::History).into(),
("history", "show", Some(_)) => return Result::Err(CommandError::InvalidArgument),
// :room name set <room-name> // :room name set <room-name>
("name", "set", Some(s)) => RoomAction::Set(RoomField::Name, s).into(), ("name", "set", Some(s)) => RoomAction::Set(RoomField::Name, s).into(),
("name", "set", None) => return Result::Err(CommandError::InvalidArgument), ("name", "set", None) => return Result::Err(CommandError::InvalidArgument),
@@ -442,10 +479,58 @@ fn iamb_room(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
("tag", "set", Some(s)) => RoomAction::Set(RoomField::Tag(tag_name(s)?), "".into()).into(), ("tag", "set", Some(s)) => RoomAction::Set(RoomField::Tag(tag_name(s)?), "".into()).into(),
("tag", "set", None) => return Result::Err(CommandError::InvalidArgument), ("tag", "set", None) => return Result::Err(CommandError::InvalidArgument),
// :room notify set <notification-level>
("notify", "set", Some(s)) => RoomAction::Set(RoomField::NotificationMode, s).into(),
("notify", "set", None) => return Result::Err(CommandError::InvalidArgument),
// :room notify unset <notification-level>
("notify", "unset", None) => RoomAction::Unset(RoomField::NotificationMode).into(),
("notify", "unset", Some(_)) => return Result::Err(CommandError::InvalidArgument),
// :room notify show
("notify", "show", None) => RoomAction::Show(RoomField::NotificationMode).into(),
("notify", "show", Some(_)) => return Result::Err(CommandError::InvalidArgument),
// :room tag unset <tag-name> // :room tag unset <tag-name>
("tag", "unset", Some(s)) => RoomAction::Unset(RoomField::Tag(tag_name(s)?)).into(), ("tag", "unset", Some(s)) => RoomAction::Unset(RoomField::Tag(tag_name(s)?)).into(),
("tag", "unset", None) => return Result::Err(CommandError::InvalidArgument), ("tag", "unset", None) => return Result::Err(CommandError::InvalidArgument),
// :room aliases show
("alias", "show", None) => RoomAction::Show(RoomField::Aliases).into(),
("alias", "show", Some(_)) => return Result::Err(CommandError::InvalidArgument),
// :room aliases unset <alias>
("alias", "unset", Some(s)) => RoomAction::Unset(RoomField::Alias(s)).into(),
("alias", "unset", None) => return Result::Err(CommandError::InvalidArgument),
// :room aliases set <alias>
("alias", "set", Some(s)) => RoomAction::Set(RoomField::Alias(s), "".into()).into(),
("alias", "set", None) => return Result::Err(CommandError::InvalidArgument),
// :room canonicalalias show
("canonicalalias" | "canon", "show", None) => {
RoomAction::Show(RoomField::CanonicalAlias).into()
},
("canonicalalias" | "canon", "show", Some(_)) => {
return Result::Err(CommandError::InvalidArgument)
},
// :room canonicalalias set
("canonicalalias" | "canon", "set", Some(s)) => {
RoomAction::Set(RoomField::CanonicalAlias, s).into()
},
("canonicalalias" | "canon", "set", None) => {
return Result::Err(CommandError::InvalidArgument)
},
// :room canonicalalias unset
("canonicalalias" | "canon", "unset", None) => {
RoomAction::Unset(RoomField::CanonicalAlias).into()
},
("canonicalalias" | "canon", "unset", Some(_)) => {
return Result::Err(CommandError::InvalidArgument)
},
_ => return Result::Err(CommandError::InvalidArgument), _ => return Result::Err(CommandError::InvalidArgument),
}; };
@@ -507,6 +592,9 @@ fn iamb_open(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
fn iamb_logout(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult { fn iamb_logout(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
let args = desc.arg.strings()?; let args = desc.arg.strings()?;
if args.is_empty() {
return Result::Err(CommandError::Error("Missing username".to_string()));
}
if args.len() != 1 { if args.len() != 1 {
return Result::Err(CommandError::InvalidArgument); return Result::Err(CommandError::InvalidArgument);
} }
@@ -584,6 +672,11 @@ fn add_iamb_commands(cmds: &mut ProgramCommands) {
aliases: vec![], aliases: vec![],
f: iamb_spaces, f: iamb_spaces,
}); });
cmds.add_command(ProgramCommand {
name: "unreads".into(),
aliases: vec![],
f: iamb_unreads,
});
cmds.add_command(ProgramCommand { cmds.add_command(ProgramCommand {
name: "unreact".into(), name: "unreact".into(),
aliases: vec![], aliases: vec![],
@@ -789,6 +882,32 @@ mod tests {
assert_eq!(res, Err(CommandError::InvalidArgument)); assert_eq!(res, Err(CommandError::InvalidArgument));
} }
#[test]
fn test_cmd_room_dm_set() {
let mut cmds = setup_commands();
let ctx = EditContext::default();
let res = cmds.input_cmd("room dm set", ctx.clone()).unwrap();
let act = RoomAction::SetDirect(true);
assert_eq!(res, vec![(act.into(), ctx.clone())]);
let res = cmds.input_cmd("room dm set true", ctx.clone());
assert_eq!(res, Err(CommandError::InvalidArgument));
}
#[test]
fn test_cmd_room_dm_unset() {
let mut cmds = setup_commands();
let ctx = EditContext::default();
let res = cmds.input_cmd("room dm unset", ctx.clone()).unwrap();
let act = RoomAction::SetDirect(false);
assert_eq!(res, vec![(act.into(), ctx.clone())]);
let res = cmds.input_cmd("room dm unset true", ctx.clone());
assert_eq!(res, Err(CommandError::InvalidArgument));
}
#[test] #[test]
fn test_cmd_room_tag_set() { fn test_cmd_room_tag_set() {
let mut cmds = setup_commands(); let mut cmds = setup_commands();
@@ -923,6 +1042,27 @@ mod tests {
); );
} }
#[test]
fn test_cmd_room_notification_mode_set() {
let mut cmds = setup_commands();
let ctx = EditContext::default();
let cmd = format!("room notify set mute");
let res = cmds.input_cmd(&cmd, ctx.clone()).unwrap();
let act = RoomAction::Set(RoomField::NotificationMode, "mute".into());
assert_eq!(res, vec![(act.into(), ctx.clone())]);
let cmd = format!("room notify unset");
let res = cmds.input_cmd(&cmd, ctx.clone()).unwrap();
let act = RoomAction::Unset(RoomField::NotificationMode);
assert_eq!(res, vec![(act.into(), ctx.clone())]);
let cmd = format!("room notify show");
let res = cmds.input_cmd(&cmd, ctx.clone()).unwrap();
let act = RoomAction::Show(RoomField::NotificationMode);
assert_eq!(res, vec![(act.into(), ctx.clone())]);
}
#[test] #[test]
fn test_cmd_invite() { fn test_cmd_invite() {
let mut cmds = setup_commands(); let mut cmds = setup_commands();
@@ -960,6 +1100,69 @@ mod tests {
assert_eq!(res, Err(CommandError::InvalidArgument)); assert_eq!(res, Err(CommandError::InvalidArgument));
} }
#[test]
fn test_cmd_room_kick() {
let mut cmds = setup_commands();
let ctx = EditContext::default();
let res = cmds.input_cmd("room kick @user:example.com", ctx.clone()).unwrap();
let act = IambAction::Room(RoomAction::MemberUpdate(
MemberUpdateAction::Kick,
"@user:example.com".into(),
None,
false,
));
assert_eq!(res, vec![(act.into(), ctx.clone())]);
let res = cmds.input_cmd("room! kick @user:example.com", ctx.clone()).unwrap();
let act = IambAction::Room(RoomAction::MemberUpdate(
MemberUpdateAction::Kick,
"@user:example.com".into(),
None,
true,
));
assert_eq!(res, vec![(act.into(), ctx.clone())]);
let res = cmds
.input_cmd("room! kick @user:example.com \"reason here\"", ctx.clone())
.unwrap();
let act = IambAction::Room(RoomAction::MemberUpdate(
MemberUpdateAction::Kick,
"@user:example.com".into(),
Some("reason here".into()),
true,
));
assert_eq!(res, vec![(act.into(), ctx.clone())]);
}
#[test]
fn test_cmd_room_ban_unban() {
let mut cmds = setup_commands();
let ctx = EditContext::default();
let res = cmds
.input_cmd("room! ban @user:example.com \"spam\"", ctx.clone())
.unwrap();
let act = IambAction::Room(RoomAction::MemberUpdate(
MemberUpdateAction::Ban,
"@user:example.com".into(),
Some("spam".into()),
true,
));
assert_eq!(res, vec![(act.into(), ctx.clone())]);
let res = cmds
.input_cmd("room unban @user:example.com \"reconciled\"", ctx.clone())
.unwrap();
let act = IambAction::Room(RoomAction::MemberUpdate(
MemberUpdateAction::Unban,
"@user:example.com".into(),
Some("reconciled".into()),
false,
));
assert_eq!(res, vec![(act.into(), ctx.clone())]);
}
#[test] #[test]
fn test_cmd_redact() { fn test_cmd_redact() {
let mut cmds = setup_commands(); let mut cmds = setup_commands();

View File

@@ -398,16 +398,26 @@ pub enum UserDisplayStyle {
DisplayName, DisplayName,
} }
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
pub enum NotifyVia { pub enum NotifyVia {
/// Deliver notifications via terminal bell. /// Deliver notifications via terminal bell.
Bell, Bell,
/// Deliver notifications via desktop mechanism. /// Deliver notifications via desktop mechanism.
#[default] #[cfg(feature = "desktop")]
Desktop, Desktop,
} }
impl Default for NotifyVia {
fn default() -> Self {
#[cfg(not(feature = "desktop"))]
return NotifyVia::Bell;
#[cfg(feature = "desktop")]
return NotifyVia::Desktop;
}
}
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)] #[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)]
pub struct Notifications { pub struct Notifications {
#[serde(default)] #[serde(default)]
@@ -507,6 +517,7 @@ pub struct TunableValues {
pub notifications: Notifications, pub notifications: Notifications,
pub image_preview: Option<ImagePreviewValues>, pub image_preview: Option<ImagePreviewValues>,
pub user_gutter_width: usize, pub user_gutter_width: usize,
pub external_edit_file_suffix: String,
} }
#[derive(Clone, Default, Deserialize)] #[derive(Clone, Default, Deserialize)]
@@ -530,6 +541,7 @@ pub struct Tunables {
pub notifications: Option<Notifications>, pub notifications: Option<Notifications>,
pub image_preview: Option<ImagePreview>, pub image_preview: Option<ImagePreview>,
pub user_gutter_width: Option<usize>, pub user_gutter_width: Option<usize>,
pub external_edit_file_suffix: Option<String>,
} }
impl Tunables { impl Tunables {
@@ -557,6 +569,9 @@ impl Tunables {
notifications: self.notifications.or(other.notifications), notifications: self.notifications.or(other.notifications),
image_preview: self.image_preview.or(other.image_preview), image_preview: self.image_preview.or(other.image_preview),
user_gutter_width: self.user_gutter_width.or(other.user_gutter_width), user_gutter_width: self.user_gutter_width.or(other.user_gutter_width),
external_edit_file_suffix: self
.external_edit_file_suffix
.or(other.external_edit_file_suffix),
} }
} }
@@ -580,6 +595,9 @@ impl Tunables {
notifications: self.notifications.unwrap_or_default(), notifications: self.notifications.unwrap_or_default(),
image_preview: self.image_preview.map(ImagePreview::values), image_preview: self.image_preview.map(ImagePreview::values),
user_gutter_width: self.user_gutter_width.unwrap_or(30), user_gutter_width: self.user_gutter_width.unwrap_or(30),
external_edit_file_suffix: self
.external_edit_file_suffix
.unwrap_or_else(|| ".md".to_string()),
} }
} }
} }
@@ -912,15 +930,16 @@ impl ApplicationSettings {
.unwrap_or_default() .unwrap_or_default()
} }
pub fn get_user_style(&self, user_id: &UserId) -> Style { pub fn get_user_color(&self, user_id: &UserId) -> Color {
let color = self self.tunables
.tunables
.users .users
.get(user_id) .get(user_id)
.and_then(|user| user.color.as_ref().map(|c| c.0)) .and_then(|user| user.color.as_ref().map(|c| c.0))
.unwrap_or_else(|| user_color(user_id.as_str())); .unwrap_or_else(|| user_color(user_id.as_str()))
}
user_style_from_color(color) pub fn get_user_style(&self, user_id: &UserId) -> Style {
user_style_from_color(self.get_user_color(user_id))
} }
pub fn get_user_span<'a>(&self, user_id: &'a UserId, info: &'a RoomInfo) -> Span<'a> { pub fn get_user_span<'a>(&self, user_id: &'a UserId, info: &'a RoomInfo) -> Span<'a> {
@@ -1164,7 +1183,7 @@ mod tests {
let j = "j".parse::<TerminalKey>().unwrap(); let j = "j".parse::<TerminalKey>().unwrap();
let esc = "<Esc>".parse::<TerminalKey>().unwrap(); let esc = "<Esc>".parse::<TerminalKey>().unwrap();
let jj = Keys(vec![j.clone(), j], "jj".into()); let jj = Keys(vec![j, j], "jj".into());
let run = mapped.get(&jj).unwrap(); let run = mapped.get(&jj).unwrap();
let exp = Keys(vec![esc], "<Esc>".into()); let exp = Keys(vec![esc], "<Esc>".into());
assert_eq!(run, &exp); assert_eq!(run, &exp);

View File

@@ -3,13 +3,13 @@
//! The keybindings are set up here. We define some iamb-specific keybindings, but the default Vim //! The keybindings are set up here. We define some iamb-specific keybindings, but the default Vim
//! keys come from [modalkit::env::vim::keybindings]. //! keys come from [modalkit::env::vim::keybindings].
use modalkit::{ use modalkit::{
actions::{MacroAction, WindowAction}, actions::{InsertTextAction, MacroAction, WindowAction},
env::vim::keybindings::{InputStep, VimBindings}, env::vim::keybindings::{InputStep, VimBindings},
env::vim::VimMode, env::vim::VimMode,
env::CommonKeyClass, env::CommonKeyClass,
key::TerminalKey, key::TerminalKey,
keybindings::{EdgeEvent, EdgeRepeat, InputBindings}, keybindings::{EdgeEvent, EdgeRepeat, InputBindings},
prelude::Count, prelude::*,
}; };
use crate::base::{IambAction, IambInfo, Keybindings, MATRIX_ID_WORD}; use crate::base::{IambAction, IambInfo, Keybindings, MATRIX_ID_WORD};
@@ -36,6 +36,7 @@ pub fn setup_keybindings() -> Keybindings {
let ctrl_z = "<C-Z>".parse::<TerminalKey>().unwrap(); let ctrl_z = "<C-Z>".parse::<TerminalKey>().unwrap();
let key_m_lc = "m".parse::<TerminalKey>().unwrap(); let key_m_lc = "m".parse::<TerminalKey>().unwrap();
let key_z_lc = "z".parse::<TerminalKey>().unwrap(); let key_z_lc = "z".parse::<TerminalKey>().unwrap();
let shift_enter = "<S-Enter>".parse::<TerminalKey>().unwrap();
let cwz = vec![once(&ctrl_w), once(&key_z_lc)]; let cwz = vec![once(&ctrl_w), once(&key_z_lc)];
let cwcz = vec![once(&ctrl_w), once(&ctrl_z)]; let cwcz = vec![once(&ctrl_w), once(&ctrl_z)];
@@ -57,6 +58,17 @@ 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);
let shift_enter = vec![once(&shift_enter)];
let newline = IambStep::new().actions(vec![InsertTextAction::Type(
Char::Single('\n').into(),
MoveDir1D::Previous,
1.into(),
)
.into()]);
ism.add_mapping(VimMode::Insert, &cwm, &newline);
ism.add_mapping(VimMode::Insert, &shift_enter, &newline);
ism ism
} }

View File

@@ -48,6 +48,9 @@ use modalkit::crossterm::{
EnableFocusChange, EnableFocusChange,
Event, Event,
KeyEventKind, KeyEventKind,
KeyboardEnhancementFlags,
PopKeyboardEnhancementFlags,
PushKeyboardEnhancementFlags,
}, },
execute, execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, SetTitle}, terminal::{EnterAlternateScreen, LeaveAlternateScreen, SetTitle},
@@ -126,8 +129,8 @@ use modalkit::{
use modalkit_ratatui::{ use modalkit_ratatui::{
cmdbar::CommandBarState, cmdbar::CommandBarState,
screen::{Screen, ScreenState, TabLayoutDescription}, screen::{Screen, ScreenState, TabbedLayoutDescription},
windows::WindowLayoutDescription, windows::{WindowLayoutDescription, WindowLayoutState},
TerminalCursor, TerminalCursor,
TerminalExtOps, TerminalExtOps,
Window, Window,
@@ -173,6 +176,17 @@ fn config_tab_to_desc(
Ok(desc) Ok(desc)
} }
fn restore_layout(
area: Rect,
settings: &ApplicationSettings,
store: &mut ProgramStore,
) -> IambResult<FocusList<WindowLayoutState<IambWindow, IambInfo>>> {
let layout = std::fs::read(&settings.layout_json)?;
let tabs: TabbedLayoutDescription<IambInfo> =
serde_json::from_slice(&layout).map_err(IambError::from)?;
tabs.to_layout(area.into(), store)
}
fn setup_screen( fn setup_screen(
settings: ApplicationSettings, settings: ApplicationSettings,
store: &mut ProgramStore, store: &mut ProgramStore,
@@ -183,12 +197,14 @@ fn setup_screen(
match settings.layout { match settings.layout {
config::Layout::Restore => { config::Layout::Restore => {
if let Ok(layout) = std::fs::read(&settings.layout_json) { match restore_layout(area, &settings, store) {
let tabs: TabLayoutDescription<IambInfo> = Ok(tabs) => {
serde_json::from_slice(&layout).map_err(IambError::from)?; return Ok(ScreenState::from_list(tabs, cmd));
let tabs = tabs.to_layout(area.into(), store)?; },
Err(e) => {
return Ok(ScreenState::from_list(tabs, cmd)); // Log the issue with restoring and then continue.
tracing::warn!(err = %e, "Failed to restore layout from disk");
},
} }
}, },
config::Layout::New => {}, config::Layout::New => {},
@@ -239,7 +255,7 @@ struct Application {
focused: bool, focused: bool,
/// The tab layout before the last executed [TabAction]. /// The tab layout before the last executed [TabAction].
last_layout: Option<TabLayoutDescription<IambInfo>>, last_layout: Option<TabbedLayoutDescription<IambInfo>>,
/// Whether we need to do a full redraw (e.g., after running a subprocess). /// Whether we need to do a full redraw (e.g., after running a subprocess).
dirty: bool, dirty: bool,
@@ -250,16 +266,7 @@ impl Application {
settings: ApplicationSettings, settings: ApplicationSettings,
store: AsyncProgramStore, store: AsyncProgramStore,
) -> IambResult<Application> { ) -> IambResult<Application> {
let mut stdout = stdout(); let backend = CrosstermBackend::new(stdout());
crossterm::terminal::enable_raw_mode()?;
crossterm::execute!(stdout, EnterAlternateScreen)?;
crossterm::execute!(stdout, EnableBracketedPaste)?;
crossterm::execute!(stdout, EnableFocusChange)?;
let title = format!("iamb ({})", settings.profile.user_id);
crossterm::execute!(stdout, SetTitle(title))?;
let backend = CrosstermBackend::new(stdout);
let terminal = Terminal::new(backend)?; let terminal = Terminal::new(backend)?;
let mut bindings = crate::keybindings::setup_keybindings(); let mut bindings = crate::keybindings::setup_keybindings();
@@ -361,9 +368,13 @@ impl Application {
// Do nothing for now. // Do nothing for now.
}, },
Event::FocusGained => { Event::FocusGained => {
let mut store = self.store.lock().await;
store.application.focused = true;
self.focused = true; self.focused = true;
}, },
Event::FocusLost => { Event::FocusLost => {
let mut store = self.store.lock().await;
store.application.focused = false;
self.focused = false; self.focused = false;
}, },
Event::Resize(_, _) => { Event::Resize(_, _) => {
@@ -481,7 +492,7 @@ impl Application {
None None
}, },
Action::Command(act) => { Action::Command(act) => {
let acts = store.application.cmds.command(&act, &ctx)?; let acts = store.application.cmds.command(&act, &ctx, &mut store.registers)?;
self.action_prepend(acts); self.action_prepend(acts);
None None
@@ -518,6 +529,18 @@ impl Application {
} }
let info = match action { let info = match action {
IambAction::ClearUnreads => {
let user_id = &store.application.settings.profile.user_id;
for room_id in store.application.sync_info.chats() {
if let Some(room) = store.application.rooms.get_mut(room_id) {
room.fully_read(user_id.clone());
}
}
None
},
IambAction::ToggleScrollbackFocus => { IambAction::ToggleScrollbackFocus => {
self.screen.current_window_mut()?.focus_toggle(); self.screen.current_window_mut()?.focus_toggle();
@@ -905,6 +928,42 @@ async fn login_normal(
Ok(()) Ok(())
} }
/// Set up the terminal for drawing the TUI, and getting additional info.
fn setup_tty(title: &str, enable_enhanced_keys: bool) -> std::io::Result<()> {
let title = format!("iamb ({})", title);
// Enable raw mode and enter the alternate screen.
crossterm::terminal::enable_raw_mode()?;
crossterm::execute!(stdout(), EnterAlternateScreen)?;
if enable_enhanced_keys {
// Enable the Kitty keyboard enhancement protocol for improved keypresses.
crossterm::queue!(
stdout(),
PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES)
)?;
}
crossterm::execute!(stdout(), EnableBracketedPaste, EnableFocusChange, SetTitle(title))
}
// Do our best to reverse what we did in setup_tty() when we exit or crash.
fn restore_tty(enable_enhanced_keys: bool) {
if enable_enhanced_keys {
let _ = crossterm::queue!(stdout(), PopKeyboardEnhancementFlags);
}
let _ = crossterm::execute!(
stdout(),
DisableBracketedPaste,
DisableFocusChange,
LeaveAlternateScreen,
CursorShow,
);
let _ = crossterm::terminal::disable_raw_mode();
}
async fn run(settings: ApplicationSettings) -> IambResult<()> { async fn run(settings: ApplicationSettings) -> IambResult<()> {
// Get old keys the first time we run w/ the upgraded SDK. // Get old keys the first time we run w/ the upgraded SDK.
let import_keys = check_import_keys(&settings).await?; let import_keys = check_import_keys(&settings).await?;
@@ -938,27 +997,30 @@ async fn run(settings: ApplicationSettings) -> IambResult<()> {
Ok(()) => (), Ok(()) => (),
} }
fn restore_tty() { // Set up the terminal for drawing, and cleanup properly on panics.
let _ = crossterm::terminal::disable_raw_mode(); let enable_enhanced_keys = match crossterm::terminal::supports_keyboard_enhancement() {
let _ = crossterm::execute!(stdout(), DisableBracketedPaste); Ok(supported) => supported,
let _ = crossterm::execute!(stdout(), DisableFocusChange); Err(e) => {
let _ = crossterm::execute!(stdout(), LeaveAlternateScreen); tracing::warn!(err = %e,
let _ = crossterm::execute!(stdout(), CursorShow); "Failed to determine whether the terminal supports keyboard enhancements");
} false
},
};
setup_tty(settings.profile.user_id.as_str(), enable_enhanced_keys)?;
// 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| {
restore_tty(); restore_tty(enable_enhanced_keys);
orig_hook(panic_info); orig_hook(panic_info);
process::exit(1); process::exit(1);
})); }));
// And finally, start running the terminal UI.
let mut application = Application::new(settings, store).await?; let mut application = Application::new(settings, store).await?;
// We can now run the application.
application.run().await?; application.run().await?;
restore_tty();
// Clean up the terminal on exit.
restore_tty(enable_enhanced_keys);
Ok(()) Ok(())
} }

376
src/message/compose.rs Normal file
View File

@@ -0,0 +1,376 @@
//! Code for converting composed messages into content to send to the homeserver.
use comrak::{markdown_to_html, ComrakOptions};
use nom::{
branch::alt,
bytes::complete::tag,
character::complete::space0,
combinator::value,
IResult,
};
use matrix_sdk::ruma::events::room::message::{
EmoteMessageEventContent,
MessageType,
RoomMessageEventContent,
TextMessageEventContent,
};
#[derive(Clone, Debug, Default)]
enum SlashCommand {
/// Send an emote message.
Emote,
/// Send a message as literal HTML.
Html,
/// Send a message without parsing any markup.
Plaintext,
/// Send a Markdown message (the default message markup).
#[default]
Markdown,
/// Send a message with confetti effects in clients that show them.
Confetti,
/// Send a message with fireworks effects in clients that show them.
Fireworks,
/// Send a message with heart effects in clients that show them.
Hearts,
/// Send a message with rainfall effects in clients that show them.
Rainfall,
/// Send a message with snowfall effects in clients that show them.
Snowfall,
/// Send a message with heart effects in clients that show them.
SpaceInvaders,
}
impl SlashCommand {
fn to_message(&self, input: &str) -> anyhow::Result<MessageType> {
let msgtype = match self {
SlashCommand::Emote => {
let msg = if let Some(html) = text_to_html(input) {
EmoteMessageEventContent::html(input, html)
} else {
EmoteMessageEventContent::plain(input)
};
MessageType::Emote(msg)
},
SlashCommand::Html => {
let msg = TextMessageEventContent::html(input, input);
MessageType::Text(msg)
},
SlashCommand::Plaintext => {
let msg = TextMessageEventContent::plain(input);
MessageType::Text(msg)
},
SlashCommand::Markdown => {
let msg = text_to_message_content(input.to_string());
MessageType::Text(msg)
},
SlashCommand::Confetti => {
MessageType::new("nic.custom.confetti", input.into(), Default::default())?
},
SlashCommand::Fireworks => {
MessageType::new("nic.custom.fireworks", input.into(), Default::default())?
},
SlashCommand::Hearts => {
MessageType::new("io.element.effect.hearts", input.into(), Default::default())?
},
SlashCommand::Rainfall => {
MessageType::new("io.element.effect.rainfall", input.into(), Default::default())?
},
SlashCommand::Snowfall => {
MessageType::new("io.element.effect.snowfall", input.into(), Default::default())?
},
SlashCommand::SpaceInvaders => {
MessageType::new(
"io.element.effects.space_invaders",
input.into(),
Default::default(),
)?
},
};
Ok(msgtype)
}
}
fn parse_slash_command_inner(input: &str) -> IResult<&str, SlashCommand> {
let (input, _) = space0(input)?;
let (input, slash) = alt((
value(SlashCommand::Emote, tag("/me ")),
value(SlashCommand::Html, tag("/h ")),
value(SlashCommand::Html, tag("/html ")),
value(SlashCommand::Plaintext, tag("/p ")),
value(SlashCommand::Plaintext, tag("/plain ")),
value(SlashCommand::Plaintext, tag("/plaintext ")),
value(SlashCommand::Markdown, tag("/md ")),
value(SlashCommand::Markdown, tag("/markdown ")),
value(SlashCommand::Confetti, tag("/confetti ")),
value(SlashCommand::Fireworks, tag("/fireworks ")),
value(SlashCommand::Hearts, tag("/hearts ")),
value(SlashCommand::Rainfall, tag("/rainfall ")),
value(SlashCommand::Snowfall, tag("/snowfall ")),
value(SlashCommand::SpaceInvaders, tag("/spaceinvaders ")),
))(input)?;
let (input, _) = space0(input)?;
Ok((input, slash))
}
fn parse_slash_command(input: &str) -> anyhow::Result<(&str, SlashCommand)> {
match parse_slash_command_inner(input) {
Ok(input) => Ok(input),
Err(e) => Err(anyhow::anyhow!("Failed to parse slash command: {e}")),
}
}
/// Check whether this character is not used for markup in Markdown.
///
/// Markdown uses just about every ASCII punctuation symbol in some way, especially
/// once autolinking is involved, so we really just check whether it's non-punctuation or
/// single/double quotations.
fn not_markdown_char(c: char) -> bool {
if !c.is_ascii_punctuation() {
return true;
}
matches!(c, '"' | '\'')
}
/// Check whether the input actually needs to be processed as Markdown.
fn not_markdown(input: &str) -> bool {
input.chars().all(not_markdown_char)
}
fn text_to_html(input: &str) -> Option<String> {
if not_markdown(input) {
return None;
}
let mut options = ComrakOptions::default();
options.extension.autolink = true;
options.extension.shortcodes = true;
options.extension.strikethrough = true;
options.render.hardbreaks = true;
markdown_to_html(input, &options).into()
}
fn text_to_message_content(input: String) -> TextMessageEventContent {
if let Some(html) = text_to_html(input.as_str()) {
TextMessageEventContent::html(input, html)
} else {
TextMessageEventContent::plain(input)
}
}
pub fn text_to_message(input: String) -> RoomMessageEventContent {
let msg = parse_slash_command(input.as_str())
.and_then(|(input, slash)| slash.to_message(input))
.unwrap_or_else(|_| MessageType::Text(text_to_message_content(input)));
RoomMessageEventContent::new(msg)
}
#[cfg(test)]
pub mod tests {
use super::*;
#[test]
fn test_markdown_autolink() {
let input = "http://example.com\n";
let content = text_to_message_content(input.into());
assert_eq!(content.body, input);
assert_eq!(
content.formatted.unwrap().body,
"<p><a href=\"http://example.com\">http://example.com</a></p>\n"
);
let input = "www.example.com\n";
let content = text_to_message_content(input.into());
assert_eq!(content.body, input);
assert_eq!(
content.formatted.unwrap().body,
"<p><a href=\"http://www.example.com\">www.example.com</a></p>\n"
);
let input = "See docs (they're at https://iamb.chat)\n";
let content = text_to_message_content(input.into());
assert_eq!(content.body, input);
assert_eq!(
content.formatted.unwrap().body,
"<p>See docs (they're at <a href=\"https://iamb.chat\">https://iamb.chat</a>)</p>\n"
);
}
#[test]
fn test_markdown_message() {
let input = "**bold**\n";
let content = text_to_message_content(input.into());
assert_eq!(content.body, input);
assert_eq!(content.formatted.unwrap().body, "<p><strong>bold</strong></p>\n");
let input = "*emphasis*\n";
let content = text_to_message_content(input.into());
assert_eq!(content.body, input);
assert_eq!(content.formatted.unwrap().body, "<p><em>emphasis</em></p>\n");
let input = "`code`\n";
let content = text_to_message_content(input.into());
assert_eq!(content.body, input);
assert_eq!(content.formatted.unwrap().body, "<p><code>code</code></p>\n");
let input = "```rust\nconst A: usize = 1;\n```\n";
let content = text_to_message_content(input.into());
assert_eq!(content.body, input);
assert_eq!(
content.formatted.unwrap().body,
"<pre><code class=\"language-rust\">const A: usize = 1;\n</code></pre>\n"
);
let input = ":heart:\n";
let content = text_to_message_content(input.into());
assert_eq!(content.body, input);
assert_eq!(content.formatted.unwrap().body, "<p>\u{2764}\u{FE0F}</p>\n");
let input = "para *1*\n\npara _2_\n";
let content = text_to_message_content(input.into());
assert_eq!(content.body, input);
assert_eq!(
content.formatted.unwrap().body,
"<p>para <em>1</em></p>\n<p>para <em>2</em></p>\n"
);
let input = "line 1\nline ~~2~~\n";
let content = text_to_message_content(input.into());
assert_eq!(content.body, input);
assert_eq!(content.formatted.unwrap().body, "<p>line 1<br />\nline <del>2</del></p>\n");
let input = "# Heading\n## Subheading\n\ntext\n";
let content = text_to_message_content(input.into());
assert_eq!(content.body, input);
assert_eq!(
content.formatted.unwrap().body,
"<h1>Heading</h1>\n<h2>Subheading</h2>\n<p>text</p>\n"
);
}
#[test]
fn test_markdown_headers() {
let input = "hello\n=====\n";
let content = text_to_message_content(input.into());
assert_eq!(content.body, input);
assert_eq!(content.formatted.unwrap().body, "<h1>hello</h1>\n");
let input = "hello\n-----\n";
let content = text_to_message_content(input.into());
assert_eq!(content.body, input);
assert_eq!(content.formatted.unwrap().body, "<h2>hello</h2>\n");
}
#[test]
fn test_markdown_lists() {
let input = "- A\n- B\n- C\n";
let content = text_to_message_content(input.into());
assert_eq!(content.body, input);
assert_eq!(
content.formatted.unwrap().body,
"<ul>\n<li>A</li>\n<li>B</li>\n<li>C</li>\n</ul>\n"
);
let input = "1) A\n2) B\n3) C\n";
let content = text_to_message_content(input.into());
assert_eq!(content.body, input);
assert_eq!(
content.formatted.unwrap().body,
"<ol>\n<li>A</li>\n<li>B</li>\n<li>C</li>\n</ol>\n"
);
}
#[test]
fn test_no_markdown_conversion_on_simple_text() {
let input = "para 1\n\npara 2\n";
let content = text_to_message_content(input.into());
assert_eq!(content.body, input);
assert!(content.formatted.is_none());
let input = "line 1\nline 2\n";
let content = text_to_message_content(input.into());
assert_eq!(content.body, input);
assert!(content.formatted.is_none());
let input = "isn't markdown\n";
let content = text_to_message_content(input.into());
assert_eq!(content.body, input);
assert!(content.formatted.is_none());
let input = "\"scare quotes\"\n";
let content = text_to_message_content(input.into());
assert_eq!(content.body, input);
assert!(content.formatted.is_none());
}
#[test]
fn text_to_message_slash_commands() {
let MessageType::Text(content) = text_to_message("/html <b>bold</b>".into()).msgtype else {
panic!("Expected MessageType::Text");
};
assert_eq!(content.body, "<b>bold</b>");
assert_eq!(content.formatted.unwrap().body, "<b>bold</b>");
let MessageType::Text(content) = text_to_message("/h <b>bold</b>".into()).msgtype else {
panic!("Expected MessageType::Text");
};
assert_eq!(content.body, "<b>bold</b>");
assert_eq!(content.formatted.unwrap().body, "<b>bold</b>");
let MessageType::Text(content) = text_to_message("/plain <b>bold</b>".into()).msgtype
else {
panic!("Expected MessageType::Text");
};
assert_eq!(content.body, "<b>bold</b>");
assert!(content.formatted.is_none(), "{:?}", content.formatted);
let MessageType::Text(content) = text_to_message("/p <b>bold</b>".into()).msgtype else {
panic!("Expected MessageType::Text");
};
assert_eq!(content.body, "<b>bold</b>");
assert!(content.formatted.is_none(), "{:?}", content.formatted);
let MessageType::Emote(content) = text_to_message("/me *bold*".into()).msgtype else {
panic!("Expected MessageType::Emote");
};
assert_eq!(content.body, "*bold*");
assert_eq!(content.formatted.unwrap().body, "<p><em>bold</em></p>\n");
let content = text_to_message("/confetti hello".into()).msgtype;
assert_eq!(content.msgtype(), "nic.custom.confetti");
assert_eq!(content.body(), "hello");
let content = text_to_message("/fireworks hello".into()).msgtype;
assert_eq!(content.msgtype(), "nic.custom.fireworks");
assert_eq!(content.body(), "hello");
let content = text_to_message("/hearts hello".into()).msgtype;
assert_eq!(content.msgtype(), "io.element.effect.hearts");
assert_eq!(content.body(), "hello");
let content = text_to_message("/rainfall hello".into()).msgtype;
assert_eq!(content.msgtype(), "io.element.effect.rainfall");
assert_eq!(content.body(), "hello");
let content = text_to_message("/snowfall hello".into()).msgtype;
assert_eq!(content.msgtype(), "io.element.effect.snowfall");
assert_eq!(content.body(), "hello");
let content = text_to_message("/spaceinvaders hello".into()).msgtype;
assert_eq!(content.msgtype(), "io.element.effects.space_invaders");
assert_eq!(content.body(), "hello");
}
}

View File

@@ -260,6 +260,7 @@ pub enum StyleTreeNode {
Anchor(Box<StyleTreeNode>, char, Url), Anchor(Box<StyleTreeNode>, char, Url),
Blockquote(Box<StyleTreeNode>), Blockquote(Box<StyleTreeNode>),
Break, Break,
#[allow(dead_code)]
Code(Box<StyleTreeNode>, Option<String>), Code(Box<StyleTreeNode>, Option<String>),
Header(Box<StyleTreeNode>, usize), Header(Box<StyleTreeNode>, usize),
Image(Option<String>), Image(Option<String>),

View File

@@ -4,12 +4,13 @@ use std::cmp::{Ord, Ordering, PartialOrd};
use std::collections::hash_map::DefaultHasher; use std::collections::hash_map::DefaultHasher;
use std::collections::hash_set; use std::collections::hash_set;
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::convert::TryFrom; use std::convert::{TryFrom, TryInto};
use std::fmt::{self, Display};
use std::hash::{Hash, Hasher}; use std::hash::{Hash, Hasher};
use std::ops::{Deref, DerefMut}; use std::ops::{Deref, DerefMut};
use chrono::{DateTime, Local as LocalTz, NaiveDateTime, TimeZone}; use chrono::{DateTime, Local as LocalTz, NaiveDateTime, TimeZone};
use comrak::{markdown_to_html, ComrakOptions}; use humansize::{format_size, DECIMAL};
use serde_json::json; use serde_json::json;
use unicode_width::UnicodeWidthStr; use unicode_width::UnicodeWidthStr;
@@ -31,7 +32,6 @@ use matrix_sdk::ruma::{
Relation, Relation,
RoomMessageEvent, RoomMessageEvent,
RoomMessageEventContent, RoomMessageEventContent,
TextMessageEventContent,
}, },
redaction::SyncRoomRedactionEvent, redaction::SyncRoomRedactionEvent,
}, },
@@ -64,9 +64,12 @@ use crate::{
util::{replace_emojis_in_str, space, space_span, take_width, wrapped_text}, util::{replace_emojis_in_str, space, space_span, take_width, wrapped_text},
}; };
mod compose;
mod html; mod html;
mod printer; mod printer;
pub use self::compose::text_to_message;
pub type MessageKey = (MessageTimeStamp, OwnedEventId); pub type MessageKey = (MessageTimeStamp, OwnedEventId);
#[derive(Default)] #[derive(Default)]
@@ -127,20 +130,22 @@ const MIN_MSG_LEN: usize = 30;
const TIME_GUTTER_EMPTY: &str = " "; const TIME_GUTTER_EMPTY: &str = " ";
const TIME_GUTTER_EMPTY_SPAN: Span<'static> = span_static(TIME_GUTTER_EMPTY); const TIME_GUTTER_EMPTY_SPAN: Span<'static> = span_static(TIME_GUTTER_EMPTY);
fn text_to_message_content(input: String) -> TextMessageEventContent { const USIZE_TOO_SMALL: bool = usize::BITS < u64::BITS;
let mut options = ComrakOptions::default();
options.extension.autolink = true;
options.extension.shortcodes = true;
options.extension.strikethrough = true;
options.render.hardbreaks = true;
let html = markdown_to_html(input.as_str(), &options);
TextMessageEventContent::html(input, html) /// Convert the [u64] hash to [usize] as needed.
fn hash_finish_usize(hasher: DefaultHasher) -> Option<usize> {
if USIZE_TOO_SMALL {
(hasher.finish() % usize::MAX as u64).try_into().ok()
} else {
hasher.finish().try_into().ok()
}
} }
pub fn text_to_message(input: String) -> RoomMessageEventContent { /// Hash an [EventId] into a [usize].
let msg = MessageType::Text(text_to_message_content(input)); fn hash_event_id(event_id: &EventId) -> Option<usize> {
RoomMessageEventContent::new(msg) let mut hasher = DefaultHasher::new();
event_id.hash(&mut hasher);
hash_finish_usize(hasher)
} }
/// Before the image is loaded, already display a placeholder frame of the image size. /// Before the image is loaded, already display a placeholder frame of the image size.
@@ -326,17 +331,14 @@ impl MessageCursor {
} }
pub fn from_cursor(cursor: &Cursor, thread: &Messages) -> Option<Self> { pub fn from_cursor(cursor: &Cursor, thread: &Messages) -> Option<Self> {
let ev_hash = u64::try_from(cursor.get_x()).ok()?; let ev_hash = cursor.get_x();
let ev_term = OwnedEventId::try_from("$").ok()?; let ev_term = OwnedEventId::try_from("$").ok()?;
let ts_start = MessageTimeStamp::try_from(cursor.get_y()).ok()?; let ts_start = MessageTimeStamp::try_from(cursor.get_y()).ok()?;
let start = (ts_start, ev_term); let start = (ts_start, ev_term);
for ((ts, event_id), _) in thread.range(&start..) { for ((ts, event_id), _) in thread.range(&start..) {
let mut hasher = DefaultHasher::new(); if hash_event_id(event_id)? == ev_hash {
event_id.hash(&mut hasher);
if hasher.finish() == ev_hash {
return Self::from((*ts, event_id.clone())).into(); return Self::from((*ts, event_id.clone())).into();
} }
@@ -355,11 +357,8 @@ impl MessageCursor {
pub fn to_cursor(&self, thread: &Messages) -> Option<Cursor> { pub fn to_cursor(&self, thread: &Messages) -> Option<Cursor> {
let (ts, event_id) = self.to_key(thread)?; let (ts, event_id) = self.to_key(thread)?;
let y: usize = usize::try_from(ts).ok()?; let y = usize::try_from(ts).ok()?;
let x = hash_event_id(event_id)?;
let mut hasher = DefaultHasher::new();
event_id.hash(&mut hasher);
let x = usize::try_from(hasher.finish()).ok()?;
Cursor::new(y, x).into() Cursor::new(y, x).into()
} }
@@ -509,6 +508,27 @@ impl MessageEvent {
} }
} }
/// Macro rule converting a File / Image / Audio / Video to its text content with the shape:
/// `[Attached <type>: <content>[ (<human readable file size>)]]`
macro_rules! display_file_to_text {
( $msgtype:ident, $content:expr ) => {
return Cow::Owned(format!(
"[Attached {}: {}{}]",
stringify!($msgtype),
$content.body,
$content
.info
.as_ref()
.map(|info| {
info.size
.map(|s| format!(" ({})", format_size(u64::from(s), DECIMAL)))
.unwrap_or_else(String::new)
})
.unwrap_or_else(String::new)
))
};
}
fn body_cow_content(content: &RoomMessageEventContent) -> Cow<'_, str> { fn body_cow_content(content: &RoomMessageEventContent) -> Cow<'_, str> {
let s = match &content.msgtype { let s = match &content.msgtype {
MessageType::Text(content) => content.body.as_str(), MessageType::Text(content) => content.body.as_str(),
@@ -518,19 +538,30 @@ fn body_cow_content(content: &RoomMessageEventContent) -> Cow<'_, str> {
MessageType::ServerNotice(content) => content.body.as_str(), MessageType::ServerNotice(content) => content.body.as_str(),
MessageType::Audio(content) => { MessageType::Audio(content) => {
return Cow::Owned(format!("[Attached Audio: {}]", content.body)); display_file_to_text!(Audio, content);
}, },
MessageType::File(content) => { MessageType::File(content) => {
return Cow::Owned(format!("[Attached File: {}]", content.body)); display_file_to_text!(File, content);
}, },
MessageType::Image(content) => { MessageType::Image(content) => {
return Cow::Owned(format!("[Attached Image: {}]", content.body)); display_file_to_text!(Image, content);
}, },
MessageType::Video(content) => { MessageType::Video(content) => {
return Cow::Owned(format!("[Attached Video: {}]", content.body)); display_file_to_text!(Video, content);
}, },
_ => { _ => {
return Cow::Owned(format!("[Unknown message type: {:?}]", content.msgtype())); match content.msgtype() {
// Just show the body text for the special Element messages.
"nic.custom.confetti" |
"nic.custom.fireworks" |
"io.element.effect.hearts" |
"io.element.effect.rainfall" |
"io.element.effect.snowfall" |
"io.element.effects.space_invaders" => content.body(),
other => {
return Cow::Owned(format!("[Unknown message type: {other:?}]"));
},
}
}, },
}; };
@@ -863,7 +894,7 @@ impl Message {
} }
if settings.tunables.message_user_color { if settings.tunables.message_user_color {
let color = crate::config::user_color(self.sender.as_str()); let color = settings.get_user_color(&self.sender);
style = style.fg(color); style = style.fg(color);
} }
@@ -936,7 +967,7 @@ impl Message {
let style = self.get_render_style(selected, settings); let style = self.get_render_style(selected, settings);
let mut fmt = self.get_render_format(prev, width, info, settings); let mut fmt = self.get_render_format(prev, width, info, settings);
let mut text = Text { lines: vec![] }; let mut text = Text::default();
let width = fmt.width(); let width = fmt.width();
// Show the message that this one replied to, if any. // Show the message that this one replied to, if any.
@@ -961,6 +992,8 @@ impl Message {
let proto = proto.map(|p| { let proto = proto.map(|p| {
let y_off = text.lines.len() as u16; let y_off = text.lines.len() as u16;
let x_off = fmt.cols.user_gutter_width(settings); let x_off = fmt.cols.user_gutter_width(settings);
// Adjust y_off by 1 if a date was printed before the message to account for the extra line.
let y_off = if fmt.date.is_some() { y_off + 1 } else { y_off };
(p, x_off, y_off) (p, x_off, y_off)
}); });
@@ -1120,14 +1153,27 @@ impl From<RoomMessageEvent> for Message {
} }
} }
impl ToString for Message { impl Display for Message {
fn to_string(&self) -> String { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
self.event.body().into_owned() write!(f, "{}", self.event.body())
} }
} }
#[cfg(test)] #[cfg(test)]
pub mod tests { pub mod tests {
use matrix_sdk::ruma::events::room::{
message::{
AudioInfo,
AudioMessageEventContent,
FileInfo,
FileMessageEventContent,
ImageMessageEventContent,
VideoInfo,
VideoMessageEventContent,
},
ImageInfo,
};
use super::*; use super::*;
use crate::tests::*; use crate::tests::*;
@@ -1236,82 +1282,6 @@ pub mod tests {
assert_eq!(identity(&mc6), mc1); assert_eq!(identity(&mc6), mc1);
} }
#[test]
fn test_markdown_autolink() {
let input = "http://example.com\n";
let content = text_to_message_content(input.into());
assert_eq!(content.body, input);
assert_eq!(
content.formatted.unwrap().body,
"<p><a href=\"http://example.com\">http://example.com</a></p>\n"
);
let input = "www.example.com\n";
let content = text_to_message_content(input.into());
assert_eq!(content.body, input);
assert_eq!(
content.formatted.unwrap().body,
"<p><a href=\"http://www.example.com\">www.example.com</a></p>\n"
);
let input = "See docs (they're at https://iamb.chat)\n";
let content = text_to_message_content(input.into());
assert_eq!(content.body, input);
assert_eq!(
content.formatted.unwrap().body,
"<p>See docs (they're at <a href=\"https://iamb.chat\">https://iamb.chat</a>)</p>\n"
);
}
#[test]
fn test_markdown_message() {
let input = "**bold**\n";
let content = text_to_message_content(input.into());
assert_eq!(content.body, input);
assert_eq!(content.formatted.unwrap().body, "<p><strong>bold</strong></p>\n");
let input = "*emphasis*\n";
let content = text_to_message_content(input.into());
assert_eq!(content.body, input);
assert_eq!(content.formatted.unwrap().body, "<p><em>emphasis</em></p>\n");
let input = "`code`\n";
let content = text_to_message_content(input.into());
assert_eq!(content.body, input);
assert_eq!(content.formatted.unwrap().body, "<p><code>code</code></p>\n");
let input = "```rust\nconst A: usize = 1;\n```\n";
let content = text_to_message_content(input.into());
assert_eq!(content.body, input);
assert_eq!(
content.formatted.unwrap().body,
"<pre><code class=\"language-rust\">const A: usize = 1;\n</code></pre>\n"
);
let input = ":heart:\n";
let content = text_to_message_content(input.into());
assert_eq!(content.body, input);
assert_eq!(content.formatted.unwrap().body, "<p>\u{2764}\u{FE0F}</p>\n");
let input = "para 1\n\npara 2\n";
let content = text_to_message_content(input.into());
assert_eq!(content.body, input);
assert_eq!(content.formatted.unwrap().body, "<p>para 1</p>\n<p>para 2</p>\n");
let input = "line 1\nline 2\n";
let content = text_to_message_content(input.into());
assert_eq!(content.body, input);
assert_eq!(content.formatted.unwrap().body, "<p>line 1<br />\nline 2</p>\n");
let input = "# Heading\n## Subheading\n\ntext\n";
let content = text_to_message_content(input.into());
assert_eq!(content.body, input);
assert_eq!(
content.formatted.unwrap().body,
"<h1>Heading</h1>\n<h2>Subheading</h2>\n<p>text</p>\n"
);
}
#[test] #[test]
fn test_placeholder_frame() { fn test_placeholder_frame() {
fn pretty_frame_test(str: &str) -> Option<String> { fn pretty_frame_test(str: &str) -> Option<String> {
@@ -1377,4 +1347,83 @@ pub mod tests {
) )
); );
} }
#[test]
fn test_display_attachment_size() {
assert_eq!(
body_cow_content(&RoomMessageEventContent::new(MessageType::Image(
ImageMessageEventContent::plain(
"Alt text".to_string(),
"mxc://matrix.org/jDErsDugkNlfavzLTjJNUKAH".into()
)
.info(Some(Box::default()))
))),
"[Attached Image: Alt text]".to_string()
);
let mut info = ImageInfo::default();
info.size = Some(442630_u32.into());
assert_eq!(
body_cow_content(&RoomMessageEventContent::new(MessageType::Image(
ImageMessageEventContent::plain(
"Alt text".to_string(),
"mxc://matrix.org/jDErsDugkNlfavzLTjJNUKAH".into()
)
.info(Some(Box::new(info)))
))),
"[Attached Image: Alt text (442.63 kB)]".to_string()
);
let mut info = ImageInfo::default();
info.size = Some(12_u32.into());
assert_eq!(
body_cow_content(&RoomMessageEventContent::new(MessageType::Image(
ImageMessageEventContent::plain(
"Alt text".to_string(),
"mxc://matrix.org/jDErsDugkNlfavzLTjJNUKAH".into()
)
.info(Some(Box::new(info)))
))),
"[Attached Image: Alt text (12 B)]".to_string()
);
let mut info = AudioInfo::default();
info.size = Some(4294967295_u32.into());
assert_eq!(
body_cow_content(&RoomMessageEventContent::new(MessageType::Audio(
AudioMessageEventContent::plain(
"Alt text".to_string(),
"mxc://matrix.org/jDErsDugkNlfavzLTjJNUKAH".into()
)
.info(Some(Box::new(info)))
))),
"[Attached Audio: Alt text (4.29 GB)]".to_string()
);
let mut info = FileInfo::default();
info.size = Some(4426300_u32.into());
assert_eq!(
body_cow_content(&RoomMessageEventContent::new(MessageType::File(
FileMessageEventContent::plain(
"Alt text".to_string(),
"mxc://matrix.org/jDErsDugkNlfavzLTjJNUKAH".into()
)
.info(Some(Box::new(info)))
))),
"[Attached File: Alt text (4.43 MB)]".to_string()
);
let mut info = VideoInfo::default();
info.size = Some(44000_u32.into());
assert_eq!(
body_cow_content(&RoomMessageEventContent::new(MessageType::Video(
VideoMessageEventContent::plain(
"Alt text".to_string(),
"mxc://matrix.org/jDErsDugkNlfavzLTjJNUKAH".into()
)
.info(Some(Box::new(info)))
))),
"[Attached Video: Alt text (44 kB)]".to_string()
);
}
} }

View File

@@ -94,7 +94,7 @@ impl<'a> TextPrinter<'a> {
} }
fn remaining(&self) -> usize { fn remaining(&self) -> usize {
self.width - self.curr_width self.width.saturating_sub(self.curr_width)
} }
/// If there is any text on the current line, start a new one. /// If there is any text on the current line, start a new one.
@@ -276,3 +276,18 @@ impl<'a> TextPrinter<'a> {
self.text self.text
} }
} }
#[cfg(test)]
pub mod tests {
use super::*;
#[test]
fn test_push_nobreak() {
let mut printer = TextPrinter::new(5, Style::default(), false, false);
printer.push_span_nobreak("hello world".into());
let text = printer.finish();
assert_eq!(text.lines.len(), 1);
assert_eq!(text.lines[0].spans.len(), 1);
assert_eq!(text.lines[0].spans[0].content, "hello world");
}
}

View File

@@ -14,10 +14,15 @@ use matrix_sdk::{
use unicode_segmentation::UnicodeSegmentation; use unicode_segmentation::UnicodeSegmentation;
use crate::{ use crate::{
base::{AsyncProgramStore, IambError, IambResult}, base::{AsyncProgramStore, IambError, IambResult, ProgramStore},
config::{ApplicationSettings, NotifyVia}, config::{ApplicationSettings, NotifyVia},
}; };
const IAMB_XDG_NAME: &str = match option_env!("IAMB_XDG_NAME") {
None => "iamb",
Some(iamb) => iamb,
};
pub async fn register_notifications( pub async fn register_notifications(
client: &Client, client: &Client,
settings: &ApplicationSettings, settings: &ApplicationSettings,
@@ -44,7 +49,7 @@ pub async fn register_notifications(
return; return;
} }
if is_open(&store, room.room_id()).await { if is_visible_room(&store, room.room_id()).await {
return; return;
} }
@@ -59,6 +64,7 @@ pub async fn register_notifications(
} }
match notify_via { match notify_via {
#[cfg(feature = "desktop")]
NotifyVia::Desktop => send_notification_desktop(summary, body), NotifyVia::Desktop => send_notification_desktop(summary, body),
NotifyVia::Bell => send_notification_bell(&store).await, NotifyVia::Bell => send_notification_bell(&store).await,
} }
@@ -77,12 +83,13 @@ async fn send_notification_bell(store: &AsyncProgramStore) {
locked.application.ring_bell = true; locked.application.ring_bell = true;
} }
#[cfg(feature = "desktop")]
fn send_notification_desktop(summary: String, body: Option<String>) { fn send_notification_desktop(summary: String, body: Option<String>) {
let mut desktop_notification = notify_rust::Notification::new(); let mut desktop_notification = notify_rust::Notification::new();
desktop_notification desktop_notification
.summary(&summary) .summary(&summary)
.appname("iamb") .appname(IAMB_XDG_NAME)
.timeout(notify_rust::Timeout::Milliseconds(3000)) .icon(IAMB_XDG_NAME)
.action("default", "default"); .action("default", "default");
if let Some(body) = body { if let Some(body) = body {
@@ -128,8 +135,7 @@ fn is_missing_mention(body: &Option<String>, mode: RoomNotificationMode, client:
false false
} }
async fn is_open(store: &AsyncProgramStore, room_id: &RoomId) -> bool { fn is_open(locked: &mut ProgramStore, room_id: &RoomId) -> bool {
let mut locked = store.lock().await;
if let Some(draw_curr) = locked.application.draw_curr { if let Some(draw_curr) = locked.application.draw_curr {
let info = locked.application.get_room_info(room_id.to_owned()); let info = locked.application.get_room_info(room_id.to_owned());
if let Some(draw_last) = info.draw_last { if let Some(draw_last) = info.draw_last {
@@ -139,6 +145,16 @@ async fn is_open(store: &AsyncProgramStore, room_id: &RoomId) -> bool {
false false
} }
fn is_focused(locked: &ProgramStore) -> bool {
locked.application.focused
}
async fn is_visible_room(store: &AsyncProgramStore, room_id: &RoomId) -> bool {
let mut locked = store.lock().await;
is_focused(&locked) && is_open(&mut locked, room_id)
}
pub async fn parse_notification( pub async fn parse_notification(
notification: Notification, notification: Notification,
room: MatrixRoom, room: MatrixRoom,
@@ -156,6 +172,12 @@ pub async fn parse_notification(
.and_then(|m| m.display_name()) .and_then(|m| m.display_name())
.unwrap_or_else(|| sender_id.localpart()); .unwrap_or_else(|| sender_id.localpart());
let summary = if let Ok(room_name) = room.display_name().await {
format!("{sender_name} in {room_name}")
} else {
sender_name.to_string()
};
let body = if show_body { let body = if show_body {
event_notification_body( event_notification_body(
&event, &event,
@@ -167,7 +189,7 @@ pub async fn parse_notification(
None None
}; };
return Ok((sender_name.to_string(), body, server_ts)); return Ok((summary, body, server_ts));
} }
pub fn event_notification_body( pub fn event_notification_body(
@@ -220,7 +242,9 @@ pub fn event_notification_body(
MessageType::VerificationRequest(_) => { MessageType::VerificationRequest(_) => {
format!("{sender_name} sent a verification request.") format!("{sender_name} sent a verification request.")
}, },
_ => unimplemented!(), _ => {
format!("[Unknown message type: {:?}]", &message.msgtype)
},
}; };
Some(body) Some(body)
}, },

View File

@@ -100,7 +100,7 @@ pub fn spawn_insert_preview(
}) })
.and_then(|(picker, msg, image_preview)| { .and_then(|(picker, msg, image_preview)| {
picker picker
.new_protocol(img, image_preview.size.into(), Resize::Fit) .new_protocol(img, image_preview.size.into(), Resize::Fit(None))
.map_err(|err| IambError::Preview(format!("{err:?}"))) .map_err(|err| IambError::Preview(format!("{err:?}")))
.map(|backend| (backend, msg)) .map(|backend| (backend, msg))
}) { }) {

View File

@@ -186,6 +186,7 @@ pub fn mock_tunables() -> TunableValues {
.into_iter() .into_iter()
.collect::<HashMap<_, _>>(), .collect::<HashMap<_, _>>(),
open_command: None, open_command: None,
external_edit_file_suffix: String::from(".md"),
username_display: UserDisplayStyle::Username, username_display: UserDisplayStyle::Username,
message_user_color: false, message_user_color: false,
notifications: Notifications { notifications: Notifications {

View File

@@ -128,9 +128,7 @@ 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 { let mut text = Text::from(vec![Line::from(vec![join.clone()]); height]);
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 {

View File

@@ -7,6 +7,7 @@
//! example, [sending messages][crate::base::SendAction] delegate to the [room window][RoomState], //! example, [sending messages][crate::base::SendAction] delegate to the [room window][RoomState],
//! where we have the message bar and room ID easily accesible and resetable. //! where we have the message bar and room ID easily accesible and resetable.
use std::cmp::{Ord, Ordering, PartialOrd}; use std::cmp::{Ord, Ordering, PartialOrd};
use std::fmt::{self, Display};
use std::ops::Deref; use std::ops::Deref;
use std::sync::Arc; use std::sync::Arc;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
@@ -314,6 +315,7 @@ macro_rules! delegate {
IambWindow::VerifyList($id) => $e, IambWindow::VerifyList($id) => $e,
IambWindow::Welcome($id) => $e, IambWindow::Welcome($id) => $e,
IambWindow::ChatList($id) => $e, IambWindow::ChatList($id) => $e,
IambWindow::UnreadList($id) => $e,
} }
}; };
} }
@@ -327,6 +329,7 @@ pub enum IambWindow {
SpaceList(SpaceListState), SpaceList(SpaceListState),
Welcome(WelcomeState), Welcome(WelcomeState),
ChatList(ChatListState), ChatList(ChatListState),
UnreadList(UnreadListState),
} }
impl IambWindow { impl IambWindow {
@@ -382,6 +385,7 @@ pub type DirectListState = ListState<DirectItem, IambInfo>;
pub type MemberListState = ListState<MemberItem, IambInfo>; pub type MemberListState = ListState<MemberItem, IambInfo>;
pub type RoomListState = ListState<RoomItem, IambInfo>; pub type RoomListState = ListState<RoomItem, IambInfo>;
pub type ChatListState = ListState<GenericChatItem, IambInfo>; pub type ChatListState = ListState<GenericChatItem, IambInfo>;
pub type UnreadListState = ListState<GenericChatItem, IambInfo>;
pub type SpaceListState = ListState<SpaceItem, IambInfo>; pub type SpaceListState = ListState<SpaceItem, IambInfo>;
pub type VerifyListState = ListState<VerifyItem, IambInfo>; pub type VerifyListState = ListState<VerifyItem, IambInfo>;
@@ -578,6 +582,39 @@ impl WindowOps<IambInfo> for IambWindow {
.focus(focused) .focus(focused)
.render(area, buf, state); .render(area, buf, state);
}, },
IambWindow::UnreadList(state) => {
let mut items = store
.application
.sync_info
.rooms
.clone()
.into_iter()
.map(|room_info| GenericChatItem::new(room_info, store, false))
.filter(RoomLikeItem::is_unread)
.collect::<Vec<_>>();
let dms = store
.application
.sync_info
.dms
.clone()
.into_iter()
.map(|room_info| GenericChatItem::new(room_info, store, true))
.filter(RoomLikeItem::is_unread);
items.extend(dms);
let fields = &store.application.settings.tunables.sort.chats;
items.sort_by(|a, b| room_fields_cmp(a, b, fields));
state.set(items);
List::new(store)
.empty_message("You do not have rooms or dms yet")
.empty_alignment(Alignment::Center)
.focus(focused)
.render(area, buf, state);
},
IambWindow::SpaceList(state) => { IambWindow::SpaceList(state) => {
let mut items = store let mut items = store
.application .application
@@ -629,6 +666,7 @@ impl WindowOps<IambInfo> for IambWindow {
IambWindow::VerifyList(w) => w.dup(store).into(), IambWindow::VerifyList(w) => w.dup(store).into(),
IambWindow::Welcome(w) => w.dup(store).into(), IambWindow::Welcome(w) => w.dup(store).into(),
IambWindow::ChatList(w) => w.dup(store).into(), IambWindow::ChatList(w) => w.dup(store).into(),
IambWindow::UnreadList(w) => w.dup(store).into(),
} }
} }
@@ -669,6 +707,7 @@ impl Window<IambInfo> for IambWindow {
IambWindow::VerifyList(_) => IambId::VerifyList, IambWindow::VerifyList(_) => IambId::VerifyList,
IambWindow::Welcome(_) => IambId::Welcome, IambWindow::Welcome(_) => IambId::Welcome,
IambWindow::ChatList(_) => IambId::ChatList, IambWindow::ChatList(_) => IambId::ChatList,
IambWindow::UnreadList(_) => IambId::UnreadList,
} }
} }
@@ -680,6 +719,7 @@ impl Window<IambInfo> for IambWindow {
IambWindow::VerifyList(_) => bold_spans("Verifications"), IambWindow::VerifyList(_) => bold_spans("Verifications"),
IambWindow::Welcome(_) => bold_spans("Welcome to iamb"), IambWindow::Welcome(_) => bold_spans("Welcome to iamb"),
IambWindow::ChatList(_) => bold_spans("DMs & Rooms"), IambWindow::ChatList(_) => bold_spans("DMs & Rooms"),
IambWindow::UnreadList(_) => bold_spans("Unread Messages"),
IambWindow::Room(w) => { IambWindow::Room(w) => {
let title = store.application.get_room_title(w.id()); let title = store.application.get_room_title(w.id());
@@ -707,6 +747,7 @@ impl Window<IambInfo> for IambWindow {
IambWindow::VerifyList(_) => bold_spans("Verifications"), IambWindow::VerifyList(_) => bold_spans("Verifications"),
IambWindow::Welcome(_) => bold_spans("Welcome to iamb"), IambWindow::Welcome(_) => bold_spans("Welcome to iamb"),
IambWindow::ChatList(_) => bold_spans("DMs & Rooms"), IambWindow::ChatList(_) => bold_spans("DMs & Rooms"),
IambWindow::UnreadList(_) => bold_spans("Unread Messages"),
IambWindow::Room(w) => w.get_title(store), IambWindow::Room(w) => w.get_title(store),
IambWindow::MemberList(state, room_id, _) => { IambWindow::MemberList(state, room_id, _) => {
@@ -768,6 +809,11 @@ impl Window<IambInfo> for IambWindow {
Ok(list.into()) Ok(list.into())
}, },
IambId::UnreadList => {
let list = UnreadListState::new(IambBufferId::UnreadList, vec![]);
Ok(IambWindow::UnreadList(list))
},
} }
} }
@@ -820,7 +866,7 @@ impl GenericChatItem {
let name = info.name.clone().unwrap_or_default(); let name = info.name.clone().unwrap_or_default();
let alias = room.canonical_alias(); let alias = room.canonical_alias();
let unread = info.unreads(&store.application.settings); let unread = info.unreads(&store.application.settings);
info.tags = room_info.deref().1.clone(); info.tags.clone_from(&room_info.deref().1);
if let Some(alias) = &alias { if let Some(alias) = &alias {
store.application.names.insert(alias.to_string(), room_id.to_owned()); store.application.names.insert(alias.to_string(), room_id.to_owned());
@@ -870,9 +916,9 @@ impl RoomLikeItem for GenericChatItem {
} }
} }
impl ToString for GenericChatItem { impl Display for GenericChatItem {
fn to_string(&self) -> String { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
return self.name.clone(); write!(f, "{}", self.name)
} }
} }
@@ -930,7 +976,7 @@ impl RoomItem {
let name = info.name.clone().unwrap_or_default(); let name = info.name.clone().unwrap_or_default();
let alias = room.canonical_alias(); let alias = room.canonical_alias();
let unread = info.unreads(&store.application.settings); let unread = info.unreads(&store.application.settings);
info.tags = room_info.deref().1.clone(); info.tags.clone_from(&room_info.deref().1);
if let Some(alias) = &alias { if let Some(alias) = &alias {
store.application.names.insert(alias.to_string(), room_id.to_owned()); store.application.names.insert(alias.to_string(), room_id.to_owned());
@@ -980,9 +1026,9 @@ impl RoomLikeItem for RoomItem {
} }
} }
impl ToString for RoomItem { impl Display for RoomItem {
fn to_string(&self) -> String { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
return self.name.clone(); write!(f, ":verify request {}", self.name)
} }
} }
@@ -1034,7 +1080,7 @@ impl DirectItem {
let info = store.application.rooms.get_or_default(room_id); let info = store.application.rooms.get_or_default(room_id);
let name = info.name.clone().unwrap_or_default(); let name = info.name.clone().unwrap_or_default();
let unread = info.unreads(&store.application.settings); let unread = info.unreads(&store.application.settings);
info.tags = room_info.deref().1.clone(); info.tags.clone_from(&room_info.deref().1);
DirectItem { room_info, name, alias, unread } DirectItem { room_info, name, alias, unread }
} }
@@ -1080,9 +1126,9 @@ impl RoomLikeItem for DirectItem {
} }
} }
impl ToString for DirectItem { impl Display for DirectItem {
fn to_string(&self) -> String { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
return self.name.clone(); write!(f, ":verify request {}", self.name)
} }
} }
@@ -1179,9 +1225,9 @@ impl RoomLikeItem for SpaceItem {
} }
} }
impl ToString for SpaceItem { impl Display for SpaceItem {
fn to_string(&self) -> String { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
return self.room_id().to_string(); write!(f, ":verify request {}", self.room_id())
} }
} }
@@ -1300,16 +1346,18 @@ impl From<(&String, &SasVerification)> for VerifyItem {
} }
} }
impl ToString for VerifyItem { impl Display for VerifyItem {
fn to_string(&self) -> String { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
if self.sasv1.is_done() { if self.sasv1.is_done() {
String::new() return Ok(());
} else if self.sasv1.is_cancelled() { }
format!(":verify request {}", self.sasv1.other_user_id())
if self.sasv1.is_cancelled() {
write!(f, ":verify request {}", self.sasv1.other_user_id())
} else if self.sasv1.emoji().is_some() { } else if self.sasv1.emoji().is_some() {
format!(":verify confirm {}", self.user_dev) write!(f, ":verify confirm {}", self.user_dev)
} else { } else {
format!(":verify accept {}", self.user_dev) write!(f, ":verify accept {}", self.user_dev)
} }
} }
} }
@@ -1368,7 +1416,7 @@ impl ListItem<IambInfo> for VerifyItem {
])); ]));
} }
Text { lines } Text::from(lines)
} }
fn get_word(&self) -> Option<String> { fn get_word(&self) -> Option<String> {
@@ -1413,9 +1461,9 @@ impl MemberItem {
} }
} }
impl ToString for MemberItem { impl Display for MemberItem {
fn to_string(&self) -> String { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
self.member.user_id().to_string() write!(f, "{}", self.member.user_id())
} }
} }

View File

@@ -5,7 +5,8 @@ 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 edit::edit_with_builder as external_edit;
use edit::Builder;
use modalkit::editing::store::RegisterError; use modalkit::editing::store::RegisterError;
use std::process::Command; use std::process::Command;
use tokio; use tokio;
@@ -357,7 +358,22 @@ impl ChatState {
Ok(None) Ok(None)
}, },
MessageAction::React(emoji) => { MessageAction::React(reaction, literal) => {
let emoji = if literal {
reaction
} else if let Some(emoji) =
emojis::get(&reaction).or_else(|| emojis::get_by_shortcode(&reaction))
{
emoji.to_string()
} else {
let msg = format!("{reaction:?} is not a known Emoji shortcode; do you want to react with exactly {reaction:?}?");
let act = IambAction::Message(MessageAction::React(reaction, 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::EncryptedOriginal(ev) => ev.event_id.clone(),
@@ -372,6 +388,13 @@ impl ChatState {
}, },
}; };
if info.user_reactions_contains(&settings.profile.user_id, &event_id, &emoji) {
let msg = format!("Youve already reacted to this message with {}", emoji);
let err = UIError::Failure(msg);
return Err(err);
}
let reaction = Annotation::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).await.map_err(IambError::from)?; let _ = room.send(msg).await.map_err(IambError::from)?;
@@ -414,7 +437,27 @@ impl ChatState {
Ok(None) Ok(None)
}, },
MessageAction::Unreact(emoji) => { MessageAction::Unreact(reaction, literal) => {
let emoji = match reaction {
reaction if literal => reaction,
Some(reaction) => {
if let Some(emoji) =
emojis::get(&reaction).or_else(|| emojis::get_by_shortcode(&reaction))
{
Some(emoji.to_string())
} else {
let msg = format!("{reaction:?} is not a known Emoji shortcode; do you want to remove exactly {reaction:?}?");
let act =
IambAction::Message(MessageAction::Unreact(Some(reaction), true));
let prompt = PromptYesNo::new(msg, vec![Action::from(act)]);
let prompt = Box::new(prompt);
return Err(UIError::NeedConfirm(prompt));
}
},
None => None,
};
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::EncryptedOriginal(ev) => ev.event_id.clone(),
@@ -474,7 +517,16 @@ impl ChatState {
let msg = self.tbox.get(); let msg = self.tbox.get();
let msg = if let SendAction::SubmitFromEditor = act { let msg = if let SendAction::SubmitFromEditor = act {
external_edit(msg.trim_end().to_string())? let suffix =
store.application.settings.tunables.external_edit_file_suffix.as_str();
let edited_msg =
external_edit(msg.trim_end().to_string(), Builder::new().suffix(suffix))?
.trim_end()
.to_string();
if edited_msg.is_empty() {
return Ok(None);
}
edited_msg
} else if msg.is_blank() { } else if msg.is_blank() {
return Ok(None); return Ok(None);
} else { } else {

View File

@@ -1,12 +1,29 @@
//! # Windows for Matrix rooms and spaces //! # Windows for Matrix rooms and spaces
use std::collections::HashSet;
use matrix_sdk::{ use matrix_sdk::{
notification_settings::RoomNotificationMode,
room::Room as MatrixRoom, room::Room as MatrixRoom,
ruma::{ ruma::{
api::client::{
alias::{
create_alias::v3::Request as CreateAliasRequest,
delete_alias::v3::Request as DeleteAliasRequest,
},
error::ErrorKind as ClientApiErrorKind,
},
events::{ events::{
room::{name::RoomNameEventContent, topic::RoomTopicEventContent}, room::{
canonical_alias::RoomCanonicalAliasEventContent,
history_visibility::{HistoryVisibility, RoomHistoryVisibilityEventContent},
name::RoomNameEventContent,
topic::RoomTopicEventContent,
},
tag::{TagInfo, Tags}, tag::{TagInfo, Tags},
}, },
OwnedEventId, OwnedEventId,
OwnedRoomAliasId,
OwnedUserId,
RoomId, RoomId,
}, },
DisplayName, DisplayName,
@@ -41,6 +58,7 @@ use crate::base::{
IambId, IambId,
IambInfo, IambInfo,
IambResult, IambResult,
MemberUpdateAction,
MessageAction, MessageAction,
ProgramAction, ProgramAction,
ProgramContext, ProgramContext,
@@ -53,6 +71,8 @@ use crate::base::{
use self::chat::ChatState; use self::chat::ChatState;
use self::space::{Space, SpaceState}; use self::space::{Space, SpaceState};
use std::convert::TryFrom;
mod chat; mod chat;
mod scrollback; mod scrollback;
mod space; mod space;
@@ -66,6 +86,33 @@ macro_rules! delegate {
}; };
} }
fn notification_mode(name: impl Into<String>) -> IambResult<RoomNotificationMode> {
let name = name.into();
let mode = match name.to_lowercase().as_str() {
"mute" => RoomNotificationMode::Mute,
"mentions" | "keywords" => RoomNotificationMode::MentionsAndKeywordsOnly,
"all" => RoomNotificationMode::AllMessages,
_ => return Err(IambError::InvalidNotificationLevel(name).into()),
};
Ok(mode)
}
fn hist_visibility_mode(name: impl Into<String>) -> IambResult<HistoryVisibility> {
let name = name.into();
let mode = match name.to_lowercase().as_str() {
"invited" => HistoryVisibility::Invited,
"joined" => HistoryVisibility::Joined,
"shared" => HistoryVisibility::Shared,
"world" | "world_readable" => HistoryVisibility::WorldReadable,
_ => return Err(IambError::InvalidHistoryVisibility(name).into()),
};
Ok(mode)
}
/// State for a Matrix room or space. /// State for a Matrix room or space.
/// ///
/// Since spaces function as special rooms within Matrix, we wrap their window state together, so /// Since spaces function as special rooms within Matrix, we wrap their window state together, so
@@ -148,7 +195,7 @@ impl RoomState {
let l2 = Line::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::from(vec![l1, l2]);
Paragraph::new(text).alignment(Alignment::Center).render(area, buf); Paragraph::new(text).alignment(Alignment::Center).render(area, buf);
@@ -182,7 +229,7 @@ impl RoomState {
pub async fn room_command( pub async fn room_command(
&mut self, &mut self,
act: RoomAction, act: RoomAction,
_: ProgramContext, ctx: ProgramContext,
store: &mut ProgramStore, store: &mut ProgramStore,
) -> IambResult<Vec<(Action<IambInfo>, ProgramContext)>> { ) -> IambResult<Vec<(Action<IambInfo>, ProgramContext)>> {
match act { match act {
@@ -239,6 +286,47 @@ impl RoomState {
Err(IambError::NotJoined.into()) Err(IambError::NotJoined.into())
} }
}, },
RoomAction::MemberUpdate(mua, user, reason, skip_confirm) => {
let Some(room) = store.application.worker.client.get_room(self.id()) else {
return Err(IambError::NotJoined.into());
};
let Ok(user_id) = OwnedUserId::try_from(user.as_str()) else {
let err = IambError::InvalidUserId(user);
return Err(err.into());
};
if !skip_confirm {
let msg = format!("Do you really want to {mua} {user} from this room?");
let act = RoomAction::MemberUpdate(mua, user, reason, true);
let act = IambAction::from(act);
let prompt = PromptYesNo::new(msg, vec![Action::from(act)]);
let prompt = Box::new(prompt);
return Err(UIError::NeedConfirm(prompt));
}
match mua {
MemberUpdateAction::Ban => {
room.ban_user(&user_id, reason.as_deref())
.await
.map_err(IambError::from)?;
},
MemberUpdateAction::Unban => {
room.unban_user(&user_id, reason.as_deref())
.await
.map_err(IambError::from)?;
},
MemberUpdateAction::Kick => {
room.kick_user(&user_id, reason.as_deref())
.await
.map_err(IambError::from)?;
},
}
Ok(vec![])
},
RoomAction::Members(mut cmd) => { RoomAction::Members(mut cmd) => {
let width = Count::Exact(30); let width = Count::Exact(30);
let act = let act =
@@ -249,6 +337,16 @@ impl RoomState {
Ok(vec![(act, cmd.context.clone())]) Ok(vec![(act, cmd.context.clone())])
}, },
RoomAction::SetDirect(is_direct) => {
let room = store
.application
.get_joined_room(self.id())
.ok_or(UIError::Application(IambError::NotJoined))?;
room.set_is_direct(is_direct).await.map_err(IambError::from)?;
Ok(vec![])
},
RoomAction::Set(field, value) => { RoomAction::Set(field, value) => {
let room = store let room = store
.application .application
@@ -256,6 +354,11 @@ impl RoomState {
.ok_or(UIError::Application(IambError::NotJoined))?; .ok_or(UIError::Application(IambError::NotJoined))?;
match field { match field {
RoomField::History => {
let visibility = hist_visibility_mode(value)?;
let ev = RoomHistoryVisibilityEventContent::new(visibility);
let _ = room.send_state_event(ev).await.map_err(IambError::from)?;
},
RoomField::Name => { RoomField::Name => {
let ev = RoomNameEventContent::new(value); 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)?;
@@ -270,6 +373,97 @@ impl RoomState {
let ev = RoomTopicEventContent::new(value); let ev = RoomTopicEventContent::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::NotificationMode => {
let mode = notification_mode(value)?;
let client = &store.application.worker.client;
let notifications = client.notification_settings().await;
notifications
.set_room_notification_mode(self.id(), mode)
.await
.map_err(IambError::from)?;
},
RoomField::CanonicalAlias => {
let client = &mut store.application.worker.client;
let Ok(orai) = OwnedRoomAliasId::try_from(value.as_str()) else {
let err = IambError::InvalidRoomAlias(value);
return Err(err.into());
};
let mut alt_aliases =
room.alt_aliases().into_iter().collect::<HashSet<_>>();
let canonical_old = room.canonical_alias();
// If the room's alias is already that, ignore it
if canonical_old.as_ref() == Some(&orai) {
let msg = format!("The canonical room alias is already {orai}");
return Ok(vec![(Action::ShowInfoMessage(msg.into()), ctx)]);
}
// Try creating the room alias on the server.
let alias_create_req =
CreateAliasRequest::new(orai.clone(), room.room_id().into());
if let Err(e) = client.send(alias_create_req, None).await {
if let Some(ClientApiErrorKind::Unknown) = e.client_api_error_kind() {
// Ignore when it already exists.
} else {
return Err(IambError::from(e).into());
}
}
// Demote the previous one to an alt alias.
alt_aliases.extend(canonical_old);
// At this point the room alias definitely exists, and we can update the
// state event.
let mut ev = RoomCanonicalAliasEventContent::new();
ev.alias = Some(orai);
ev.alt_aliases = alt_aliases.into_iter().collect();
let _ = room.send_state_event(ev).await.map_err(IambError::from)?;
},
RoomField::Alias(alias) => {
let client = &mut store.application.worker.client;
let Ok(orai) = OwnedRoomAliasId::try_from(alias.as_str()) else {
let err = IambError::InvalidRoomAlias(alias);
return Err(err.into());
};
let mut alt_aliases =
room.alt_aliases().into_iter().collect::<HashSet<_>>();
let canonical = room.canonical_alias();
if alt_aliases.contains(&orai) || canonical.as_ref() == Some(&orai) {
let msg = format!("The alias {orai} already maps to this room");
return Ok(vec![(Action::ShowInfoMessage(msg.into()), ctx)]);
} else {
alt_aliases.insert(orai.clone());
}
// If the room alias does not exist on the server, create it
let alias_create_req = CreateAliasRequest::new(orai, room.room_id().into());
if let Err(e) = client.send(alias_create_req, None).await {
if let Some(ClientApiErrorKind::Unknown) = e.client_api_error_kind() {
// Ignore when it already exists.
} else {
return Err(IambError::from(e).into());
}
}
// And add it to the aliases in the state event.
let mut ev = RoomCanonicalAliasEventContent::new();
ev.alias = canonical;
ev.alt_aliases = alt_aliases.into_iter().collect();
let _ = room.send_state_event(ev).await.map_err(IambError::from)?;
},
RoomField::Aliases => {
// This never happens, aliases is only used for showing
},
} }
Ok(vec![]) Ok(vec![])
@@ -281,6 +475,11 @@ impl RoomState {
.ok_or(UIError::Application(IambError::NotJoined))?; .ok_or(UIError::Application(IambError::NotJoined))?;
match field { match field {
RoomField::History => {
let visibility = HistoryVisibility::Joined;
let ev = RoomHistoryVisibilityEventContent::new(visibility);
let _ = room.send_state_event(ev).await.map_err(IambError::from)?;
},
RoomField::Name => { RoomField::Name => {
let ev = RoomNameEventContent::new("".into()); 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)?;
@@ -292,10 +491,146 @@ impl RoomState {
let ev = RoomTopicEventContent::new("".into()); let ev = RoomTopicEventContent::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::NotificationMode => {
let client = &store.application.worker.client;
let notifications = client.notification_settings().await;
notifications
.delete_user_defined_room_rules(self.id())
.await
.map_err(IambError::from)?;
},
RoomField::CanonicalAlias => {
let Some(alias_to_destroy) = room.canonical_alias() else {
let msg = "This room has no canonical alias to unset";
return Ok(vec![(Action::ShowInfoMessage(msg.into()), ctx)]);
};
// Remove the canonical alias from the state event.
let mut ev = RoomCanonicalAliasEventContent::new();
ev.alias = None;
ev.alt_aliases = room.alt_aliases();
let _ = room.send_state_event(ev).await.map_err(IambError::from)?;
// And then unmap it on the server.
let del_req = DeleteAliasRequest::new(alias_to_destroy);
let _ = store
.application
.worker
.client
.send(del_req, None)
.await
.map_err(IambError::from)?;
},
RoomField::Alias(alias) => {
let Ok(orai) = OwnedRoomAliasId::try_from(alias.as_str()) else {
let err = IambError::InvalidRoomAlias(alias);
return Err(err.into());
};
let alt_aliases = room.alt_aliases();
let canonical = room.canonical_alias();
if !alt_aliases.contains(&orai) && canonical.as_ref() != Some(&orai) {
let msg = format!("The alias {orai:?} isn't mapped to this room");
return Ok(vec![(Action::ShowInfoMessage(msg.into()), ctx)]);
}
// Remove the alias from the state event if it's in it.
let mut ev = RoomCanonicalAliasEventContent::new();
ev.alias = canonical.filter(|canon| canon != &orai);
ev.alt_aliases = alt_aliases;
ev.alt_aliases.retain(|in_orai| in_orai != &orai);
let _ = room.send_state_event(ev).await.map_err(IambError::from)?;
// And then unmap it on the server.
let del_req = DeleteAliasRequest::new(orai);
let _ = store
.application
.worker
.client
.send(del_req, None)
.await
.map_err(IambError::from)?;
},
RoomField::Aliases => {
// This will not happen, you cannot unset all aliases
},
} }
Ok(vec![]) Ok(vec![])
}, },
RoomAction::Show(field) => {
let room = store
.application
.get_joined_room(self.id())
.ok_or(UIError::Application(IambError::NotJoined))?;
let msg = match field {
RoomField::History => {
let visibility = room.history_visibility();
format!("Room history visibility: {visibility}")
},
RoomField::Name => {
match room.name() {
None => "Room has no name".into(),
Some(name) => format!("Room name: {name:?}"),
}
},
RoomField::Topic => {
match room.topic() {
None => "Room has no topic".into(),
Some(topic) => format!("Room topic: {topic:?}"),
}
},
RoomField::NotificationMode => {
let client = &store.application.worker.client;
let notifications = client.notification_settings().await;
let mode =
notifications.get_user_defined_room_notification_mode(self.id()).await;
let level = match mode {
Some(RoomNotificationMode::Mute) => "mute",
Some(RoomNotificationMode::MentionsAndKeywordsOnly) => "keywords",
Some(RoomNotificationMode::AllMessages) => "all",
None => "default",
};
format!("Room notification level: {level:?}")
},
RoomField::Aliases => {
let aliases = room
.alt_aliases()
.iter()
.map(OwnedRoomAliasId::to_string)
.collect::<Vec<String>>();
if aliases.is_empty() {
"No alternative aliases in room".into()
} else {
format!("Alternative aliases: {}.", aliases.join(", "))
}
},
RoomField::CanonicalAlias => {
match room.canonical_alias() {
None => "No canonical alias for room".into(),
Some(can) => format!("Canonical alias: {can}"),
}
},
RoomField::Tag(_) => "Cannot currently show value for a tag".into(),
RoomField::Alias(_) => {
"Cannot show a single alias; use `:room aliases show` instead.".into()
},
};
let msg = InfoMessage::Pager(msg);
let act = Action::ShowInfoMessage(msg);
Ok(vec![(act, ctx)])
},
} }
} }
@@ -462,3 +797,27 @@ impl WindowOps<IambInfo> for RoomState {
} }
} }
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_room_notification_level() {
let tests = vec![
("mute", RoomNotificationMode::Mute),
("mentions", RoomNotificationMode::MentionsAndKeywordsOnly),
("keywords", RoomNotificationMode::MentionsAndKeywordsOnly),
("all", RoomNotificationMode::AllMessages),
];
for (input, expect) in tests {
let res = notification_mode(input).unwrap();
assert_eq!(expect, res);
}
assert!(notification_mode("invalid").is_err());
assert!(notification_mode("not a level").is_err());
assert!(notification_mode("@user:example.com").is_err());
}
}

View File

@@ -673,8 +673,7 @@ 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 lsearch = store.registers.get(&Register::LastSearch)?; let lsearch = store.registers.get_last_search().to_string();
let lsearch = lsearch.value.to_string();
let needle = Regex::new(lsearch.as_ref())?; let needle = Regex::new(lsearch.as_ref())?;
let (mc, needs_load) = self.find_message(key, dir, &needle, count, info); let (mc, needs_load) = self.find_message(key, dir, &needle, count, info);
@@ -753,8 +752,7 @@ 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 lsearch = store.registers.get(&Register::LastSearch)?; let lsearch = store.registers.get_last_search().to_string();
let lsearch = lsearch.value.to_string();
let needle = Regex::new(lsearch.as_ref())?; let needle = Regex::new(lsearch.as_ref())?;
let (mc, needs_load) = self.find_message(key, dir, &needle, count, info); let (mc, needs_load) = self.find_message(key, dir, &needle, count, info);
@@ -1452,16 +1450,16 @@ mod tests {
// MSG4: "help" // MSG4: "help"
// MSG5: "character" // MSG5: "character"
// MSG1: "writhe" // MSG1: "writhe"
store.set_last_search("he"); store.registers.set_last_search("he");
assert_eq!(scrollback.cursor, MessageCursor::latest()); assert_eq!(scrollback.cursor, MessageCursor::latest());
// Search backwards to MSG4. // Search backwards to MSG4.
scrollback.search(prev.clone(), 1.into(), &ctx, &mut store).unwrap(); scrollback.search(prev, 1.into(), &ctx, &mut store).unwrap();
assert_eq!(scrollback.cursor, MSG4_KEY.clone().into()); assert_eq!(scrollback.cursor, MSG4_KEY.clone().into());
// Search backwards to MSG2. // Search backwards to MSG2.
scrollback.search(prev.clone(), 1.into(), &ctx, &mut store).unwrap(); scrollback.search(prev, 1.into(), &ctx, &mut store).unwrap();
assert_eq!(scrollback.cursor, MSG2_KEY.clone().into()); assert_eq!(scrollback.cursor, MSG2_KEY.clone().into());
assert_eq!( assert_eq!(
std::mem::take(&mut store.application.need_load) std::mem::take(&mut store.application.need_load)
@@ -1472,7 +1470,7 @@ mod tests {
); );
// 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, 1.into(), &ctx, &mut store).unwrap();
assert_eq!(scrollback.cursor, MSG2_KEY.clone().into()); assert_eq!(scrollback.cursor, MSG2_KEY.clone().into());
assert_eq!( assert_eq!(
std::mem::take(&mut store.application.need_load) std::mem::take(&mut store.application.need_load)
@@ -1482,11 +1480,11 @@ mod tests {
); );
// Search forward twice to MSG1. // Search forward twice to MSG1.
scrollback.search(next.clone(), 2.into(), &ctx, &mut store).unwrap(); scrollback.search(next, 2.into(), &ctx, &mut store).unwrap();
assert_eq!(scrollback.cursor, MSG1_KEY.clone().into()); assert_eq!(scrollback.cursor, MSG1_KEY.clone().into());
// Can't go any further. // Can't go any further.
scrollback.search(next.clone(), 2.into(), &ctx, &mut store).unwrap(); scrollback.search(next, 2.into(), &ctx, &mut store).unwrap();
assert_eq!(scrollback.cursor, MSG1_KEY.clone().into()); assert_eq!(scrollback.cursor, MSG1_KEY.clone().into());
} }

View File

@@ -148,7 +148,7 @@ impl<'a> StatefulWidget for Space<'a> {
Span::styled(e.to_string(), Style::default().fg(Color::Red)).into(), Span::styled(e.to_string(), Style::default().fg(Color::Red)).into(),
]; ];
empty_message = Text { lines }.into(); empty_message = Text::from(lines).into();
}, },
} }
} }

View File

@@ -37,10 +37,10 @@ The different subcommands are:
## Additional Configuration ## Additional Configuration
You can customize iamb in your `$CONFIG_DIR/iamb/config.json` file, where You can customize iamb in your `$CONFIG_DIR/iamb/config.toml` file, where
`$CONFIG_DIR` is your system's per-user configuration directory. `$CONFIG_DIR` is your system's per-user configuration directory. For example,
this is typically `~/.config/iamb/config.toml` on systems that use the XDG
Base Directory Specification.
You can edit the following values in the file: See the manual pages or <https://iamb.chat> for more details on how to
further configure or use iamb.
- `"default_profile"`, a profile name to use when starting iamb if one wasn't specified
- `"cache"`, a directory for cached iamb