Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e6376ff86 | ||
|
|
480888a1fc | ||
|
|
4fc05c7b40 | ||
|
|
3003f0a528 | ||
|
|
df3896df9c | ||
|
|
2a66496913 | ||
|
|
b4fc574163 | ||
|
|
e63341fe32 | ||
|
|
657e61fe2e | ||
|
|
94999dc4c0 | ||
|
|
54cb7991be | ||
|
|
c94d7d0ad7 | ||
|
|
d44961c461 | ||
|
|
6d80b516f8 | ||
|
|
04480eda1b | ||
|
|
653287478e | ||
|
|
4571788678 | ||
|
|
9a1adfb287 | ||
|
|
cb4455655f | ||
|
|
4fc71c9291 | ||
|
|
d8d8e91295 | ||
|
|
497be7f099 | ||
|
|
64e4f67e43 | ||
|
|
a18d0f54eb | ||
|
|
59e1862e9c | ||
|
|
14415a30fc | ||
|
|
6c0d126f4b | ||
|
|
c6982c9737 | ||
|
|
46f6d37f76 | ||
|
|
3971801aa3 | ||
|
|
7bc34c8145 | ||
|
|
91ca50aecb | ||
|
|
949100bdc7 | ||
|
|
b995906c79 | ||
|
|
e5b284ed19 | ||
|
|
0f17bbfa17 | ||
|
|
aba72aa64d | ||
|
|
72d35431de | ||
|
|
a98bbd97be |
94
.github/workflows/binaries.yml
vendored
Normal file
94
.github/workflows/binaries.yml
vendored
Normal 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
|
||||||
9
.github/workflows/ci.yml
vendored
9
.github/workflows/ci.yml
vendored
@@ -45,12 +45,3 @@ jobs:
|
|||||||
reporter: 'github-check'
|
reporter: 'github-check'
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: cargo test --locked
|
run: cargo test --locked
|
||||||
- name: Build artifacts
|
|
||||||
run: cargo build --release --locked
|
|
||||||
- name: Upload artifacts
|
|
||||||
uses: actions/upload-artifact@master
|
|
||||||
with:
|
|
||||||
name: iamb-${{ matrix.platform }}
|
|
||||||
path: |
|
|
||||||
./target/release/iamb
|
|
||||||
./target/release/iamb.exe
|
|
||||||
|
|||||||
1627
Cargo.lock
generated
1627
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
67
Cargo.toml
67
Cargo.toml
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "iamb"
|
name = "iamb"
|
||||||
version = "0.0.9"
|
version = "0.0.10"
|
||||||
edition = "2018"
|
edition = "2018"
|
||||||
authors = ["Ulyssa <git@ulyssa.dev>"]
|
authors = ["Ulyssa <git@ulyssa.dev>"]
|
||||||
repository = "https://github.com/ulyssa/iamb"
|
repository = "https://github.com/ulyssa/iamb"
|
||||||
@@ -15,8 +15,9 @@ rust-version = "1.70"
|
|||||||
build = "build.rs"
|
build = "build.rs"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["bundled"]
|
default = ["bundled", "desktop"]
|
||||||
bundled = ["matrix-sdk/bundled-sqlite", "rustls-tls"]
|
bundled = ["matrix-sdk/bundled-sqlite", "rustls-tls"]
|
||||||
|
desktop = ["dep:notify-rust", "modalkit/clipboard"]
|
||||||
native-tls = ["matrix-sdk/native-tls"]
|
native-tls = ["matrix-sdk/native-tls"]
|
||||||
rustls-tls = ["matrix-sdk/rustls-tls"]
|
rustls-tls = ["matrix-sdk/rustls-tls"]
|
||||||
|
|
||||||
@@ -26,14 +27,13 @@ default-features = false
|
|||||||
features = ["build", "git", "gitcl",]
|
features = ["build", "git", "gitcl",]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
arboard = "3.3.0"
|
anyhow = "1.0"
|
||||||
bitflags = "^2.3"
|
bitflags = "^2.3"
|
||||||
chrono = "0.4"
|
chrono = "0.4"
|
||||||
clap = {version = "~4.3", features = ["derive"]}
|
clap = {version = "~4.3", features = ["derive"]}
|
||||||
comrak = {version = "0.18.0", features = ["shortcodes"]}
|
|
||||||
css-color-parser = "0.1.2"
|
css-color-parser = "0.1.2"
|
||||||
dirs = "4.0.0"
|
dirs = "4.0.0"
|
||||||
emojis = "~0.5.2"
|
emojis = "0.5"
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
gethostname = "0.4.1"
|
gethostname = "0.4.1"
|
||||||
html5ever = "0.26.0"
|
html5ever = "0.26.0"
|
||||||
@@ -42,11 +42,11 @@ libc = "0.2"
|
|||||||
markup5ever_rcdom = "0.2.0"
|
markup5ever_rcdom = "0.2.0"
|
||||||
mime = "^0.3.16"
|
mime = "^0.3.16"
|
||||||
mime_guess = "^2.0.4"
|
mime_guess = "^2.0.4"
|
||||||
notify-rust = { version = "4.10.0", default-features = false, features = ["zbus", "serde"] }
|
nom = "7.0.0"
|
||||||
open = "3.2.0"
|
open = "3.2.0"
|
||||||
rand = "0.8.5"
|
rand = "0.8.5"
|
||||||
ratatui = "0.23"
|
ratatui = "0.26"
|
||||||
ratatui-image = { version = "0.8.1", features = ["serde"] }
|
ratatui-image = { version = "1.0.0", features = ["serde"] }
|
||||||
regex = "^1.5"
|
regex = "^1.5"
|
||||||
rpassword = "^7.2"
|
rpassword = "^7.2"
|
||||||
serde = "^1.0"
|
serde = "^1.0"
|
||||||
@@ -62,16 +62,29 @@ unicode-segmentation = "^1.7"
|
|||||||
unicode-width = "0.1.10"
|
unicode-width = "0.1.10"
|
||||||
url = {version = "^2.2.2", features = ["serde"]}
|
url = {version = "^2.2.2", features = ["serde"]}
|
||||||
edit = "0.1.4"
|
edit = "0.1.4"
|
||||||
|
humansize = "2.0.0"
|
||||||
|
|
||||||
|
[dependencies.comrak]
|
||||||
|
version = "0.22.0"
|
||||||
|
default-features = false
|
||||||
|
features = ["shortcodes"]
|
||||||
|
|
||||||
|
[dependencies.notify-rust]
|
||||||
|
version = "4.10.0"
|
||||||
|
default-features = false
|
||||||
|
features = ["zbus", "serde"]
|
||||||
|
optional = true
|
||||||
|
|
||||||
[dependencies.modalkit]
|
[dependencies.modalkit]
|
||||||
version = "0.0.18"
|
version = "0.0.20"
|
||||||
|
default-features = false
|
||||||
#git = "https://github.com/ulyssa/modalkit"
|
#git = "https://github.com/ulyssa/modalkit"
|
||||||
#rev = "cb8c8aeb9a499b9b16615ce144f9014d78036e01"
|
#rev = "24f3ec11c7f634005a27b26878d0fbbdcc08f272"
|
||||||
|
|
||||||
[dependencies.modalkit-ratatui]
|
[dependencies.modalkit-ratatui]
|
||||||
version = "0.0.18"
|
version = "0.0.20"
|
||||||
#git = "https://github.com/ulyssa/modalkit"
|
#git = "https://github.com/ulyssa/modalkit"
|
||||||
#rev = "cb8c8aeb9a499b9b16615ce144f9014d78036e01"
|
#rev = "24f3ec11c7f634005a27b26878d0fbbdcc08f272"
|
||||||
|
|
||||||
[dependencies.matrix-sdk]
|
[dependencies.matrix-sdk]
|
||||||
version = "0.7.1"
|
version = "0.7.1"
|
||||||
@@ -90,3 +103,33 @@ pretty_assertions = "1.4.0"
|
|||||||
inherits = "release"
|
inherits = "release"
|
||||||
incremental = false
|
incremental = false
|
||||||
lto = true
|
lto = true
|
||||||
|
|
||||||
|
[package.metadata.deb]
|
||||||
|
section = "net"
|
||||||
|
license-file = ["LICENSE", "0"]
|
||||||
|
assets = [
|
||||||
|
# Binary:
|
||||||
|
["target/release/iamb", "usr/bin/iamb", "755"],
|
||||||
|
# Manual pages:
|
||||||
|
["docs/iamb.1", "usr/share/man/man1/iamb.1", "644"],
|
||||||
|
["docs/iamb.5", "usr/share/man/man5/iamb.5", "644"],
|
||||||
|
# Other assets:
|
||||||
|
["iamb.desktop", "usr/share/applications/iamb.desktop", "644"],
|
||||||
|
["config.example.toml", "usr/share/iamb/config.example.toml", "644"],
|
||||||
|
["docs/iamb.svg", "usr/share/icons/hicolor/scalable/apps/iamb.svg", "644"],
|
||||||
|
["docs/iamb.metainfo.xml", "usr/share/metainfo/iamb.metainfo.xml", "644"],
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata.generate-rpm]
|
||||||
|
assets = [
|
||||||
|
# Binary:
|
||||||
|
{ source = "target/release/iamb", dest = "/usr/bin/iamb", mode = "755" },
|
||||||
|
# Manual pages:
|
||||||
|
{ source = "docs/iamb.1", dest = "/usr/share/man/man1/iamb.1", mode = "644" },
|
||||||
|
{ source = "docs/iamb.5", dest = "/usr/share/man/man5/iamb.5", mode = "644" },
|
||||||
|
# Other assets:
|
||||||
|
{ source = "iamb.desktop", dest = "/usr/share/applications/iamb.desktop", mode = "644" },
|
||||||
|
{ source = "config.example.toml", dest = "/usr/share/iamb/config.example.toml", mode = "644"},
|
||||||
|
{ source = "docs/iamb.svg", dest = "/usr/share/icons/hicolor/scalable/apps/iamb.svg", mode = "644"},
|
||||||
|
{ source = "docs/iamb.metainfo.xml", dest = "/usr/share/metainfo/iamb.metainfo.xml", mode = "644"},
|
||||||
|
]
|
||||||
|
|||||||
2
LICENSE
2
LICENSE
@@ -186,7 +186,7 @@
|
|||||||
same "printed page" as the copyright notice for easier
|
same "printed page" as the copyright notice for easier
|
||||||
identification within third-party archives.
|
identification within third-party archives.
|
||||||
|
|
||||||
Copyright [yyyy] [name of copyright owner]
|
Copyright 2024 Ulyssa Mello
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
you may not use this file except in compliance with the License.
|
you may not use this file except in compliance with the License.
|
||||||
|
|||||||
20
PACKAGING.md
20
PACKAGING.md
@@ -28,15 +28,17 @@ and GCC are present.
|
|||||||
In addition to the compiled binary, there are other files in the repo that
|
In addition to the compiled binary, there are other files in the repo that
|
||||||
you'll want to install as part of a package:
|
you'll want to install as part of a package:
|
||||||
|
|
||||||
| Repository Path | Installed Path (may vary per OS) |
|
<!-- Please keep in sync w/ the `deb`/`generate-rpm` sections of `Cargo.toml` -->
|
||||||
| -------------------- | ----------------------------------------------- |
|
| Repository Path | Installed Path (may vary per OS) |
|
||||||
| /iamb.desktop | /usr/share/applications/iamb.desktop |
|
| ----------------------- | ----------------------------------------------- |
|
||||||
| /config.example.toml | /usr/share/iamb/config.example.toml |
|
| /iamb.desktop | /usr/share/applications/iamb.desktop |
|
||||||
| /docs/iamb-256x256.png | /usr/share/icons/hicolor/256x256/apps/iamb.png |
|
| /config.example.toml | /usr/share/iamb/config.example.toml |
|
||||||
| /docs/iamb-512x512.png | /usr/share/icons/hicolor/512x512/apps/iamb.png |
|
| /docs/iamb-256x256.png | /usr/share/icons/hicolor/256x256/apps/iamb.png |
|
||||||
| /docs/iamb.svg | /usr/share/icons/hicolor/scalable/apps/iamb.svg |
|
| /docs/iamb-512x512.png | /usr/share/icons/hicolor/512x512/apps/iamb.png |
|
||||||
| /docs/iamb.1 | /usr/share/man/man1/iamb.1 |
|
| /docs/iamb.svg | /usr/share/icons/hicolor/scalable/apps/iamb.svg |
|
||||||
| /docs/iamb.5 | /usr/share/man/man5/iamb.5 |
|
| /docs/iamb.1 | /usr/share/man/man1/iamb.1 |
|
||||||
|
| /docs/iamb.5 | /usr/share/man/man5/iamb.5 |
|
||||||
|
| /docs/iamb.metainfo.xml | /usr/share/metainfo/iamb.metainfo.xml |
|
||||||
|
|
||||||
[ring-lto]: https://github.com/briansmith/ring/issues/1444
|
[ring-lto]: https://github.com/briansmith/ring/issues/1444
|
||||||
[rustls]: https://crates.io/crates/rustls
|
[rustls]: https://crates.io/crates/rustls
|
||||||
|
|||||||
121
README.md
121
README.md
@@ -11,7 +11,6 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
## About
|
## About
|
||||||
|
|
||||||
`iamb` is a Matrix client for the terminal that uses Vim keybindings. It includes support for:
|
`iamb` is a Matrix client for the terminal that uses Vim keybindings. It includes support for:
|
||||||
@@ -19,6 +18,7 @@
|
|||||||
- Threads, spaces, E2EE, and read receipts
|
- Threads, spaces, E2EE, and read receipts
|
||||||
- Image previews in terminals that support it (sixels, Kitty, and iTerm2), or using pixelated blocks for those that don't
|
- Image previews in terminals that support it (sixels, Kitty, and iTerm2), or using pixelated blocks for those that don't
|
||||||
- Notifications via terminal bell or desktop environment
|
- Notifications via terminal bell or desktop environment
|
||||||
|
- Send Markdown, HTML or plaintext messages
|
||||||
- Creating, joining, and leaving rooms
|
- Creating, joining, and leaving rooms
|
||||||
- Sending and accepting room invitations
|
- Sending and accepting room invitations
|
||||||
- Editing, redacting, and reacting to messages
|
- Editing, redacting, and reacting to messages
|
||||||
@@ -32,54 +32,6 @@ _You may want to [see this page as it was when the latest version was published]
|
|||||||
You can find documentation for installing, configuring, and using iamb on its
|
You can find documentation for installing, configuring, and using iamb on its
|
||||||
website, [iamb.chat].
|
website, [iamb.chat].
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
Install Rust (1.70.0 or above) and Cargo, and then run:
|
|
||||||
|
|
||||||
```
|
|
||||||
cargo install --locked iamb
|
|
||||||
```
|
|
||||||
|
|
||||||
See [Configuration](#configuration) for getting a profile set up.
|
|
||||||
|
|
||||||
### NetBSD
|
|
||||||
|
|
||||||
On NetBSD a package is available from the official repositories. To install it simply run:
|
|
||||||
|
|
||||||
```
|
|
||||||
pkgin install iamb
|
|
||||||
```
|
|
||||||
|
|
||||||
### Arch Linux
|
|
||||||
|
|
||||||
On Arch Linux a [package](https://aur.archlinux.org/packages/iamb-git) is available in the
|
|
||||||
Arch User Repositories (AUR). To install it simply run with your favorite AUR helper:
|
|
||||||
|
|
||||||
```
|
|
||||||
paru iamb-git
|
|
||||||
```
|
|
||||||
### openSUSE Tumbleweed
|
|
||||||
|
|
||||||
On openSUSE Tumbleweed a [package](https://build.opensuse.org/package/show/home%3Asmolsheep/iamb) is available from openSUSE Build Service (OBS). To install just use OBS Package Installer:
|
|
||||||
|
|
||||||
```
|
|
||||||
opi iamb
|
|
||||||
```
|
|
||||||
|
|
||||||
### Nix / NixOS (flake)
|
|
||||||
|
|
||||||
```
|
|
||||||
nix profile install "github:ulyssa/iamb"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Snap
|
|
||||||
|
|
||||||
A snap for Linux distributions which [support](https://snapcraft.io/docs/installing-snapd) the packaging system.
|
|
||||||
|
|
||||||
```
|
|
||||||
snap install iamb
|
|
||||||
```
|
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
You can create a basic configuration in `$CONFIG_DIR/iamb/config.toml` that looks like:
|
You can create a basic configuration in `$CONFIG_DIR/iamb/config.toml` that looks like:
|
||||||
@@ -99,14 +51,79 @@ url = "https://example.com"
|
|||||||
user_id = "@user:example.com"
|
user_id = "@user:example.com"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Installation (via `crates.io`)
|
||||||
|
|
||||||
|
Install Rust (1.70.0 or above) and Cargo, and then run:
|
||||||
|
|
||||||
|
```
|
||||||
|
cargo install --locked iamb
|
||||||
|
```
|
||||||
|
|
||||||
|
See [Configuration](#configuration) for getting a profile set up.
|
||||||
|
|
||||||
|
## Installation (via package managers)
|
||||||
|
|
||||||
|
### Arch Linux
|
||||||
|
|
||||||
|
On Arch Linux a [package](https://aur.archlinux.org/packages/iamb-git) is available in the
|
||||||
|
Arch User Repositories (AUR). To install it simply run with your favorite AUR helper:
|
||||||
|
|
||||||
|
```
|
||||||
|
paru iamb-git
|
||||||
|
```
|
||||||
|
|
||||||
|
### FreeBSD
|
||||||
|
|
||||||
|
On FreeBSD a package is available from the official repositories. To install it simply run:
|
||||||
|
|
||||||
|
```
|
||||||
|
pkg install iamb
|
||||||
|
```
|
||||||
|
|
||||||
|
### macOS
|
||||||
|
|
||||||
|
On macOS a [package](https://formulae.brew.sh/formula/iamb#default) is availabe in Homebrew's
|
||||||
|
repository. To install it simply run:
|
||||||
|
|
||||||
|
```
|
||||||
|
brew install iamb
|
||||||
|
```
|
||||||
|
|
||||||
|
### NetBSD
|
||||||
|
|
||||||
|
On NetBSD a package is available from the official repositories. To install it simply run:
|
||||||
|
|
||||||
|
```
|
||||||
|
pkgin install iamb
|
||||||
|
```
|
||||||
|
|
||||||
|
### Nix / NixOS (flake)
|
||||||
|
|
||||||
|
```
|
||||||
|
nix profile install "github:ulyssa/iamb"
|
||||||
|
```
|
||||||
|
|
||||||
|
### openSUSE Tumbleweed
|
||||||
|
|
||||||
|
On openSUSE Tumbleweed a [package](https://build.opensuse.org/package/show/openSUSE:Factory/iamb) is available from the official repositories. To install it simply run:
|
||||||
|
|
||||||
|
```
|
||||||
|
zypper install iamb
|
||||||
|
```
|
||||||
|
|
||||||
|
### Snap
|
||||||
|
|
||||||
|
A snap for Linux distributions which [support](https://snapcraft.io/docs/installing-snapd) the packaging system.
|
||||||
|
|
||||||
|
```
|
||||||
|
snap install iamb
|
||||||
|
```
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
iamb is released under the [Apache License, Version 2.0].
|
iamb is released under the [Apache License, Version 2.0].
|
||||||
|
|
||||||
[Apache License, Version 2.0]: https://github.com/ulyssa/iamb/blob/master/LICENSE
|
[Apache License, Version 2.0]: https://github.com/ulyssa/iamb/blob/master/LICENSE
|
||||||
[client-comparison-matrix]: https://matrix.org/clients-matrix/
|
|
||||||
[crates-io-iamb]: https://crates.io/crates/iamb
|
[crates-io-iamb]: https://crates.io/crates/iamb
|
||||||
[iamb.chat]: https://iamb.chat
|
[iamb.chat]: https://iamb.chat
|
||||||
[gomuks]: https://github.com/tulir/gomuks
|
|
||||||
[weechat-matrix]: https://github.com/poljar/weechat-matrix
|
|
||||||
[well_known_entry]: https://spec.matrix.org/latest/client-server-api/#getwell-knownmatrixclient
|
[well_known_entry]: https://spec.matrix.org/latest/client-server-api/#getwell-knownmatrixclient
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ url = "https://matrix.org"
|
|||||||
|
|
||||||
[settings]
|
[settings]
|
||||||
default_room = "#iamb-users:0x.badd.cafe"
|
default_room = "#iamb-users:0x.badd.cafe"
|
||||||
|
external_edit_file_suffix = ".md"
|
||||||
log_level = "warn"
|
log_level = "warn"
|
||||||
message_shortcode_display = false
|
message_shortcode_display = false
|
||||||
open_command = ["my-open", "--file"]
|
open_command = ["my-open", "--file"]
|
||||||
|
|||||||
78
docs/iamb.1
78
docs/iamb.1
@@ -61,6 +61,8 @@ Log out of
|
|||||||
View a list of joined rooms.
|
View a list of joined rooms.
|
||||||
.It Sy ":spaces"
|
.It Sy ":spaces"
|
||||||
View a list of joined spaces.
|
View a list of joined spaces.
|
||||||
|
.It Sy ":unreads"
|
||||||
|
View a list of unread rooms.
|
||||||
.It Sy ":welcome"
|
.It Sy ":welcome"
|
||||||
View the startup Welcome window.
|
View the startup Welcome window.
|
||||||
.El
|
.El
|
||||||
@@ -95,6 +97,8 @@ React to the selected message with an Emoji.
|
|||||||
Redact the selected message.
|
Redact the selected message.
|
||||||
.It Sy ":reply"
|
.It Sy ":reply"
|
||||||
Reply to the selected message.
|
Reply to the selected message.
|
||||||
|
.It Sy ":unreads clear"
|
||||||
|
Mark all unread rooms as read.
|
||||||
.It Sy ":unreact [shortcode]"
|
.It Sy ":unreact [shortcode]"
|
||||||
Remove your reaction from the selected message.
|
Remove your reaction from the selected message.
|
||||||
When no arguments are given, remove all of your reactions from the message.
|
When no arguments are given, remove all of your reactions from the message.
|
||||||
@@ -122,6 +126,25 @@ View a list of members of the currently focused room.
|
|||||||
Set the name of the currently focused room.
|
Set the name of the currently focused room.
|
||||||
.It Sy ":room name unset"
|
.It Sy ":room name unset"
|
||||||
Unset the name of the currently focused room.
|
Unset the name of the currently focused room.
|
||||||
|
.It Sy ":room notify set [level]"
|
||||||
|
Set a notification level for the currently focused room.
|
||||||
|
Valid levels are
|
||||||
|
.Dq mute ,
|
||||||
|
.Dq mentions ,
|
||||||
|
.Dq keywords ,
|
||||||
|
and
|
||||||
|
.Dq all .
|
||||||
|
Note that
|
||||||
|
.Dq mentions
|
||||||
|
and
|
||||||
|
.Dq keywords
|
||||||
|
are aliases for the same behaviour.
|
||||||
|
.It Sy ":room notify unset"
|
||||||
|
Unset any room-level notification configuration.
|
||||||
|
.It Sy ":room notify show"
|
||||||
|
Show the current room-level notification configuration.
|
||||||
|
If the room is using the account-level default, then this will print
|
||||||
|
.Dq default .
|
||||||
.It Sy ":room tag set [tag]"
|
.It Sy ":room tag set [tag]"
|
||||||
Add a tag to the currently focused room.
|
Add a tag to the currently focused room.
|
||||||
.It Sy ":room tag unset [tag]"
|
.It Sy ":room tag unset [tag]"
|
||||||
@@ -130,6 +153,24 @@ Remove a tag from the currently focused room.
|
|||||||
Set the topic of the currently focused room.
|
Set the topic of the currently focused room.
|
||||||
.It Sy ":room topic unset"
|
.It Sy ":room topic unset"
|
||||||
Unset the topic of the currently focused room.
|
Unset the topic of the currently focused room.
|
||||||
|
.It Sy ":room alias set [alias]"
|
||||||
|
Create and point the given alias to the room.
|
||||||
|
.It Sy ":room alias unset [alias]"
|
||||||
|
Delete the provided alias from the room's alternative alias list.
|
||||||
|
.It Sy ":room alias show"
|
||||||
|
Show alternative aliases to the room, if any are set.
|
||||||
|
.It Sy ":room canon set [alias]"
|
||||||
|
Set the room's canonical alias to the one provided, and make the previous one an alternative alias.
|
||||||
|
.It Sy ":room canon unset [alias]"
|
||||||
|
Delete the room's canonical alias.
|
||||||
|
.It Sy ":room canon show"
|
||||||
|
Show the room's canonical alias, if any is set.
|
||||||
|
.It Sy ":room ban [user] [reason]"
|
||||||
|
Ban a user from this room with an optional reason.
|
||||||
|
.It Sy ":room unban [user] [reason]"
|
||||||
|
Unban a user from this room with an optional reason.
|
||||||
|
.It Sy ":room kick [user] [reason]"
|
||||||
|
Kick a user from this room with an optional reason.
|
||||||
.El
|
.El
|
||||||
|
|
||||||
.Sh "WINDOW COMMANDS"
|
.Sh "WINDOW COMMANDS"
|
||||||
@@ -176,6 +217,43 @@ Close all but one tab.
|
|||||||
Go to the preview tab.
|
Go to the preview tab.
|
||||||
.El
|
.El
|
||||||
|
|
||||||
|
.Sh "SLASH COMMANDS"
|
||||||
|
.Bl -tag -width Ds
|
||||||
|
.It Sy "/markdown" , Sy "/md"
|
||||||
|
Interpret the message body as Markdown markup.
|
||||||
|
This is the default behaviour.
|
||||||
|
.It Sy "/html" , Sy "/h"
|
||||||
|
Send the message body as literal HTML.
|
||||||
|
.It Sy "/plaintext" , Sy "/plain" , Sy "/p"
|
||||||
|
Do not interpret any markup in the message body and send it as it is.
|
||||||
|
.It Sy "/me"
|
||||||
|
Send an emote message.
|
||||||
|
.It Sy "/confetti"
|
||||||
|
Produces no effect in
|
||||||
|
.Nm ,
|
||||||
|
but will display confetti in Matrix clients that support doing so.
|
||||||
|
.It Sy "/fireworks"
|
||||||
|
Produces no effect in
|
||||||
|
.Nm ,
|
||||||
|
but will display fireworks in Matrix clients that support doing so.
|
||||||
|
.It Sy "/hearts"
|
||||||
|
Produces no effect in
|
||||||
|
.Nm ,
|
||||||
|
but will display floating hearts in Matrix clients that support doing so.
|
||||||
|
.It Sy "/rainfall"
|
||||||
|
Produces no effect in
|
||||||
|
.Nm ,
|
||||||
|
but will display rainfall in Matrix clients that support doing so.
|
||||||
|
.It Sy "/snowfall"
|
||||||
|
Produces no effect in
|
||||||
|
.Nm ,
|
||||||
|
but will display snowfall in Matrix clients that support doing so.
|
||||||
|
.It Sy "/spaceinvaders"
|
||||||
|
Produces no effect in
|
||||||
|
.Nm ,
|
||||||
|
but will display aliens from Space Invaders in Matrix clients that support doing so.
|
||||||
|
.El
|
||||||
|
|
||||||
.Sh EXAMPLES
|
.Sh EXAMPLES
|
||||||
.Ss Example 1: Starting with a specific profile
|
.Ss Example 1: Starting with a specific profile
|
||||||
To start with a profile named
|
To start with a profile named
|
||||||
|
|||||||
@@ -125,6 +125,9 @@ key and can be overridden as described in
|
|||||||
.Sx PROFILES .
|
.Sx PROFILES .
|
||||||
.Bl -tag -width Ds
|
.Bl -tag -width Ds
|
||||||
|
|
||||||
|
.It Sy external_edit_file_suffix
|
||||||
|
Suffix to append to temporary file names when using the :editor command. Defaults to .md.
|
||||||
|
|
||||||
.It Sy default_room
|
.It Sy default_room
|
||||||
The room to show by default instead of the
|
The room to show by default instead of the
|
||||||
.Sy :welcome
|
.Sy :welcome
|
||||||
|
|||||||
51
docs/iamb.metainfo.xml
Normal file
51
docs/iamb.metainfo.xml
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<component type="console-application">
|
||||||
|
<id>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.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/iamb-demo.gif</image>
|
||||||
|
<caption>Example conversation 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>
|
||||||
|
|
||||||
|
<icon type="remote">https://iamb.chat/images/iamb.svg</icon>
|
||||||
|
<launchable type="desktop-id">iamb.desktop</launchable>
|
||||||
|
|
||||||
|
<categories>
|
||||||
|
<category>Network</category>
|
||||||
|
<category>Chat</category>
|
||||||
|
</categories>
|
||||||
|
|
||||||
|
<provides>
|
||||||
|
<binary>iamb</binary>
|
||||||
|
</provides>
|
||||||
|
</component>
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
};
|
};
|
||||||
nativeBuildInputs = [ pkg-config ];
|
nativeBuildInputs = [ pkg-config ];
|
||||||
buildInputs = [ openssl ] ++ lib.optionals stdenv.isDarwin
|
buildInputs = [ openssl ] ++ lib.optionals stdenv.isDarwin
|
||||||
(with darwin.apple_sdk.frameworks; [ AppKit Security ]);
|
(with darwin.apple_sdk.frameworks; [ AppKit Security Cocoa]);
|
||||||
};
|
};
|
||||||
|
|
||||||
devShell = mkShell {
|
devShell = mkShell {
|
||||||
|
|||||||
225
src/base.rs
225
src/base.rs
@@ -3,7 +3,7 @@
|
|||||||
//! The types defined here get used throughout iamb.
|
//! The types defined here get used throughout iamb.
|
||||||
use std::borrow::Cow;
|
use std::borrow::Cow;
|
||||||
use std::collections::hash_map::IntoIter;
|
use std::collections::hash_map::IntoIter;
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{BTreeSet, HashMap, HashSet};
|
||||||
use std::convert::TryFrom;
|
use std::convert::TryFrom;
|
||||||
use std::fmt::{self, Display};
|
use std::fmt::{self, Display};
|
||||||
use std::hash::Hash;
|
use std::hash::Hash;
|
||||||
@@ -152,7 +152,11 @@ pub enum MessageAction {
|
|||||||
Edit,
|
Edit,
|
||||||
|
|
||||||
/// React to a message with an Emoji.
|
/// React to a message with an Emoji.
|
||||||
React(String),
|
///
|
||||||
|
/// `:react` will by default try to convert the [String] argument to an Emoji, and error when
|
||||||
|
/// it doesn't recognize it. The second [bool] argument forces it to be interpreted literally
|
||||||
|
/// when it is `true`.
|
||||||
|
React(String, bool),
|
||||||
|
|
||||||
/// Redact a message, with an optional reason.
|
/// Redact a message, with an optional reason.
|
||||||
///
|
///
|
||||||
@@ -166,7 +170,11 @@ pub enum MessageAction {
|
|||||||
///
|
///
|
||||||
/// If no specific Emoji to remove to is specified, then all reactions from the user on the
|
/// If no specific Emoji to remove to is specified, then all reactions from the user on the
|
||||||
/// message are removed.
|
/// message are removed.
|
||||||
Unreact(Option<String>),
|
///
|
||||||
|
/// Like `:react`, `:unreact` will by default try to convert the [String] argument to an Emoji,
|
||||||
|
/// and error when it doesn't recognize it. The second [bool] argument forces it to be
|
||||||
|
/// interpreted literally when it is `true`.
|
||||||
|
Unreact(Option<String>, bool),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The type of room being created.
|
/// The type of room being created.
|
||||||
@@ -361,6 +369,9 @@ impl<'de> Visitor<'de> for SortUserVisitor {
|
|||||||
/// A room property.
|
/// A room property.
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
pub enum RoomField {
|
pub enum RoomField {
|
||||||
|
/// The room's history visibility.
|
||||||
|
History,
|
||||||
|
|
||||||
/// The room name.
|
/// The room name.
|
||||||
Name,
|
Name,
|
||||||
|
|
||||||
@@ -369,6 +380,36 @@ pub enum RoomField {
|
|||||||
|
|
||||||
/// The room topic.
|
/// The room topic.
|
||||||
Topic,
|
Topic,
|
||||||
|
|
||||||
|
/// Notification level.
|
||||||
|
NotificationMode,
|
||||||
|
|
||||||
|
/// The room's entire list of alternative aliases.
|
||||||
|
Aliases,
|
||||||
|
|
||||||
|
/// A specific alternative alias to the room.
|
||||||
|
Alias(String),
|
||||||
|
|
||||||
|
/// The room's canonical alias.
|
||||||
|
CanonicalAlias,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An action that operates on a room member.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub enum MemberUpdateAction {
|
||||||
|
Ban,
|
||||||
|
Kick,
|
||||||
|
Unban,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for MemberUpdateAction {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
MemberUpdateAction::Ban => write!(f, "ban"),
|
||||||
|
MemberUpdateAction::Kick => write!(f, "kick"),
|
||||||
|
MemberUpdateAction::Unban => write!(f, "unban"),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An action that operates on a focused room.
|
/// An action that operates on a focused room.
|
||||||
@@ -386,14 +427,23 @@ pub enum RoomAction {
|
|||||||
/// Leave this room.
|
/// Leave this room.
|
||||||
Leave(bool),
|
Leave(bool),
|
||||||
|
|
||||||
|
/// Update a user's membership in this room.
|
||||||
|
MemberUpdate(MemberUpdateAction, String, Option<String>, bool),
|
||||||
|
|
||||||
/// Open the members window.
|
/// Open the members window.
|
||||||
Members(Box<CommandContext>),
|
Members(Box<CommandContext>),
|
||||||
|
|
||||||
|
/// Set whether a room is a direct message.
|
||||||
|
SetDirect(bool),
|
||||||
|
|
||||||
/// Set a room property.
|
/// Set a room property.
|
||||||
Set(RoomField, String),
|
Set(RoomField, String),
|
||||||
|
|
||||||
/// Unset a room property.
|
/// Unset a room property.
|
||||||
Unset(RoomField),
|
Unset(RoomField),
|
||||||
|
|
||||||
|
/// List the values in a list room property.
|
||||||
|
Show(RoomField),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An action that sends a message to a room.
|
/// An action that sends a message to a room.
|
||||||
@@ -460,6 +510,9 @@ pub enum IambAction {
|
|||||||
|
|
||||||
/// Toggle the focus within the focused room.
|
/// Toggle the focus within the focused room.
|
||||||
ToggleScrollbackFocus,
|
ToggleScrollbackFocus,
|
||||||
|
|
||||||
|
/// Clear all unread messages.
|
||||||
|
ClearUnreads,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl IambAction {
|
impl IambAction {
|
||||||
@@ -496,6 +549,7 @@ impl From<SendAction> for IambAction {
|
|||||||
impl ApplicationAction for IambAction {
|
impl ApplicationAction for IambAction {
|
||||||
fn is_edit_sequence(&self, _: &EditContext) -> SequenceStatus {
|
fn is_edit_sequence(&self, _: &EditContext) -> SequenceStatus {
|
||||||
match self {
|
match self {
|
||||||
|
IambAction::ClearUnreads => SequenceStatus::Break,
|
||||||
IambAction::Homeserver(..) => SequenceStatus::Break,
|
IambAction::Homeserver(..) => SequenceStatus::Break,
|
||||||
IambAction::Keys(..) => SequenceStatus::Break,
|
IambAction::Keys(..) => SequenceStatus::Break,
|
||||||
IambAction::Message(..) => SequenceStatus::Break,
|
IambAction::Message(..) => SequenceStatus::Break,
|
||||||
@@ -510,6 +564,7 @@ impl ApplicationAction for IambAction {
|
|||||||
|
|
||||||
fn is_last_action(&self, _: &EditContext) -> SequenceStatus {
|
fn is_last_action(&self, _: &EditContext) -> SequenceStatus {
|
||||||
match self {
|
match self {
|
||||||
|
IambAction::ClearUnreads => SequenceStatus::Atom,
|
||||||
IambAction::Homeserver(..) => SequenceStatus::Atom,
|
IambAction::Homeserver(..) => SequenceStatus::Atom,
|
||||||
IambAction::Keys(..) => SequenceStatus::Atom,
|
IambAction::Keys(..) => SequenceStatus::Atom,
|
||||||
IambAction::Message(..) => SequenceStatus::Atom,
|
IambAction::Message(..) => SequenceStatus::Atom,
|
||||||
@@ -524,6 +579,7 @@ impl ApplicationAction for IambAction {
|
|||||||
|
|
||||||
fn is_last_selection(&self, _: &EditContext) -> SequenceStatus {
|
fn is_last_selection(&self, _: &EditContext) -> SequenceStatus {
|
||||||
match self {
|
match self {
|
||||||
|
IambAction::ClearUnreads => SequenceStatus::Ignore,
|
||||||
IambAction::Homeserver(..) => SequenceStatus::Ignore,
|
IambAction::Homeserver(..) => SequenceStatus::Ignore,
|
||||||
IambAction::Keys(..) => SequenceStatus::Ignore,
|
IambAction::Keys(..) => SequenceStatus::Ignore,
|
||||||
IambAction::Message(..) => SequenceStatus::Ignore,
|
IambAction::Message(..) => SequenceStatus::Ignore,
|
||||||
@@ -538,6 +594,7 @@ impl ApplicationAction for IambAction {
|
|||||||
|
|
||||||
fn is_switchable(&self, _: &EditContext) -> bool {
|
fn is_switchable(&self, _: &EditContext) -> bool {
|
||||||
match self {
|
match self {
|
||||||
|
IambAction::ClearUnreads => false,
|
||||||
IambAction::Homeserver(..) => false,
|
IambAction::Homeserver(..) => false,
|
||||||
IambAction::Message(..) => false,
|
IambAction::Message(..) => false,
|
||||||
IambAction::Room(..) => false,
|
IambAction::Room(..) => false,
|
||||||
@@ -589,10 +646,22 @@ pub type MessageReactions = HashMap<OwnedEventId, (String, OwnedUserId)>;
|
|||||||
/// Errors encountered during application use.
|
/// Errors encountered during application use.
|
||||||
#[derive(thiserror::Error, Debug)]
|
#[derive(thiserror::Error, Debug)]
|
||||||
pub enum IambError {
|
pub enum IambError {
|
||||||
|
/// An invalid history visibility was specified.
|
||||||
|
#[error("Invalid history visibility setting: {0}")]
|
||||||
|
InvalidHistoryVisibility(String),
|
||||||
|
|
||||||
|
/// An invalid notification level was specified.
|
||||||
|
#[error("Invalid notification level: {0}")]
|
||||||
|
InvalidNotificationLevel(String),
|
||||||
|
|
||||||
/// An invalid user identifier was specified.
|
/// An invalid user identifier was specified.
|
||||||
#[error("Invalid user identifier: {0}")]
|
#[error("Invalid user identifier: {0}")]
|
||||||
InvalidUserId(String),
|
InvalidUserId(String),
|
||||||
|
|
||||||
|
/// An invalid user identifier was specified.
|
||||||
|
#[error("Invalid room alias: {0}")]
|
||||||
|
InvalidRoomAlias(String),
|
||||||
|
|
||||||
/// An invalid verification identifier was specified.
|
/// An invalid verification identifier was specified.
|
||||||
#[error("Invalid verification user/device pair: {0}")]
|
#[error("Invalid verification user/device pair: {0}")]
|
||||||
InvalidVerificationId(String),
|
InvalidVerificationId(String),
|
||||||
@@ -656,10 +725,17 @@ pub enum IambError {
|
|||||||
#[error("Unknown room identifier: {0}")]
|
#[error("Unknown room identifier: {0}")]
|
||||||
UnknownRoom(OwnedRoomId),
|
UnknownRoom(OwnedRoomId),
|
||||||
|
|
||||||
|
/// An invalid room alias id was specified.
|
||||||
|
#[error("Invalid room alias id: {0}")]
|
||||||
|
InvalidRoomAliasId(#[from] matrix_sdk::ruma::IdParseError),
|
||||||
|
|
||||||
/// A failure occurred during verification.
|
/// A failure occurred during verification.
|
||||||
#[error("Verification request error: {0}")]
|
#[error("Verification request error: {0}")]
|
||||||
VerificationRequestError(#[from] matrix_sdk::encryption::identities::RequestVerificationError),
|
VerificationRequestError(#[from] matrix_sdk::encryption::identities::RequestVerificationError),
|
||||||
|
|
||||||
|
#[error("Notification setting error: {0}")]
|
||||||
|
NotificationSettingError(#[from] matrix_sdk::NotificationSettingsError),
|
||||||
|
|
||||||
/// A failure related to images.
|
/// A failure related to images.
|
||||||
#[error("Image error: {0}")]
|
#[error("Image error: {0}")]
|
||||||
Image(#[from] image::ImageError),
|
Image(#[from] image::ImageError),
|
||||||
@@ -835,9 +911,14 @@ impl RoomInfo {
|
|||||||
if let Some(reacts) = self.reactions.get(event_id) {
|
if let Some(reacts) = self.reactions.get(event_id) {
|
||||||
let mut counts = HashMap::new();
|
let mut counts = HashMap::new();
|
||||||
|
|
||||||
for (key, _) in reacts.values() {
|
let mut seen_user_reactions = BTreeSet::new();
|
||||||
let count = counts.entry(key.as_str()).or_default();
|
|
||||||
*count += 1;
|
for (key, user) in reacts.values() {
|
||||||
|
if !seen_user_reactions.contains(&(key, user)) {
|
||||||
|
seen_user_reactions.insert((key, user));
|
||||||
|
let count = counts.entry(key.as_str()).or_default();
|
||||||
|
*count += 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut reactions = counts.into_iter().collect::<Vec<_>>();
|
let mut reactions = counts.into_iter().collect::<Vec<_>>();
|
||||||
@@ -1074,6 +1155,14 @@ impl RoomInfo {
|
|||||||
self.user_receipts.insert(user_id, event_id);
|
self.user_receipts.insert(user_id, event_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn fully_read(&mut self, user_id: OwnedUserId) {
|
||||||
|
let Some(((_, event_id), _)) = self.messages.last_key_value() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
self.set_receipt(user_id, event_id.clone());
|
||||||
|
}
|
||||||
|
|
||||||
pub fn get_receipt(&self, user_id: &UserId) -> Option<&OwnedEventId> {
|
pub fn get_receipt(&self, user_id: &UserId) -> Option<&OwnedEventId> {
|
||||||
self.user_receipts.get(user_id)
|
self.user_receipts.get(user_id)
|
||||||
}
|
}
|
||||||
@@ -1146,6 +1235,22 @@ impl RoomInfo {
|
|||||||
|
|
||||||
return top;
|
return top;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Checks if a given user has reacted with the given emoji on the given event
|
||||||
|
pub fn user_reactions_contains(
|
||||||
|
&mut self,
|
||||||
|
user_id: &UserId,
|
||||||
|
event_id: &EventId,
|
||||||
|
emoji: &str,
|
||||||
|
) -> bool {
|
||||||
|
if let Some(reactions) = self.reactions.get(event_id) {
|
||||||
|
reactions
|
||||||
|
.values()
|
||||||
|
.any(|(annotation, user)| annotation == emoji && user == user_id)
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generate a [CompletionMap] for Emoji shortcodes.
|
/// Generate a [CompletionMap] for Emoji shortcodes.
|
||||||
@@ -1219,6 +1324,20 @@ pub struct SyncInfo {
|
|||||||
pub dms: Vec<Arc<(MatrixRoom, Option<Tags>)>>,
|
pub dms: Vec<Arc<(MatrixRoom, Option<Tags>)>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl SyncInfo {
|
||||||
|
pub fn rooms(&self) -> impl Iterator<Item = &RoomId> {
|
||||||
|
self.rooms.iter().map(|r| r.0.room_id())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn dms(&self) -> impl Iterator<Item = &RoomId> {
|
||||||
|
self.dms.iter().map(|r| r.0.room_id())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn chats(&self) -> impl Iterator<Item = &RoomId> {
|
||||||
|
self.rooms().chain(self.dms())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
bitflags::bitflags! {
|
bitflags::bitflags! {
|
||||||
/// Load-needs
|
/// Load-needs
|
||||||
#[derive(Debug, Default, PartialEq)]
|
#[derive(Debug, Default, PartialEq)]
|
||||||
@@ -1295,6 +1414,9 @@ pub struct ChatStore {
|
|||||||
|
|
||||||
/// Whether to ring the terminal bell on the next redraw.
|
/// Whether to ring the terminal bell on the next redraw.
|
||||||
pub ring_bell: bool,
|
pub ring_bell: bool,
|
||||||
|
|
||||||
|
/// Whether the application is currently focused
|
||||||
|
pub focused: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ChatStore {
|
impl ChatStore {
|
||||||
@@ -1317,14 +1439,13 @@ impl ChatStore {
|
|||||||
sync_info: Default::default(),
|
sync_info: Default::default(),
|
||||||
draw_curr: None,
|
draw_curr: None,
|
||||||
ring_bell: false,
|
ring_bell: false,
|
||||||
|
focused: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get a joined room.
|
/// Get a joined room.
|
||||||
pub fn get_joined_room(&self, room_id: &RoomId) -> Option<MatrixRoom> {
|
pub fn get_joined_room(&self, room_id: &RoomId) -> Option<MatrixRoom> {
|
||||||
let Some(room) = self.worker.client.get_room(room_id) else {
|
let room = self.worker.client.get_room(room_id)?;
|
||||||
return None;
|
|
||||||
};
|
|
||||||
|
|
||||||
if room.state() == MatrixRoomState::Joined {
|
if room.state() == MatrixRoomState::Joined {
|
||||||
Some(room)
|
Some(room)
|
||||||
@@ -1388,6 +1509,9 @@ pub enum IambId {
|
|||||||
|
|
||||||
/// The `:chats` window.
|
/// The `:chats` window.
|
||||||
ChatList,
|
ChatList,
|
||||||
|
|
||||||
|
/// The `:unreads` window.
|
||||||
|
UnreadList,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Display for IambId {
|
impl Display for IambId {
|
||||||
@@ -1408,6 +1532,7 @@ impl Display for IambId {
|
|||||||
IambId::VerifyList => f.write_str("iamb://verify"),
|
IambId::VerifyList => f.write_str("iamb://verify"),
|
||||||
IambId::Welcome => f.write_str("iamb://welcome"),
|
IambId::Welcome => f.write_str("iamb://welcome"),
|
||||||
IambId::ChatList => f.write_str("iamb://chats"),
|
IambId::ChatList => f.write_str("iamb://chats"),
|
||||||
|
IambId::UnreadList => f.write_str("iamb://unreads"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1539,6 +1664,13 @@ impl<'de> Visitor<'de> for IambIdVisitor {
|
|||||||
|
|
||||||
Ok(IambId::ChatList)
|
Ok(IambId::ChatList)
|
||||||
},
|
},
|
||||||
|
Some("unreads") => {
|
||||||
|
if url.path() != "" {
|
||||||
|
return Err(E::custom("iamb://unreads takes no path"));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(IambId::UnreadList)
|
||||||
|
},
|
||||||
Some(s) => Err(E::custom(format!("{s:?} is not a valid window"))),
|
Some(s) => Err(E::custom(format!("{s:?} is not a valid window"))),
|
||||||
None => Err(E::custom("Invalid iamb window URL")),
|
None => Err(E::custom("Invalid iamb window URL")),
|
||||||
}
|
}
|
||||||
@@ -1599,6 +1731,9 @@ pub enum IambBufferId {
|
|||||||
|
|
||||||
/// The `:chats` window.
|
/// The `:chats` window.
|
||||||
ChatList,
|
ChatList,
|
||||||
|
|
||||||
|
/// The `:unreads` window.
|
||||||
|
UnreadList,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl IambBufferId {
|
impl IambBufferId {
|
||||||
@@ -1614,6 +1749,7 @@ impl IambBufferId {
|
|||||||
IambBufferId::VerifyList => IambId::VerifyList,
|
IambBufferId::VerifyList => IambId::VerifyList,
|
||||||
IambBufferId::Welcome => IambId::Welcome,
|
IambBufferId::Welcome => IambId::Welcome,
|
||||||
IambBufferId::ChatList => IambId::ChatList,
|
IambBufferId::ChatList => IambId::ChatList,
|
||||||
|
IambBufferId::UnreadList => IambId::UnreadList,
|
||||||
};
|
};
|
||||||
|
|
||||||
Some(id)
|
Some(id)
|
||||||
@@ -1648,6 +1784,7 @@ impl ApplicationInfo for IambInfo {
|
|||||||
IambBufferId::VerifyList => vec![],
|
IambBufferId::VerifyList => vec![],
|
||||||
IambBufferId::Welcome => vec![],
|
IambBufferId::Welcome => vec![],
|
||||||
IambBufferId::ChatList => vec![],
|
IambBufferId::ChatList => vec![],
|
||||||
|
IambBufferId::UnreadList => vec![],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1836,8 +1973,78 @@ pub mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::config::user_style_from_color;
|
use crate::config::user_style_from_color;
|
||||||
use crate::tests::*;
|
use crate::tests::*;
|
||||||
|
use matrix_sdk::ruma::{
|
||||||
|
events::{reaction::ReactionEventContent, relation::Annotation, MessageLikeUnsigned},
|
||||||
|
owned_event_id,
|
||||||
|
owned_room_id,
|
||||||
|
owned_user_id,
|
||||||
|
MilliSecondsSinceUnixEpoch,
|
||||||
|
};
|
||||||
|
use pretty_assertions::assert_eq;
|
||||||
use ratatui::style::Color;
|
use ratatui::style::Color;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn multiple_identical_reactions() {
|
||||||
|
let mut info = RoomInfo::default();
|
||||||
|
|
||||||
|
let content = ReactionEventContent::new(Annotation::new(
|
||||||
|
owned_event_id!("$my_reaction"),
|
||||||
|
"🏠".to_owned(),
|
||||||
|
));
|
||||||
|
|
||||||
|
for i in 0..3 {
|
||||||
|
let event_id = format!("$house_{}", i);
|
||||||
|
info.insert_reaction(MessageLikeEvent::Original(
|
||||||
|
matrix_sdk::ruma::events::OriginalMessageLikeEvent {
|
||||||
|
content: content.clone(),
|
||||||
|
event_id: OwnedEventId::from_str(&event_id).unwrap(),
|
||||||
|
sender: owned_user_id!("@foo:example.org"),
|
||||||
|
origin_server_ts: MilliSecondsSinceUnixEpoch::now(),
|
||||||
|
room_id: owned_room_id!("!foo:example.org"),
|
||||||
|
unsigned: MessageLikeUnsigned::new(),
|
||||||
|
},
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = ReactionEventContent::new(Annotation::new(
|
||||||
|
owned_event_id!("$my_reaction"),
|
||||||
|
"🙂".to_owned(),
|
||||||
|
));
|
||||||
|
|
||||||
|
for i in 0..2 {
|
||||||
|
let event_id = format!("$smile_{}", i);
|
||||||
|
info.insert_reaction(MessageLikeEvent::Original(
|
||||||
|
matrix_sdk::ruma::events::OriginalMessageLikeEvent {
|
||||||
|
content: content.clone(),
|
||||||
|
event_id: OwnedEventId::from_str(&event_id).unwrap(),
|
||||||
|
sender: owned_user_id!("@foo:example.org"),
|
||||||
|
origin_server_ts: MilliSecondsSinceUnixEpoch::now(),
|
||||||
|
room_id: owned_room_id!("!foo:example.org"),
|
||||||
|
unsigned: MessageLikeUnsigned::new(),
|
||||||
|
},
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
for i in 2..4 {
|
||||||
|
let event_id = format!("$smile_{}", i);
|
||||||
|
info.insert_reaction(MessageLikeEvent::Original(
|
||||||
|
matrix_sdk::ruma::events::OriginalMessageLikeEvent {
|
||||||
|
content: content.clone(),
|
||||||
|
event_id: OwnedEventId::from_str(&event_id).unwrap(),
|
||||||
|
sender: owned_user_id!("@bar:example.org"),
|
||||||
|
origin_server_ts: MilliSecondsSinceUnixEpoch::now(),
|
||||||
|
room_id: owned_room_id!("!foo:example.org"),
|
||||||
|
unsigned: MessageLikeUnsigned::new(),
|
||||||
|
},
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(info.get_reactions(&owned_event_id!("$my_reaction")), vec![
|
||||||
|
("🏠", 1),
|
||||||
|
("🙂", 2)
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_typing_spans() {
|
fn test_typing_spans() {
|
||||||
let mut info = RoomInfo::default();
|
let mut info = RoomInfo::default();
|
||||||
|
|||||||
257
src/commands.rs
257
src/commands.rs
@@ -20,6 +20,7 @@ use crate::base::{
|
|||||||
IambAction,
|
IambAction,
|
||||||
IambId,
|
IambId,
|
||||||
KeysAction,
|
KeysAction,
|
||||||
|
MemberUpdateAction,
|
||||||
MessageAction,
|
MessageAction,
|
||||||
ProgramCommand,
|
ProgramCommand,
|
||||||
ProgramCommands,
|
ProgramCommands,
|
||||||
@@ -34,7 +35,7 @@ type ProgResult = CommandResult<ProgramCommand>;
|
|||||||
|
|
||||||
/// Convert strings the user types into a tag name.
|
/// Convert strings the user types into a tag name.
|
||||||
fn tag_name(name: String) -> Result<TagName, CommandError> {
|
fn tag_name(name: String) -> Result<TagName, CommandError> {
|
||||||
let tag = match name.as_str() {
|
let tag = match name.to_lowercase().as_str() {
|
||||||
"fav" | "favorite" | "favourite" | "m.favourite" => TagName::Favorite,
|
"fav" | "favorite" | "favourite" | "m.favourite" => TagName::Favorite,
|
||||||
"low" | "lowpriority" | "low_priority" | "low-priority" | "m.lowpriority" => {
|
"low" | "lowpriority" | "low_priority" | "low-priority" | "m.lowpriority" => {
|
||||||
TagName::LowPriority
|
TagName::LowPriority
|
||||||
@@ -221,24 +222,17 @@ fn iamb_edit(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn iamb_react(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
fn iamb_react(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
||||||
let args = desc.arg.strings()?;
|
let mut args = desc.arg.strings()?;
|
||||||
|
|
||||||
if args.len() != 1 {
|
if args.len() != 1 {
|
||||||
return Result::Err(CommandError::InvalidArgument);
|
return Result::Err(CommandError::InvalidArgument);
|
||||||
}
|
}
|
||||||
|
|
||||||
let k = args[0].as_str();
|
let react = args.remove(0);
|
||||||
|
let mact = IambAction::from(MessageAction::React(react, desc.bang));
|
||||||
|
let step = CommandStep::Continue(mact.into(), ctx.context.clone());
|
||||||
|
|
||||||
if let Some(emoji) = emojis::get(k).or_else(|| emojis::get_by_shortcode(k)) {
|
return Ok(step);
|
||||||
let mact = IambAction::from(MessageAction::React(emoji.to_string()));
|
|
||||||
let step = CommandStep::Continue(mact.into(), ctx.context.clone());
|
|
||||||
|
|
||||||
return Ok(step);
|
|
||||||
} else {
|
|
||||||
let msg = format!("Invalid Emoji or shortcode: {k}");
|
|
||||||
|
|
||||||
return Result::Err(CommandError::Error(msg));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn iamb_unreact(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
fn iamb_unreact(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
||||||
@@ -248,20 +242,8 @@ fn iamb_unreact(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
|||||||
return Result::Err(CommandError::InvalidArgument);
|
return Result::Err(CommandError::InvalidArgument);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mact = if let Some(k) = args.pop() {
|
let reaction = args.pop();
|
||||||
let k = k.as_str();
|
let mact = IambAction::from(MessageAction::Unreact(reaction, desc.bang));
|
||||||
|
|
||||||
if let Some(emoji) = emojis::get(k).or_else(|| emojis::get_by_shortcode(k)) {
|
|
||||||
IambAction::from(MessageAction::Unreact(Some(emoji.to_string())))
|
|
||||||
} else {
|
|
||||||
let msg = format!("Invalid Emoji or shortcode: {k}");
|
|
||||||
|
|
||||||
return Result::Err(CommandError::Error(msg));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
IambAction::from(MessageAction::Unreact(None))
|
|
||||||
};
|
|
||||||
|
|
||||||
let step = CommandStep::Continue(mact.into(), ctx.context.clone());
|
let step = CommandStep::Continue(mact.into(), ctx.context.clone());
|
||||||
|
|
||||||
return Ok(step);
|
return Ok(step);
|
||||||
@@ -325,6 +307,30 @@ fn iamb_chats(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
|||||||
return Ok(step);
|
return Ok(step);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn iamb_unreads(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
||||||
|
let mut args = desc.arg.strings()?;
|
||||||
|
|
||||||
|
if args.len() > 1 {
|
||||||
|
return Result::Err(CommandError::InvalidArgument);
|
||||||
|
}
|
||||||
|
|
||||||
|
match args.pop().as_deref() {
|
||||||
|
Some("clear") => {
|
||||||
|
let clear = IambAction::ClearUnreads;
|
||||||
|
let step = CommandStep::Continue(clear.into(), ctx.context.clone());
|
||||||
|
|
||||||
|
return Ok(step);
|
||||||
|
},
|
||||||
|
Some(_) => return Result::Err(CommandError::InvalidArgument),
|
||||||
|
None => {
|
||||||
|
let open = ctx.switch(OpenTarget::Application(IambId::UnreadList));
|
||||||
|
let step = CommandStep::Continue(open, ctx.context.clone());
|
||||||
|
|
||||||
|
return Ok(step);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn iamb_spaces(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
fn iamb_spaces(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
||||||
if !desc.arg.text.is_empty() {
|
if !desc.arg.text.is_empty() {
|
||||||
return Result::Err(CommandError::InvalidArgument);
|
return Result::Err(CommandError::InvalidArgument);
|
||||||
@@ -422,6 +428,37 @@ fn iamb_room(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let act: IambAction = match (field.as_str(), action.as_str(), args.pop()) {
|
let act: IambAction = match (field.as_str(), action.as_str(), args.pop()) {
|
||||||
|
// :room dm set
|
||||||
|
("dm", "set", None) => RoomAction::SetDirect(true).into(),
|
||||||
|
("dm", "set", Some(_)) => return Result::Err(CommandError::InvalidArgument),
|
||||||
|
|
||||||
|
// :room dm set
|
||||||
|
("dm", "unset", None) => RoomAction::SetDirect(false).into(),
|
||||||
|
("dm", "unset", Some(_)) => return Result::Err(CommandError::InvalidArgument),
|
||||||
|
|
||||||
|
// :room [kick|ban|unban] <user>
|
||||||
|
("kick", u, r) => {
|
||||||
|
RoomAction::MemberUpdate(MemberUpdateAction::Kick, u.into(), r, desc.bang).into()
|
||||||
|
},
|
||||||
|
("ban", u, r) => {
|
||||||
|
RoomAction::MemberUpdate(MemberUpdateAction::Ban, u.into(), r, desc.bang).into()
|
||||||
|
},
|
||||||
|
("unban", u, r) => {
|
||||||
|
RoomAction::MemberUpdate(MemberUpdateAction::Unban, u.into(), r, desc.bang).into()
|
||||||
|
},
|
||||||
|
|
||||||
|
// :room history set <visibility>
|
||||||
|
("history", "set", Some(s)) => RoomAction::Set(RoomField::History, s).into(),
|
||||||
|
("history", "set", None) => return Result::Err(CommandError::InvalidArgument),
|
||||||
|
|
||||||
|
// :room history unset
|
||||||
|
("history", "unset", None) => RoomAction::Unset(RoomField::History).into(),
|
||||||
|
("history", "unset", Some(_)) => return Result::Err(CommandError::InvalidArgument),
|
||||||
|
|
||||||
|
// :room history show
|
||||||
|
("history", "show", None) => RoomAction::Show(RoomField::History).into(),
|
||||||
|
("history", "show", Some(_)) => return Result::Err(CommandError::InvalidArgument),
|
||||||
|
|
||||||
// :room name set <room-name>
|
// :room name set <room-name>
|
||||||
("name", "set", Some(s)) => RoomAction::Set(RoomField::Name, s).into(),
|
("name", "set", Some(s)) => RoomAction::Set(RoomField::Name, s).into(),
|
||||||
("name", "set", None) => return Result::Err(CommandError::InvalidArgument),
|
("name", "set", None) => return Result::Err(CommandError::InvalidArgument),
|
||||||
@@ -442,10 +479,58 @@ fn iamb_room(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
|||||||
("tag", "set", Some(s)) => RoomAction::Set(RoomField::Tag(tag_name(s)?), "".into()).into(),
|
("tag", "set", Some(s)) => RoomAction::Set(RoomField::Tag(tag_name(s)?), "".into()).into(),
|
||||||
("tag", "set", None) => return Result::Err(CommandError::InvalidArgument),
|
("tag", "set", None) => return Result::Err(CommandError::InvalidArgument),
|
||||||
|
|
||||||
|
// :room notify set <notification-level>
|
||||||
|
("notify", "set", Some(s)) => RoomAction::Set(RoomField::NotificationMode, s).into(),
|
||||||
|
("notify", "set", None) => return Result::Err(CommandError::InvalidArgument),
|
||||||
|
|
||||||
|
// :room notify unset <notification-level>
|
||||||
|
("notify", "unset", None) => RoomAction::Unset(RoomField::NotificationMode).into(),
|
||||||
|
("notify", "unset", Some(_)) => return Result::Err(CommandError::InvalidArgument),
|
||||||
|
|
||||||
|
// :room notify show
|
||||||
|
("notify", "show", None) => RoomAction::Show(RoomField::NotificationMode).into(),
|
||||||
|
("notify", "show", Some(_)) => return Result::Err(CommandError::InvalidArgument),
|
||||||
|
|
||||||
// :room tag unset <tag-name>
|
// :room tag unset <tag-name>
|
||||||
("tag", "unset", Some(s)) => RoomAction::Unset(RoomField::Tag(tag_name(s)?)).into(),
|
("tag", "unset", Some(s)) => RoomAction::Unset(RoomField::Tag(tag_name(s)?)).into(),
|
||||||
("tag", "unset", None) => return Result::Err(CommandError::InvalidArgument),
|
("tag", "unset", None) => return Result::Err(CommandError::InvalidArgument),
|
||||||
|
|
||||||
|
// :room aliases show
|
||||||
|
("alias", "show", None) => RoomAction::Show(RoomField::Aliases).into(),
|
||||||
|
("alias", "show", Some(_)) => return Result::Err(CommandError::InvalidArgument),
|
||||||
|
|
||||||
|
// :room aliases unset <alias>
|
||||||
|
("alias", "unset", Some(s)) => RoomAction::Unset(RoomField::Alias(s)).into(),
|
||||||
|
("alias", "unset", None) => return Result::Err(CommandError::InvalidArgument),
|
||||||
|
|
||||||
|
// :room aliases set <alias>
|
||||||
|
("alias", "set", Some(s)) => RoomAction::Set(RoomField::Alias(s), "".into()).into(),
|
||||||
|
("alias", "set", None) => return Result::Err(CommandError::InvalidArgument),
|
||||||
|
|
||||||
|
// :room canonicalalias show
|
||||||
|
("canonicalalias" | "canon", "show", None) => {
|
||||||
|
RoomAction::Show(RoomField::CanonicalAlias).into()
|
||||||
|
},
|
||||||
|
("canonicalalias" | "canon", "show", Some(_)) => {
|
||||||
|
return Result::Err(CommandError::InvalidArgument)
|
||||||
|
},
|
||||||
|
|
||||||
|
// :room canonicalalias set
|
||||||
|
("canonicalalias" | "canon", "set", Some(s)) => {
|
||||||
|
RoomAction::Set(RoomField::CanonicalAlias, s).into()
|
||||||
|
},
|
||||||
|
("canonicalalias" | "canon", "set", None) => {
|
||||||
|
return Result::Err(CommandError::InvalidArgument)
|
||||||
|
},
|
||||||
|
|
||||||
|
// :room canonicalalias unset
|
||||||
|
("canonicalalias" | "canon", "unset", None) => {
|
||||||
|
RoomAction::Unset(RoomField::CanonicalAlias).into()
|
||||||
|
},
|
||||||
|
("canonicalalias" | "canon", "unset", Some(_)) => {
|
||||||
|
return Result::Err(CommandError::InvalidArgument)
|
||||||
|
},
|
||||||
|
|
||||||
_ => return Result::Err(CommandError::InvalidArgument),
|
_ => return Result::Err(CommandError::InvalidArgument),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -507,6 +592,9 @@ fn iamb_open(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
|||||||
fn iamb_logout(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
fn iamb_logout(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
|
||||||
let args = desc.arg.strings()?;
|
let args = desc.arg.strings()?;
|
||||||
|
|
||||||
|
if args.is_empty() {
|
||||||
|
return Result::Err(CommandError::Error("Missing username".to_string()));
|
||||||
|
}
|
||||||
if args.len() != 1 {
|
if args.len() != 1 {
|
||||||
return Result::Err(CommandError::InvalidArgument);
|
return Result::Err(CommandError::InvalidArgument);
|
||||||
}
|
}
|
||||||
@@ -584,6 +672,11 @@ fn add_iamb_commands(cmds: &mut ProgramCommands) {
|
|||||||
aliases: vec![],
|
aliases: vec![],
|
||||||
f: iamb_spaces,
|
f: iamb_spaces,
|
||||||
});
|
});
|
||||||
|
cmds.add_command(ProgramCommand {
|
||||||
|
name: "unreads".into(),
|
||||||
|
aliases: vec![],
|
||||||
|
f: iamb_unreads,
|
||||||
|
});
|
||||||
cmds.add_command(ProgramCommand {
|
cmds.add_command(ProgramCommand {
|
||||||
name: "unreact".into(),
|
name: "unreact".into(),
|
||||||
aliases: vec![],
|
aliases: vec![],
|
||||||
@@ -789,6 +882,32 @@ mod tests {
|
|||||||
assert_eq!(res, Err(CommandError::InvalidArgument));
|
assert_eq!(res, Err(CommandError::InvalidArgument));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cmd_room_dm_set() {
|
||||||
|
let mut cmds = setup_commands();
|
||||||
|
let ctx = EditContext::default();
|
||||||
|
|
||||||
|
let res = cmds.input_cmd("room dm set", ctx.clone()).unwrap();
|
||||||
|
let act = RoomAction::SetDirect(true);
|
||||||
|
assert_eq!(res, vec![(act.into(), ctx.clone())]);
|
||||||
|
|
||||||
|
let res = cmds.input_cmd("room dm set true", ctx.clone());
|
||||||
|
assert_eq!(res, Err(CommandError::InvalidArgument));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cmd_room_dm_unset() {
|
||||||
|
let mut cmds = setup_commands();
|
||||||
|
let ctx = EditContext::default();
|
||||||
|
|
||||||
|
let res = cmds.input_cmd("room dm unset", ctx.clone()).unwrap();
|
||||||
|
let act = RoomAction::SetDirect(false);
|
||||||
|
assert_eq!(res, vec![(act.into(), ctx.clone())]);
|
||||||
|
|
||||||
|
let res = cmds.input_cmd("room dm unset true", ctx.clone());
|
||||||
|
assert_eq!(res, Err(CommandError::InvalidArgument));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_cmd_room_tag_set() {
|
fn test_cmd_room_tag_set() {
|
||||||
let mut cmds = setup_commands();
|
let mut cmds = setup_commands();
|
||||||
@@ -923,6 +1042,27 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cmd_room_notification_mode_set() {
|
||||||
|
let mut cmds = setup_commands();
|
||||||
|
let ctx = EditContext::default();
|
||||||
|
|
||||||
|
let cmd = format!("room notify set mute");
|
||||||
|
let res = cmds.input_cmd(&cmd, ctx.clone()).unwrap();
|
||||||
|
let act = RoomAction::Set(RoomField::NotificationMode, "mute".into());
|
||||||
|
assert_eq!(res, vec![(act.into(), ctx.clone())]);
|
||||||
|
|
||||||
|
let cmd = format!("room notify unset");
|
||||||
|
let res = cmds.input_cmd(&cmd, ctx.clone()).unwrap();
|
||||||
|
let act = RoomAction::Unset(RoomField::NotificationMode);
|
||||||
|
assert_eq!(res, vec![(act.into(), ctx.clone())]);
|
||||||
|
|
||||||
|
let cmd = format!("room notify show");
|
||||||
|
let res = cmds.input_cmd(&cmd, ctx.clone()).unwrap();
|
||||||
|
let act = RoomAction::Show(RoomField::NotificationMode);
|
||||||
|
assert_eq!(res, vec![(act.into(), ctx.clone())]);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_cmd_invite() {
|
fn test_cmd_invite() {
|
||||||
let mut cmds = setup_commands();
|
let mut cmds = setup_commands();
|
||||||
@@ -960,6 +1100,69 @@ mod tests {
|
|||||||
assert_eq!(res, Err(CommandError::InvalidArgument));
|
assert_eq!(res, Err(CommandError::InvalidArgument));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cmd_room_kick() {
|
||||||
|
let mut cmds = setup_commands();
|
||||||
|
let ctx = EditContext::default();
|
||||||
|
|
||||||
|
let res = cmds.input_cmd("room kick @user:example.com", ctx.clone()).unwrap();
|
||||||
|
let act = IambAction::Room(RoomAction::MemberUpdate(
|
||||||
|
MemberUpdateAction::Kick,
|
||||||
|
"@user:example.com".into(),
|
||||||
|
None,
|
||||||
|
false,
|
||||||
|
));
|
||||||
|
assert_eq!(res, vec![(act.into(), ctx.clone())]);
|
||||||
|
|
||||||
|
let res = cmds.input_cmd("room! kick @user:example.com", ctx.clone()).unwrap();
|
||||||
|
let act = IambAction::Room(RoomAction::MemberUpdate(
|
||||||
|
MemberUpdateAction::Kick,
|
||||||
|
"@user:example.com".into(),
|
||||||
|
None,
|
||||||
|
true,
|
||||||
|
));
|
||||||
|
assert_eq!(res, vec![(act.into(), ctx.clone())]);
|
||||||
|
|
||||||
|
let res = cmds
|
||||||
|
.input_cmd("room! kick @user:example.com \"reason here\"", ctx.clone())
|
||||||
|
.unwrap();
|
||||||
|
let act = IambAction::Room(RoomAction::MemberUpdate(
|
||||||
|
MemberUpdateAction::Kick,
|
||||||
|
"@user:example.com".into(),
|
||||||
|
Some("reason here".into()),
|
||||||
|
true,
|
||||||
|
));
|
||||||
|
assert_eq!(res, vec![(act.into(), ctx.clone())]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cmd_room_ban_unban() {
|
||||||
|
let mut cmds = setup_commands();
|
||||||
|
let ctx = EditContext::default();
|
||||||
|
|
||||||
|
let res = cmds
|
||||||
|
.input_cmd("room! ban @user:example.com \"spam\"", ctx.clone())
|
||||||
|
.unwrap();
|
||||||
|
let act = IambAction::Room(RoomAction::MemberUpdate(
|
||||||
|
MemberUpdateAction::Ban,
|
||||||
|
"@user:example.com".into(),
|
||||||
|
Some("spam".into()),
|
||||||
|
true,
|
||||||
|
));
|
||||||
|
assert_eq!(res, vec![(act.into(), ctx.clone())]);
|
||||||
|
|
||||||
|
let res = cmds
|
||||||
|
.input_cmd("room unban @user:example.com \"reconciled\"", ctx.clone())
|
||||||
|
.unwrap();
|
||||||
|
let act = IambAction::Room(RoomAction::MemberUpdate(
|
||||||
|
MemberUpdateAction::Unban,
|
||||||
|
"@user:example.com".into(),
|
||||||
|
Some("reconciled".into()),
|
||||||
|
false,
|
||||||
|
));
|
||||||
|
assert_eq!(res, vec![(act.into(), ctx.clone())]);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_cmd_redact() {
|
fn test_cmd_redact() {
|
||||||
let mut cmds = setup_commands();
|
let mut cmds = setup_commands();
|
||||||
|
|||||||
@@ -398,16 +398,26 @@ pub enum UserDisplayStyle {
|
|||||||
DisplayName,
|
DisplayName,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum NotifyVia {
|
pub enum NotifyVia {
|
||||||
/// Deliver notifications via terminal bell.
|
/// Deliver notifications via terminal bell.
|
||||||
Bell,
|
Bell,
|
||||||
/// Deliver notifications via desktop mechanism.
|
/// Deliver notifications via desktop mechanism.
|
||||||
#[default]
|
#[cfg(feature = "desktop")]
|
||||||
Desktop,
|
Desktop,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Default for NotifyVia {
|
||||||
|
fn default() -> Self {
|
||||||
|
#[cfg(not(feature = "desktop"))]
|
||||||
|
return NotifyVia::Bell;
|
||||||
|
|
||||||
|
#[cfg(feature = "desktop")]
|
||||||
|
return NotifyVia::Desktop;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)]
|
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)]
|
||||||
pub struct Notifications {
|
pub struct Notifications {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -507,6 +517,7 @@ pub struct TunableValues {
|
|||||||
pub notifications: Notifications,
|
pub notifications: Notifications,
|
||||||
pub image_preview: Option<ImagePreviewValues>,
|
pub image_preview: Option<ImagePreviewValues>,
|
||||||
pub user_gutter_width: usize,
|
pub user_gutter_width: usize,
|
||||||
|
pub external_edit_file_suffix: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Default, Deserialize)]
|
#[derive(Clone, Default, Deserialize)]
|
||||||
@@ -530,6 +541,7 @@ pub struct Tunables {
|
|||||||
pub notifications: Option<Notifications>,
|
pub notifications: Option<Notifications>,
|
||||||
pub image_preview: Option<ImagePreview>,
|
pub image_preview: Option<ImagePreview>,
|
||||||
pub user_gutter_width: Option<usize>,
|
pub user_gutter_width: Option<usize>,
|
||||||
|
pub external_edit_file_suffix: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Tunables {
|
impl Tunables {
|
||||||
@@ -557,6 +569,9 @@ impl Tunables {
|
|||||||
notifications: self.notifications.or(other.notifications),
|
notifications: self.notifications.or(other.notifications),
|
||||||
image_preview: self.image_preview.or(other.image_preview),
|
image_preview: self.image_preview.or(other.image_preview),
|
||||||
user_gutter_width: self.user_gutter_width.or(other.user_gutter_width),
|
user_gutter_width: self.user_gutter_width.or(other.user_gutter_width),
|
||||||
|
external_edit_file_suffix: self
|
||||||
|
.external_edit_file_suffix
|
||||||
|
.or(other.external_edit_file_suffix),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -580,6 +595,9 @@ impl Tunables {
|
|||||||
notifications: self.notifications.unwrap_or_default(),
|
notifications: self.notifications.unwrap_or_default(),
|
||||||
image_preview: self.image_preview.map(ImagePreview::values),
|
image_preview: self.image_preview.map(ImagePreview::values),
|
||||||
user_gutter_width: self.user_gutter_width.unwrap_or(30),
|
user_gutter_width: self.user_gutter_width.unwrap_or(30),
|
||||||
|
external_edit_file_suffix: self
|
||||||
|
.external_edit_file_suffix
|
||||||
|
.unwrap_or_else(|| ".md".to_string()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -912,15 +930,16 @@ impl ApplicationSettings {
|
|||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_user_style(&self, user_id: &UserId) -> Style {
|
pub fn get_user_color(&self, user_id: &UserId) -> Color {
|
||||||
let color = self
|
self.tunables
|
||||||
.tunables
|
|
||||||
.users
|
.users
|
||||||
.get(user_id)
|
.get(user_id)
|
||||||
.and_then(|user| user.color.as_ref().map(|c| c.0))
|
.and_then(|user| user.color.as_ref().map(|c| c.0))
|
||||||
.unwrap_or_else(|| user_color(user_id.as_str()));
|
.unwrap_or_else(|| user_color(user_id.as_str()))
|
||||||
|
}
|
||||||
|
|
||||||
user_style_from_color(color)
|
pub fn get_user_style(&self, user_id: &UserId) -> Style {
|
||||||
|
user_style_from_color(self.get_user_color(user_id))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_user_span<'a>(&self, user_id: &'a UserId, info: &'a RoomInfo) -> Span<'a> {
|
pub fn get_user_span<'a>(&self, user_id: &'a UserId, info: &'a RoomInfo) -> Span<'a> {
|
||||||
@@ -1164,7 +1183,7 @@ mod tests {
|
|||||||
let j = "j".parse::<TerminalKey>().unwrap();
|
let j = "j".parse::<TerminalKey>().unwrap();
|
||||||
let esc = "<Esc>".parse::<TerminalKey>().unwrap();
|
let esc = "<Esc>".parse::<TerminalKey>().unwrap();
|
||||||
|
|
||||||
let jj = Keys(vec![j.clone(), j], "jj".into());
|
let jj = Keys(vec![j, j], "jj".into());
|
||||||
let run = mapped.get(&jj).unwrap();
|
let run = mapped.get(&jj).unwrap();
|
||||||
let exp = Keys(vec![esc], "<Esc>".into());
|
let exp = Keys(vec![esc], "<Esc>".into());
|
||||||
assert_eq!(run, &exp);
|
assert_eq!(run, &exp);
|
||||||
|
|||||||
@@ -3,13 +3,13 @@
|
|||||||
//! The keybindings are set up here. We define some iamb-specific keybindings, but the default Vim
|
//! The keybindings are set up here. We define some iamb-specific keybindings, but the default Vim
|
||||||
//! keys come from [modalkit::env::vim::keybindings].
|
//! keys come from [modalkit::env::vim::keybindings].
|
||||||
use modalkit::{
|
use modalkit::{
|
||||||
actions::{MacroAction, WindowAction},
|
actions::{InsertTextAction, MacroAction, WindowAction},
|
||||||
env::vim::keybindings::{InputStep, VimBindings},
|
env::vim::keybindings::{InputStep, VimBindings},
|
||||||
env::vim::VimMode,
|
env::vim::VimMode,
|
||||||
env::CommonKeyClass,
|
env::CommonKeyClass,
|
||||||
key::TerminalKey,
|
key::TerminalKey,
|
||||||
keybindings::{EdgeEvent, EdgeRepeat, InputBindings},
|
keybindings::{EdgeEvent, EdgeRepeat, InputBindings},
|
||||||
prelude::Count,
|
prelude::*,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::base::{IambAction, IambInfo, Keybindings, MATRIX_ID_WORD};
|
use crate::base::{IambAction, IambInfo, Keybindings, MATRIX_ID_WORD};
|
||||||
@@ -36,6 +36,7 @@ pub fn setup_keybindings() -> Keybindings {
|
|||||||
let ctrl_z = "<C-Z>".parse::<TerminalKey>().unwrap();
|
let ctrl_z = "<C-Z>".parse::<TerminalKey>().unwrap();
|
||||||
let key_m_lc = "m".parse::<TerminalKey>().unwrap();
|
let key_m_lc = "m".parse::<TerminalKey>().unwrap();
|
||||||
let key_z_lc = "z".parse::<TerminalKey>().unwrap();
|
let key_z_lc = "z".parse::<TerminalKey>().unwrap();
|
||||||
|
let shift_enter = "<S-Enter>".parse::<TerminalKey>().unwrap();
|
||||||
|
|
||||||
let cwz = vec![once(&ctrl_w), once(&key_z_lc)];
|
let cwz = vec![once(&ctrl_w), once(&key_z_lc)];
|
||||||
let cwcz = vec![once(&ctrl_w), once(&ctrl_z)];
|
let cwcz = vec![once(&ctrl_w), once(&ctrl_z)];
|
||||||
@@ -57,6 +58,17 @@ pub fn setup_keybindings() -> Keybindings {
|
|||||||
ism.add_mapping(VimMode::Visual, &cwm, &stoggle);
|
ism.add_mapping(VimMode::Visual, &cwm, &stoggle);
|
||||||
ism.add_mapping(VimMode::Normal, &cwcm, &stoggle);
|
ism.add_mapping(VimMode::Normal, &cwcm, &stoggle);
|
||||||
ism.add_mapping(VimMode::Visual, &cwcm, &stoggle);
|
ism.add_mapping(VimMode::Visual, &cwcm, &stoggle);
|
||||||
|
|
||||||
|
let shift_enter = vec![once(&shift_enter)];
|
||||||
|
let newline = IambStep::new().actions(vec![InsertTextAction::Type(
|
||||||
|
Char::Single('\n').into(),
|
||||||
|
MoveDir1D::Previous,
|
||||||
|
1.into(),
|
||||||
|
)
|
||||||
|
.into()]);
|
||||||
|
ism.add_mapping(VimMode::Insert, &cwm, &newline);
|
||||||
|
ism.add_mapping(VimMode::Insert, &shift_enter, &newline);
|
||||||
|
|
||||||
ism
|
ism
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
126
src/main.rs
126
src/main.rs
@@ -48,6 +48,9 @@ use modalkit::crossterm::{
|
|||||||
EnableFocusChange,
|
EnableFocusChange,
|
||||||
Event,
|
Event,
|
||||||
KeyEventKind,
|
KeyEventKind,
|
||||||
|
KeyboardEnhancementFlags,
|
||||||
|
PopKeyboardEnhancementFlags,
|
||||||
|
PushKeyboardEnhancementFlags,
|
||||||
},
|
},
|
||||||
execute,
|
execute,
|
||||||
terminal::{EnterAlternateScreen, LeaveAlternateScreen, SetTitle},
|
terminal::{EnterAlternateScreen, LeaveAlternateScreen, SetTitle},
|
||||||
@@ -126,8 +129,8 @@ use modalkit::{
|
|||||||
|
|
||||||
use modalkit_ratatui::{
|
use modalkit_ratatui::{
|
||||||
cmdbar::CommandBarState,
|
cmdbar::CommandBarState,
|
||||||
screen::{Screen, ScreenState, TabLayoutDescription},
|
screen::{Screen, ScreenState, TabbedLayoutDescription},
|
||||||
windows::WindowLayoutDescription,
|
windows::{WindowLayoutDescription, WindowLayoutState},
|
||||||
TerminalCursor,
|
TerminalCursor,
|
||||||
TerminalExtOps,
|
TerminalExtOps,
|
||||||
Window,
|
Window,
|
||||||
@@ -173,6 +176,17 @@ fn config_tab_to_desc(
|
|||||||
Ok(desc)
|
Ok(desc)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn restore_layout(
|
||||||
|
area: Rect,
|
||||||
|
settings: &ApplicationSettings,
|
||||||
|
store: &mut ProgramStore,
|
||||||
|
) -> IambResult<FocusList<WindowLayoutState<IambWindow, IambInfo>>> {
|
||||||
|
let layout = std::fs::read(&settings.layout_json)?;
|
||||||
|
let tabs: TabbedLayoutDescription<IambInfo> =
|
||||||
|
serde_json::from_slice(&layout).map_err(IambError::from)?;
|
||||||
|
tabs.to_layout(area.into(), store)
|
||||||
|
}
|
||||||
|
|
||||||
fn setup_screen(
|
fn setup_screen(
|
||||||
settings: ApplicationSettings,
|
settings: ApplicationSettings,
|
||||||
store: &mut ProgramStore,
|
store: &mut ProgramStore,
|
||||||
@@ -183,12 +197,14 @@ fn setup_screen(
|
|||||||
|
|
||||||
match settings.layout {
|
match settings.layout {
|
||||||
config::Layout::Restore => {
|
config::Layout::Restore => {
|
||||||
if let Ok(layout) = std::fs::read(&settings.layout_json) {
|
match restore_layout(area, &settings, store) {
|
||||||
let tabs: TabLayoutDescription<IambInfo> =
|
Ok(tabs) => {
|
||||||
serde_json::from_slice(&layout).map_err(IambError::from)?;
|
return Ok(ScreenState::from_list(tabs, cmd));
|
||||||
let tabs = tabs.to_layout(area.into(), store)?;
|
},
|
||||||
|
Err(e) => {
|
||||||
return Ok(ScreenState::from_list(tabs, cmd));
|
// Log the issue with restoring and then continue.
|
||||||
|
tracing::warn!(err = %e, "Failed to restore layout from disk");
|
||||||
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
config::Layout::New => {},
|
config::Layout::New => {},
|
||||||
@@ -239,7 +255,7 @@ struct Application {
|
|||||||
focused: bool,
|
focused: bool,
|
||||||
|
|
||||||
/// The tab layout before the last executed [TabAction].
|
/// The tab layout before the last executed [TabAction].
|
||||||
last_layout: Option<TabLayoutDescription<IambInfo>>,
|
last_layout: Option<TabbedLayoutDescription<IambInfo>>,
|
||||||
|
|
||||||
/// Whether we need to do a full redraw (e.g., after running a subprocess).
|
/// Whether we need to do a full redraw (e.g., after running a subprocess).
|
||||||
dirty: bool,
|
dirty: bool,
|
||||||
@@ -250,16 +266,7 @@ impl Application {
|
|||||||
settings: ApplicationSettings,
|
settings: ApplicationSettings,
|
||||||
store: AsyncProgramStore,
|
store: AsyncProgramStore,
|
||||||
) -> IambResult<Application> {
|
) -> IambResult<Application> {
|
||||||
let mut stdout = stdout();
|
let backend = CrosstermBackend::new(stdout());
|
||||||
crossterm::terminal::enable_raw_mode()?;
|
|
||||||
crossterm::execute!(stdout, EnterAlternateScreen)?;
|
|
||||||
crossterm::execute!(stdout, EnableBracketedPaste)?;
|
|
||||||
crossterm::execute!(stdout, EnableFocusChange)?;
|
|
||||||
|
|
||||||
let title = format!("iamb ({})", settings.profile.user_id);
|
|
||||||
crossterm::execute!(stdout, SetTitle(title))?;
|
|
||||||
|
|
||||||
let backend = CrosstermBackend::new(stdout);
|
|
||||||
let terminal = Terminal::new(backend)?;
|
let terminal = Terminal::new(backend)?;
|
||||||
|
|
||||||
let mut bindings = crate::keybindings::setup_keybindings();
|
let mut bindings = crate::keybindings::setup_keybindings();
|
||||||
@@ -361,9 +368,13 @@ impl Application {
|
|||||||
// Do nothing for now.
|
// Do nothing for now.
|
||||||
},
|
},
|
||||||
Event::FocusGained => {
|
Event::FocusGained => {
|
||||||
|
let mut store = self.store.lock().await;
|
||||||
|
store.application.focused = true;
|
||||||
self.focused = true;
|
self.focused = true;
|
||||||
},
|
},
|
||||||
Event::FocusLost => {
|
Event::FocusLost => {
|
||||||
|
let mut store = self.store.lock().await;
|
||||||
|
store.application.focused = false;
|
||||||
self.focused = false;
|
self.focused = false;
|
||||||
},
|
},
|
||||||
Event::Resize(_, _) => {
|
Event::Resize(_, _) => {
|
||||||
@@ -481,7 +492,7 @@ impl Application {
|
|||||||
None
|
None
|
||||||
},
|
},
|
||||||
Action::Command(act) => {
|
Action::Command(act) => {
|
||||||
let acts = store.application.cmds.command(&act, &ctx)?;
|
let acts = store.application.cmds.command(&act, &ctx, &mut store.registers)?;
|
||||||
self.action_prepend(acts);
|
self.action_prepend(acts);
|
||||||
|
|
||||||
None
|
None
|
||||||
@@ -518,6 +529,18 @@ impl Application {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let info = match action {
|
let info = match action {
|
||||||
|
IambAction::ClearUnreads => {
|
||||||
|
let user_id = &store.application.settings.profile.user_id;
|
||||||
|
|
||||||
|
for room_id in store.application.sync_info.chats() {
|
||||||
|
if let Some(room) = store.application.rooms.get_mut(room_id) {
|
||||||
|
room.fully_read(user_id.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
},
|
||||||
|
|
||||||
IambAction::ToggleScrollbackFocus => {
|
IambAction::ToggleScrollbackFocus => {
|
||||||
self.screen.current_window_mut()?.focus_toggle();
|
self.screen.current_window_mut()?.focus_toggle();
|
||||||
|
|
||||||
@@ -905,6 +928,42 @@ async fn login_normal(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set up the terminal for drawing the TUI, and getting additional info.
|
||||||
|
fn setup_tty(title: &str, enable_enhanced_keys: bool) -> std::io::Result<()> {
|
||||||
|
let title = format!("iamb ({})", title);
|
||||||
|
|
||||||
|
// Enable raw mode and enter the alternate screen.
|
||||||
|
crossterm::terminal::enable_raw_mode()?;
|
||||||
|
crossterm::execute!(stdout(), EnterAlternateScreen)?;
|
||||||
|
|
||||||
|
if enable_enhanced_keys {
|
||||||
|
// Enable the Kitty keyboard enhancement protocol for improved keypresses.
|
||||||
|
crossterm::queue!(
|
||||||
|
stdout(),
|
||||||
|
PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES)
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
crossterm::execute!(stdout(), EnableBracketedPaste, EnableFocusChange, SetTitle(title))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Do our best to reverse what we did in setup_tty() when we exit or crash.
|
||||||
|
fn restore_tty(enable_enhanced_keys: bool) {
|
||||||
|
if enable_enhanced_keys {
|
||||||
|
let _ = crossterm::queue!(stdout(), PopKeyboardEnhancementFlags);
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = crossterm::execute!(
|
||||||
|
stdout(),
|
||||||
|
DisableBracketedPaste,
|
||||||
|
DisableFocusChange,
|
||||||
|
LeaveAlternateScreen,
|
||||||
|
CursorShow,
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = crossterm::terminal::disable_raw_mode();
|
||||||
|
}
|
||||||
|
|
||||||
async fn run(settings: ApplicationSettings) -> IambResult<()> {
|
async fn run(settings: ApplicationSettings) -> IambResult<()> {
|
||||||
// Get old keys the first time we run w/ the upgraded SDK.
|
// Get old keys the first time we run w/ the upgraded SDK.
|
||||||
let import_keys = check_import_keys(&settings).await?;
|
let import_keys = check_import_keys(&settings).await?;
|
||||||
@@ -938,27 +997,30 @@ async fn run(settings: ApplicationSettings) -> IambResult<()> {
|
|||||||
Ok(()) => (),
|
Ok(()) => (),
|
||||||
}
|
}
|
||||||
|
|
||||||
fn restore_tty() {
|
// Set up the terminal for drawing, and cleanup properly on panics.
|
||||||
let _ = crossterm::terminal::disable_raw_mode();
|
let enable_enhanced_keys = match crossterm::terminal::supports_keyboard_enhancement() {
|
||||||
let _ = crossterm::execute!(stdout(), DisableBracketedPaste);
|
Ok(supported) => supported,
|
||||||
let _ = crossterm::execute!(stdout(), DisableFocusChange);
|
Err(e) => {
|
||||||
let _ = crossterm::execute!(stdout(), LeaveAlternateScreen);
|
tracing::warn!(err = %e,
|
||||||
let _ = crossterm::execute!(stdout(), CursorShow);
|
"Failed to determine whether the terminal supports keyboard enhancements");
|
||||||
}
|
false
|
||||||
|
},
|
||||||
|
};
|
||||||
|
setup_tty(settings.profile.user_id.as_str(), enable_enhanced_keys)?;
|
||||||
|
|
||||||
// Make sure panics clean up the terminal properly.
|
|
||||||
let orig_hook = std::panic::take_hook();
|
let orig_hook = std::panic::take_hook();
|
||||||
std::panic::set_hook(Box::new(move |panic_info| {
|
std::panic::set_hook(Box::new(move |panic_info| {
|
||||||
restore_tty();
|
restore_tty(enable_enhanced_keys);
|
||||||
orig_hook(panic_info);
|
orig_hook(panic_info);
|
||||||
process::exit(1);
|
process::exit(1);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// And finally, start running the terminal UI.
|
||||||
let mut application = Application::new(settings, store).await?;
|
let mut application = Application::new(settings, store).await?;
|
||||||
|
|
||||||
// We can now run the application.
|
|
||||||
application.run().await?;
|
application.run().await?;
|
||||||
restore_tty();
|
|
||||||
|
// Clean up the terminal on exit.
|
||||||
|
restore_tty(enable_enhanced_keys);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
376
src/message/compose.rs
Normal file
376
src/message/compose.rs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -260,6 +260,7 @@ pub enum StyleTreeNode {
|
|||||||
Anchor(Box<StyleTreeNode>, char, Url),
|
Anchor(Box<StyleTreeNode>, char, Url),
|
||||||
Blockquote(Box<StyleTreeNode>),
|
Blockquote(Box<StyleTreeNode>),
|
||||||
Break,
|
Break,
|
||||||
|
#[allow(dead_code)]
|
||||||
Code(Box<StyleTreeNode>, Option<String>),
|
Code(Box<StyleTreeNode>, Option<String>),
|
||||||
Header(Box<StyleTreeNode>, usize),
|
Header(Box<StyleTreeNode>, usize),
|
||||||
Image(Option<String>),
|
Image(Option<String>),
|
||||||
|
|||||||
@@ -4,12 +4,13 @@ use std::cmp::{Ord, Ordering, PartialOrd};
|
|||||||
use std::collections::hash_map::DefaultHasher;
|
use std::collections::hash_map::DefaultHasher;
|
||||||
use std::collections::hash_set;
|
use std::collections::hash_set;
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::convert::TryFrom;
|
use std::convert::{TryFrom, TryInto};
|
||||||
|
use std::fmt::{self, Display};
|
||||||
use std::hash::{Hash, Hasher};
|
use std::hash::{Hash, Hasher};
|
||||||
use std::ops::{Deref, DerefMut};
|
use std::ops::{Deref, DerefMut};
|
||||||
|
|
||||||
use chrono::{DateTime, Local as LocalTz, NaiveDateTime, TimeZone};
|
use chrono::{DateTime, Local as LocalTz, NaiveDateTime, TimeZone};
|
||||||
use comrak::{markdown_to_html, ComrakOptions};
|
use humansize::{format_size, DECIMAL};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use unicode_width::UnicodeWidthStr;
|
use unicode_width::UnicodeWidthStr;
|
||||||
|
|
||||||
@@ -31,7 +32,6 @@ use matrix_sdk::ruma::{
|
|||||||
Relation,
|
Relation,
|
||||||
RoomMessageEvent,
|
RoomMessageEvent,
|
||||||
RoomMessageEventContent,
|
RoomMessageEventContent,
|
||||||
TextMessageEventContent,
|
|
||||||
},
|
},
|
||||||
redaction::SyncRoomRedactionEvent,
|
redaction::SyncRoomRedactionEvent,
|
||||||
},
|
},
|
||||||
@@ -64,9 +64,12 @@ use crate::{
|
|||||||
util::{replace_emojis_in_str, space, space_span, take_width, wrapped_text},
|
util::{replace_emojis_in_str, space, space_span, take_width, wrapped_text},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
mod compose;
|
||||||
mod html;
|
mod html;
|
||||||
mod printer;
|
mod printer;
|
||||||
|
|
||||||
|
pub use self::compose::text_to_message;
|
||||||
|
|
||||||
pub type MessageKey = (MessageTimeStamp, OwnedEventId);
|
pub type MessageKey = (MessageTimeStamp, OwnedEventId);
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
@@ -127,20 +130,22 @@ const MIN_MSG_LEN: usize = 30;
|
|||||||
const TIME_GUTTER_EMPTY: &str = " ";
|
const TIME_GUTTER_EMPTY: &str = " ";
|
||||||
const TIME_GUTTER_EMPTY_SPAN: Span<'static> = span_static(TIME_GUTTER_EMPTY);
|
const TIME_GUTTER_EMPTY_SPAN: Span<'static> = span_static(TIME_GUTTER_EMPTY);
|
||||||
|
|
||||||
fn text_to_message_content(input: String) -> TextMessageEventContent {
|
const USIZE_TOO_SMALL: bool = usize::BITS < u64::BITS;
|
||||||
let mut options = ComrakOptions::default();
|
|
||||||
options.extension.autolink = true;
|
|
||||||
options.extension.shortcodes = true;
|
|
||||||
options.extension.strikethrough = true;
|
|
||||||
options.render.hardbreaks = true;
|
|
||||||
let html = markdown_to_html(input.as_str(), &options);
|
|
||||||
|
|
||||||
TextMessageEventContent::html(input, html)
|
/// Convert the [u64] hash to [usize] as needed.
|
||||||
|
fn hash_finish_usize(hasher: DefaultHasher) -> Option<usize> {
|
||||||
|
if USIZE_TOO_SMALL {
|
||||||
|
(hasher.finish() % usize::MAX as u64).try_into().ok()
|
||||||
|
} else {
|
||||||
|
hasher.finish().try_into().ok()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn text_to_message(input: String) -> RoomMessageEventContent {
|
/// Hash an [EventId] into a [usize].
|
||||||
let msg = MessageType::Text(text_to_message_content(input));
|
fn hash_event_id(event_id: &EventId) -> Option<usize> {
|
||||||
RoomMessageEventContent::new(msg)
|
let mut hasher = DefaultHasher::new();
|
||||||
|
event_id.hash(&mut hasher);
|
||||||
|
hash_finish_usize(hasher)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Before the image is loaded, already display a placeholder frame of the image size.
|
/// Before the image is loaded, already display a placeholder frame of the image size.
|
||||||
@@ -326,17 +331,14 @@ impl MessageCursor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn from_cursor(cursor: &Cursor, thread: &Messages) -> Option<Self> {
|
pub fn from_cursor(cursor: &Cursor, thread: &Messages) -> Option<Self> {
|
||||||
let ev_hash = u64::try_from(cursor.get_x()).ok()?;
|
let ev_hash = cursor.get_x();
|
||||||
let ev_term = OwnedEventId::try_from("$").ok()?;
|
let ev_term = OwnedEventId::try_from("$").ok()?;
|
||||||
|
|
||||||
let ts_start = MessageTimeStamp::try_from(cursor.get_y()).ok()?;
|
let ts_start = MessageTimeStamp::try_from(cursor.get_y()).ok()?;
|
||||||
let start = (ts_start, ev_term);
|
let start = (ts_start, ev_term);
|
||||||
|
|
||||||
for ((ts, event_id), _) in thread.range(&start..) {
|
for ((ts, event_id), _) in thread.range(&start..) {
|
||||||
let mut hasher = DefaultHasher::new();
|
if hash_event_id(event_id)? == ev_hash {
|
||||||
event_id.hash(&mut hasher);
|
|
||||||
|
|
||||||
if hasher.finish() == ev_hash {
|
|
||||||
return Self::from((*ts, event_id.clone())).into();
|
return Self::from((*ts, event_id.clone())).into();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -355,11 +357,8 @@ impl MessageCursor {
|
|||||||
pub fn to_cursor(&self, thread: &Messages) -> Option<Cursor> {
|
pub fn to_cursor(&self, thread: &Messages) -> Option<Cursor> {
|
||||||
let (ts, event_id) = self.to_key(thread)?;
|
let (ts, event_id) = self.to_key(thread)?;
|
||||||
|
|
||||||
let y: usize = usize::try_from(ts).ok()?;
|
let y = usize::try_from(ts).ok()?;
|
||||||
|
let x = hash_event_id(event_id)?;
|
||||||
let mut hasher = DefaultHasher::new();
|
|
||||||
event_id.hash(&mut hasher);
|
|
||||||
let x = usize::try_from(hasher.finish()).ok()?;
|
|
||||||
|
|
||||||
Cursor::new(y, x).into()
|
Cursor::new(y, x).into()
|
||||||
}
|
}
|
||||||
@@ -509,6 +508,27 @@ impl MessageEvent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Macro rule converting a File / Image / Audio / Video to its text content with the shape:
|
||||||
|
/// `[Attached <type>: <content>[ (<human readable file size>)]]`
|
||||||
|
macro_rules! display_file_to_text {
|
||||||
|
( $msgtype:ident, $content:expr ) => {
|
||||||
|
return Cow::Owned(format!(
|
||||||
|
"[Attached {}: {}{}]",
|
||||||
|
stringify!($msgtype),
|
||||||
|
$content.body,
|
||||||
|
$content
|
||||||
|
.info
|
||||||
|
.as_ref()
|
||||||
|
.map(|info| {
|
||||||
|
info.size
|
||||||
|
.map(|s| format!(" ({})", format_size(u64::from(s), DECIMAL)))
|
||||||
|
.unwrap_or_else(String::new)
|
||||||
|
})
|
||||||
|
.unwrap_or_else(String::new)
|
||||||
|
))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
fn body_cow_content(content: &RoomMessageEventContent) -> Cow<'_, str> {
|
fn body_cow_content(content: &RoomMessageEventContent) -> Cow<'_, str> {
|
||||||
let s = match &content.msgtype {
|
let s = match &content.msgtype {
|
||||||
MessageType::Text(content) => content.body.as_str(),
|
MessageType::Text(content) => content.body.as_str(),
|
||||||
@@ -518,19 +538,30 @@ fn body_cow_content(content: &RoomMessageEventContent) -> Cow<'_, str> {
|
|||||||
MessageType::ServerNotice(content) => content.body.as_str(),
|
MessageType::ServerNotice(content) => content.body.as_str(),
|
||||||
|
|
||||||
MessageType::Audio(content) => {
|
MessageType::Audio(content) => {
|
||||||
return Cow::Owned(format!("[Attached Audio: {}]", content.body));
|
display_file_to_text!(Audio, content);
|
||||||
},
|
},
|
||||||
MessageType::File(content) => {
|
MessageType::File(content) => {
|
||||||
return Cow::Owned(format!("[Attached File: {}]", content.body));
|
display_file_to_text!(File, content);
|
||||||
},
|
},
|
||||||
MessageType::Image(content) => {
|
MessageType::Image(content) => {
|
||||||
return Cow::Owned(format!("[Attached Image: {}]", content.body));
|
display_file_to_text!(Image, content);
|
||||||
},
|
},
|
||||||
MessageType::Video(content) => {
|
MessageType::Video(content) => {
|
||||||
return Cow::Owned(format!("[Attached Video: {}]", content.body));
|
display_file_to_text!(Video, content);
|
||||||
},
|
},
|
||||||
_ => {
|
_ => {
|
||||||
return Cow::Owned(format!("[Unknown message type: {:?}]", content.msgtype()));
|
match content.msgtype() {
|
||||||
|
// Just show the body text for the special Element messages.
|
||||||
|
"nic.custom.confetti" |
|
||||||
|
"nic.custom.fireworks" |
|
||||||
|
"io.element.effect.hearts" |
|
||||||
|
"io.element.effect.rainfall" |
|
||||||
|
"io.element.effect.snowfall" |
|
||||||
|
"io.element.effects.space_invaders" => content.body(),
|
||||||
|
other => {
|
||||||
|
return Cow::Owned(format!("[Unknown message type: {other:?}]"));
|
||||||
|
},
|
||||||
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -863,7 +894,7 @@ impl Message {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if settings.tunables.message_user_color {
|
if settings.tunables.message_user_color {
|
||||||
let color = crate::config::user_color(self.sender.as_str());
|
let color = settings.get_user_color(&self.sender);
|
||||||
style = style.fg(color);
|
style = style.fg(color);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -936,7 +967,7 @@ impl Message {
|
|||||||
|
|
||||||
let style = self.get_render_style(selected, settings);
|
let style = self.get_render_style(selected, settings);
|
||||||
let mut fmt = self.get_render_format(prev, width, info, settings);
|
let mut fmt = self.get_render_format(prev, width, info, settings);
|
||||||
let mut text = Text { lines: vec![] };
|
let mut text = Text::default();
|
||||||
let width = fmt.width();
|
let width = fmt.width();
|
||||||
|
|
||||||
// Show the message that this one replied to, if any.
|
// Show the message that this one replied to, if any.
|
||||||
@@ -961,6 +992,8 @@ impl Message {
|
|||||||
let proto = proto.map(|p| {
|
let proto = proto.map(|p| {
|
||||||
let y_off = text.lines.len() as u16;
|
let y_off = text.lines.len() as u16;
|
||||||
let x_off = fmt.cols.user_gutter_width(settings);
|
let x_off = fmt.cols.user_gutter_width(settings);
|
||||||
|
// Adjust y_off by 1 if a date was printed before the message to account for the extra line.
|
||||||
|
let y_off = if fmt.date.is_some() { y_off + 1 } else { y_off };
|
||||||
(p, x_off, y_off)
|
(p, x_off, y_off)
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1120,14 +1153,27 @@ impl From<RoomMessageEvent> for Message {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ToString for Message {
|
impl Display for Message {
|
||||||
fn to_string(&self) -> String {
|
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||||
self.event.body().into_owned()
|
write!(f, "{}", self.event.body())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub mod tests {
|
pub mod tests {
|
||||||
|
use matrix_sdk::ruma::events::room::{
|
||||||
|
message::{
|
||||||
|
AudioInfo,
|
||||||
|
AudioMessageEventContent,
|
||||||
|
FileInfo,
|
||||||
|
FileMessageEventContent,
|
||||||
|
ImageMessageEventContent,
|
||||||
|
VideoInfo,
|
||||||
|
VideoMessageEventContent,
|
||||||
|
},
|
||||||
|
ImageInfo,
|
||||||
|
};
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::tests::*;
|
use crate::tests::*;
|
||||||
|
|
||||||
@@ -1236,82 +1282,6 @@ pub mod tests {
|
|||||||
assert_eq!(identity(&mc6), mc1);
|
assert_eq!(identity(&mc6), mc1);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_markdown_autolink() {
|
|
||||||
let input = "http://example.com\n";
|
|
||||||
let content = text_to_message_content(input.into());
|
|
||||||
assert_eq!(content.body, input);
|
|
||||||
assert_eq!(
|
|
||||||
content.formatted.unwrap().body,
|
|
||||||
"<p><a href=\"http://example.com\">http://example.com</a></p>\n"
|
|
||||||
);
|
|
||||||
|
|
||||||
let input = "www.example.com\n";
|
|
||||||
let content = text_to_message_content(input.into());
|
|
||||||
assert_eq!(content.body, input);
|
|
||||||
assert_eq!(
|
|
||||||
content.formatted.unwrap().body,
|
|
||||||
"<p><a href=\"http://www.example.com\">www.example.com</a></p>\n"
|
|
||||||
);
|
|
||||||
|
|
||||||
let input = "See docs (they're at https://iamb.chat)\n";
|
|
||||||
let content = text_to_message_content(input.into());
|
|
||||||
assert_eq!(content.body, input);
|
|
||||||
assert_eq!(
|
|
||||||
content.formatted.unwrap().body,
|
|
||||||
"<p>See docs (they're at <a href=\"https://iamb.chat\">https://iamb.chat</a>)</p>\n"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_markdown_message() {
|
|
||||||
let input = "**bold**\n";
|
|
||||||
let content = text_to_message_content(input.into());
|
|
||||||
assert_eq!(content.body, input);
|
|
||||||
assert_eq!(content.formatted.unwrap().body, "<p><strong>bold</strong></p>\n");
|
|
||||||
|
|
||||||
let input = "*emphasis*\n";
|
|
||||||
let content = text_to_message_content(input.into());
|
|
||||||
assert_eq!(content.body, input);
|
|
||||||
assert_eq!(content.formatted.unwrap().body, "<p><em>emphasis</em></p>\n");
|
|
||||||
|
|
||||||
let input = "`code`\n";
|
|
||||||
let content = text_to_message_content(input.into());
|
|
||||||
assert_eq!(content.body, input);
|
|
||||||
assert_eq!(content.formatted.unwrap().body, "<p><code>code</code></p>\n");
|
|
||||||
|
|
||||||
let input = "```rust\nconst A: usize = 1;\n```\n";
|
|
||||||
let content = text_to_message_content(input.into());
|
|
||||||
assert_eq!(content.body, input);
|
|
||||||
assert_eq!(
|
|
||||||
content.formatted.unwrap().body,
|
|
||||||
"<pre><code class=\"language-rust\">const A: usize = 1;\n</code></pre>\n"
|
|
||||||
);
|
|
||||||
|
|
||||||
let input = ":heart:\n";
|
|
||||||
let content = text_to_message_content(input.into());
|
|
||||||
assert_eq!(content.body, input);
|
|
||||||
assert_eq!(content.formatted.unwrap().body, "<p>\u{2764}\u{FE0F}</p>\n");
|
|
||||||
|
|
||||||
let input = "para 1\n\npara 2\n";
|
|
||||||
let content = text_to_message_content(input.into());
|
|
||||||
assert_eq!(content.body, input);
|
|
||||||
assert_eq!(content.formatted.unwrap().body, "<p>para 1</p>\n<p>para 2</p>\n");
|
|
||||||
|
|
||||||
let input = "line 1\nline 2\n";
|
|
||||||
let content = text_to_message_content(input.into());
|
|
||||||
assert_eq!(content.body, input);
|
|
||||||
assert_eq!(content.formatted.unwrap().body, "<p>line 1<br />\nline 2</p>\n");
|
|
||||||
|
|
||||||
let input = "# Heading\n## Subheading\n\ntext\n";
|
|
||||||
let content = text_to_message_content(input.into());
|
|
||||||
assert_eq!(content.body, input);
|
|
||||||
assert_eq!(
|
|
||||||
content.formatted.unwrap().body,
|
|
||||||
"<h1>Heading</h1>\n<h2>Subheading</h2>\n<p>text</p>\n"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_placeholder_frame() {
|
fn test_placeholder_frame() {
|
||||||
fn pretty_frame_test(str: &str) -> Option<String> {
|
fn pretty_frame_test(str: &str) -> Option<String> {
|
||||||
@@ -1377,4 +1347,83 @@ pub mod tests {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_display_attachment_size() {
|
||||||
|
assert_eq!(
|
||||||
|
body_cow_content(&RoomMessageEventContent::new(MessageType::Image(
|
||||||
|
ImageMessageEventContent::plain(
|
||||||
|
"Alt text".to_string(),
|
||||||
|
"mxc://matrix.org/jDErsDugkNlfavzLTjJNUKAH".into()
|
||||||
|
)
|
||||||
|
.info(Some(Box::default()))
|
||||||
|
))),
|
||||||
|
"[Attached Image: Alt text]".to_string()
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut info = ImageInfo::default();
|
||||||
|
info.size = Some(442630_u32.into());
|
||||||
|
assert_eq!(
|
||||||
|
body_cow_content(&RoomMessageEventContent::new(MessageType::Image(
|
||||||
|
ImageMessageEventContent::plain(
|
||||||
|
"Alt text".to_string(),
|
||||||
|
"mxc://matrix.org/jDErsDugkNlfavzLTjJNUKAH".into()
|
||||||
|
)
|
||||||
|
.info(Some(Box::new(info)))
|
||||||
|
))),
|
||||||
|
"[Attached Image: Alt text (442.63 kB)]".to_string()
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut info = ImageInfo::default();
|
||||||
|
info.size = Some(12_u32.into());
|
||||||
|
assert_eq!(
|
||||||
|
body_cow_content(&RoomMessageEventContent::new(MessageType::Image(
|
||||||
|
ImageMessageEventContent::plain(
|
||||||
|
"Alt text".to_string(),
|
||||||
|
"mxc://matrix.org/jDErsDugkNlfavzLTjJNUKAH".into()
|
||||||
|
)
|
||||||
|
.info(Some(Box::new(info)))
|
||||||
|
))),
|
||||||
|
"[Attached Image: Alt text (12 B)]".to_string()
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut info = AudioInfo::default();
|
||||||
|
info.size = Some(4294967295_u32.into());
|
||||||
|
assert_eq!(
|
||||||
|
body_cow_content(&RoomMessageEventContent::new(MessageType::Audio(
|
||||||
|
AudioMessageEventContent::plain(
|
||||||
|
"Alt text".to_string(),
|
||||||
|
"mxc://matrix.org/jDErsDugkNlfavzLTjJNUKAH".into()
|
||||||
|
)
|
||||||
|
.info(Some(Box::new(info)))
|
||||||
|
))),
|
||||||
|
"[Attached Audio: Alt text (4.29 GB)]".to_string()
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut info = FileInfo::default();
|
||||||
|
info.size = Some(4426300_u32.into());
|
||||||
|
assert_eq!(
|
||||||
|
body_cow_content(&RoomMessageEventContent::new(MessageType::File(
|
||||||
|
FileMessageEventContent::plain(
|
||||||
|
"Alt text".to_string(),
|
||||||
|
"mxc://matrix.org/jDErsDugkNlfavzLTjJNUKAH".into()
|
||||||
|
)
|
||||||
|
.info(Some(Box::new(info)))
|
||||||
|
))),
|
||||||
|
"[Attached File: Alt text (4.43 MB)]".to_string()
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut info = VideoInfo::default();
|
||||||
|
info.size = Some(44000_u32.into());
|
||||||
|
assert_eq!(
|
||||||
|
body_cow_content(&RoomMessageEventContent::new(MessageType::Video(
|
||||||
|
VideoMessageEventContent::plain(
|
||||||
|
"Alt text".to_string(),
|
||||||
|
"mxc://matrix.org/jDErsDugkNlfavzLTjJNUKAH".into()
|
||||||
|
)
|
||||||
|
.info(Some(Box::new(info)))
|
||||||
|
))),
|
||||||
|
"[Attached Video: Alt text (44 kB)]".to_string()
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ impl<'a> TextPrinter<'a> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn remaining(&self) -> usize {
|
fn remaining(&self) -> usize {
|
||||||
self.width - self.curr_width
|
self.width.saturating_sub(self.curr_width)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// If there is any text on the current line, start a new one.
|
/// If there is any text on the current line, start a new one.
|
||||||
@@ -276,3 +276,18 @@ impl<'a> TextPrinter<'a> {
|
|||||||
self.text
|
self.text
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_push_nobreak() {
|
||||||
|
let mut printer = TextPrinter::new(5, Style::default(), false, false);
|
||||||
|
printer.push_span_nobreak("hello world".into());
|
||||||
|
let text = printer.finish();
|
||||||
|
assert_eq!(text.lines.len(), 1);
|
||||||
|
assert_eq!(text.lines[0].spans.len(), 1);
|
||||||
|
assert_eq!(text.lines[0].spans[0].content, "hello world");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,10 +14,15 @@ use matrix_sdk::{
|
|||||||
use unicode_segmentation::UnicodeSegmentation;
|
use unicode_segmentation::UnicodeSegmentation;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
base::{AsyncProgramStore, IambError, IambResult},
|
base::{AsyncProgramStore, IambError, IambResult, ProgramStore},
|
||||||
config::{ApplicationSettings, NotifyVia},
|
config::{ApplicationSettings, NotifyVia},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const IAMB_XDG_NAME: &str = match option_env!("IAMB_XDG_NAME") {
|
||||||
|
None => "iamb",
|
||||||
|
Some(iamb) => iamb,
|
||||||
|
};
|
||||||
|
|
||||||
pub async fn register_notifications(
|
pub async fn register_notifications(
|
||||||
client: &Client,
|
client: &Client,
|
||||||
settings: &ApplicationSettings,
|
settings: &ApplicationSettings,
|
||||||
@@ -44,7 +49,7 @@ pub async fn register_notifications(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if is_open(&store, room.room_id()).await {
|
if is_visible_room(&store, room.room_id()).await {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,6 +64,7 @@ pub async fn register_notifications(
|
|||||||
}
|
}
|
||||||
|
|
||||||
match notify_via {
|
match notify_via {
|
||||||
|
#[cfg(feature = "desktop")]
|
||||||
NotifyVia::Desktop => send_notification_desktop(summary, body),
|
NotifyVia::Desktop => send_notification_desktop(summary, body),
|
||||||
NotifyVia::Bell => send_notification_bell(&store).await,
|
NotifyVia::Bell => send_notification_bell(&store).await,
|
||||||
}
|
}
|
||||||
@@ -77,12 +83,13 @@ async fn send_notification_bell(store: &AsyncProgramStore) {
|
|||||||
locked.application.ring_bell = true;
|
locked.application.ring_bell = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "desktop")]
|
||||||
fn send_notification_desktop(summary: String, body: Option<String>) {
|
fn send_notification_desktop(summary: String, body: Option<String>) {
|
||||||
let mut desktop_notification = notify_rust::Notification::new();
|
let mut desktop_notification = notify_rust::Notification::new();
|
||||||
desktop_notification
|
desktop_notification
|
||||||
.summary(&summary)
|
.summary(&summary)
|
||||||
.appname("iamb")
|
.appname(IAMB_XDG_NAME)
|
||||||
.timeout(notify_rust::Timeout::Milliseconds(3000))
|
.icon(IAMB_XDG_NAME)
|
||||||
.action("default", "default");
|
.action("default", "default");
|
||||||
|
|
||||||
if let Some(body) = body {
|
if let Some(body) = body {
|
||||||
@@ -128,8 +135,7 @@ fn is_missing_mention(body: &Option<String>, mode: RoomNotificationMode, client:
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn is_open(store: &AsyncProgramStore, room_id: &RoomId) -> bool {
|
fn is_open(locked: &mut ProgramStore, room_id: &RoomId) -> bool {
|
||||||
let mut locked = store.lock().await;
|
|
||||||
if let Some(draw_curr) = locked.application.draw_curr {
|
if let Some(draw_curr) = locked.application.draw_curr {
|
||||||
let info = locked.application.get_room_info(room_id.to_owned());
|
let info = locked.application.get_room_info(room_id.to_owned());
|
||||||
if let Some(draw_last) = info.draw_last {
|
if let Some(draw_last) = info.draw_last {
|
||||||
@@ -139,6 +145,16 @@ async fn is_open(store: &AsyncProgramStore, room_id: &RoomId) -> bool {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn is_focused(locked: &ProgramStore) -> bool {
|
||||||
|
locked.application.focused
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn is_visible_room(store: &AsyncProgramStore, room_id: &RoomId) -> bool {
|
||||||
|
let mut locked = store.lock().await;
|
||||||
|
|
||||||
|
is_focused(&locked) && is_open(&mut locked, room_id)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn parse_notification(
|
pub async fn parse_notification(
|
||||||
notification: Notification,
|
notification: Notification,
|
||||||
room: MatrixRoom,
|
room: MatrixRoom,
|
||||||
@@ -156,6 +172,12 @@ pub async fn parse_notification(
|
|||||||
.and_then(|m| m.display_name())
|
.and_then(|m| m.display_name())
|
||||||
.unwrap_or_else(|| sender_id.localpart());
|
.unwrap_or_else(|| sender_id.localpart());
|
||||||
|
|
||||||
|
let summary = if let Ok(room_name) = room.display_name().await {
|
||||||
|
format!("{sender_name} in {room_name}")
|
||||||
|
} else {
|
||||||
|
sender_name.to_string()
|
||||||
|
};
|
||||||
|
|
||||||
let body = if show_body {
|
let body = if show_body {
|
||||||
event_notification_body(
|
event_notification_body(
|
||||||
&event,
|
&event,
|
||||||
@@ -167,7 +189,7 @@ pub async fn parse_notification(
|
|||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
return Ok((sender_name.to_string(), body, server_ts));
|
return Ok((summary, body, server_ts));
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn event_notification_body(
|
pub fn event_notification_body(
|
||||||
@@ -220,7 +242,9 @@ pub fn event_notification_body(
|
|||||||
MessageType::VerificationRequest(_) => {
|
MessageType::VerificationRequest(_) => {
|
||||||
format!("{sender_name} sent a verification request.")
|
format!("{sender_name} sent a verification request.")
|
||||||
},
|
},
|
||||||
_ => unimplemented!(),
|
_ => {
|
||||||
|
format!("[Unknown message type: {:?}]", &message.msgtype)
|
||||||
|
},
|
||||||
};
|
};
|
||||||
Some(body)
|
Some(body)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ pub fn spawn_insert_preview(
|
|||||||
})
|
})
|
||||||
.and_then(|(picker, msg, image_preview)| {
|
.and_then(|(picker, msg, image_preview)| {
|
||||||
picker
|
picker
|
||||||
.new_protocol(img, image_preview.size.into(), Resize::Fit)
|
.new_protocol(img, image_preview.size.into(), Resize::Fit(None))
|
||||||
.map_err(|err| IambError::Preview(format!("{err:?}")))
|
.map_err(|err| IambError::Preview(format!("{err:?}")))
|
||||||
.map(|backend| (backend, msg))
|
.map(|backend| (backend, msg))
|
||||||
}) {
|
}) {
|
||||||
|
|||||||
@@ -186,6 +186,7 @@ pub fn mock_tunables() -> TunableValues {
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.collect::<HashMap<_, _>>(),
|
.collect::<HashMap<_, _>>(),
|
||||||
open_command: None,
|
open_command: None,
|
||||||
|
external_edit_file_suffix: String::from(".md"),
|
||||||
username_display: UserDisplayStyle::Username,
|
username_display: UserDisplayStyle::Username,
|
||||||
message_user_color: false,
|
message_user_color: false,
|
||||||
notifications: Notifications {
|
notifications: Notifications {
|
||||||
|
|||||||
@@ -128,9 +128,7 @@ pub fn space_text(width: usize, style: Style) -> Text<'static> {
|
|||||||
|
|
||||||
pub fn join_cell_text<'a>(texts: Vec<(Text<'a>, usize)>, join: Span<'a>, style: Style) -> Text<'a> {
|
pub fn join_cell_text<'a>(texts: Vec<(Text<'a>, usize)>, join: Span<'a>, style: Style) -> Text<'a> {
|
||||||
let height = texts.iter().map(|t| t.0.height()).max().unwrap_or(0);
|
let height = texts.iter().map(|t| t.0.height()).max().unwrap_or(0);
|
||||||
let mut text = Text {
|
let mut text = Text::from(vec![Line::from(vec![join.clone()]); height]);
|
||||||
lines: vec![Line::from(vec![join.clone()]); height],
|
|
||||||
};
|
|
||||||
|
|
||||||
for (mut t, w) in texts.into_iter() {
|
for (mut t, w) in texts.into_iter() {
|
||||||
for i in 0..height {
|
for i in 0..height {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
//! example, [sending messages][crate::base::SendAction] delegate to the [room window][RoomState],
|
//! example, [sending messages][crate::base::SendAction] delegate to the [room window][RoomState],
|
||||||
//! where we have the message bar and room ID easily accesible and resetable.
|
//! where we have the message bar and room ID easily accesible and resetable.
|
||||||
use std::cmp::{Ord, Ordering, PartialOrd};
|
use std::cmp::{Ord, Ordering, PartialOrd};
|
||||||
|
use std::fmt::{self, Display};
|
||||||
use std::ops::Deref;
|
use std::ops::Deref;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
@@ -314,6 +315,7 @@ macro_rules! delegate {
|
|||||||
IambWindow::VerifyList($id) => $e,
|
IambWindow::VerifyList($id) => $e,
|
||||||
IambWindow::Welcome($id) => $e,
|
IambWindow::Welcome($id) => $e,
|
||||||
IambWindow::ChatList($id) => $e,
|
IambWindow::ChatList($id) => $e,
|
||||||
|
IambWindow::UnreadList($id) => $e,
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -327,6 +329,7 @@ pub enum IambWindow {
|
|||||||
SpaceList(SpaceListState),
|
SpaceList(SpaceListState),
|
||||||
Welcome(WelcomeState),
|
Welcome(WelcomeState),
|
||||||
ChatList(ChatListState),
|
ChatList(ChatListState),
|
||||||
|
UnreadList(UnreadListState),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl IambWindow {
|
impl IambWindow {
|
||||||
@@ -382,6 +385,7 @@ pub type DirectListState = ListState<DirectItem, IambInfo>;
|
|||||||
pub type MemberListState = ListState<MemberItem, IambInfo>;
|
pub type MemberListState = ListState<MemberItem, IambInfo>;
|
||||||
pub type RoomListState = ListState<RoomItem, IambInfo>;
|
pub type RoomListState = ListState<RoomItem, IambInfo>;
|
||||||
pub type ChatListState = ListState<GenericChatItem, IambInfo>;
|
pub type ChatListState = ListState<GenericChatItem, IambInfo>;
|
||||||
|
pub type UnreadListState = ListState<GenericChatItem, IambInfo>;
|
||||||
pub type SpaceListState = ListState<SpaceItem, IambInfo>;
|
pub type SpaceListState = ListState<SpaceItem, IambInfo>;
|
||||||
pub type VerifyListState = ListState<VerifyItem, IambInfo>;
|
pub type VerifyListState = ListState<VerifyItem, IambInfo>;
|
||||||
|
|
||||||
@@ -578,6 +582,39 @@ impl WindowOps<IambInfo> for IambWindow {
|
|||||||
.focus(focused)
|
.focus(focused)
|
||||||
.render(area, buf, state);
|
.render(area, buf, state);
|
||||||
},
|
},
|
||||||
|
IambWindow::UnreadList(state) => {
|
||||||
|
let mut items = store
|
||||||
|
.application
|
||||||
|
.sync_info
|
||||||
|
.rooms
|
||||||
|
.clone()
|
||||||
|
.into_iter()
|
||||||
|
.map(|room_info| GenericChatItem::new(room_info, store, false))
|
||||||
|
.filter(RoomLikeItem::is_unread)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
let dms = store
|
||||||
|
.application
|
||||||
|
.sync_info
|
||||||
|
.dms
|
||||||
|
.clone()
|
||||||
|
.into_iter()
|
||||||
|
.map(|room_info| GenericChatItem::new(room_info, store, true))
|
||||||
|
.filter(RoomLikeItem::is_unread);
|
||||||
|
|
||||||
|
items.extend(dms);
|
||||||
|
|
||||||
|
let fields = &store.application.settings.tunables.sort.chats;
|
||||||
|
items.sort_by(|a, b| room_fields_cmp(a, b, fields));
|
||||||
|
|
||||||
|
state.set(items);
|
||||||
|
|
||||||
|
List::new(store)
|
||||||
|
.empty_message("You do not have rooms or dms yet")
|
||||||
|
.empty_alignment(Alignment::Center)
|
||||||
|
.focus(focused)
|
||||||
|
.render(area, buf, state);
|
||||||
|
},
|
||||||
IambWindow::SpaceList(state) => {
|
IambWindow::SpaceList(state) => {
|
||||||
let mut items = store
|
let mut items = store
|
||||||
.application
|
.application
|
||||||
@@ -629,6 +666,7 @@ impl WindowOps<IambInfo> for IambWindow {
|
|||||||
IambWindow::VerifyList(w) => w.dup(store).into(),
|
IambWindow::VerifyList(w) => w.dup(store).into(),
|
||||||
IambWindow::Welcome(w) => w.dup(store).into(),
|
IambWindow::Welcome(w) => w.dup(store).into(),
|
||||||
IambWindow::ChatList(w) => w.dup(store).into(),
|
IambWindow::ChatList(w) => w.dup(store).into(),
|
||||||
|
IambWindow::UnreadList(w) => w.dup(store).into(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -669,6 +707,7 @@ impl Window<IambInfo> for IambWindow {
|
|||||||
IambWindow::VerifyList(_) => IambId::VerifyList,
|
IambWindow::VerifyList(_) => IambId::VerifyList,
|
||||||
IambWindow::Welcome(_) => IambId::Welcome,
|
IambWindow::Welcome(_) => IambId::Welcome,
|
||||||
IambWindow::ChatList(_) => IambId::ChatList,
|
IambWindow::ChatList(_) => IambId::ChatList,
|
||||||
|
IambWindow::UnreadList(_) => IambId::UnreadList,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -680,6 +719,7 @@ impl Window<IambInfo> for IambWindow {
|
|||||||
IambWindow::VerifyList(_) => bold_spans("Verifications"),
|
IambWindow::VerifyList(_) => bold_spans("Verifications"),
|
||||||
IambWindow::Welcome(_) => bold_spans("Welcome to iamb"),
|
IambWindow::Welcome(_) => bold_spans("Welcome to iamb"),
|
||||||
IambWindow::ChatList(_) => bold_spans("DMs & Rooms"),
|
IambWindow::ChatList(_) => bold_spans("DMs & Rooms"),
|
||||||
|
IambWindow::UnreadList(_) => bold_spans("Unread Messages"),
|
||||||
|
|
||||||
IambWindow::Room(w) => {
|
IambWindow::Room(w) => {
|
||||||
let title = store.application.get_room_title(w.id());
|
let title = store.application.get_room_title(w.id());
|
||||||
@@ -707,6 +747,7 @@ impl Window<IambInfo> for IambWindow {
|
|||||||
IambWindow::VerifyList(_) => bold_spans("Verifications"),
|
IambWindow::VerifyList(_) => bold_spans("Verifications"),
|
||||||
IambWindow::Welcome(_) => bold_spans("Welcome to iamb"),
|
IambWindow::Welcome(_) => bold_spans("Welcome to iamb"),
|
||||||
IambWindow::ChatList(_) => bold_spans("DMs & Rooms"),
|
IambWindow::ChatList(_) => bold_spans("DMs & Rooms"),
|
||||||
|
IambWindow::UnreadList(_) => bold_spans("Unread Messages"),
|
||||||
|
|
||||||
IambWindow::Room(w) => w.get_title(store),
|
IambWindow::Room(w) => w.get_title(store),
|
||||||
IambWindow::MemberList(state, room_id, _) => {
|
IambWindow::MemberList(state, room_id, _) => {
|
||||||
@@ -768,6 +809,11 @@ impl Window<IambInfo> for IambWindow {
|
|||||||
|
|
||||||
Ok(list.into())
|
Ok(list.into())
|
||||||
},
|
},
|
||||||
|
IambId::UnreadList => {
|
||||||
|
let list = UnreadListState::new(IambBufferId::UnreadList, vec![]);
|
||||||
|
|
||||||
|
Ok(IambWindow::UnreadList(list))
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -820,7 +866,7 @@ impl GenericChatItem {
|
|||||||
let name = info.name.clone().unwrap_or_default();
|
let name = info.name.clone().unwrap_or_default();
|
||||||
let alias = room.canonical_alias();
|
let alias = room.canonical_alias();
|
||||||
let unread = info.unreads(&store.application.settings);
|
let unread = info.unreads(&store.application.settings);
|
||||||
info.tags = room_info.deref().1.clone();
|
info.tags.clone_from(&room_info.deref().1);
|
||||||
|
|
||||||
if let Some(alias) = &alias {
|
if let Some(alias) = &alias {
|
||||||
store.application.names.insert(alias.to_string(), room_id.to_owned());
|
store.application.names.insert(alias.to_string(), room_id.to_owned());
|
||||||
@@ -870,9 +916,9 @@ impl RoomLikeItem for GenericChatItem {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ToString for GenericChatItem {
|
impl Display for GenericChatItem {
|
||||||
fn to_string(&self) -> String {
|
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||||
return self.name.clone();
|
write!(f, "{}", self.name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -930,7 +976,7 @@ impl RoomItem {
|
|||||||
let name = info.name.clone().unwrap_or_default();
|
let name = info.name.clone().unwrap_or_default();
|
||||||
let alias = room.canonical_alias();
|
let alias = room.canonical_alias();
|
||||||
let unread = info.unreads(&store.application.settings);
|
let unread = info.unreads(&store.application.settings);
|
||||||
info.tags = room_info.deref().1.clone();
|
info.tags.clone_from(&room_info.deref().1);
|
||||||
|
|
||||||
if let Some(alias) = &alias {
|
if let Some(alias) = &alias {
|
||||||
store.application.names.insert(alias.to_string(), room_id.to_owned());
|
store.application.names.insert(alias.to_string(), room_id.to_owned());
|
||||||
@@ -980,9 +1026,9 @@ impl RoomLikeItem for RoomItem {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ToString for RoomItem {
|
impl Display for RoomItem {
|
||||||
fn to_string(&self) -> String {
|
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||||
return self.name.clone();
|
write!(f, ":verify request {}", self.name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1034,7 +1080,7 @@ impl DirectItem {
|
|||||||
let info = store.application.rooms.get_or_default(room_id);
|
let info = store.application.rooms.get_or_default(room_id);
|
||||||
let name = info.name.clone().unwrap_or_default();
|
let name = info.name.clone().unwrap_or_default();
|
||||||
let unread = info.unreads(&store.application.settings);
|
let unread = info.unreads(&store.application.settings);
|
||||||
info.tags = room_info.deref().1.clone();
|
info.tags.clone_from(&room_info.deref().1);
|
||||||
|
|
||||||
DirectItem { room_info, name, alias, unread }
|
DirectItem { room_info, name, alias, unread }
|
||||||
}
|
}
|
||||||
@@ -1080,9 +1126,9 @@ impl RoomLikeItem for DirectItem {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ToString for DirectItem {
|
impl Display for DirectItem {
|
||||||
fn to_string(&self) -> String {
|
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||||
return self.name.clone();
|
write!(f, ":verify request {}", self.name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1179,9 +1225,9 @@ impl RoomLikeItem for SpaceItem {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ToString for SpaceItem {
|
impl Display for SpaceItem {
|
||||||
fn to_string(&self) -> String {
|
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||||
return self.room_id().to_string();
|
write!(f, ":verify request {}", self.room_id())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1300,16 +1346,18 @@ impl From<(&String, &SasVerification)> for VerifyItem {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ToString for VerifyItem {
|
impl Display for VerifyItem {
|
||||||
fn to_string(&self) -> String {
|
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||||
if self.sasv1.is_done() {
|
if self.sasv1.is_done() {
|
||||||
String::new()
|
return Ok(());
|
||||||
} else if self.sasv1.is_cancelled() {
|
}
|
||||||
format!(":verify request {}", self.sasv1.other_user_id())
|
|
||||||
|
if self.sasv1.is_cancelled() {
|
||||||
|
write!(f, ":verify request {}", self.sasv1.other_user_id())
|
||||||
} else if self.sasv1.emoji().is_some() {
|
} else if self.sasv1.emoji().is_some() {
|
||||||
format!(":verify confirm {}", self.user_dev)
|
write!(f, ":verify confirm {}", self.user_dev)
|
||||||
} else {
|
} else {
|
||||||
format!(":verify accept {}", self.user_dev)
|
write!(f, ":verify accept {}", self.user_dev)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1368,7 +1416,7 @@ impl ListItem<IambInfo> for VerifyItem {
|
|||||||
]));
|
]));
|
||||||
}
|
}
|
||||||
|
|
||||||
Text { lines }
|
Text::from(lines)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_word(&self) -> Option<String> {
|
fn get_word(&self) -> Option<String> {
|
||||||
@@ -1413,9 +1461,9 @@ impl MemberItem {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ToString for MemberItem {
|
impl Display for MemberItem {
|
||||||
fn to_string(&self) -> String {
|
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||||
self.member.user_id().to_string()
|
write!(f, "{}", self.member.user_id())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ use std::fs;
|
|||||||
use std::ops::Deref;
|
use std::ops::Deref;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use edit::edit as external_edit;
|
use edit::edit_with_builder as external_edit;
|
||||||
|
use edit::Builder;
|
||||||
use modalkit::editing::store::RegisterError;
|
use modalkit::editing::store::RegisterError;
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
use tokio;
|
use tokio;
|
||||||
@@ -357,7 +358,22 @@ impl ChatState {
|
|||||||
|
|
||||||
Ok(None)
|
Ok(None)
|
||||||
},
|
},
|
||||||
MessageAction::React(emoji) => {
|
MessageAction::React(reaction, literal) => {
|
||||||
|
let emoji = if literal {
|
||||||
|
reaction
|
||||||
|
} else if let Some(emoji) =
|
||||||
|
emojis::get(&reaction).or_else(|| emojis::get_by_shortcode(&reaction))
|
||||||
|
{
|
||||||
|
emoji.to_string()
|
||||||
|
} else {
|
||||||
|
let msg = format!("{reaction:?} is not a known Emoji shortcode; do you want to react with exactly {reaction:?}?");
|
||||||
|
let act = IambAction::Message(MessageAction::React(reaction, true));
|
||||||
|
let prompt = PromptYesNo::new(msg, vec![Action::from(act)]);
|
||||||
|
let prompt = Box::new(prompt);
|
||||||
|
|
||||||
|
return Err(UIError::NeedConfirm(prompt));
|
||||||
|
};
|
||||||
|
|
||||||
let room = self.get_joined(&store.application.worker)?;
|
let room = self.get_joined(&store.application.worker)?;
|
||||||
let event_id = match &msg.event {
|
let event_id = match &msg.event {
|
||||||
MessageEvent::EncryptedOriginal(ev) => ev.event_id.clone(),
|
MessageEvent::EncryptedOriginal(ev) => ev.event_id.clone(),
|
||||||
@@ -372,6 +388,13 @@ impl ChatState {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if info.user_reactions_contains(&settings.profile.user_id, &event_id, &emoji) {
|
||||||
|
let msg = format!("You’ve already reacted to this message with {}", emoji);
|
||||||
|
let err = UIError::Failure(msg);
|
||||||
|
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
|
||||||
let reaction = Annotation::new(event_id, emoji);
|
let reaction = Annotation::new(event_id, emoji);
|
||||||
let msg = ReactionEventContent::new(reaction);
|
let msg = ReactionEventContent::new(reaction);
|
||||||
let _ = room.send(msg).await.map_err(IambError::from)?;
|
let _ = room.send(msg).await.map_err(IambError::from)?;
|
||||||
@@ -414,7 +437,27 @@ impl ChatState {
|
|||||||
|
|
||||||
Ok(None)
|
Ok(None)
|
||||||
},
|
},
|
||||||
MessageAction::Unreact(emoji) => {
|
MessageAction::Unreact(reaction, literal) => {
|
||||||
|
let emoji = match reaction {
|
||||||
|
reaction if literal => reaction,
|
||||||
|
Some(reaction) => {
|
||||||
|
if let Some(emoji) =
|
||||||
|
emojis::get(&reaction).or_else(|| emojis::get_by_shortcode(&reaction))
|
||||||
|
{
|
||||||
|
Some(emoji.to_string())
|
||||||
|
} else {
|
||||||
|
let msg = format!("{reaction:?} is not a known Emoji shortcode; do you want to remove exactly {reaction:?}?");
|
||||||
|
let act =
|
||||||
|
IambAction::Message(MessageAction::Unreact(Some(reaction), true));
|
||||||
|
let prompt = PromptYesNo::new(msg, vec![Action::from(act)]);
|
||||||
|
let prompt = Box::new(prompt);
|
||||||
|
|
||||||
|
return Err(UIError::NeedConfirm(prompt));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
let room = self.get_joined(&store.application.worker)?;
|
let room = self.get_joined(&store.application.worker)?;
|
||||||
let event_id = match &msg.event {
|
let event_id = match &msg.event {
|
||||||
MessageEvent::EncryptedOriginal(ev) => ev.event_id.clone(),
|
MessageEvent::EncryptedOriginal(ev) => ev.event_id.clone(),
|
||||||
@@ -474,7 +517,16 @@ impl ChatState {
|
|||||||
let msg = self.tbox.get();
|
let msg = self.tbox.get();
|
||||||
|
|
||||||
let msg = if let SendAction::SubmitFromEditor = act {
|
let msg = if let SendAction::SubmitFromEditor = act {
|
||||||
external_edit(msg.trim_end().to_string())?
|
let suffix =
|
||||||
|
store.application.settings.tunables.external_edit_file_suffix.as_str();
|
||||||
|
let edited_msg =
|
||||||
|
external_edit(msg.trim_end().to_string(), Builder::new().suffix(suffix))?
|
||||||
|
.trim_end()
|
||||||
|
.to_string();
|
||||||
|
if edited_msg.is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
edited_msg
|
||||||
} else if msg.is_blank() {
|
} else if msg.is_blank() {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,12 +1,29 @@
|
|||||||
//! # Windows for Matrix rooms and spaces
|
//! # Windows for Matrix rooms and spaces
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
use matrix_sdk::{
|
use matrix_sdk::{
|
||||||
|
notification_settings::RoomNotificationMode,
|
||||||
room::Room as MatrixRoom,
|
room::Room as MatrixRoom,
|
||||||
ruma::{
|
ruma::{
|
||||||
|
api::client::{
|
||||||
|
alias::{
|
||||||
|
create_alias::v3::Request as CreateAliasRequest,
|
||||||
|
delete_alias::v3::Request as DeleteAliasRequest,
|
||||||
|
},
|
||||||
|
error::ErrorKind as ClientApiErrorKind,
|
||||||
|
},
|
||||||
events::{
|
events::{
|
||||||
room::{name::RoomNameEventContent, topic::RoomTopicEventContent},
|
room::{
|
||||||
|
canonical_alias::RoomCanonicalAliasEventContent,
|
||||||
|
history_visibility::{HistoryVisibility, RoomHistoryVisibilityEventContent},
|
||||||
|
name::RoomNameEventContent,
|
||||||
|
topic::RoomTopicEventContent,
|
||||||
|
},
|
||||||
tag::{TagInfo, Tags},
|
tag::{TagInfo, Tags},
|
||||||
},
|
},
|
||||||
OwnedEventId,
|
OwnedEventId,
|
||||||
|
OwnedRoomAliasId,
|
||||||
|
OwnedUserId,
|
||||||
RoomId,
|
RoomId,
|
||||||
},
|
},
|
||||||
DisplayName,
|
DisplayName,
|
||||||
@@ -41,6 +58,7 @@ use crate::base::{
|
|||||||
IambId,
|
IambId,
|
||||||
IambInfo,
|
IambInfo,
|
||||||
IambResult,
|
IambResult,
|
||||||
|
MemberUpdateAction,
|
||||||
MessageAction,
|
MessageAction,
|
||||||
ProgramAction,
|
ProgramAction,
|
||||||
ProgramContext,
|
ProgramContext,
|
||||||
@@ -53,6 +71,8 @@ use crate::base::{
|
|||||||
use self::chat::ChatState;
|
use self::chat::ChatState;
|
||||||
use self::space::{Space, SpaceState};
|
use self::space::{Space, SpaceState};
|
||||||
|
|
||||||
|
use std::convert::TryFrom;
|
||||||
|
|
||||||
mod chat;
|
mod chat;
|
||||||
mod scrollback;
|
mod scrollback;
|
||||||
mod space;
|
mod space;
|
||||||
@@ -66,6 +86,33 @@ macro_rules! delegate {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn notification_mode(name: impl Into<String>) -> IambResult<RoomNotificationMode> {
|
||||||
|
let name = name.into();
|
||||||
|
|
||||||
|
let mode = match name.to_lowercase().as_str() {
|
||||||
|
"mute" => RoomNotificationMode::Mute,
|
||||||
|
"mentions" | "keywords" => RoomNotificationMode::MentionsAndKeywordsOnly,
|
||||||
|
"all" => RoomNotificationMode::AllMessages,
|
||||||
|
_ => return Err(IambError::InvalidNotificationLevel(name).into()),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hist_visibility_mode(name: impl Into<String>) -> IambResult<HistoryVisibility> {
|
||||||
|
let name = name.into();
|
||||||
|
|
||||||
|
let mode = match name.to_lowercase().as_str() {
|
||||||
|
"invited" => HistoryVisibility::Invited,
|
||||||
|
"joined" => HistoryVisibility::Joined,
|
||||||
|
"shared" => HistoryVisibility::Shared,
|
||||||
|
"world" | "world_readable" => HistoryVisibility::WorldReadable,
|
||||||
|
_ => return Err(IambError::InvalidHistoryVisibility(name).into()),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(mode)
|
||||||
|
}
|
||||||
|
|
||||||
/// State for a Matrix room or space.
|
/// State for a Matrix room or space.
|
||||||
///
|
///
|
||||||
/// Since spaces function as special rooms within Matrix, we wrap their window state together, so
|
/// Since spaces function as special rooms within Matrix, we wrap their window state together, so
|
||||||
@@ -148,7 +195,7 @@ impl RoomState {
|
|||||||
let l2 = Line::from(
|
let l2 = Line::from(
|
||||||
"You can run `:invite accept` or `:invite reject` to accept or reject this invitation.",
|
"You can run `:invite accept` or `:invite reject` to accept or reject this invitation.",
|
||||||
);
|
);
|
||||||
let text = Text { lines: vec![l1, l2] };
|
let text = Text::from(vec![l1, l2]);
|
||||||
|
|
||||||
Paragraph::new(text).alignment(Alignment::Center).render(area, buf);
|
Paragraph::new(text).alignment(Alignment::Center).render(area, buf);
|
||||||
|
|
||||||
@@ -182,7 +229,7 @@ impl RoomState {
|
|||||||
pub async fn room_command(
|
pub async fn room_command(
|
||||||
&mut self,
|
&mut self,
|
||||||
act: RoomAction,
|
act: RoomAction,
|
||||||
_: ProgramContext,
|
ctx: ProgramContext,
|
||||||
store: &mut ProgramStore,
|
store: &mut ProgramStore,
|
||||||
) -> IambResult<Vec<(Action<IambInfo>, ProgramContext)>> {
|
) -> IambResult<Vec<(Action<IambInfo>, ProgramContext)>> {
|
||||||
match act {
|
match act {
|
||||||
@@ -239,6 +286,47 @@ impl RoomState {
|
|||||||
Err(IambError::NotJoined.into())
|
Err(IambError::NotJoined.into())
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
RoomAction::MemberUpdate(mua, user, reason, skip_confirm) => {
|
||||||
|
let Some(room) = store.application.worker.client.get_room(self.id()) else {
|
||||||
|
return Err(IambError::NotJoined.into());
|
||||||
|
};
|
||||||
|
|
||||||
|
let Ok(user_id) = OwnedUserId::try_from(user.as_str()) else {
|
||||||
|
let err = IambError::InvalidUserId(user);
|
||||||
|
|
||||||
|
return Err(err.into());
|
||||||
|
};
|
||||||
|
|
||||||
|
if !skip_confirm {
|
||||||
|
let msg = format!("Do you really want to {mua} {user} from this room?");
|
||||||
|
let act = RoomAction::MemberUpdate(mua, user, reason, true);
|
||||||
|
let act = IambAction::from(act);
|
||||||
|
let prompt = PromptYesNo::new(msg, vec![Action::from(act)]);
|
||||||
|
let prompt = Box::new(prompt);
|
||||||
|
|
||||||
|
return Err(UIError::NeedConfirm(prompt));
|
||||||
|
}
|
||||||
|
|
||||||
|
match mua {
|
||||||
|
MemberUpdateAction::Ban => {
|
||||||
|
room.ban_user(&user_id, reason.as_deref())
|
||||||
|
.await
|
||||||
|
.map_err(IambError::from)?;
|
||||||
|
},
|
||||||
|
MemberUpdateAction::Unban => {
|
||||||
|
room.unban_user(&user_id, reason.as_deref())
|
||||||
|
.await
|
||||||
|
.map_err(IambError::from)?;
|
||||||
|
},
|
||||||
|
MemberUpdateAction::Kick => {
|
||||||
|
room.kick_user(&user_id, reason.as_deref())
|
||||||
|
.await
|
||||||
|
.map_err(IambError::from)?;
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(vec![])
|
||||||
|
},
|
||||||
RoomAction::Members(mut cmd) => {
|
RoomAction::Members(mut cmd) => {
|
||||||
let width = Count::Exact(30);
|
let width = Count::Exact(30);
|
||||||
let act =
|
let act =
|
||||||
@@ -249,6 +337,16 @@ impl RoomState {
|
|||||||
|
|
||||||
Ok(vec![(act, cmd.context.clone())])
|
Ok(vec![(act, cmd.context.clone())])
|
||||||
},
|
},
|
||||||
|
RoomAction::SetDirect(is_direct) => {
|
||||||
|
let room = store
|
||||||
|
.application
|
||||||
|
.get_joined_room(self.id())
|
||||||
|
.ok_or(UIError::Application(IambError::NotJoined))?;
|
||||||
|
|
||||||
|
room.set_is_direct(is_direct).await.map_err(IambError::from)?;
|
||||||
|
|
||||||
|
Ok(vec![])
|
||||||
|
},
|
||||||
RoomAction::Set(field, value) => {
|
RoomAction::Set(field, value) => {
|
||||||
let room = store
|
let room = store
|
||||||
.application
|
.application
|
||||||
@@ -256,6 +354,11 @@ impl RoomState {
|
|||||||
.ok_or(UIError::Application(IambError::NotJoined))?;
|
.ok_or(UIError::Application(IambError::NotJoined))?;
|
||||||
|
|
||||||
match field {
|
match field {
|
||||||
|
RoomField::History => {
|
||||||
|
let visibility = hist_visibility_mode(value)?;
|
||||||
|
let ev = RoomHistoryVisibilityEventContent::new(visibility);
|
||||||
|
let _ = room.send_state_event(ev).await.map_err(IambError::from)?;
|
||||||
|
},
|
||||||
RoomField::Name => {
|
RoomField::Name => {
|
||||||
let ev = RoomNameEventContent::new(value);
|
let ev = RoomNameEventContent::new(value);
|
||||||
let _ = room.send_state_event(ev).await.map_err(IambError::from)?;
|
let _ = room.send_state_event(ev).await.map_err(IambError::from)?;
|
||||||
@@ -270,6 +373,97 @@ impl RoomState {
|
|||||||
let ev = RoomTopicEventContent::new(value);
|
let ev = RoomTopicEventContent::new(value);
|
||||||
let _ = room.send_state_event(ev).await.map_err(IambError::from)?;
|
let _ = room.send_state_event(ev).await.map_err(IambError::from)?;
|
||||||
},
|
},
|
||||||
|
RoomField::NotificationMode => {
|
||||||
|
let mode = notification_mode(value)?;
|
||||||
|
let client = &store.application.worker.client;
|
||||||
|
let notifications = client.notification_settings().await;
|
||||||
|
|
||||||
|
notifications
|
||||||
|
.set_room_notification_mode(self.id(), mode)
|
||||||
|
.await
|
||||||
|
.map_err(IambError::from)?;
|
||||||
|
},
|
||||||
|
RoomField::CanonicalAlias => {
|
||||||
|
let client = &mut store.application.worker.client;
|
||||||
|
|
||||||
|
let Ok(orai) = OwnedRoomAliasId::try_from(value.as_str()) else {
|
||||||
|
let err = IambError::InvalidRoomAlias(value);
|
||||||
|
|
||||||
|
return Err(err.into());
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut alt_aliases =
|
||||||
|
room.alt_aliases().into_iter().collect::<HashSet<_>>();
|
||||||
|
let canonical_old = room.canonical_alias();
|
||||||
|
|
||||||
|
// If the room's alias is already that, ignore it
|
||||||
|
if canonical_old.as_ref() == Some(&orai) {
|
||||||
|
let msg = format!("The canonical room alias is already {orai}");
|
||||||
|
|
||||||
|
return Ok(vec![(Action::ShowInfoMessage(msg.into()), ctx)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try creating the room alias on the server.
|
||||||
|
let alias_create_req =
|
||||||
|
CreateAliasRequest::new(orai.clone(), room.room_id().into());
|
||||||
|
if let Err(e) = client.send(alias_create_req, None).await {
|
||||||
|
if let Some(ClientApiErrorKind::Unknown) = e.client_api_error_kind() {
|
||||||
|
// Ignore when it already exists.
|
||||||
|
} else {
|
||||||
|
return Err(IambError::from(e).into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Demote the previous one to an alt alias.
|
||||||
|
alt_aliases.extend(canonical_old);
|
||||||
|
|
||||||
|
// At this point the room alias definitely exists, and we can update the
|
||||||
|
// state event.
|
||||||
|
let mut ev = RoomCanonicalAliasEventContent::new();
|
||||||
|
ev.alias = Some(orai);
|
||||||
|
ev.alt_aliases = alt_aliases.into_iter().collect();
|
||||||
|
let _ = room.send_state_event(ev).await.map_err(IambError::from)?;
|
||||||
|
},
|
||||||
|
RoomField::Alias(alias) => {
|
||||||
|
let client = &mut store.application.worker.client;
|
||||||
|
|
||||||
|
let Ok(orai) = OwnedRoomAliasId::try_from(alias.as_str()) else {
|
||||||
|
let err = IambError::InvalidRoomAlias(alias);
|
||||||
|
|
||||||
|
return Err(err.into());
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut alt_aliases =
|
||||||
|
room.alt_aliases().into_iter().collect::<HashSet<_>>();
|
||||||
|
let canonical = room.canonical_alias();
|
||||||
|
|
||||||
|
if alt_aliases.contains(&orai) || canonical.as_ref() == Some(&orai) {
|
||||||
|
let msg = format!("The alias {orai} already maps to this room");
|
||||||
|
|
||||||
|
return Ok(vec![(Action::ShowInfoMessage(msg.into()), ctx)]);
|
||||||
|
} else {
|
||||||
|
alt_aliases.insert(orai.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the room alias does not exist on the server, create it
|
||||||
|
let alias_create_req = CreateAliasRequest::new(orai, room.room_id().into());
|
||||||
|
if let Err(e) = client.send(alias_create_req, None).await {
|
||||||
|
if let Some(ClientApiErrorKind::Unknown) = e.client_api_error_kind() {
|
||||||
|
// Ignore when it already exists.
|
||||||
|
} else {
|
||||||
|
return Err(IambError::from(e).into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// And add it to the aliases in the state event.
|
||||||
|
let mut ev = RoomCanonicalAliasEventContent::new();
|
||||||
|
ev.alias = canonical;
|
||||||
|
ev.alt_aliases = alt_aliases.into_iter().collect();
|
||||||
|
let _ = room.send_state_event(ev).await.map_err(IambError::from)?;
|
||||||
|
},
|
||||||
|
RoomField::Aliases => {
|
||||||
|
// This never happens, aliases is only used for showing
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(vec![])
|
Ok(vec![])
|
||||||
@@ -281,6 +475,11 @@ impl RoomState {
|
|||||||
.ok_or(UIError::Application(IambError::NotJoined))?;
|
.ok_or(UIError::Application(IambError::NotJoined))?;
|
||||||
|
|
||||||
match field {
|
match field {
|
||||||
|
RoomField::History => {
|
||||||
|
let visibility = HistoryVisibility::Joined;
|
||||||
|
let ev = RoomHistoryVisibilityEventContent::new(visibility);
|
||||||
|
let _ = room.send_state_event(ev).await.map_err(IambError::from)?;
|
||||||
|
},
|
||||||
RoomField::Name => {
|
RoomField::Name => {
|
||||||
let ev = RoomNameEventContent::new("".into());
|
let ev = RoomNameEventContent::new("".into());
|
||||||
let _ = room.send_state_event(ev).await.map_err(IambError::from)?;
|
let _ = room.send_state_event(ev).await.map_err(IambError::from)?;
|
||||||
@@ -292,10 +491,146 @@ impl RoomState {
|
|||||||
let ev = RoomTopicEventContent::new("".into());
|
let ev = RoomTopicEventContent::new("".into());
|
||||||
let _ = room.send_state_event(ev).await.map_err(IambError::from)?;
|
let _ = room.send_state_event(ev).await.map_err(IambError::from)?;
|
||||||
},
|
},
|
||||||
|
RoomField::NotificationMode => {
|
||||||
|
let client = &store.application.worker.client;
|
||||||
|
let notifications = client.notification_settings().await;
|
||||||
|
|
||||||
|
notifications
|
||||||
|
.delete_user_defined_room_rules(self.id())
|
||||||
|
.await
|
||||||
|
.map_err(IambError::from)?;
|
||||||
|
},
|
||||||
|
RoomField::CanonicalAlias => {
|
||||||
|
let Some(alias_to_destroy) = room.canonical_alias() else {
|
||||||
|
let msg = "This room has no canonical alias to unset";
|
||||||
|
|
||||||
|
return Ok(vec![(Action::ShowInfoMessage(msg.into()), ctx)]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Remove the canonical alias from the state event.
|
||||||
|
let mut ev = RoomCanonicalAliasEventContent::new();
|
||||||
|
ev.alias = None;
|
||||||
|
ev.alt_aliases = room.alt_aliases();
|
||||||
|
let _ = room.send_state_event(ev).await.map_err(IambError::from)?;
|
||||||
|
|
||||||
|
// And then unmap it on the server.
|
||||||
|
let del_req = DeleteAliasRequest::new(alias_to_destroy);
|
||||||
|
let _ = store
|
||||||
|
.application
|
||||||
|
.worker
|
||||||
|
.client
|
||||||
|
.send(del_req, None)
|
||||||
|
.await
|
||||||
|
.map_err(IambError::from)?;
|
||||||
|
},
|
||||||
|
RoomField::Alias(alias) => {
|
||||||
|
let Ok(orai) = OwnedRoomAliasId::try_from(alias.as_str()) else {
|
||||||
|
let err = IambError::InvalidRoomAlias(alias);
|
||||||
|
|
||||||
|
return Err(err.into());
|
||||||
|
};
|
||||||
|
|
||||||
|
let alt_aliases = room.alt_aliases();
|
||||||
|
let canonical = room.canonical_alias();
|
||||||
|
|
||||||
|
if !alt_aliases.contains(&orai) && canonical.as_ref() != Some(&orai) {
|
||||||
|
let msg = format!("The alias {orai:?} isn't mapped to this room");
|
||||||
|
|
||||||
|
return Ok(vec![(Action::ShowInfoMessage(msg.into()), ctx)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the alias from the state event if it's in it.
|
||||||
|
let mut ev = RoomCanonicalAliasEventContent::new();
|
||||||
|
ev.alias = canonical.filter(|canon| canon != &orai);
|
||||||
|
ev.alt_aliases = alt_aliases;
|
||||||
|
ev.alt_aliases.retain(|in_orai| in_orai != &orai);
|
||||||
|
let _ = room.send_state_event(ev).await.map_err(IambError::from)?;
|
||||||
|
|
||||||
|
// And then unmap it on the server.
|
||||||
|
let del_req = DeleteAliasRequest::new(orai);
|
||||||
|
let _ = store
|
||||||
|
.application
|
||||||
|
.worker
|
||||||
|
.client
|
||||||
|
.send(del_req, None)
|
||||||
|
.await
|
||||||
|
.map_err(IambError::from)?;
|
||||||
|
},
|
||||||
|
RoomField::Aliases => {
|
||||||
|
// This will not happen, you cannot unset all aliases
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(vec![])
|
Ok(vec![])
|
||||||
},
|
},
|
||||||
|
RoomAction::Show(field) => {
|
||||||
|
let room = store
|
||||||
|
.application
|
||||||
|
.get_joined_room(self.id())
|
||||||
|
.ok_or(UIError::Application(IambError::NotJoined))?;
|
||||||
|
|
||||||
|
let msg = match field {
|
||||||
|
RoomField::History => {
|
||||||
|
let visibility = room.history_visibility();
|
||||||
|
format!("Room history visibility: {visibility}")
|
||||||
|
},
|
||||||
|
RoomField::Name => {
|
||||||
|
match room.name() {
|
||||||
|
None => "Room has no name".into(),
|
||||||
|
Some(name) => format!("Room name: {name:?}"),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
RoomField::Topic => {
|
||||||
|
match room.topic() {
|
||||||
|
None => "Room has no topic".into(),
|
||||||
|
Some(topic) => format!("Room topic: {topic:?}"),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
RoomField::NotificationMode => {
|
||||||
|
let client = &store.application.worker.client;
|
||||||
|
let notifications = client.notification_settings().await;
|
||||||
|
let mode =
|
||||||
|
notifications.get_user_defined_room_notification_mode(self.id()).await;
|
||||||
|
|
||||||
|
let level = match mode {
|
||||||
|
Some(RoomNotificationMode::Mute) => "mute",
|
||||||
|
Some(RoomNotificationMode::MentionsAndKeywordsOnly) => "keywords",
|
||||||
|
Some(RoomNotificationMode::AllMessages) => "all",
|
||||||
|
None => "default",
|
||||||
|
};
|
||||||
|
|
||||||
|
format!("Room notification level: {level:?}")
|
||||||
|
},
|
||||||
|
RoomField::Aliases => {
|
||||||
|
let aliases = room
|
||||||
|
.alt_aliases()
|
||||||
|
.iter()
|
||||||
|
.map(OwnedRoomAliasId::to_string)
|
||||||
|
.collect::<Vec<String>>();
|
||||||
|
|
||||||
|
if aliases.is_empty() {
|
||||||
|
"No alternative aliases in room".into()
|
||||||
|
} else {
|
||||||
|
format!("Alternative aliases: {}.", aliases.join(", "))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
RoomField::CanonicalAlias => {
|
||||||
|
match room.canonical_alias() {
|
||||||
|
None => "No canonical alias for room".into(),
|
||||||
|
Some(can) => format!("Canonical alias: {can}"),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
RoomField::Tag(_) => "Cannot currently show value for a tag".into(),
|
||||||
|
RoomField::Alias(_) => {
|
||||||
|
"Cannot show a single alias; use `:room aliases show` instead.".into()
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let msg = InfoMessage::Pager(msg);
|
||||||
|
let act = Action::ShowInfoMessage(msg);
|
||||||
|
|
||||||
|
Ok(vec![(act, ctx)])
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -462,3 +797,27 @@ impl WindowOps<IambInfo> for RoomState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_room_notification_level() {
|
||||||
|
let tests = vec![
|
||||||
|
("mute", RoomNotificationMode::Mute),
|
||||||
|
("mentions", RoomNotificationMode::MentionsAndKeywordsOnly),
|
||||||
|
("keywords", RoomNotificationMode::MentionsAndKeywordsOnly),
|
||||||
|
("all", RoomNotificationMode::AllMessages),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (input, expect) in tests {
|
||||||
|
let res = notification_mode(input).unwrap();
|
||||||
|
assert_eq!(expect, res);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(notification_mode("invalid").is_err());
|
||||||
|
assert!(notification_mode("not a level").is_err());
|
||||||
|
assert!(notification_mode("@user:example.com").is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -673,8 +673,7 @@ impl EditorActions<ProgramContext, ProgramStore, IambInfo> for ScrollbackState {
|
|||||||
let dir = ctx.get_search_regex_dir();
|
let dir = ctx.get_search_regex_dir();
|
||||||
let dir = flip.resolve(&dir);
|
let dir = flip.resolve(&dir);
|
||||||
|
|
||||||
let lsearch = store.registers.get(&Register::LastSearch)?;
|
let lsearch = store.registers.get_last_search().to_string();
|
||||||
let lsearch = lsearch.value.to_string();
|
|
||||||
let needle = Regex::new(lsearch.as_ref())?;
|
let needle = Regex::new(lsearch.as_ref())?;
|
||||||
|
|
||||||
let (mc, needs_load) = self.find_message(key, dir, &needle, count, info);
|
let (mc, needs_load) = self.find_message(key, dir, &needle, count, info);
|
||||||
@@ -753,8 +752,7 @@ impl EditorActions<ProgramContext, ProgramStore, IambInfo> for ScrollbackState {
|
|||||||
let dir = ctx.get_search_regex_dir();
|
let dir = ctx.get_search_regex_dir();
|
||||||
let dir = flip.resolve(&dir);
|
let dir = flip.resolve(&dir);
|
||||||
|
|
||||||
let lsearch = store.registers.get(&Register::LastSearch)?;
|
let lsearch = store.registers.get_last_search().to_string();
|
||||||
let lsearch = lsearch.value.to_string();
|
|
||||||
let needle = Regex::new(lsearch.as_ref())?;
|
let needle = Regex::new(lsearch.as_ref())?;
|
||||||
|
|
||||||
let (mc, needs_load) = self.find_message(key, dir, &needle, count, info);
|
let (mc, needs_load) = self.find_message(key, dir, &needle, count, info);
|
||||||
@@ -1452,16 +1450,16 @@ mod tests {
|
|||||||
// MSG4: "help"
|
// MSG4: "help"
|
||||||
// MSG5: "character"
|
// MSG5: "character"
|
||||||
// MSG1: "writhe"
|
// MSG1: "writhe"
|
||||||
store.set_last_search("he");
|
store.registers.set_last_search("he");
|
||||||
|
|
||||||
assert_eq!(scrollback.cursor, MessageCursor::latest());
|
assert_eq!(scrollback.cursor, MessageCursor::latest());
|
||||||
|
|
||||||
// Search backwards to MSG4.
|
// Search backwards to MSG4.
|
||||||
scrollback.search(prev.clone(), 1.into(), &ctx, &mut store).unwrap();
|
scrollback.search(prev, 1.into(), &ctx, &mut store).unwrap();
|
||||||
assert_eq!(scrollback.cursor, MSG4_KEY.clone().into());
|
assert_eq!(scrollback.cursor, MSG4_KEY.clone().into());
|
||||||
|
|
||||||
// Search backwards to MSG2.
|
// Search backwards to MSG2.
|
||||||
scrollback.search(prev.clone(), 1.into(), &ctx, &mut store).unwrap();
|
scrollback.search(prev, 1.into(), &ctx, &mut store).unwrap();
|
||||||
assert_eq!(scrollback.cursor, MSG2_KEY.clone().into());
|
assert_eq!(scrollback.cursor, MSG2_KEY.clone().into());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
std::mem::take(&mut store.application.need_load)
|
std::mem::take(&mut store.application.need_load)
|
||||||
@@ -1472,7 +1470,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Can't go any further; need_load now contains the room ID.
|
// Can't go any further; need_load now contains the room ID.
|
||||||
scrollback.search(prev.clone(), 1.into(), &ctx, &mut store).unwrap();
|
scrollback.search(prev, 1.into(), &ctx, &mut store).unwrap();
|
||||||
assert_eq!(scrollback.cursor, MSG2_KEY.clone().into());
|
assert_eq!(scrollback.cursor, MSG2_KEY.clone().into());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
std::mem::take(&mut store.application.need_load)
|
std::mem::take(&mut store.application.need_load)
|
||||||
@@ -1482,11 +1480,11 @@ mod tests {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Search forward twice to MSG1.
|
// Search forward twice to MSG1.
|
||||||
scrollback.search(next.clone(), 2.into(), &ctx, &mut store).unwrap();
|
scrollback.search(next, 2.into(), &ctx, &mut store).unwrap();
|
||||||
assert_eq!(scrollback.cursor, MSG1_KEY.clone().into());
|
assert_eq!(scrollback.cursor, MSG1_KEY.clone().into());
|
||||||
|
|
||||||
// Can't go any further.
|
// Can't go any further.
|
||||||
scrollback.search(next.clone(), 2.into(), &ctx, &mut store).unwrap();
|
scrollback.search(next, 2.into(), &ctx, &mut store).unwrap();
|
||||||
assert_eq!(scrollback.cursor, MSG1_KEY.clone().into());
|
assert_eq!(scrollback.cursor, MSG1_KEY.clone().into());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ impl<'a> StatefulWidget for Space<'a> {
|
|||||||
Span::styled(e.to_string(), Style::default().fg(Color::Red)).into(),
|
Span::styled(e.to_string(), Style::default().fg(Color::Red)).into(),
|
||||||
];
|
];
|
||||||
|
|
||||||
empty_message = Text { lines }.into();
|
empty_message = Text::from(lines).into();
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,10 +37,10 @@ The different subcommands are:
|
|||||||
|
|
||||||
## Additional Configuration
|
## Additional Configuration
|
||||||
|
|
||||||
You can customize iamb in your `$CONFIG_DIR/iamb/config.json` file, where
|
You can customize iamb in your `$CONFIG_DIR/iamb/config.toml` file, where
|
||||||
`$CONFIG_DIR` is your system's per-user configuration directory.
|
`$CONFIG_DIR` is your system's per-user configuration directory. For example,
|
||||||
|
this is typically `~/.config/iamb/config.toml` on systems that use the XDG
|
||||||
|
Base Directory Specification.
|
||||||
|
|
||||||
You can edit the following values in the file:
|
See the manual pages or <https://iamb.chat> for more details on how to
|
||||||
|
further configure or use iamb.
|
||||||
- `"default_profile"`, a profile name to use when starting iamb if one wasn't specified
|
|
||||||
- `"cache"`, a directory for cached iamb
|
|
||||||
|
|||||||
Reference in New Issue
Block a user