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'
- name: Run tests
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]
name = "iamb"
version = "0.0.9"
version = "0.0.10"
edition = "2018"
authors = ["Ulyssa <git@ulyssa.dev>"]
repository = "https://github.com/ulyssa/iamb"
@@ -15,8 +15,9 @@ rust-version = "1.70"
build = "build.rs"
[features]
default = ["bundled"]
default = ["bundled", "desktop"]
bundled = ["matrix-sdk/bundled-sqlite", "rustls-tls"]
desktop = ["dep:notify-rust", "modalkit/clipboard"]
native-tls = ["matrix-sdk/native-tls"]
rustls-tls = ["matrix-sdk/rustls-tls"]
@@ -26,14 +27,13 @@ default-features = false
features = ["build", "git", "gitcl",]
[dependencies]
arboard = "3.3.0"
anyhow = "1.0"
bitflags = "^2.3"
chrono = "0.4"
clap = {version = "~4.3", features = ["derive"]}
comrak = {version = "0.18.0", features = ["shortcodes"]}
css-color-parser = "0.1.2"
dirs = "4.0.0"
emojis = "~0.5.2"
emojis = "0.5"
futures = "0.3"
gethostname = "0.4.1"
html5ever = "0.26.0"
@@ -42,11 +42,11 @@ libc = "0.2"
markup5ever_rcdom = "0.2.0"
mime = "^0.3.16"
mime_guess = "^2.0.4"
notify-rust = { version = "4.10.0", default-features = false, features = ["zbus", "serde"] }
nom = "7.0.0"
open = "3.2.0"
rand = "0.8.5"
ratatui = "0.23"
ratatui-image = { version = "0.8.1", features = ["serde"] }
ratatui = "0.26"
ratatui-image = { version = "1.0.0", features = ["serde"] }
regex = "^1.5"
rpassword = "^7.2"
serde = "^1.0"
@@ -62,16 +62,29 @@ unicode-segmentation = "^1.7"
unicode-width = "0.1.10"
url = {version = "^2.2.2", features = ["serde"]}
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]
version = "0.0.18"
version = "0.0.20"
default-features = false
#git = "https://github.com/ulyssa/modalkit"
#rev = "cb8c8aeb9a499b9b16615ce144f9014d78036e01"
#rev = "24f3ec11c7f634005a27b26878d0fbbdcc08f272"
[dependencies.modalkit-ratatui]
version = "0.0.18"
version = "0.0.20"
#git = "https://github.com/ulyssa/modalkit"
#rev = "cb8c8aeb9a499b9b16615ce144f9014d78036e01"
#rev = "24f3ec11c7f634005a27b26878d0fbbdcc08f272"
[dependencies.matrix-sdk]
version = "0.7.1"
@@ -90,3 +103,33 @@ pretty_assertions = "1.4.0"
inherits = "release"
incremental = false
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
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");
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
you'll want to install as part of a package:
| Repository Path | Installed Path (may vary per OS) |
| -------------------- | ----------------------------------------------- |
| /iamb.desktop | /usr/share/applications/iamb.desktop |
| /config.example.toml | /usr/share/iamb/config.example.toml |
| /docs/iamb-256x256.png | /usr/share/icons/hicolor/256x256/apps/iamb.png |
| /docs/iamb-512x512.png | /usr/share/icons/hicolor/512x512/apps/iamb.png |
| /docs/iamb.svg | /usr/share/icons/hicolor/scalable/apps/iamb.svg |
| /docs/iamb.1 | /usr/share/man/man1/iamb.1 |
| /docs/iamb.5 | /usr/share/man/man5/iamb.5 |
<!-- 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 |
| /docs/iamb-256x256.png | /usr/share/icons/hicolor/256x256/apps/iamb.png |
| /docs/iamb-512x512.png | /usr/share/icons/hicolor/512x512/apps/iamb.png |
| /docs/iamb.svg | /usr/share/icons/hicolor/scalable/apps/iamb.svg |
| /docs/iamb.1 | /usr/share/man/man1/iamb.1 |
| /docs/iamb.5 | /usr/share/man/man5/iamb.5 |
| /docs/iamb.metainfo.xml | /usr/share/metainfo/iamb.metainfo.xml |
[ring-lto]: https://github.com/briansmith/ring/issues/1444
[rustls]: https://crates.io/crates/rustls

121
README.md
View File

@@ -11,7 +11,6 @@
</div>
## About
`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
- 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
- Send Markdown, HTML or plaintext messages
- Creating, joining, and leaving rooms
- Sending and accepting room invitations
- 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
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
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"
```
## 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
iamb is released under the [Apache License, Version 2.0].
[Apache License, Version 2.0]: https://github.com/ulyssa/iamb/blob/master/LICENSE
[client-comparison-matrix]: https://matrix.org/clients-matrix/
[crates-io-iamb]: https://crates.io/crates/iamb
[iamb.chat]: https://iamb.chat
[gomuks]: https://github.com/tulir/gomuks
[weechat-matrix]: https://github.com/poljar/weechat-matrix
[well_known_entry]: https://spec.matrix.org/latest/client-server-api/#getwell-knownmatrixclient

View File

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

View File

@@ -61,6 +61,8 @@ Log out of
View a list of joined rooms.
.It Sy ":spaces"
View a list of joined spaces.
.It Sy ":unreads"
View a list of unread rooms.
.It Sy ":welcome"
View the startup Welcome window.
.El
@@ -95,6 +97,8 @@ React to the selected message with an Emoji.
Redact the selected message.
.It Sy ":reply"
Reply to the selected message.
.It Sy ":unreads clear"
Mark all unread rooms as read.
.It Sy ":unreact [shortcode]"
Remove your reaction from the selected 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.
.It Sy ":room name unset"
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]"
Add a tag to the currently focused room.
.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.
.It Sy ":room topic unset"
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
.Sh "WINDOW COMMANDS"
@@ -176,6 +217,43 @@ Close all but one tab.
Go to the preview tab.
.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
.Ss Example 1: Starting with a specific profile
To start with a profile named

View File

@@ -125,6 +125,9 @@ key and can be overridden as described in
.Sx PROFILES .
.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
The room to show by default instead of the
.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 ];
buildInputs = [ openssl ] ++ lib.optionals stdenv.isDarwin
(with darwin.apple_sdk.frameworks; [ AppKit Security ]);
(with darwin.apple_sdk.frameworks; [ AppKit Security Cocoa]);
};
devShell = mkShell {

View File

@@ -3,7 +3,7 @@
//! The types defined here get used throughout iamb.
use std::borrow::Cow;
use std::collections::hash_map::IntoIter;
use std::collections::{HashMap, HashSet};
use std::collections::{BTreeSet, HashMap, HashSet};
use std::convert::TryFrom;
use std::fmt::{self, Display};
use std::hash::Hash;
@@ -152,7 +152,11 @@ pub enum MessageAction {
Edit,
/// 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.
///
@@ -166,7 +170,11 @@ pub enum MessageAction {
///
/// If no specific Emoji to remove to is specified, then all reactions from the user on the
/// 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.
@@ -361,6 +369,9 @@ impl<'de> Visitor<'de> for SortUserVisitor {
/// A room property.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum RoomField {
/// The room's history visibility.
History,
/// The room name.
Name,
@@ -369,6 +380,36 @@ pub enum RoomField {
/// The room 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.
@@ -386,14 +427,23 @@ pub enum RoomAction {
/// Leave this room.
Leave(bool),
/// Update a user's membership in this room.
MemberUpdate(MemberUpdateAction, String, Option<String>, bool),
/// Open the members window.
Members(Box<CommandContext>),
/// Set whether a room is a direct message.
SetDirect(bool),
/// Set a room property.
Set(RoomField, String),
/// Unset a room property.
Unset(RoomField),
/// List the values in a list room property.
Show(RoomField),
}
/// An action that sends a message to a room.
@@ -460,6 +510,9 @@ pub enum IambAction {
/// Toggle the focus within the focused room.
ToggleScrollbackFocus,
/// Clear all unread messages.
ClearUnreads,
}
impl IambAction {
@@ -496,6 +549,7 @@ impl From<SendAction> for IambAction {
impl ApplicationAction for IambAction {
fn is_edit_sequence(&self, _: &EditContext) -> SequenceStatus {
match self {
IambAction::ClearUnreads => SequenceStatus::Break,
IambAction::Homeserver(..) => SequenceStatus::Break,
IambAction::Keys(..) => SequenceStatus::Break,
IambAction::Message(..) => SequenceStatus::Break,
@@ -510,6 +564,7 @@ impl ApplicationAction for IambAction {
fn is_last_action(&self, _: &EditContext) -> SequenceStatus {
match self {
IambAction::ClearUnreads => SequenceStatus::Atom,
IambAction::Homeserver(..) => SequenceStatus::Atom,
IambAction::Keys(..) => SequenceStatus::Atom,
IambAction::Message(..) => SequenceStatus::Atom,
@@ -524,6 +579,7 @@ impl ApplicationAction for IambAction {
fn is_last_selection(&self, _: &EditContext) -> SequenceStatus {
match self {
IambAction::ClearUnreads => SequenceStatus::Ignore,
IambAction::Homeserver(..) => SequenceStatus::Ignore,
IambAction::Keys(..) => SequenceStatus::Ignore,
IambAction::Message(..) => SequenceStatus::Ignore,
@@ -538,6 +594,7 @@ impl ApplicationAction for IambAction {
fn is_switchable(&self, _: &EditContext) -> bool {
match self {
IambAction::ClearUnreads => false,
IambAction::Homeserver(..) => false,
IambAction::Message(..) => false,
IambAction::Room(..) => false,
@@ -589,10 +646,22 @@ pub type MessageReactions = HashMap<OwnedEventId, (String, OwnedUserId)>;
/// Errors encountered during application use.
#[derive(thiserror::Error, Debug)]
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.
#[error("Invalid user identifier: {0}")]
InvalidUserId(String),
/// An invalid user identifier was specified.
#[error("Invalid room alias: {0}")]
InvalidRoomAlias(String),
/// An invalid verification identifier was specified.
#[error("Invalid verification user/device pair: {0}")]
InvalidVerificationId(String),
@@ -656,10 +725,17 @@ pub enum IambError {
#[error("Unknown room identifier: {0}")]
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.
#[error("Verification request error: {0}")]
VerificationRequestError(#[from] matrix_sdk::encryption::identities::RequestVerificationError),
#[error("Notification setting error: {0}")]
NotificationSettingError(#[from] matrix_sdk::NotificationSettingsError),
/// A failure related to images.
#[error("Image error: {0}")]
Image(#[from] image::ImageError),
@@ -835,9 +911,14 @@ impl RoomInfo {
if let Some(reacts) = self.reactions.get(event_id) {
let mut counts = HashMap::new();
for (key, _) in reacts.values() {
let count = counts.entry(key.as_str()).or_default();
*count += 1;
let mut seen_user_reactions = BTreeSet::new();
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<_>>();
@@ -1074,6 +1155,14 @@ impl RoomInfo {
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> {
self.user_receipts.get(user_id)
}
@@ -1146,6 +1235,22 @@ impl RoomInfo {
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.
@@ -1219,6 +1324,20 @@ pub struct SyncInfo {
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! {
/// Load-needs
#[derive(Debug, Default, PartialEq)]
@@ -1295,6 +1414,9 @@ pub struct ChatStore {
/// Whether to ring the terminal bell on the next redraw.
pub ring_bell: bool,
/// Whether the application is currently focused
pub focused: bool,
}
impl ChatStore {
@@ -1317,14 +1439,13 @@ impl ChatStore {
sync_info: Default::default(),
draw_curr: None,
ring_bell: false,
focused: true,
}
}
/// Get a joined room.
pub fn get_joined_room(&self, room_id: &RoomId) -> Option<MatrixRoom> {
let Some(room) = self.worker.client.get_room(room_id) else {
return None;
};
let room = self.worker.client.get_room(room_id)?;
if room.state() == MatrixRoomState::Joined {
Some(room)
@@ -1388,6 +1509,9 @@ pub enum IambId {
/// The `:chats` window.
ChatList,
/// The `:unreads` window.
UnreadList,
}
impl Display for IambId {
@@ -1408,6 +1532,7 @@ impl Display for IambId {
IambId::VerifyList => f.write_str("iamb://verify"),
IambId::Welcome => f.write_str("iamb://welcome"),
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)
},
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"))),
None => Err(E::custom("Invalid iamb window URL")),
}
@@ -1599,6 +1731,9 @@ pub enum IambBufferId {
/// The `:chats` window.
ChatList,
/// The `:unreads` window.
UnreadList,
}
impl IambBufferId {
@@ -1614,6 +1749,7 @@ impl IambBufferId {
IambBufferId::VerifyList => IambId::VerifyList,
IambBufferId::Welcome => IambId::Welcome,
IambBufferId::ChatList => IambId::ChatList,
IambBufferId::UnreadList => IambId::UnreadList,
};
Some(id)
@@ -1648,6 +1784,7 @@ impl ApplicationInfo for IambInfo {
IambBufferId::VerifyList => vec![],
IambBufferId::Welcome => vec![],
IambBufferId::ChatList => vec![],
IambBufferId::UnreadList => vec![],
}
}
@@ -1836,8 +1973,78 @@ pub mod tests {
use super::*;
use crate::config::user_style_from_color;
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;
#[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]
fn test_typing_spans() {
let mut info = RoomInfo::default();

View File

@@ -20,6 +20,7 @@ use crate::base::{
IambAction,
IambId,
KeysAction,
MemberUpdateAction,
MessageAction,
ProgramCommand,
ProgramCommands,
@@ -34,7 +35,7 @@ type ProgResult = CommandResult<ProgramCommand>;
/// Convert strings the user types into a tag name.
fn tag_name(name: String) -> Result<TagName, CommandError> {
let tag = match name.as_str() {
let tag = match name.to_lowercase().as_str() {
"fav" | "favorite" | "favourite" | "m.favourite" => TagName::Favorite,
"low" | "lowpriority" | "low_priority" | "low-priority" | "m.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 {
let args = desc.arg.strings()?;
let mut args = desc.arg.strings()?;
if args.len() != 1 {
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)) {
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));
}
return Ok(step);
}
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);
}
let mact = if let Some(k) = args.pop() {
let k = k.as_str();
if let Some(emoji) = emojis::get(k).or_else(|| emojis::get_by_shortcode(k)) {
IambAction::from(MessageAction::Unreact(Some(emoji.to_string())))
} else {
let msg = format!("Invalid Emoji or shortcode: {k}");
return Result::Err(CommandError::Error(msg));
}
} else {
IambAction::from(MessageAction::Unreact(None))
};
let reaction = args.pop();
let mact = IambAction::from(MessageAction::Unreact(reaction, desc.bang));
let step = CommandStep::Continue(mact.into(), ctx.context.clone());
return Ok(step);
@@ -325,6 +307,30 @@ fn iamb_chats(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
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 {
if !desc.arg.text.is_empty() {
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()) {
// :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>
("name", "set", Some(s)) => RoomAction::Set(RoomField::Name, s).into(),
("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", 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>
("tag", "unset", Some(s)) => RoomAction::Unset(RoomField::Tag(tag_name(s)?)).into(),
("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),
};
@@ -507,6 +592,9 @@ fn iamb_open(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
fn iamb_logout(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
let args = desc.arg.strings()?;
if args.is_empty() {
return Result::Err(CommandError::Error("Missing username".to_string()));
}
if args.len() != 1 {
return Result::Err(CommandError::InvalidArgument);
}
@@ -584,6 +672,11 @@ fn add_iamb_commands(cmds: &mut ProgramCommands) {
aliases: vec![],
f: iamb_spaces,
});
cmds.add_command(ProgramCommand {
name: "unreads".into(),
aliases: vec![],
f: iamb_unreads,
});
cmds.add_command(ProgramCommand {
name: "unreact".into(),
aliases: vec![],
@@ -789,6 +882,32 @@ mod tests {
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]
fn test_cmd_room_tag_set() {
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]
fn test_cmd_invite() {
let mut cmds = setup_commands();
@@ -960,6 +1100,69 @@ mod tests {
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]
fn test_cmd_redact() {
let mut cmds = setup_commands();

View File

@@ -398,16 +398,26 @@ pub enum UserDisplayStyle {
DisplayName,
}
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)]
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum NotifyVia {
/// Deliver notifications via terminal bell.
Bell,
/// Deliver notifications via desktop mechanism.
#[default]
#[cfg(feature = "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)]
pub struct Notifications {
#[serde(default)]
@@ -507,6 +517,7 @@ pub struct TunableValues {
pub notifications: Notifications,
pub image_preview: Option<ImagePreviewValues>,
pub user_gutter_width: usize,
pub external_edit_file_suffix: String,
}
#[derive(Clone, Default, Deserialize)]
@@ -530,6 +541,7 @@ pub struct Tunables {
pub notifications: Option<Notifications>,
pub image_preview: Option<ImagePreview>,
pub user_gutter_width: Option<usize>,
pub external_edit_file_suffix: Option<String>,
}
impl Tunables {
@@ -557,6 +569,9 @@ impl Tunables {
notifications: self.notifications.or(other.notifications),
image_preview: self.image_preview.or(other.image_preview),
user_gutter_width: self.user_gutter_width.or(other.user_gutter_width),
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(),
image_preview: self.image_preview.map(ImagePreview::values),
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()
}
pub fn get_user_style(&self, user_id: &UserId) -> Style {
let color = self
.tunables
pub fn get_user_color(&self, user_id: &UserId) -> Color {
self.tunables
.users
.get(user_id)
.and_then(|user| user.color.as_ref().map(|c| c.0))
.unwrap_or_else(|| user_color(user_id.as_str()));
.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> {
@@ -1164,7 +1183,7 @@ mod tests {
let j = "j".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 exp = Keys(vec![esc], "<Esc>".into());
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
//! keys come from [modalkit::env::vim::keybindings].
use modalkit::{
actions::{MacroAction, WindowAction},
actions::{InsertTextAction, MacroAction, WindowAction},
env::vim::keybindings::{InputStep, VimBindings},
env::vim::VimMode,
env::CommonKeyClass,
key::TerminalKey,
keybindings::{EdgeEvent, EdgeRepeat, InputBindings},
prelude::Count,
prelude::*,
};
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 key_m_lc = "m".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 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::Normal, &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
}

View File

@@ -48,6 +48,9 @@ use modalkit::crossterm::{
EnableFocusChange,
Event,
KeyEventKind,
KeyboardEnhancementFlags,
PopKeyboardEnhancementFlags,
PushKeyboardEnhancementFlags,
},
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, SetTitle},
@@ -126,8 +129,8 @@ use modalkit::{
use modalkit_ratatui::{
cmdbar::CommandBarState,
screen::{Screen, ScreenState, TabLayoutDescription},
windows::WindowLayoutDescription,
screen::{Screen, ScreenState, TabbedLayoutDescription},
windows::{WindowLayoutDescription, WindowLayoutState},
TerminalCursor,
TerminalExtOps,
Window,
@@ -173,6 +176,17 @@ fn config_tab_to_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(
settings: ApplicationSettings,
store: &mut ProgramStore,
@@ -183,12 +197,14 @@ fn setup_screen(
match settings.layout {
config::Layout::Restore => {
if let Ok(layout) = std::fs::read(&settings.layout_json) {
let tabs: TabLayoutDescription<IambInfo> =
serde_json::from_slice(&layout).map_err(IambError::from)?;
let tabs = tabs.to_layout(area.into(), store)?;
return Ok(ScreenState::from_list(tabs, cmd));
match restore_layout(area, &settings, store) {
Ok(tabs) => {
return Ok(ScreenState::from_list(tabs, cmd));
},
Err(e) => {
// Log the issue with restoring and then continue.
tracing::warn!(err = %e, "Failed to restore layout from disk");
},
}
},
config::Layout::New => {},
@@ -239,7 +255,7 @@ struct Application {
focused: bool,
/// 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).
dirty: bool,
@@ -250,16 +266,7 @@ impl Application {
settings: ApplicationSettings,
store: AsyncProgramStore,
) -> IambResult<Application> {
let mut stdout = 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 backend = CrosstermBackend::new(stdout());
let terminal = Terminal::new(backend)?;
let mut bindings = crate::keybindings::setup_keybindings();
@@ -361,9 +368,13 @@ impl Application {
// Do nothing for now.
},
Event::FocusGained => {
let mut store = self.store.lock().await;
store.application.focused = true;
self.focused = true;
},
Event::FocusLost => {
let mut store = self.store.lock().await;
store.application.focused = false;
self.focused = false;
},
Event::Resize(_, _) => {
@@ -481,7 +492,7 @@ impl Application {
None
},
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);
None
@@ -518,6 +529,18 @@ impl Application {
}
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 => {
self.screen.current_window_mut()?.focus_toggle();
@@ -905,6 +928,42 @@ async fn login_normal(
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<()> {
// Get old keys the first time we run w/ the upgraded SDK.
let import_keys = check_import_keys(&settings).await?;
@@ -938,27 +997,30 @@ async fn run(settings: ApplicationSettings) -> IambResult<()> {
Ok(()) => (),
}
fn restore_tty() {
let _ = crossterm::terminal::disable_raw_mode();
let _ = crossterm::execute!(stdout(), DisableBracketedPaste);
let _ = crossterm::execute!(stdout(), DisableFocusChange);
let _ = crossterm::execute!(stdout(), LeaveAlternateScreen);
let _ = crossterm::execute!(stdout(), CursorShow);
}
// Set up the terminal for drawing, and cleanup properly on panics.
let enable_enhanced_keys = match crossterm::terminal::supports_keyboard_enhancement() {
Ok(supported) => supported,
Err(e) => {
tracing::warn!(err = %e,
"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();
std::panic::set_hook(Box::new(move |panic_info| {
restore_tty();
restore_tty(enable_enhanced_keys);
orig_hook(panic_info);
process::exit(1);
}));
// And finally, start running the terminal UI.
let mut application = Application::new(settings, store).await?;
// We can now run the application.
application.run().await?;
restore_tty();
// Clean up the terminal on exit.
restore_tty(enable_enhanced_keys);
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),
Blockquote(Box<StyleTreeNode>),
Break,
#[allow(dead_code)]
Code(Box<StyleTreeNode>, Option<String>),
Header(Box<StyleTreeNode>, usize),
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_set;
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::ops::{Deref, DerefMut};
use chrono::{DateTime, Local as LocalTz, NaiveDateTime, TimeZone};
use comrak::{markdown_to_html, ComrakOptions};
use humansize::{format_size, DECIMAL};
use serde_json::json;
use unicode_width::UnicodeWidthStr;
@@ -31,7 +32,6 @@ use matrix_sdk::ruma::{
Relation,
RoomMessageEvent,
RoomMessageEventContent,
TextMessageEventContent,
},
redaction::SyncRoomRedactionEvent,
},
@@ -64,9 +64,12 @@ use crate::{
util::{replace_emojis_in_str, space, space_span, take_width, wrapped_text},
};
mod compose;
mod html;
mod printer;
pub use self::compose::text_to_message;
pub type MessageKey = (MessageTimeStamp, OwnedEventId);
#[derive(Default)]
@@ -127,20 +130,22 @@ const MIN_MSG_LEN: usize = 30;
const TIME_GUTTER_EMPTY: &str = " ";
const TIME_GUTTER_EMPTY_SPAN: Span<'static> = span_static(TIME_GUTTER_EMPTY);
fn text_to_message_content(input: String) -> TextMessageEventContent {
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);
const USIZE_TOO_SMALL: bool = usize::BITS < u64::BITS;
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 {
let msg = MessageType::Text(text_to_message_content(input));
RoomMessageEventContent::new(msg)
/// Hash an [EventId] into a [usize].
fn hash_event_id(event_id: &EventId) -> Option<usize> {
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.
@@ -326,17 +331,14 @@ impl MessageCursor {
}
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 ts_start = MessageTimeStamp::try_from(cursor.get_y()).ok()?;
let start = (ts_start, ev_term);
for ((ts, event_id), _) in thread.range(&start..) {
let mut hasher = DefaultHasher::new();
event_id.hash(&mut hasher);
if hasher.finish() == ev_hash {
if hash_event_id(event_id)? == ev_hash {
return Self::from((*ts, event_id.clone())).into();
}
@@ -355,11 +357,8 @@ impl MessageCursor {
pub fn to_cursor(&self, thread: &Messages) -> Option<Cursor> {
let (ts, event_id) = self.to_key(thread)?;
let y: usize = usize::try_from(ts).ok()?;
let mut hasher = DefaultHasher::new();
event_id.hash(&mut hasher);
let x = usize::try_from(hasher.finish()).ok()?;
let y = usize::try_from(ts).ok()?;
let x = hash_event_id(event_id)?;
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> {
let s = match &content.msgtype {
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::Audio(content) => {
return Cow::Owned(format!("[Attached Audio: {}]", content.body));
display_file_to_text!(Audio, content);
},
MessageType::File(content) => {
return Cow::Owned(format!("[Attached File: {}]", content.body));
display_file_to_text!(File, content);
},
MessageType::Image(content) => {
return Cow::Owned(format!("[Attached Image: {}]", content.body));
display_file_to_text!(Image, 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 {
let color = crate::config::user_color(self.sender.as_str());
let color = settings.get_user_color(&self.sender);
style = style.fg(color);
}
@@ -936,7 +967,7 @@ impl Message {
let style = self.get_render_style(selected, 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();
// Show the message that this one replied to, if any.
@@ -961,6 +992,8 @@ impl Message {
let proto = proto.map(|p| {
let y_off = text.lines.len() as u16;
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)
});
@@ -1120,14 +1153,27 @@ impl From<RoomMessageEvent> for Message {
}
}
impl ToString for Message {
fn to_string(&self) -> String {
self.event.body().into_owned()
impl Display for Message {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{}", self.event.body())
}
}
#[cfg(test)]
pub mod tests {
use matrix_sdk::ruma::events::room::{
message::{
AudioInfo,
AudioMessageEventContent,
FileInfo,
FileMessageEventContent,
ImageMessageEventContent,
VideoInfo,
VideoMessageEventContent,
},
ImageInfo,
};
use super::*;
use crate::tests::*;
@@ -1236,82 +1282,6 @@ pub mod tests {
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]
fn test_placeholder_frame() {
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 {
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.
@@ -276,3 +276,18 @@ impl<'a> TextPrinter<'a> {
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 crate::{
base::{AsyncProgramStore, IambError, IambResult},
base::{AsyncProgramStore, IambError, IambResult, ProgramStore},
config::{ApplicationSettings, NotifyVia},
};
const IAMB_XDG_NAME: &str = match option_env!("IAMB_XDG_NAME") {
None => "iamb",
Some(iamb) => iamb,
};
pub async fn register_notifications(
client: &Client,
settings: &ApplicationSettings,
@@ -44,7 +49,7 @@ pub async fn register_notifications(
return;
}
if is_open(&store, room.room_id()).await {
if is_visible_room(&store, room.room_id()).await {
return;
}
@@ -59,6 +64,7 @@ pub async fn register_notifications(
}
match notify_via {
#[cfg(feature = "desktop")]
NotifyVia::Desktop => send_notification_desktop(summary, body),
NotifyVia::Bell => send_notification_bell(&store).await,
}
@@ -77,12 +83,13 @@ async fn send_notification_bell(store: &AsyncProgramStore) {
locked.application.ring_bell = true;
}
#[cfg(feature = "desktop")]
fn send_notification_desktop(summary: String, body: Option<String>) {
let mut desktop_notification = notify_rust::Notification::new();
desktop_notification
.summary(&summary)
.appname("iamb")
.timeout(notify_rust::Timeout::Milliseconds(3000))
.appname(IAMB_XDG_NAME)
.icon(IAMB_XDG_NAME)
.action("default", "default");
if let Some(body) = body {
@@ -128,8 +135,7 @@ fn is_missing_mention(body: &Option<String>, mode: RoomNotificationMode, client:
false
}
async fn is_open(store: &AsyncProgramStore, room_id: &RoomId) -> bool {
let mut locked = store.lock().await;
fn is_open(locked: &mut ProgramStore, room_id: &RoomId) -> bool {
if let Some(draw_curr) = locked.application.draw_curr {
let info = locked.application.get_room_info(room_id.to_owned());
if let Some(draw_last) = info.draw_last {
@@ -139,6 +145,16 @@ async fn is_open(store: &AsyncProgramStore, room_id: &RoomId) -> bool {
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(
notification: Notification,
room: MatrixRoom,
@@ -156,6 +172,12 @@ pub async fn parse_notification(
.and_then(|m| m.display_name())
.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 {
event_notification_body(
&event,
@@ -167,7 +189,7 @@ pub async fn parse_notification(
None
};
return Ok((sender_name.to_string(), body, server_ts));
return Ok((summary, body, server_ts));
}
pub fn event_notification_body(
@@ -220,7 +242,9 @@ pub fn event_notification_body(
MessageType::VerificationRequest(_) => {
format!("{sender_name} sent a verification request.")
},
_ => unimplemented!(),
_ => {
format!("[Unknown message type: {:?}]", &message.msgtype)
},
};
Some(body)
},

View File

@@ -100,7 +100,7 @@ pub fn spawn_insert_preview(
})
.and_then(|(picker, msg, image_preview)| {
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(|backend| (backend, msg))
}) {

View File

@@ -186,6 +186,7 @@ pub fn mock_tunables() -> TunableValues {
.into_iter()
.collect::<HashMap<_, _>>(),
open_command: None,
external_edit_file_suffix: String::from(".md"),
username_display: UserDisplayStyle::Username,
message_user_color: false,
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> {
let height = texts.iter().map(|t| t.0.height()).max().unwrap_or(0);
let mut text = Text {
lines: vec![Line::from(vec![join.clone()]); height],
};
let mut text = Text::from(vec![Line::from(vec![join.clone()]); height]);
for (mut t, w) in texts.into_iter() {
for i in 0..height {

View File

@@ -7,6 +7,7 @@
//! 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.
use std::cmp::{Ord, Ordering, PartialOrd};
use std::fmt::{self, Display};
use std::ops::Deref;
use std::sync::Arc;
use std::time::{Duration, Instant};
@@ -314,6 +315,7 @@ macro_rules! delegate {
IambWindow::VerifyList($id) => $e,
IambWindow::Welcome($id) => $e,
IambWindow::ChatList($id) => $e,
IambWindow::UnreadList($id) => $e,
}
};
}
@@ -327,6 +329,7 @@ pub enum IambWindow {
SpaceList(SpaceListState),
Welcome(WelcomeState),
ChatList(ChatListState),
UnreadList(UnreadListState),
}
impl IambWindow {
@@ -382,6 +385,7 @@ pub type DirectListState = ListState<DirectItem, IambInfo>;
pub type MemberListState = ListState<MemberItem, IambInfo>;
pub type RoomListState = ListState<RoomItem, IambInfo>;
pub type ChatListState = ListState<GenericChatItem, IambInfo>;
pub type UnreadListState = ListState<GenericChatItem, IambInfo>;
pub type SpaceListState = ListState<SpaceItem, IambInfo>;
pub type VerifyListState = ListState<VerifyItem, IambInfo>;
@@ -578,6 +582,39 @@ impl WindowOps<IambInfo> for IambWindow {
.focus(focused)
.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) => {
let mut items = store
.application
@@ -629,6 +666,7 @@ impl WindowOps<IambInfo> for IambWindow {
IambWindow::VerifyList(w) => w.dup(store).into(),
IambWindow::Welcome(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::Welcome(_) => IambId::Welcome,
IambWindow::ChatList(_) => IambId::ChatList,
IambWindow::UnreadList(_) => IambId::UnreadList,
}
}
@@ -680,6 +719,7 @@ impl Window<IambInfo> for IambWindow {
IambWindow::VerifyList(_) => bold_spans("Verifications"),
IambWindow::Welcome(_) => bold_spans("Welcome to iamb"),
IambWindow::ChatList(_) => bold_spans("DMs & Rooms"),
IambWindow::UnreadList(_) => bold_spans("Unread Messages"),
IambWindow::Room(w) => {
let title = store.application.get_room_title(w.id());
@@ -707,6 +747,7 @@ impl Window<IambInfo> for IambWindow {
IambWindow::VerifyList(_) => bold_spans("Verifications"),
IambWindow::Welcome(_) => bold_spans("Welcome to iamb"),
IambWindow::ChatList(_) => bold_spans("DMs & Rooms"),
IambWindow::UnreadList(_) => bold_spans("Unread Messages"),
IambWindow::Room(w) => w.get_title(store),
IambWindow::MemberList(state, room_id, _) => {
@@ -768,6 +809,11 @@ impl Window<IambInfo> for IambWindow {
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 alias = room.canonical_alias();
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 {
store.application.names.insert(alias.to_string(), room_id.to_owned());
@@ -870,9 +916,9 @@ impl RoomLikeItem for GenericChatItem {
}
}
impl ToString for GenericChatItem {
fn to_string(&self) -> String {
return self.name.clone();
impl Display for GenericChatItem {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{}", self.name)
}
}
@@ -930,7 +976,7 @@ impl RoomItem {
let name = info.name.clone().unwrap_or_default();
let alias = room.canonical_alias();
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 {
store.application.names.insert(alias.to_string(), room_id.to_owned());
@@ -980,9 +1026,9 @@ impl RoomLikeItem for RoomItem {
}
}
impl ToString for RoomItem {
fn to_string(&self) -> String {
return self.name.clone();
impl Display for RoomItem {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, ":verify request {}", self.name)
}
}
@@ -1034,7 +1080,7 @@ impl DirectItem {
let info = store.application.rooms.get_or_default(room_id);
let name = info.name.clone().unwrap_or_default();
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 }
}
@@ -1080,9 +1126,9 @@ impl RoomLikeItem for DirectItem {
}
}
impl ToString for DirectItem {
fn to_string(&self) -> String {
return self.name.clone();
impl Display for DirectItem {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, ":verify request {}", self.name)
}
}
@@ -1179,9 +1225,9 @@ impl RoomLikeItem for SpaceItem {
}
}
impl ToString for SpaceItem {
fn to_string(&self) -> String {
return self.room_id().to_string();
impl Display for SpaceItem {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, ":verify request {}", self.room_id())
}
}
@@ -1300,16 +1346,18 @@ impl From<(&String, &SasVerification)> for VerifyItem {
}
}
impl ToString for VerifyItem {
fn to_string(&self) -> String {
impl Display for VerifyItem {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
if self.sasv1.is_done() {
String::new()
} else if self.sasv1.is_cancelled() {
format!(":verify request {}", self.sasv1.other_user_id())
return Ok(());
}
if self.sasv1.is_cancelled() {
write!(f, ":verify request {}", self.sasv1.other_user_id())
} else if self.sasv1.emoji().is_some() {
format!(":verify confirm {}", self.user_dev)
write!(f, ":verify confirm {}", self.user_dev)
} 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> {
@@ -1413,9 +1461,9 @@ impl MemberItem {
}
}
impl ToString for MemberItem {
fn to_string(&self) -> String {
self.member.user_id().to_string()
impl Display for MemberItem {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{}", self.member.user_id())
}
}

View File

@@ -5,7 +5,8 @@ use std::fs;
use std::ops::Deref;
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 std::process::Command;
use tokio;
@@ -357,7 +358,22 @@ impl ChatState {
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 event_id = match &msg.event {
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 msg = ReactionEventContent::new(reaction);
let _ = room.send(msg).await.map_err(IambError::from)?;
@@ -414,7 +437,27 @@ impl ChatState {
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 event_id = match &msg.event {
MessageEvent::EncryptedOriginal(ev) => ev.event_id.clone(),
@@ -474,7 +517,16 @@ impl ChatState {
let msg = self.tbox.get();
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() {
return Ok(None);
} else {

View File

@@ -1,12 +1,29 @@
//! # Windows for Matrix rooms and spaces
use std::collections::HashSet;
use matrix_sdk::{
notification_settings::RoomNotificationMode,
room::Room as MatrixRoom,
ruma::{
api::client::{
alias::{
create_alias::v3::Request as CreateAliasRequest,
delete_alias::v3::Request as DeleteAliasRequest,
},
error::ErrorKind as ClientApiErrorKind,
},
events::{
room::{name::RoomNameEventContent, topic::RoomTopicEventContent},
room::{
canonical_alias::RoomCanonicalAliasEventContent,
history_visibility::{HistoryVisibility, RoomHistoryVisibilityEventContent},
name::RoomNameEventContent,
topic::RoomTopicEventContent,
},
tag::{TagInfo, Tags},
},
OwnedEventId,
OwnedRoomAliasId,
OwnedUserId,
RoomId,
},
DisplayName,
@@ -41,6 +58,7 @@ use crate::base::{
IambId,
IambInfo,
IambResult,
MemberUpdateAction,
MessageAction,
ProgramAction,
ProgramContext,
@@ -53,6 +71,8 @@ use crate::base::{
use self::chat::ChatState;
use self::space::{Space, SpaceState};
use std::convert::TryFrom;
mod chat;
mod scrollback;
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.
///
/// 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(
"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);
@@ -182,7 +229,7 @@ impl RoomState {
pub async fn room_command(
&mut self,
act: RoomAction,
_: ProgramContext,
ctx: ProgramContext,
store: &mut ProgramStore,
) -> IambResult<Vec<(Action<IambInfo>, ProgramContext)>> {
match act {
@@ -239,6 +286,47 @@ impl RoomState {
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) => {
let width = Count::Exact(30);
let act =
@@ -249,6 +337,16 @@ impl RoomState {
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) => {
let room = store
.application
@@ -256,6 +354,11 @@ impl RoomState {
.ok_or(UIError::Application(IambError::NotJoined))?;
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 => {
let ev = RoomNameEventContent::new(value);
let _ = room.send_state_event(ev).await.map_err(IambError::from)?;
@@ -270,6 +373,97 @@ impl RoomState {
let ev = RoomTopicEventContent::new(value);
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![])
@@ -281,6 +475,11 @@ impl RoomState {
.ok_or(UIError::Application(IambError::NotJoined))?;
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 => {
let ev = RoomNameEventContent::new("".into());
let _ = room.send_state_event(ev).await.map_err(IambError::from)?;
@@ -292,10 +491,146 @@ impl RoomState {
let ev = RoomTopicEventContent::new("".into());
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![])
},
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 = flip.resolve(&dir);
let lsearch = store.registers.get(&Register::LastSearch)?;
let lsearch = lsearch.value.to_string();
let lsearch = store.registers.get_last_search().to_string();
let needle = Regex::new(lsearch.as_ref())?;
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 = flip.resolve(&dir);
let lsearch = store.registers.get(&Register::LastSearch)?;
let lsearch = lsearch.value.to_string();
let lsearch = store.registers.get_last_search().to_string();
let needle = Regex::new(lsearch.as_ref())?;
let (mc, needs_load) = self.find_message(key, dir, &needle, count, info);
@@ -1452,16 +1450,16 @@ mod tests {
// MSG4: "help"
// MSG5: "character"
// MSG1: "writhe"
store.set_last_search("he");
store.registers.set_last_search("he");
assert_eq!(scrollback.cursor, MessageCursor::latest());
// 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());
// 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!(
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.
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!(
std::mem::take(&mut store.application.need_load)
@@ -1482,11 +1480,11 @@ mod tests {
);
// 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());
// 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());
}

View File

@@ -148,7 +148,7 @@ impl<'a> StatefulWidget for Space<'a> {
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
You can customize iamb in your `$CONFIG_DIR/iamb/config.json` file, where
`$CONFIG_DIR` is your system's per-user configuration directory.
You can customize iamb in your `$CONFIG_DIR/iamb/config.toml` file, where
`$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:
- `"default_profile"`, a profile name to use when starting iamb if one wasn't specified
- `"cache"`, a directory for cached iamb
See the manual pages or <https://iamb.chat> for more details on how to
further configure or use iamb.