157 Commits

Author SHA1 Message Date
Ulyssa
ffc528e56a Do not include icon tag in MetaInfo 2024-08-21 18:57:48 -07:00
Ulyssa
7b6c5df268 Update MetaInfo for v0.0.10 release (#335) 2024-08-21 16:10:56 +00:00
Ulyssa
2e6376ff86 Release v0.0.10 (#333) 2024-08-20 22:26:52 -07:00
Ulyssa
480888a1fc Add commands for viewing and clearing unreads (#332) 2024-08-20 19:33:46 -07:00
Ulyssa
4fc05c7b40 Handle message marks on non-64-bit platforms (#329) 2024-08-18 08:31:42 +00:00
Ulyssa
3003f0a528 Add command for setting room history visibility (#328) 2024-08-18 07:33:45 +00:00
Ulyssa
df3896df9c Add ban/unban/kick room commands (#327) 2024-08-18 01:50:48 +00:00
Tony
2a66496913 Add command to set per-room notification levels (#305) 2024-08-17 14:43:19 -07:00
Ulyssa
b4fc574163 Include room name in desktop notifications (#326) 2024-08-17 01:03:13 -07:00
Ulyssa
e63341fe32 Avoid treating simple messages as Markdown (#325) 2024-08-16 23:56:30 -07:00
Ulyssa
657e61fe2e Remove modifyOtherKeys enablement (#324) 2024-08-17 03:06:35 +00:00
Ulyssa
94999dc4c0 Build cross-platform binaries and packages of main (#323) 2024-08-16 10:06:26 -07:00
Ulyssa
54cb7991be Fix underflow panics when using TextPrinter::push_span_nobreak (#322) 2024-08-15 03:48:04 +00:00
Ulyssa
c94d7d0ad7 Add metadata for cargo-deb and cargo-generate-rpm (#321) 2024-08-15 03:37:56 +00:00
Ulyssa
d44961c461 Support reacting literally with non-Emojis (#320) 2024-08-13 06:21:11 +00:00
Ulyssa
6d80b516f8 Update to modalkit{,-ratatui}@0.0.20 (#319) 2024-08-12 04:59:32 +00:00
Ulyssa
04480eda1b Add message slash commands (#317) 2024-08-08 05:49:54 +00:00
Ulyssa
653287478e Add FreeDesktop MetaInfo file (#315) 2024-08-01 07:45:50 +00:00
lymkwi
4571788678 Implement set/unset/show for alternative and canonical aliases (#279) 2024-08-01 06:51:01 +00:00
Andrew Collins
9a1adfb287 Allow notifications on open room if terminal not focused (#281) 2024-08-01 03:37:21 +00:00
Backroom8816
cb4455655f Add Hombrew as install method on MacOS (#303) 2024-08-01 03:10:31 +00:00
Jarkko Sakkinen
4fc71c9291 Fix newer Clippy warnings for 1.80 (#301) 2024-08-01 03:02:42 +00:00
Veronika
d8d8e91295 Remove timeout for desktop notifications (#314) 2024-08-01 02:56:33 +00:00
Aurore Poirier
497be7f099 Display file sizes for attachments (#278) 2024-05-25 16:16:04 -07:00
mordquist
64e4f67e43 Add error for missing username on :logout (#277) 2024-05-25 15:53:52 -07:00
Andrew Collins
a18d0f54eb Trim :editor output and check if it's empty (#275) 2024-05-25 15:52:41 -07:00
Lars E
59e1862e9c Add FreeBSD installation instructions (#280) 2024-05-25 20:42:46 +00:00
Joshua Smith
14415a30fc Fix openSUSE link and installation command in README (#283) 2024-05-25 20:40:25 +00:00
Gabor Pihaj
6c0d126f4b Add missing darwin build dependency (#286) 2024-05-25 20:38:01 +00:00
Ulyssa
c6982c9737 Fix LICENSE file (#274) 2024-04-24 06:59:00 +00:00
Ulyssa
46f6d37f76 Update to modalkit{,-ratatui}@0.0.19 (#273) 2024-04-24 06:30:01 +00:00
Ulyssa
3971801aa3 Allow typing newline with <S-Enter> and enable keyboard enhancement protocol (#272) 2024-04-21 18:19:53 -07:00
Ulyssa
7bc34c8145 Update Cargo.toml to v0.0.10-alpha.1 and update dependencies (#269) 2024-04-17 08:06:08 +00:00
Ethan Reynolds
91ca50aecb Fix image preview placement when messages are preceded by a date in the timeline (#257) 2024-04-13 15:47:08 -07:00
Ulyssa
949100bdc7 Update Welcome window to reference TOML instead of JSON (#254) 2024-04-12 06:20:05 +00:00
Ethan Reynolds
b995906c79 Add external_edit_file_suffix to config (#253) 2024-04-11 20:50:26 -07:00
Ulyssa
e5b284ed19 Use color overrides for users when message_user_color is enabled (#245) 2024-04-02 15:42:27 +00:00
Matthias Ahouansou
0f17bbfa17 Fix reaction count when there are duplicate reaction events from a user (#239) 2024-04-02 15:40:25 +00:00
Matthias Ahouansou
aba72aa64d Prevent sending duplicate reaction events (#240) 2024-04-02 15:21:24 +00:00
Benjamin Grosse
72d35431de Update to ratatui-image@1.0.0 (#241) 2024-04-02 08:01:00 -07:00
Ulyssa
a98bbd97be Support marking a room as a direct message room (#92) 2024-03-31 00:12:57 -07:00
Ulyssa
82645c8828 Release v0.0.9 (#236) 2024-03-29 04:35:38 +00:00
Ulyssa
5a2a7b028d Wait to log in before starting background tasks (#234) 2024-03-29 04:14:37 +00:00
Ulyssa
2327658e8c Add commands for importing and exporting room keys (#233) 2024-03-28 20:58:34 -07:00
Ulyssa
b4e9c213e6 Add an icon for iamb (#232) 2024-03-28 16:20:27 +00:00
Ulyssa
79f6b5b75c Reset message bar when ! is passed with :cancel (#231) 2024-03-27 19:35:15 -07:00
Ulyssa
6600685dd5 Update manual pages to use mdoc(7) and list commands (#230) 2024-03-26 15:55:22 +00:00
Ulyssa
ed1b88c197 Support loading a TOML configuration (#229) 2024-03-25 21:30:35 -07:00
Ulyssa
99996e275b Support notifications via terminal bell (#227)
Co-authored-by: Benjamin Grosse <ste3ls@gmail.com>
2024-03-24 17:19:34 +00:00
Ulyssa
db9cb92737 Enable autolinking when rendering Markdown (#226) 2024-03-24 03:06:33 +00:00
Ulyssa
d3b717d1be Fix image previews in replies (#225) 2024-03-24 02:41:05 +00:00
Ulyssa
2ac71da9a6 Fix entering thread view when there's no messages yet (#224) 2024-03-24 02:20:06 +00:00
Ulyssa
1e9b6cc271 Provide better error message for M_UNKNOWN_TOKEN (#101) 2024-03-23 19:09:11 -07:00
mordquist
46e081b1e4 Support configuring user gutter width (#223) 2024-03-23 18:54:26 -07:00
Bernhard Bliem
23a729e565 Support displaying shortcodes instead of Emojis in messages (#222) 2024-03-23 16:35:10 -07:00
Benjamin Grosse
0c52375e06 Add support for desktop notifications (#192) 2024-03-21 17:46:46 -07:00
Ulyssa
c63f8d98d5 Fix odd Windows-only compile error (#221) 2024-03-20 22:30:14 -07:00
Ulyssa
013214899a Ignore key releases on platforms that support it (#220) 2024-03-21 05:13:47 +00:00
Ulyssa
8a5049fb25 GitHub workflow should use --locked to avoid broken Cargo.lock (#219) 2024-03-20 15:29:04 +00:00
Ulyssa
9c6ff58b96 Support linking against system OpenSSL (#218) 2024-03-19 21:55:14 -07:00
Thomas Vodrazka
b41faff9b7 Add example of mapping "V" to toggle message selection mode (#195) 2024-03-09 22:57:35 -08:00
Ulyssa
e7f158ffcd Add support for custom key macros (#217) 2024-03-10 06:49:40 +00:00
Ulyssa
ef868175cb Add support for threads (#216) 2024-03-09 00:47:05 -08:00
Benjamin Grosse
8ee203c9a9 Update to ratatui-image@0.8.1 (#215) 2024-03-08 20:04:52 -08:00
Ryan
95f2c7af30 Nix flake updates (#214) 2024-03-08 20:03:55 -08:00
Ali Elnwegy
c71cec1f54 Fix Nix flake hashes (#206) 2024-03-08 06:06:02 +00:00
Ulyssa
ec81b72f2c Load receipts for room before acquiring lock (#213) 2024-03-07 07:49:35 +00:00
Ulyssa
dd001af365 Download rooms keys from backups if they exist (#211) 2024-03-02 23:55:27 +00:00
Ulyssa
9732971fc2 Update to matrix-sdk@0.7.1 (#200) 2024-03-02 23:00:29 +00:00
Alan Pope
1948d80ec8 Add snap install instructions (#210) 2024-03-02 21:48:46 +00:00
Ulyssa
84bc6be822 Support following the .well-known entries for a username's domain (#209) 2024-02-29 07:21:31 +00:00
Ulyssa
c5999bffc8 Pull in modalkit repository with a Cargo.lock (#208) 2024-02-29 07:00:25 +00:00
Ulyssa
aa878f7569 Move LTO into its own "release-lto" profile (#207) 2024-02-29 06:31:00 +00:00
Ulyssa
a2a708f1ae Indicate and sort on rooms with unread messages (#205)
Fixes #83
2024-02-28 09:03:28 -08:00
Benjamin Grosse
3ed87aae05 Support coloring entire message with the user color (#193) 2024-02-28 06:52:24 +00:00
Ulyssa
1325295d2b Update modalkit dependencies (#204) 2024-02-28 05:21:05 +00:00
Benjamin Lee
1cb280df8b Fix truncation/padding for non-ASCII sender names (#182) 2024-02-27 21:09:37 -08:00
Rerum02
5be886301b Update README.md to add openSUSE Tumbleweed (#191) 2024-02-28 03:43:03 +00:00
O. C. Taskin
3e3b771b2e Rename Nix flake build input from pkgconfig to pkg-config (#203) 2024-02-28 03:23:17 +00:00
FormindVER
b7ae01499b Add a new :chats window that lists both DMs and Rooms (#184)
Fixes #172
2024-02-27 18:37:10 -08:00
Benjamin Grosse
88af9bfec3 Fix crash on small image preview (#198) 2024-01-27 23:35:07 -08:00
Benjamin Grosse
999399a70f Fix not showing display names in already synced rooms (#171)
Fixes #149
2023-12-18 20:55:04 -08:00
sem pruijs
b33759cbc3 Enable direnv for Nix flakes (#183) 2023-12-19 00:53:17 +00:00
Benjamin Grosse
4236d9f53e Update to ratatui-image@0.4.3 to use native sixel lib (#181) 2023-11-24 15:22:39 -08:00
Benjamin Grosse
1ae22086f6 Fix image preview offset (#179) 2023-11-20 13:22:15 -08:00
Benjamin Grosse
221faa828d Add support for previewing images in room scrollback (#108) 2023-11-16 08:36:22 -08:00
Ron Waldon-Howe
974775b29b feat: desktop file for GUI environment launchers (#178) 2023-11-14 12:19:54 -08:00
chloe
25eef55eb7 Add support for logging in with SSO (#160) 2023-11-04 21:39:17 +00:00
Ulyssa
8943909f06 Support custom sorting for room and user lists (#170) 2023-10-21 02:32:33 +00:00
Ulyssa
443ad241b4 Use mozilla-actions/sccache-action for caching builds (#169) 2023-10-21 01:48:06 +00:00
Aaditya Dhruv
3b86be0545 Add new command for logging out of iamb session (#162) 2023-10-19 21:40:22 -07:00
Benjamin Lee
b2b47ed7a0 Reduce CPU usage by instead fetching read receipts after related sync events (#168) 2023-10-16 01:12:39 +00:00
Ulyssa
df3148b9f5 Links should be "openable" (#43) 2023-10-07 18:25:25 -07:00
Benjamin Große
95af00ba93 Update modalkit for newer ratatui and crossterm 2023-10-07 17:21:48 -07:00
Ulyssa
9197864c5c Add more documentation (#166) 2023-10-06 22:35:27 -07:00
Ulyssa
2673cfaeb9 Fix CI workflow (#164) 2023-10-05 18:37:31 -07:00
Ulyssa
c7864cb869 Enable sending strikethrough text (#141) 2023-09-12 17:27:04 -07:00
Ulyssa
7fdb5f98e3 Update Cargo.lock file (#157) 2023-09-12 17:17:29 -07:00
Leonid Dyachkov
0565b6eb05 Support composing messages in an external editor (#155) 2023-09-12 17:07:56 -07:00
balejk
47e650c2be Fix example config (#140) 2023-09-12 16:50:37 -07:00
Ulyssa
89bb107c87 Release v0.0.8 (fix cargo publish issues) (#134) 2023-07-07 23:21:47 -07:00
Ulyssa
ca4c0034d9 Release v0.0.8 (#134) 2023-07-07 22:46:08 -07:00
Ulyssa
bb30cecc63 Handle sync failure after successful password entry (#133) 2023-07-07 22:35:33 -07:00
Ulyssa
7b050f82aa Indicate when there are new messages below scrollback viewport (#131) 2023-07-07 22:16:57 -07:00
Ulyssa
b1ccec6732 Code blocks get rendered without line breaks (#122) 2023-07-07 21:46:13 -07:00
Ulyssa
6e8e12b579 Need fallback behaviour when dirs::download_dir returns None (#118) 2023-07-07 20:35:01 -07:00
Ulyssa
3da9835a17 Profile session token should only be readable by the user (#130) 2023-07-07 20:34:52 -07:00
Ulyssa
64891ec68f Support hiding server part of username in message scrollback (#71) 2023-07-06 23:15:58 -07:00
Ulyssa
61aba80be1 Reduce number of Tokio workers (#129) 2023-07-05 15:25:42 -07:00
Ulyssa
8d4539831f Remove trailing newlines in body (#125) 2023-06-30 21:18:08 -07:00
Ulyssa
7c39e88ba2 Restore opened tabs and windows upon restart (#72) 2023-06-28 23:42:31 -07:00
satoqz
758397eb5a Fix Nix flake build on Darwin (#117) 2023-06-22 23:05:50 -07:00
u2on
1a0af6df37 Link to AUR pkg in README (#121) 2023-06-22 23:01:07 -07:00
Ulyssa
885b56038f Use terminal window focus to determine when a message has actually been seen (#94) 2023-06-14 22:42:23 -07:00
Benjamin Große
430c601ff2 Support configuring which program :open runs (#95) 2023-06-14 21:36:23 -07:00
Moritz Poldrack
0ddefcd7b3 Add manual pages (#88) 2023-06-14 21:14:23 -07:00
Ulyssa
2a573b6056 Show Git SHA information when printing version information (#120) 2023-06-14 20:28:01 -07:00
mikoto
a020b860dd Indicate number of members in room (#110) 2023-06-14 19:42:53 -07:00
jasalltime
6c031f589e Mention Minimum Supported Rust Version in README (#115) 2023-06-14 19:28:23 -07:00
Ulyssa
b0256d7120 Replace GitHub actions using deprecated features (#114) 2023-05-28 21:46:43 -07:00
Ulyssa
0f870367b3 Show errors fetching space hierarchy when list is empty (#113) 2023-05-28 13:16:37 -07:00
Ulyssa
8d22b83d85 Support sending and completing Emoji shortcodes in the message bar (#100) 2023-05-24 21:14:13 -07:00
Pavlo Rudy
529073f4d4 Upload artifacts built in GitHub Actions (#105) 2023-05-22 17:20:17 -07:00
Ulyssa
17c87a617e Cache build directory in GitHub Actions (#107) 2023-05-19 18:25:09 -07:00
Benjamin Große
2899d4f45a Support uploading image attachments from clipboard (#36) 2023-05-19 17:38:23 -07:00
Ulyssa
ad8b4a60d2 ChatStore::set_receipts locks up app for bad connections (#99) 2023-05-12 17:42:25 -07:00
Ulyssa
4935899aed Indicate when you're editing a message (#75) 2023-05-01 22:14:08 -07:00
Ulyssa
cc1d2f3bf8 Gracefully handle verification events that are unknown locally (#90) 2023-05-01 21:33:12 -07:00
Ulyssa
5df9fe7960 Tab completion panics for unrecognized commands (#81) 2023-05-01 21:14:19 -07:00
Ulyssa
a5c25f2487 Support leaving rooms (#45) 2023-04-28 16:52:33 -07:00
Benjamin Große
50023bad40 Append suffix to download filenames to avoid overwrites (#35) 2023-04-28 15:56:14 -07:00
Moritz Poldrack
b6a318dfe3 Fix error message for undefined download directory (#87) 2023-04-25 13:57:03 -07:00
Ulyssa
ad3b40d538 Interpret newlines as line breaks when converting Markdown to HTML (#74) 2023-04-06 16:10:48 -07:00
Ulyssa
953be6a195 Add FUNDING.yml to project (#77) 2023-03-31 18:38:13 -07:00
Benjamin Große
463d46b8ab Add Nix flake (#73) 2023-03-31 11:43:22 -07:00
Ulyssa
274234ce4c Update locked Cargo dependencies (#70) 2023-03-23 13:39:57 -07:00
Ulyssa
a2590b6bbb Release v0.0.7 (#68) 2023-03-22 21:28:34 -07:00
Ulyssa
725ebb9fd6 Redacted messages should have their HTML removed (#67) 2023-03-22 21:25:37 -07:00
jahway603
ca395097e1 Update README.md to include Arch Linux package (#66) 2023-03-22 20:13:25 -07:00
Ulyssa
e98d58a8cc Emote messages should always show sender (#65) 2023-03-21 14:02:42 -07:00
Ulyssa
e6cdd02f22 HTML self-closing tags are getting parsed incorrectly (#63) 2023-03-20 17:53:55 -07:00
Ulyssa
0bc4ff07b0 Lazy load room state events on initial sync (#62) 2023-03-20 16:17:59 -07:00
Ulyssa
14fe916d94 Allow log level to be configured (#58) 2023-03-13 16:43:08 -07:00
Ulyssa
db35581d07 Indicate when an encrypted room event has been redacted (#59) 2023-03-13 16:43:04 -07:00
Ulyssa
7c1c62897a Show events that couldn't be decrypted (#57) 2023-03-13 15:18:53 -07:00
Ulyssa
61897ea6f2 Fetch scrollback history independently of main loop (#39) 2023-03-13 10:46:26 -07:00
Ulyssa
6a0722795a Fix empty message check when sending (#56) 2023-03-13 09:26:49 -07:00
Ulyssa
f3bbc6ad9f Support configuring client request timeout (#54) 2023-03-12 15:43:13 -07:00
Ulyssa
2dd8c0fddf Link to iamb space in README (#55) 2023-03-10 18:08:42 -08:00
Pavlo Rudy
a786369b14 Create release profile with LTO (#52) 2023-03-10 16:41:32 -08:00
pin
066f60ad32 Add NetBSD install instructions (#51) 2023-03-09 09:27:40 -08:00
Ulyssa
10b142c071 Release v0.0.6 (#48) 2023-03-05 13:28:08 -08:00
Ulyssa
ac6ff63d25 Avoid breaking up words during wrapping when possible (#47) 2023-03-05 12:59:34 -08:00
Ulyssa
54a0e76823 Edited messages need to have their HTML reprocessed (#46) 2023-03-05 12:48:31 -08:00
Ulyssa
93eff79f79 Support creating new rooms and spaces (#40) 2023-03-04 12:23:17 -08:00
Ulyssa
11625262f1 Direct message rooms should be encrypted from creation (#29) 2023-03-03 16:37:11 -08:00
Ulyssa
0ed1d53946 Support completing commands, usernames, and room names (#44) 2023-03-01 18:46:33 -08:00
45 changed files with 13917 additions and 3219 deletions

1
.envrc Normal file
View File

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

4
.gitattributes vendored
View File

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

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

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

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

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

View File

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

2
.gitignore vendored
View File

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

4305
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "iamb" name = "iamb"
version = "0.0.5" 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"
@@ -11,40 +11,85 @@ license = "Apache-2.0"
exclude = [".github", "CONTRIBUTING.md"] exclude = [".github", "CONTRIBUTING.md"]
keywords = ["matrix", "chat", "tui", "vim"] keywords = ["matrix", "chat", "tui", "vim"]
categories = ["command-line-utilities"] categories = ["command-line-utilities"]
rust-version = "1.66" rust-version = "1.70"
build = "build.rs"
[features]
default = ["bundled", "desktop"]
bundled = ["matrix-sdk/bundled-sqlite", "rustls-tls"]
desktop = ["dep:notify-rust", "modalkit/clipboard"]
native-tls = ["matrix-sdk/native-tls"]
rustls-tls = ["matrix-sdk/rustls-tls"]
[build-dependencies.vergen]
version = "8"
default-features = false
features = ["build", "git", "gitcl",]
[dependencies] [dependencies]
bitflags = "1.3.2" anyhow = "1.0"
bitflags = "^2.3"
chrono = "0.4" chrono = "0.4"
clap = {version = "4.0", features = ["derive"]} clap = {version = "~4.3", features = ["derive"]}
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"
gethostname = "0.4.1" gethostname = "0.4.1"
html5ever = "0.26.0" html5ever = "0.26.0"
image = "0.24.5"
libc = "0.2"
markup5ever_rcdom = "0.2.0" markup5ever_rcdom = "0.2.0"
mime = "^0.3.16" mime = "^0.3.16"
mime_guess = "^2.0.4" mime_guess = "^2.0.4"
nom = "7.0.0"
open = "3.2.0" open = "3.2.0"
rand = "0.8.5"
ratatui = "0.26"
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"
serde_json = "^1.0" serde_json = "^1.0"
sled = "0.34.7"
temp-dir = "0.1.12"
thiserror = "^1.0.37" thiserror = "^1.0.37"
toml = "^0.8.12"
tracing = "~0.1.36" tracing = "~0.1.36"
tracing-appender = "~0.2.2" tracing-appender = "~0.2.2"
tracing-subscriber = "0.3.16" tracing-subscriber = "0.3.16"
unicode-segmentation = "^1.7" unicode-segmentation = "^1.7"
unicode-width = "0.1.10" unicode-width = "0.1.10"
url = {version = "^2.2.2", features = ["serde"]} url = {version = "^2.2.2", features = ["serde"]}
edit = "0.1.4"
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.11" version = "0.0.20"
default-features = false
#git = "https://github.com/ulyssa/modalkit"
#rev = "24f3ec11c7f634005a27b26878d0fbbdcc08f272"
[dependencies.modalkit-ratatui]
version = "0.0.20"
#git = "https://github.com/ulyssa/modalkit"
#rev = "24f3ec11c7f634005a27b26878d0fbbdcc08f272"
[dependencies.matrix-sdk] [dependencies.matrix-sdk]
version = "0.6" version = "0.7.1"
default-features = false default-features = false
features = ["e2e-encryption", "markdown", "sled", "rustls-tls"] features = ["e2e-encryption", "sqlite", "sso-login"]
[dependencies.tokio] [dependencies.tokio]
version = "1.24.1" version = "1.24.1"
@@ -52,3 +97,39 @@ features = ["macros", "net", "rt-multi-thread", "sync", "time"]
[dev-dependencies] [dev-dependencies]
lazy_static = "1.4.0" lazy_static = "1.4.0"
pretty_assertions = "1.4.0"
[profile.release-lto]
inherits = "release"
incremental = false
lto = true
[package.metadata.deb]
section = "net"
license-file = ["LICENSE", "0"]
assets = [
# Binary:
["target/release/iamb", "usr/bin/iamb", "755"],
# Manual pages:
["docs/iamb.1", "usr/share/man/man1/iamb.1", "644"],
["docs/iamb.5", "usr/share/man/man5/iamb.5", "644"],
# Other assets:
["iamb.desktop", "usr/share/applications/iamb.desktop", "644"],
["config.example.toml", "usr/share/iamb/config.example.toml", "644"],
["docs/iamb.svg", "usr/share/icons/hicolor/scalable/apps/iamb.svg", "644"],
["docs/iamb.metainfo.xml", "usr/share/metainfo/iamb.metainfo.xml", "644"],
]
[package.metadata.generate-rpm]
assets = [
# Binary:
{ source = "target/release/iamb", dest = "/usr/bin/iamb", mode = "755" },
# Manual pages:
{ source = "docs/iamb.1", dest = "/usr/share/man/man1/iamb.1", mode = "644" },
{ source = "docs/iamb.5", dest = "/usr/share/man/man5/iamb.5", mode = "644" },
# Other assets:
{ source = "iamb.desktop", dest = "/usr/share/applications/iamb.desktop", mode = "644" },
{ source = "config.example.toml", dest = "/usr/share/iamb/config.example.toml", mode = "644"},
{ source = "docs/iamb.svg", dest = "/usr/share/icons/hicolor/scalable/apps/iamb.svg", mode = "644"},
{ source = "docs/iamb.metainfo.xml", dest = "/usr/share/metainfo/iamb.metainfo.xml", mode = "644"},
]

View File

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

44
PACKAGING.md Normal file
View File

@@ -0,0 +1,44 @@
# Notes For Package Maintainers
## Linking Against System Packages
The default Cargo features for __iamb__ will bundle SQLite and use [rustls] for
TLS. Package maintainers may want to link against the system's native SQLite
and TLS libraries instead. To do so, you'll want to build without the default
features and specify that it should build with `native-tls`:
```
% cargo build --release --no-default-features --features=native-tls
```
## Enabling LTO
Enabling LTO can result in smaller binaries. There is a separate profile to
enable it when building:
```
% cargo build --profile release-lto
```
Note that this [can fail][ring-lto] in some build environments if both Clang
and GCC are present.
## Documentation
In addition to the compiled binary, there are other files in the repo that
you'll want to install as part of a package:
<!-- Please keep in sync w/ the `deb`/`generate-rpm` sections of `Cargo.toml` -->
| Repository Path | Installed Path (may vary per OS) |
| ----------------------- | ----------------------------------------------- |
| /iamb.desktop | /usr/share/applications/iamb.desktop |
| /config.example.toml | /usr/share/iamb/config.example.toml |
| /docs/iamb-256x256.png | /usr/share/icons/hicolor/256x256/apps/iamb.png |
| /docs/iamb-512x512.png | /usr/share/icons/hicolor/512x512/apps/iamb.png |
| /docs/iamb.svg | /usr/share/icons/hicolor/scalable/apps/iamb.svg |
| /docs/iamb.1 | /usr/share/man/man1/iamb.1 |
| /docs/iamb.5 | /usr/share/man/man5/iamb.5 |
| /docs/iamb.metainfo.xml | /usr/share/metainfo/iamb.metainfo.xml |
[ring-lto]: https://github.com/briansmith/ring/issues/1444
[rustls]: https://crates.io/crates/rustls

176
README.md
View File

@@ -1,105 +1,129 @@
# iamb <div align="center">
<h1><img width="200" height="200" src="docs/iamb.svg"></h1>
[![Build Status](https://github.com/ulyssa/iamb/workflows/CI/badge.svg)](https://github.com/ulyssa/iamb/actions?query=workflow%3ACI+) [![Build Status](https://github.com/ulyssa/iamb/actions/workflows/ci.yml/badge.svg)](https://github.com/ulyssa/iamb/actions?query=workflow%3ACI+)
[![License: Apache 2.0](https://img.shields.io/crates/l/iamb.svg?logo=apache)](https://crates.io/crates/iamb) [![License: Apache 2.0](https://img.shields.io/crates/l/iamb.svg?logo=apache)][crates-io-iamb]
[![Latest Version](https://img.shields.io/crates/v/iamb.svg?logo=rust)](https://crates.io/crates/iamb) [![#iamb:0x.badd.cafe](https://img.shields.io/badge/matrix-%23iamb:0x.badd.cafe-blue)](https://matrix.to/#/#iamb:0x.badd.cafe)
[![Latest Version](https://img.shields.io/crates/v/iamb.svg?logo=rust)][crates-io-iamb]
[![iamb](https://snapcraft.io/iamb/badge.svg)](https://snapcraft.io/iamb)
![Example Usage](https://iamb.chat/static/images/iamb-demo.gif)
</div>
## About ## About
`iamb` is a Matrix client for the terminal that uses Vim keybindings. `iamb` is a Matrix client for the terminal that uses Vim keybindings. It includes support for:
This project is a work-in-progress, and there's still a lot to be implemented, - Threads, spaces, E2EE, and read receipts
but much of the basic client functionality is already present. - Image previews in terminals that support it (sixels, Kitty, and iTerm2), or using pixelated blocks for those that don't
- Notifications via terminal bell or desktop environment
- Send Markdown, HTML or plaintext messages
- Creating, joining, and leaving rooms
- Sending and accepting room invitations
- Editing, redacting, and reacting to messages
- Custom keybindings
- Multiple profiles
_You may want to [see this page as it was when the latest version was published][crates-io-iamb]._
## Documentation ## Documentation
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 ## Configuration
Install Rust and Cargo, and then run: You can create a basic configuration in `$CONFIG_DIR/iamb/config.toml` that looks like:
```toml
[profiles."example.com"]
user_id = "@user:example.com"
```
If you homeserver is located on a different domain than the server part of the
`user_id` and you don't have a [`/.well-known`][well_known_entry] entry, then
you can explicitly specify the homeserver URL to use:
```toml
[profiles."example.com"]
url = "https://example.com"
user_id = "@user:example.com"
```
## Installation (via `crates.io`)
Install Rust (1.70.0 or above) and Cargo, and then run:
``` ```
cargo install --locked iamb cargo install --locked iamb
``` ```
## Configuration See [Configuration](#configuration) for getting a profile set up.
You can create a basic configuration in `$CONFIG_DIR/iamb/config.json` that looks like: ## Installation (via package managers)
```json ### Arch Linux
{
"profiles": { On Arch Linux a [package](https://aur.archlinux.org/packages/iamb-git) is available in the
"example.com": { Arch User Repositories (AUR). To install it simply run with your favorite AUR helper:
"url": "https://example.com",
"user_id": "@user:example.com" ```
} paru iamb-git
}
}
``` ```
## Comparison With Other Clients ### FreeBSD
To get an idea of what is and isn't yet implemented, here is a subset of the On FreeBSD a package is available from the official repositories. To install it simply run:
Matrix website's [features comparison table][client-comparison-matrix], showing
two other TUI clients and Element Web: ```
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
```
| | iamb | [gomuks] | [weechat-matrix] | Element Web/Desktop |
| --------------------------------------- | :---------- | :------: | :--------------: | :-----------------: |
| Room directory | ❌ ([#14]) | ❌ | ✔️ | ✔️ |
| Room tag showing | ✔️ | ✔️ | ❌ | ✔️ |
| Room tag editing | ✔️ | ✔️ | ❌ | ✔️ |
| Search joined rooms | ❌ ([#16]) | ✔️ | ❌ | ✔️ |
| Room user list | ✔️ | ✔️ | ✔️ | ✔️ |
| Display Room Description | ✔️ | ✔️ | ✔️ | ✔️ |
| Edit Room Description | ✔️ | ❌ | ✔️ | ✔️ |
| Highlights | ❌ ([#8]) | ✔️ | ✔️ | ✔️ |
| Pushrules | ❌ | ✔️ | ❌ | ✔️ |
| Send read markers | ✔️ | ✔️ | ✔️ | ✔️ |
| Display read markers | ✔️ | ❌ | ❌ | ✔️ |
| Sending Invites | ✔️ | ✔️ | ✔️ | ✔️ |
| Accepting Invites | ✔️ | ✔️ | ✔️ | ✔️ |
| Typing Notification | ✔️ | ✔️ | ✔️ | ✔️ |
| E2E | ✔️ | ✔️ | ✔️ | ✔️ |
| Replies | ✔️ | ✔️ | ❌ | ✔️ |
| Attachment uploading | ✔️ | ❌ | ✔️ | ✔️ |
| Attachment downloading | ✔️ | ✔️ | ✔️ | ✔️ |
| Send stickers | ❌ | ❌ | ❌ | ✔️ |
| Send formatted messages (markdown) | ✔️ | ✔️ | ✔️ | ✔️ |
| Rich Text Editor for formatted messages | ❌ | ❌ | ❌ | ✔️ |
| Display formatted messages | ✔️ | ✔️ | ✔️ | ✔️ |
| Redacting | ✔️ | ✔️ | ✔️ | ✔️ |
| Multiple Matrix Accounts | ✔️ | ❌ | ✔️ | ❌ |
| New user registration | ❌ | ❌ | ❌ | ✔️ |
| VOIP | ❌ | ❌ | ❌ | ✔️ |
| Reactions | ✔️ | ✔️ | ❌ | ✔️ |
| Message editing | ✔️ | ✔️ | ❌ | ✔️ |
| Room upgrades | ❌ | ✔️ | ❌ | ✔️ |
| Localisations | ❌ | 1 | ❌ | 44 |
| SSO Support | ❌ | ✔️ | ✔️ | ✔️ |
## 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
[iamb.chat]: https://iamb.chat [iamb.chat]: https://iamb.chat
[gomuks]: https://github.com/tulir/gomuks [well_known_entry]: https://spec.matrix.org/latest/client-server-api/#getwell-knownmatrixclient
[weechat-matrix]: https://github.com/poljar/weechat-matrix
[#2]: https://github.com/ulyssa/iamb/issues/2
[#3]: https://github.com/ulyssa/iamb/issues/3
[#4]: https://github.com/ulyssa/iamb/issues/4
[#5]: https://github.com/ulyssa/iamb/issues/5
[#6]: https://github.com/ulyssa/iamb/issues/6
[#7]: https://github.com/ulyssa/iamb/issues/7
[#8]: https://github.com/ulyssa/iamb/issues/8
[#9]: https://github.com/ulyssa/iamb/issues/9
[#10]: https://github.com/ulyssa/iamb/issues/10
[#11]: https://github.com/ulyssa/iamb/issues/11
[#12]: https://github.com/ulyssa/iamb/issues/12
[#13]: https://github.com/ulyssa/iamb/issues/13
[#14]: https://github.com/ulyssa/iamb/issues/14
[#15]: https://github.com/ulyssa/iamb/issues/15
[#16]: https://github.com/ulyssa/iamb/issues/16

9
build.rs Normal file
View File

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

58
config.example.toml Normal file
View File

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

BIN
docs/iamb-256x256.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

BIN
docs/iamb-512x512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

284
docs/iamb.1 Normal file
View File

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

558
docs/iamb.5 Normal file
View File

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

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

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

BIN
docs/iamb.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

128
docs/iamb.svg Normal file
View File

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

After

Width:  |  Height:  |  Size: 7.1 KiB

130
flake.lock generated Normal file
View File

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

44
flake.nix Normal file
View File

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

12
iamb.desktop Normal file
View File

@@ -0,0 +1,12 @@
[Desktop Entry]
Categories=Network;InstantMessaging;Chat;
Comment=A Matrix client for Vim addicts
Exec=iamb
GenericName=Matrix Client
Keywords=Matrix;matrix.org;chat;communications;talk;
Name=iamb
Icon=iamb
StartupNotify=false
Terminal=true
TryExec=iamb
Type=Application

File diff suppressed because it is too large Load Diff

View File

@@ -1,34 +1,41 @@
//! # Default Commands
//!
//! The command-bar commands are set up here, and iamb-specific commands are defined here. See
//! [modalkit::env::vim::command] for additional Vim commands we pull in.
use std::convert::TryFrom; use std::convert::TryFrom;
use matrix_sdk::ruma::{events::tag::TagName, OwnedUserId}; use matrix_sdk::ruma::{events::tag::TagName, OwnedUserId};
use modalkit::{ use modalkit::{
editing::base::OpenTarget, commands::{CommandError, CommandResult, CommandStep},
env::vim::command::{CommandContext, CommandDescription}, env::vim::command::{CommandContext, CommandDescription, OptionType},
input::commands::{CommandError, CommandResult, CommandStep}, prelude::OpenTarget,
input::InputContext,
}; };
use crate::base::{ use crate::base::{
CreateRoomFlags,
CreateRoomType,
DownloadFlags, DownloadFlags,
HomeserverAction,
IambAction, IambAction,
IambId, IambId,
KeysAction,
MemberUpdateAction,
MessageAction, MessageAction,
ProgramCommand, ProgramCommand,
ProgramCommands, ProgramCommands,
ProgramContext,
RoomAction, RoomAction,
RoomField, RoomField,
SendAction, SendAction,
VerifyAction, VerifyAction,
}; };
type ProgContext = CommandContext<ProgramContext>; type ProgContext = CommandContext;
type ProgResult = CommandResult<ProgramCommand>; type ProgResult = CommandResult<ProgramCommand>;
/// Convert strings the user types into a tag name. /// Convert strings the user types into a tag name.
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
@@ -92,7 +99,30 @@ fn iamb_invite(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
}; };
let iact = IambAction::from(ract); let iact = IambAction::from(ract);
let step = CommandStep::Continue(iact.into(), ctx.context.take()); let step = CommandStep::Continue(iact.into(), ctx.context.clone());
return Ok(step);
}
fn iamb_keys(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
let mut args = desc.arg.strings()?;
if args.len() != 3 {
return Err(CommandError::InvalidArgument);
}
let act = args.remove(0);
let path = args.remove(0);
let passphrase = args.remove(0);
let act = match act.as_str() {
"export" => KeysAction::Export(path, passphrase),
"import" => KeysAction::Import(path, passphrase),
_ => return Err(CommandError::InvalidArgument),
};
let vact = IambAction::Keys(act);
let step = CommandStep::Continue(vact.into(), ctx.context.clone());
return Ok(step); return Ok(step);
} }
@@ -103,7 +133,7 @@ fn iamb_verify(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
match args.len() { match args.len() {
0 => { 0 => {
let open = ctx.switch(OpenTarget::Application(IambId::VerifyList)); let open = ctx.switch(OpenTarget::Application(IambId::VerifyList));
let step = CommandStep::Continue(open, ctx.context.take()); let step = CommandStep::Continue(open, ctx.context.clone());
return Ok(step); return Ok(step);
}, },
@@ -118,7 +148,7 @@ fn iamb_verify(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
"mismatch" => VerifyAction::Mismatch, "mismatch" => VerifyAction::Mismatch,
"request" => { "request" => {
let iact = IambAction::VerifyRequest(args.remove(1)); let iact = IambAction::VerifyRequest(args.remove(1));
let step = CommandStep::Continue(iact.into(), ctx.context.take()); let step = CommandStep::Continue(iact.into(), ctx.context.clone());
return Ok(step); return Ok(step);
}, },
@@ -126,7 +156,7 @@ fn iamb_verify(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
}; };
let vact = IambAction::Verify(act, args.remove(1)); let vact = IambAction::Verify(act, args.remove(1));
let step = CommandStep::Continue(vact.into(), ctx.context.take()); let step = CommandStep::Continue(vact.into(), ctx.context.clone());
return Ok(step); return Ok(step);
}, },
@@ -142,7 +172,7 @@ fn iamb_dms(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
} }
let open = ctx.switch(OpenTarget::Application(IambId::DirectList)); let open = ctx.switch(OpenTarget::Application(IambId::DirectList));
let step = CommandStep::Continue(open, ctx.context.take()); let step = CommandStep::Continue(open, ctx.context.clone());
return Ok(step); return Ok(step);
} }
@@ -153,7 +183,18 @@ fn iamb_members(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
} }
let open = IambAction::Room(RoomAction::Members(ctx.clone().into())); let open = IambAction::Room(RoomAction::Members(ctx.clone().into()));
let step = CommandStep::Continue(open.into(), ctx.context.take()); let step = CommandStep::Continue(open.into(), ctx.context.clone());
return Ok(step);
}
fn iamb_leave(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
if !desc.arg.text.is_empty() {
return Result::Err(CommandError::InvalidArgument);
}
let leave = IambAction::Room(RoomAction::Leave(desc.bang));
let step = CommandStep::Continue(leave.into(), ctx.context.clone());
return Ok(step); return Ok(step);
} }
@@ -163,8 +204,8 @@ fn iamb_cancel(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
return Result::Err(CommandError::InvalidArgument); return Result::Err(CommandError::InvalidArgument);
} }
let mact = IambAction::from(MessageAction::Cancel); let mact = IambAction::from(MessageAction::Cancel(desc.bang));
let step = CommandStep::Continue(mact.into(), ctx.context.take()); let step = CommandStep::Continue(mact.into(), ctx.context.clone());
return Ok(step); return Ok(step);
} }
@@ -175,30 +216,23 @@ fn iamb_edit(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
} }
let mact = IambAction::from(MessageAction::Edit); let mact = IambAction::from(MessageAction::Edit);
let step = CommandStep::Continue(mact.into(), ctx.context.take()); let step = CommandStep::Continue(mact.into(), ctx.context.clone());
return Ok(step); return Ok(step);
} }
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.take());
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 {
@@ -208,21 +242,9 @@ 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));
let step = CommandStep::Continue(mact.into(), ctx.context.clone());
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.take());
return Ok(step); return Ok(step);
} }
@@ -234,8 +256,9 @@ fn iamb_redact(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
return Result::Err(CommandError::InvalidArgument); return Result::Err(CommandError::InvalidArgument);
} }
let ract = IambAction::from(MessageAction::Redact(args.into_iter().next())); let reason = args.into_iter().next();
let step = CommandStep::Continue(ract.into(), ctx.context.take()); let ract = IambAction::from(MessageAction::Redact(reason, desc.bang));
let step = CommandStep::Continue(ract.into(), ctx.context.clone());
return Ok(step); return Ok(step);
} }
@@ -246,7 +269,18 @@ fn iamb_reply(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
} }
let ract = IambAction::from(MessageAction::Reply); let ract = IambAction::from(MessageAction::Reply);
let step = CommandStep::Continue(ract.into(), ctx.context.take()); let step = CommandStep::Continue(ract.into(), ctx.context.clone());
return Ok(step);
}
fn iamb_editor(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
if !desc.arg.text.is_empty() {
return Result::Err(CommandError::InvalidArgument);
}
let sact = IambAction::from(SendAction::SubmitFromEditor);
let step = CommandStep::Continue(sact.into(), ctx.context.clone());
return Ok(step); return Ok(step);
} }
@@ -257,18 +291,53 @@ fn iamb_rooms(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
} }
let open = ctx.switch(OpenTarget::Application(IambId::RoomList)); let open = ctx.switch(OpenTarget::Application(IambId::RoomList));
let step = CommandStep::Continue(open, ctx.context.take()); let step = CommandStep::Continue(open, ctx.context.clone());
return Ok(step); return Ok(step);
} }
fn iamb_chats(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
if !desc.arg.text.is_empty() {
return Result::Err(CommandError::InvalidArgument);
}
let open = ctx.switch(OpenTarget::Application(IambId::ChatList));
let step = CommandStep::Continue(open, ctx.context.clone());
return Ok(step);
}
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);
} }
let open = ctx.switch(OpenTarget::Application(IambId::SpaceList)); let open = ctx.switch(OpenTarget::Application(IambId::SpaceList));
let step = CommandStep::Continue(open, ctx.context.take()); let step = CommandStep::Continue(open, ctx.context.clone());
return Ok(step); return Ok(step);
} }
@@ -279,7 +348,7 @@ fn iamb_welcome(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
} }
let open = ctx.switch(OpenTarget::Application(IambId::Welcome)); let open = ctx.switch(OpenTarget::Application(IambId::Welcome));
let step = CommandStep::Continue(open, ctx.context.take()); let step = CommandStep::Continue(open, ctx.context.clone());
return Ok(step); return Ok(step);
} }
@@ -292,7 +361,54 @@ fn iamb_join(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
} }
let open = ctx.switch(args.remove(0)); let open = ctx.switch(args.remove(0));
let step = CommandStep::Continue(open, ctx.context.take()); let step = CommandStep::Continue(open, ctx.context.clone());
return Ok(step);
}
fn iamb_create(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
let args = desc.arg.options()?;
let mut flags = CreateRoomFlags::NONE;
let mut alias = None;
let mut ct = CreateRoomType::Room;
for arg in args {
match arg {
OptionType::Flag(name, Some(arg)) => {
match name.as_str() {
"alias" => {
if alias.is_some() {
let msg = "Multiple ++alias arguments are not allowed";
let err = CommandError::Error(msg.into());
return Err(err);
} else {
alias = Some(arg);
}
},
_ => return Err(CommandError::InvalidArgument),
}
},
OptionType::Flag(name, None) => {
match name.as_str() {
"public" => flags |= CreateRoomFlags::PUBLIC,
"space" => ct = CreateRoomType::Space,
"enc" | "encrypted" => flags |= CreateRoomFlags::ENCRYPTED,
_ => return Err(CommandError::InvalidArgument),
}
},
OptionType::Positional(_) => {
let msg = ":create doesn't take any positional arguments";
let err = CommandError::Error(msg.into());
return Err(err);
},
}
}
let hact = HomeserverAction::CreateRoom(alias, ct, flags);
let iact = IambAction::from(hact);
let step = CommandStep::Continue(iact.into(), ctx.context.clone());
return Ok(step); return Ok(step);
} }
@@ -312,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),
@@ -332,14 +479,62 @@ 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),
}; };
let step = CommandStep::Continue(act.into(), ctx.context.take()); let step = CommandStep::Continue(act.into(), ctx.context.clone());
return Ok(step); return Ok(step);
} }
@@ -353,7 +548,7 @@ fn iamb_upload(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
let sact = SendAction::Upload(args.remove(0)); let sact = SendAction::Upload(args.remove(0));
let iact = IambAction::from(sact); let iact = IambAction::from(sact);
let step = CommandStep::Continue(iact.into(), ctx.context.take()); let step = CommandStep::Continue(iact.into(), ctx.context.clone());
return Ok(step); return Ok(step);
} }
@@ -371,7 +566,7 @@ fn iamb_download(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult
}; };
let mact = MessageAction::Download(args.pop(), flags); let mact = MessageAction::Download(args.pop(), flags);
let iact = IambAction::from(mact); let iact = IambAction::from(mact);
let step = CommandStep::Continue(iact.into(), ctx.context.take()); let step = CommandStep::Continue(iact.into(), ctx.context.clone());
return Ok(step); return Ok(step);
} }
@@ -389,32 +584,132 @@ fn iamb_open(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
}; };
let mact = MessageAction::Download(args.pop(), flags); let mact = MessageAction::Download(args.pop(), flags);
let iact = IambAction::from(mact); let iact = IambAction::from(mact);
let step = CommandStep::Continue(iact.into(), ctx.context.take()); let step = CommandStep::Continue(iact.into(), ctx.context.clone());
return Ok(step);
}
fn iamb_logout(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
let args = desc.arg.strings()?;
if args.is_empty() {
return Result::Err(CommandError::Error("Missing username".to_string()));
}
if args.len() != 1 {
return Result::Err(CommandError::InvalidArgument);
}
let iact = IambAction::from(HomeserverAction::Logout(args[0].clone(), desc.bang));
let step = CommandStep::Continue(iact.into(), ctx.context.clone());
return Ok(step); return Ok(step);
} }
fn add_iamb_commands(cmds: &mut ProgramCommands) { fn add_iamb_commands(cmds: &mut ProgramCommands) {
cmds.add_command(ProgramCommand { names: vec!["cancel".into()], f: iamb_cancel }); cmds.add_command(ProgramCommand {
cmds.add_command(ProgramCommand { names: vec!["dms".into()], f: iamb_dms }); name: "cancel".into(),
cmds.add_command(ProgramCommand { names: vec!["download".into()], f: iamb_download }); aliases: vec![],
cmds.add_command(ProgramCommand { names: vec!["open".into()], f: iamb_open }); f: iamb_cancel,
cmds.add_command(ProgramCommand { names: vec!["edit".into()], f: iamb_edit }); });
cmds.add_command(ProgramCommand { names: vec!["invite".into()], f: iamb_invite }); cmds.add_command(ProgramCommand {
cmds.add_command(ProgramCommand { names: vec!["join".into()], f: iamb_join }); name: "create".into(),
cmds.add_command(ProgramCommand { names: vec!["members".into()], f: iamb_members }); aliases: vec![],
cmds.add_command(ProgramCommand { names: vec!["react".into()], f: iamb_react }); f: iamb_create,
cmds.add_command(ProgramCommand { names: vec!["redact".into()], f: iamb_redact }); });
cmds.add_command(ProgramCommand { names: vec!["reply".into()], f: iamb_reply }); cmds.add_command(ProgramCommand {
cmds.add_command(ProgramCommand { names: vec!["rooms".into()], f: iamb_rooms }); name: "chats".into(),
cmds.add_command(ProgramCommand { names: vec!["room".into()], f: iamb_room }); aliases: vec![],
cmds.add_command(ProgramCommand { names: vec!["spaces".into()], f: iamb_spaces }); f: iamb_chats,
cmds.add_command(ProgramCommand { names: vec!["unreact".into()], f: iamb_unreact }); });
cmds.add_command(ProgramCommand { names: vec!["upload".into()], f: iamb_upload }); cmds.add_command(ProgramCommand { name: "dms".into(), aliases: vec![], f: iamb_dms });
cmds.add_command(ProgramCommand { names: vec!["verify".into()], f: iamb_verify }); cmds.add_command(ProgramCommand {
cmds.add_command(ProgramCommand { names: vec!["welcome".into()], f: iamb_welcome }); name: "download".into(),
aliases: vec![],
f: iamb_download,
});
cmds.add_command(ProgramCommand { name: "open".into(), aliases: vec![], f: iamb_open });
cmds.add_command(ProgramCommand { name: "edit".into(), aliases: vec![], f: iamb_edit });
cmds.add_command(ProgramCommand {
name: "invite".into(),
aliases: vec![],
f: iamb_invite,
});
cmds.add_command(ProgramCommand { name: "join".into(), aliases: vec![], f: iamb_join });
cmds.add_command(ProgramCommand { name: "keys".into(), aliases: vec![], f: iamb_keys });
cmds.add_command(ProgramCommand {
name: "leave".into(),
aliases: vec![],
f: iamb_leave,
});
cmds.add_command(ProgramCommand {
name: "members".into(),
aliases: vec![],
f: iamb_members,
});
cmds.add_command(ProgramCommand {
name: "react".into(),
aliases: vec![],
f: iamb_react,
});
cmds.add_command(ProgramCommand {
name: "redact".into(),
aliases: vec![],
f: iamb_redact,
});
cmds.add_command(ProgramCommand {
name: "reply".into(),
aliases: vec![],
f: iamb_reply,
});
cmds.add_command(ProgramCommand {
name: "rooms".into(),
aliases: vec![],
f: iamb_rooms,
});
cmds.add_command(ProgramCommand { name: "room".into(), aliases: vec![], f: iamb_room });
cmds.add_command(ProgramCommand {
name: "spaces".into(),
aliases: vec![],
f: iamb_spaces,
});
cmds.add_command(ProgramCommand {
name: "unreads".into(),
aliases: vec![],
f: iamb_unreads,
});
cmds.add_command(ProgramCommand {
name: "unreact".into(),
aliases: vec![],
f: iamb_unreact,
});
cmds.add_command(ProgramCommand {
name: "upload".into(),
aliases: vec![],
f: iamb_upload,
});
cmds.add_command(ProgramCommand {
name: "verify".into(),
aliases: vec![],
f: iamb_verify,
});
cmds.add_command(ProgramCommand {
name: "welcome".into(),
aliases: vec![],
f: iamb_welcome,
});
cmds.add_command(ProgramCommand {
name: "editor".into(),
aliases: vec![],
f: iamb_editor,
});
cmds.add_command(ProgramCommand {
name: "logout".into(),
aliases: vec![],
f: iamb_logout,
});
} }
/// Initialize the default command state.
pub fn setup_commands() -> ProgramCommands { pub fn setup_commands() -> ProgramCommands {
let mut cmds = ProgramCommands::default(); let mut cmds = ProgramCommands::default();
@@ -427,12 +722,13 @@ pub fn setup_commands() -> ProgramCommands {
mod tests { mod tests {
use super::*; use super::*;
use matrix_sdk::ruma::user_id; use matrix_sdk::ruma::user_id;
use modalkit::editing::action::WindowAction; use modalkit::actions::WindowAction;
use modalkit::editing::context::EditContext;
#[test] #[test]
fn test_cmd_verify() { fn test_cmd_verify() {
let mut cmds = setup_commands(); let mut cmds = setup_commands();
let ctx = ProgramContext::default(); let ctx = EditContext::default();
let res = cmds.input_cmd(":verify", ctx.clone()).unwrap(); let res = cmds.input_cmd(":verify", ctx.clone()).unwrap();
let act = WindowAction::Switch(OpenTarget::Application(IambId::VerifyList)); let act = WindowAction::Switch(OpenTarget::Application(IambId::VerifyList));
@@ -479,7 +775,7 @@ mod tests {
#[test] #[test]
fn test_cmd_join() { fn test_cmd_join() {
let mut cmds = setup_commands(); let mut cmds = setup_commands();
let ctx = ProgramContext::default(); let ctx = EditContext::default();
let res = cmds.input_cmd("join #foobar:example.com", ctx.clone()).unwrap(); let res = cmds.input_cmd("join #foobar:example.com", ctx.clone()).unwrap();
let act = WindowAction::Switch(OpenTarget::Name("#foobar:example.com".into())); let act = WindowAction::Switch(OpenTarget::Name("#foobar:example.com".into()));
@@ -499,7 +795,7 @@ mod tests {
#[test] #[test]
fn test_cmd_room_invalid() { fn test_cmd_room_invalid() {
let mut cmds = setup_commands(); let mut cmds = setup_commands();
let ctx = ProgramContext::default(); let ctx = EditContext::default();
let res = cmds.input_cmd("room", ctx.clone()); let res = cmds.input_cmd("room", ctx.clone());
assert_eq!(res, Err(CommandError::InvalidArgument)); assert_eq!(res, Err(CommandError::InvalidArgument));
@@ -514,7 +810,7 @@ mod tests {
#[test] #[test]
fn test_cmd_room_topic_set() { fn test_cmd_room_topic_set() {
let mut cmds = setup_commands(); let mut cmds = setup_commands();
let ctx = ProgramContext::default(); let ctx = EditContext::default();
let res = cmds let res = cmds
.input_cmd("room topic set \"Lots of fun discussion!\"", ctx.clone()) .input_cmd("room topic set \"Lots of fun discussion!\"", ctx.clone())
@@ -545,7 +841,7 @@ mod tests {
#[test] #[test]
fn test_cmd_room_name_invalid() { fn test_cmd_room_name_invalid() {
let mut cmds = setup_commands(); let mut cmds = setup_commands();
let ctx = ProgramContext::default(); let ctx = EditContext::default();
let res = cmds.input_cmd("room name", ctx.clone()); let res = cmds.input_cmd("room name", ctx.clone());
assert_eq!(res, Err(CommandError::InvalidArgument)); assert_eq!(res, Err(CommandError::InvalidArgument));
@@ -557,7 +853,7 @@ mod tests {
#[test] #[test]
fn test_cmd_room_name_set() { fn test_cmd_room_name_set() {
let mut cmds = setup_commands(); let mut cmds = setup_commands();
let ctx = ProgramContext::default(); let ctx = EditContext::default();
let res = cmds.input_cmd("room name set Development", ctx.clone()).unwrap(); let res = cmds.input_cmd("room name set Development", ctx.clone()).unwrap();
let act = RoomAction::Set(RoomField::Name, "Development".into()); let act = RoomAction::Set(RoomField::Name, "Development".into());
@@ -576,7 +872,7 @@ mod tests {
#[test] #[test]
fn test_cmd_room_name_unset() { fn test_cmd_room_name_unset() {
let mut cmds = setup_commands(); let mut cmds = setup_commands();
let ctx = ProgramContext::default(); let ctx = EditContext::default();
let res = cmds.input_cmd("room name unset", ctx.clone()).unwrap(); let res = cmds.input_cmd("room name unset", ctx.clone()).unwrap();
let act = RoomAction::Unset(RoomField::Name); let act = RoomAction::Unset(RoomField::Name);
@@ -586,10 +882,36 @@ 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();
let ctx = ProgramContext::default(); let ctx = EditContext::default();
let res = cmds.input_cmd("room tag set favourite", ctx.clone()).unwrap(); let res = cmds.input_cmd("room tag set favourite", ctx.clone()).unwrap();
let act = RoomAction::Set(RoomField::Tag(TagName::Favorite), "".into()); let act = RoomAction::Set(RoomField::Tag(TagName::Favorite), "".into());
@@ -658,7 +980,7 @@ mod tests {
#[test] #[test]
fn test_cmd_room_tag_unset() { fn test_cmd_room_tag_unset() {
let mut cmds = setup_commands(); let mut cmds = setup_commands();
let ctx = ProgramContext::default(); let ctx = EditContext::default();
let res = cmds.input_cmd("room tag unset favourite", ctx.clone()).unwrap(); let res = cmds.input_cmd("room tag unset favourite", ctx.clone()).unwrap();
let act = RoomAction::Unset(RoomField::Tag(TagName::Favorite)); let act = RoomAction::Unset(RoomField::Tag(TagName::Favorite));
@@ -720,10 +1042,31 @@ 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();
let ctx = ProgramContext::default(); let ctx = EditContext::default();
let res = cmds.input_cmd("invite accept", ctx.clone()).unwrap(); let res = cmds.input_cmd("invite accept", ctx.clone()).unwrap();
let act = IambAction::Room(RoomAction::InviteAccept); let act = IambAction::Room(RoomAction::InviteAccept);
@@ -757,24 +1100,118 @@ 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();
let ctx = ProgramContext::default(); let ctx = EditContext::default();
let res = cmds.input_cmd("redact", ctx.clone()).unwrap(); let res = cmds.input_cmd("redact", ctx.clone()).unwrap();
let act = IambAction::Message(MessageAction::Redact(None)); let act = IambAction::Message(MessageAction::Redact(None, false));
assert_eq!(res, vec![(act.into(), ctx.clone())]);
let res = cmds.input_cmd("redact!", ctx.clone()).unwrap();
let act = IambAction::Message(MessageAction::Redact(None, true));
assert_eq!(res, vec![(act.into(), ctx.clone())]); assert_eq!(res, vec![(act.into(), ctx.clone())]);
let res = cmds.input_cmd("redact Removed", ctx.clone()).unwrap(); let res = cmds.input_cmd("redact Removed", ctx.clone()).unwrap();
let act = IambAction::Message(MessageAction::Redact(Some("Removed".into()))); let act = IambAction::Message(MessageAction::Redact(Some("Removed".into()), false));
assert_eq!(res, vec![(act.into(), ctx.clone())]); assert_eq!(res, vec![(act.into(), ctx.clone())]);
let res = cmds.input_cmd("redact \"Removed\"", ctx.clone()).unwrap(); let res = cmds.input_cmd("redact \"Removed\"", ctx.clone()).unwrap();
let act = IambAction::Message(MessageAction::Redact(Some("Removed".into()))); let act = IambAction::Message(MessageAction::Redact(Some("Removed".into()), false));
assert_eq!(res, vec![(act.into(), ctx.clone())]); assert_eq!(res, vec![(act.into(), ctx.clone())]);
let res = cmds.input_cmd("redact Removed Removed", ctx.clone()); let res = cmds.input_cmd("redact Removed Removed", ctx.clone());
assert_eq!(res, Err(CommandError::InvalidArgument)); assert_eq!(res, Err(CommandError::InvalidArgument));
} }
#[test]
fn test_cmd_keys() {
let mut cmds = setup_commands();
let ctx = EditContext::default();
let res = cmds.input_cmd("keys import /a/b/c pword", ctx.clone()).unwrap();
let act = IambAction::Keys(KeysAction::Import("/a/b/c".into(), "pword".into()));
assert_eq!(res, vec![(act.into(), ctx.clone())]);
let res = cmds.input_cmd("keys export /a/b/c pword", ctx.clone()).unwrap();
let act = IambAction::Keys(KeysAction::Export("/a/b/c".into(), "pword".into()));
assert_eq!(res, vec![(act.into(), ctx.clone())]);
// Invalid invocations.
let res = cmds.input_cmd("keys", ctx.clone());
assert_eq!(res, Err(CommandError::InvalidArgument));
let res = cmds.input_cmd("keys import", ctx.clone());
assert_eq!(res, Err(CommandError::InvalidArgument));
let res = cmds.input_cmd("keys import foo", ctx.clone());
assert_eq!(res, Err(CommandError::InvalidArgument));
let res = cmds.input_cmd("keys import foo bar baz", ctx.clone());
assert_eq!(res, Err(CommandError::InvalidArgument));
}
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,51 +1,45 @@
//! # Default Keybindings
//!
//! The keybindings are set up here. We define some iamb-specific keybindings, but the default Vim
//! keys come from [modalkit::env::vim::keybindings].
use modalkit::{ use modalkit::{
editing::action::WindowAction, actions::{InsertTextAction, MacroAction, WindowAction},
editing::base::WordStyle,
env::vim::keybindings::{InputStep, VimBindings}, env::vim::keybindings::{InputStep, VimBindings},
env::vim::VimMode, env::vim::VimMode,
input::bindings::{EdgeEvent, EdgeRepeat, InputBindings}, env::CommonKeyClass,
input::key::TerminalKey, key::TerminalKey,
keybindings::{EdgeEvent, EdgeRepeat, InputBindings},
prelude::*,
}; };
use crate::base::{IambAction, IambInfo, Keybindings}; use crate::base::{IambAction, IambInfo, Keybindings, MATRIX_ID_WORD};
use crate::config::{ApplicationSettings, Keys};
type IambStep = InputStep<IambInfo>; pub type IambStep = InputStep<IambInfo>;
/// Find the boundaries for a Matrix username, room alias, or room ID. fn once(key: &TerminalKey) -> (EdgeRepeat, EdgeEvent<TerminalKey, CommonKeyClass>) {
/// (EdgeRepeat::Once, EdgeEvent::Key(*key))
/// Technically "[" and "]" should be here since IPv6 addresses are allowed
/// in the server name, but in practice that should be uncommon, and people
/// can just use `gf` and friends in Visual mode instead.
fn is_mxid_char(c: char) -> bool {
return c >= 'a' && c <= 'z' ||
c >= 'A' && c <= 'Z' ||
c >= '0' && c <= '9' ||
":-./@_#!".contains(c);
} }
/// Initialize the default keybinding state.
pub fn setup_keybindings() -> Keybindings { pub fn setup_keybindings() -> Keybindings {
let mut ism = Keybindings::empty(); let mut ism = Keybindings::empty();
let vim = VimBindings::default() let vim = VimBindings::default()
.submit_on_enter() .submit_on_enter()
.cursor_open(WordStyle::CharSet(is_mxid_char)); .cursor_open(MATRIX_ID_WORD.clone());
vim.setup(&mut ism); vim.setup(&mut ism);
let ctrl_w = EdgeEvent::Key("<C-W>".parse::<TerminalKey>().unwrap()); let ctrl_w = "<C-W>".parse::<TerminalKey>().unwrap();
let ctrl_m = EdgeEvent::Key("<C-M>".parse::<TerminalKey>().unwrap()); let ctrl_m = "<C-M>".parse::<TerminalKey>().unwrap();
let ctrl_z = EdgeEvent::Key("<C-Z>".parse::<TerminalKey>().unwrap()); let ctrl_z = "<C-Z>".parse::<TerminalKey>().unwrap();
let key_m_lc = EdgeEvent::Key("m".parse::<TerminalKey>().unwrap()); let key_m_lc = "m".parse::<TerminalKey>().unwrap();
let key_z_lc = EdgeEvent::Key("z".parse::<TerminalKey>().unwrap()); let key_z_lc = "z".parse::<TerminalKey>().unwrap();
let shift_enter = "<S-Enter>".parse::<TerminalKey>().unwrap();
let cwz = vec![ let cwz = vec![once(&ctrl_w), once(&key_z_lc)];
(EdgeRepeat::Once, ctrl_w.clone()), let cwcz = vec![once(&ctrl_w), once(&ctrl_z)];
(EdgeRepeat::Once, key_z_lc),
];
let cwcz = vec![
(EdgeRepeat::Once, ctrl_w.clone()),
(EdgeRepeat::Once, ctrl_z),
];
let zoom = IambStep::new() let zoom = IambStep::new()
.actions(vec![WindowAction::ZoomToggle.into()]) .actions(vec![WindowAction::ZoomToggle.into()])
.goto(VimMode::Normal); .goto(VimMode::Normal);
@@ -55,11 +49,8 @@ pub fn setup_keybindings() -> Keybindings {
ism.add_mapping(VimMode::Normal, &cwcz, &zoom); ism.add_mapping(VimMode::Normal, &cwcz, &zoom);
ism.add_mapping(VimMode::Visual, &cwcz, &zoom); ism.add_mapping(VimMode::Visual, &cwcz, &zoom);
let cwm = vec![ let cwm = vec![once(&ctrl_w), once(&key_m_lc)];
(EdgeRepeat::Once, ctrl_w.clone()), let cwcm = vec![once(&ctrl_w), once(&ctrl_m)];
(EdgeRepeat::Once, key_m_lc),
];
let cwcm = vec![(EdgeRepeat::Once, ctrl_w), (EdgeRepeat::Once, ctrl_m)];
let stoggle = IambStep::new() let stoggle = IambStep::new()
.actions(vec![IambAction::ToggleScrollbackFocus.into()]) .actions(vec![IambAction::ToggleScrollbackFocus.into()])
.goto(VimMode::Normal); .goto(VimMode::Normal);
@@ -68,5 +59,31 @@ pub fn setup_keybindings() -> Keybindings {
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);
return ism; 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
}
impl InputBindings<TerminalKey, IambStep> for ApplicationSettings {
fn setup(&self, bindings: &mut Keybindings) {
for (modes, keys) in &self.macros {
for (Keys(input, _), Keys(_, run)) in keys {
let act = MacroAction::Run(run.clone(), Count::Contextual);
let step = IambStep::new().actions(vec![act.into()]);
let input = input.iter().map(once).collect::<Vec<_>>();
for mode in &modes.0 {
bindings.add_mapping(*mode, &input, &step);
}
}
}
}
} }

View File

@@ -1,3 +1,16 @@
//! # iamb
//!
//! The iamb client loops over user input and commands, and turns them into actions, [some of
//! which][IambAction] are specific to iamb, and [some of which][Action] come from [modalkit]. When
//! adding new functionality, you will usually want to extend [IambAction] or one of its variants
//! (like [RoomAction][base::RoomAction]), and then add an appropriate [command][commands] or
//! [keybinding][keybindings].
//!
//! For more complicated changes, you may need to update [the async worker thread][worker], which
//! handles background Matrix tasks with [matrix-rust-sdk][matrix_sdk].
//!
//! Most rendering logic lives under the [windows] module, but [Matrix messages][message] have
//! their own module.
#![allow(clippy::manual_range_contains)] #![allow(clippy::manual_range_contains)]
#![allow(clippy::needless_return)] #![allow(clippy::needless_return)]
#![allow(clippy::result_large_err)] #![allow(clippy::result_large_err)]
@@ -6,29 +19,44 @@ use std::collections::VecDeque;
use std::convert::TryFrom; use std::convert::TryFrom;
use std::fmt::Display; use std::fmt::Display;
use std::fs::{create_dir_all, File}; use std::fs::{create_dir_all, File};
use std::io::{stdout, BufReader, Stdout}; use std::io::{stdout, BufWriter, Stdout, Write};
use std::ops::DerefMut; use std::ops::DerefMut;
use std::process; use std::process;
use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::{Duration, Instant};
use clap::Parser; use clap::Parser;
use tokio::sync::Mutex as AsyncMutex; use matrix_sdk::crypto::encrypt_room_key_export;
use tracing::{self, Level}; use matrix_sdk::ruma::api::client::error::ErrorKind;
use tracing_subscriber::FmtSubscriber;
use matrix_sdk::ruma::OwnedUserId; use matrix_sdk::ruma::OwnedUserId;
use modalkit::keybindings::InputBindings;
use rand::{distributions::Alphanumeric, Rng};
use temp_dir::TempDir;
use tokio::sync::Mutex as AsyncMutex;
use tracing_subscriber::FmtSubscriber;
use modalkit::crossterm::{ use modalkit::crossterm::{
self, self,
cursor::Show as CursorShow, cursor::Show as CursorShow,
event::{poll, read, DisableBracketedPaste, EnableBracketedPaste, Event}, event::{
poll,
read,
DisableBracketedPaste,
DisableFocusChange,
EnableBracketedPaste,
EnableFocusChange,
Event,
KeyEventKind,
KeyboardEnhancementFlags,
PopKeyboardEnhancementFlags,
PushKeyboardEnhancementFlags,
},
execute, execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, SetTitle}, terminal::{EnterAlternateScreen, LeaveAlternateScreen, SetTitle},
}; };
use modalkit::tui::{ use ratatui::{
backend::CrosstermBackend, backend::CrosstermBackend,
layout::Rect, layout::Rect,
style::{Color, Style}, style::{Color, Style},
@@ -42,6 +70,9 @@ mod commands;
mod config; mod config;
mod keybindings; mod keybindings;
mod message; mod message;
mod notifications;
mod preview;
mod sled_export;
mod util; mod util;
mod windows; mod windows;
mod worker; mod worker;
@@ -53,71 +84,181 @@ use crate::{
base::{ base::{
AsyncProgramStore, AsyncProgramStore,
ChatStore, ChatStore,
HomeserverAction,
IambAction, IambAction,
IambBufferId,
IambError, IambError,
IambId, IambId,
IambInfo, IambInfo,
IambResult, IambResult,
KeysAction,
ProgramAction, ProgramAction,
ProgramCommands,
ProgramContext, ProgramContext,
ProgramStore, ProgramStore,
}, },
config::{ApplicationSettings, Iamb}, config::{ApplicationSettings, Iamb},
windows::IambWindow, windows::IambWindow,
worker::{ClientWorker, LoginStyle, Requester}, worker::{create_room, ClientWorker, LoginStyle, Requester},
}; };
use modalkit::{ use modalkit::{
editing::{ actions::{
action::{ Action,
Action, Commandable,
Commandable, Editable,
EditError, EditorAction,
EditInfo, InsertTextAction,
Editable, Jumpable,
EditorAction, Promptable,
InsertTextAction, Scrollable,
Jumpable, TabAction,
Promptable, TabContainer,
Scrollable, TabCount,
TabContainer, WindowAction,
TabCount, WindowContainer,
WindowAction,
WindowContainer,
},
base::{MoveDir1D, OpenTarget, RepeatType},
context::Resolve,
key::KeyManager,
store::Store,
}, },
input::{bindings::BindingMachine, key::TerminalKey}, editing::{context::Resolve, key::KeyManager, store::Store},
widgets::{ errors::{EditError, UIError},
cmdbar::CommandBarState, key::TerminalKey,
screen::{Screen, ScreenState}, keybindings::{
TerminalCursor, dialog::{Pager, PromptYesNo},
TerminalExtOps, BindingMachine,
Window,
}, },
prelude::*,
ui::FocusList,
}; };
const MIN_MSG_LOAD: u32 = 50; use modalkit_ratatui::{
cmdbar::CommandBarState,
screen::{Screen, ScreenState, TabbedLayoutDescription},
windows::{WindowLayoutDescription, WindowLayoutState},
TerminalCursor,
TerminalExtOps,
Window,
};
fn msg_load_req(area: Rect) -> u32 { fn config_tab_to_desc(
let n = area.height as u32; layout: config::WindowLayout,
store: &mut ProgramStore,
) -> IambResult<WindowLayoutDescription<IambInfo>> {
let desc = match layout {
config::WindowLayout::Window { window } => {
let ChatStore { names, worker, .. } = &mut store.application;
n.max(MIN_MSG_LOAD) let window = match window {
config::WindowPath::UserId(user_id) => {
let name = user_id.to_string();
let room_id = worker.join_room(name.clone())?;
names.insert(name, room_id.clone());
IambId::Room(room_id, None)
},
config::WindowPath::RoomId(room_id) => IambId::Room(room_id, None),
config::WindowPath::AliasId(alias) => {
let name = alias.to_string();
let room_id = worker.join_room(name.clone())?;
names.insert(name, room_id.clone());
IambId::Room(room_id, None)
},
config::WindowPath::Window(id) => id,
};
WindowLayoutDescription::Window { window, length: None }
},
config::WindowLayout::Split { split } => {
let children = split
.into_iter()
.map(|child| config_tab_to_desc(child, store))
.collect::<IambResult<Vec<_>>>()?;
WindowLayoutDescription::Split { children, length: None }
},
};
Ok(desc)
} }
fn restore_layout(
area: Rect,
settings: &ApplicationSettings,
store: &mut ProgramStore,
) -> IambResult<FocusList<WindowLayoutState<IambWindow, IambInfo>>> {
let layout = std::fs::read(&settings.layout_json)?;
let tabs: TabbedLayoutDescription<IambInfo> =
serde_json::from_slice(&layout).map_err(IambError::from)?;
tabs.to_layout(area.into(), store)
}
fn setup_screen(
settings: ApplicationSettings,
store: &mut ProgramStore,
) -> IambResult<ScreenState<IambWindow, IambInfo>> {
let cmd = CommandBarState::new(store);
let dims = crossterm::terminal::size()?;
let area = Rect::new(0, 0, dims.0, dims.1);
match settings.layout {
config::Layout::Restore => {
match restore_layout(area, &settings, store) {
Ok(tabs) => {
return Ok(ScreenState::from_list(tabs, cmd));
},
Err(e) => {
// Log the issue with restoring and then continue.
tracing::warn!(err = %e, "Failed to restore layout from disk");
},
}
},
config::Layout::New => {},
config::Layout::Config { tabs } => {
let mut list = FocusList::default();
for tab in tabs.into_iter() {
let tab = config_tab_to_desc(tab, store)?;
let tab = tab.to_layout(area.into(), store)?;
list.push(tab);
}
return Ok(ScreenState::from_list(list, cmd));
},
}
let win = settings
.tunables
.default_room
.and_then(|room| IambWindow::find(room, store).ok())
.or_else(|| IambWindow::open(IambId::Welcome, store).ok())
.unwrap();
return Ok(ScreenState::new(win, cmd));
}
/// The main application state and event loop.
struct Application { struct Application {
store: AsyncProgramStore, /// Terminal backend.
worker: Requester,
terminal: Terminal<CrosstermBackend<Stdout>>, terminal: Terminal<CrosstermBackend<Stdout>>,
bindings: KeyManager<TerminalKey, ProgramAction, RepeatType, ProgramContext>,
actstack: VecDeque<(ProgramAction, ProgramContext)>, /// State for the Matrix client, editing, etc.
cmds: ProgramCommands, store: AsyncProgramStore,
/// UI state (open tabs, command bar, etc.) to use when rendering.
screen: ScreenState<IambWindow, IambInfo>, screen: ScreenState<IambWindow, IambInfo>,
/// Handle to communicate synchronously with the Matrix worker task.
worker: Requester,
/// Mapped keybindings.
bindings: KeyManager<TerminalKey, ProgramAction, RepeatType>,
/// Pending actions to run.
actstack: VecDeque<(ProgramAction, ProgramContext)>,
/// Whether or not the terminal is currently focused.
focused: bool,
/// The tab layout before the last executed [TabAction].
last_layout: Option<TabbedLayoutDescription<IambInfo>>,
/// Whether we need to do a full redraw (e.g., after running a subprocess).
dirty: bool,
} }
impl Application { impl Application {
@@ -125,34 +266,18 @@ 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)?;
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 bindings = crate::keybindings::setup_keybindings(); let mut bindings = crate::keybindings::setup_keybindings();
settings.setup(&mut bindings);
let bindings = KeyManager::new(bindings); let bindings = KeyManager::new(bindings);
let cmds = crate::commands::setup_commands();
let mut locked = store.lock().await; let mut locked = store.lock().await;
let screen = setup_screen(settings, locked.deref_mut())?;
let win = settings
.tunables
.default_room
.and_then(|room| IambWindow::find(room, locked.deref_mut()).ok())
.or_else(|| IambWindow::open(IambId::Welcome, locked.deref_mut()).ok())
.unwrap();
let cmd = CommandBarState::new(IambBufferId::Command, locked.deref_mut());
let screen = ScreenState::new(win, cmd);
let worker = locked.application.worker.clone(); let worker = locked.application.worker.clone();
drop(locked); drop(locked);
let actstack = VecDeque::new(); let actstack = VecDeque::new();
@@ -163,17 +288,23 @@ impl Application {
terminal, terminal,
bindings, bindings,
actstack, actstack,
cmds,
screen, screen,
focused: true,
last_layout: None,
dirty: true,
}) })
} }
fn redraw(&mut self, full: bool, store: &mut ProgramStore) -> Result<(), std::io::Error> { fn redraw(&mut self, full: bool, store: &mut ProgramStore) -> Result<(), std::io::Error> {
let modestr = self.bindings.showmode(); let bindings = &mut self.bindings;
let cursor = self.bindings.get_cursor_indicator(); let focused = self.focused;
let sstate = &mut self.screen; let sstate = &mut self.screen;
let term = &mut self.terminal; let term = &mut self.terminal;
if store.application.ring_bell {
store.application.ring_bell = term.backend_mut().write_all(&[7]).is_err();
}
if full { if full {
term.clear()?; term.clear()?;
} }
@@ -181,9 +312,25 @@ impl Application {
term.draw(|f| { term.draw(|f| {
let area = f.size(); let area = f.size();
let screen = Screen::new(store).showmode(modestr).borders(true); let modestr = bindings.show_mode();
let cursor = bindings.get_cursor_indicator();
let dialogstr = bindings.show_dialog(area.height as usize, area.width as usize);
// Don't show terminal cursor when we show a dialog.
let hide_cursor = !dialogstr.is_empty();
store.application.draw_curr = Some(Instant::now());
let screen = Screen::new(store)
.show_dialog(dialogstr)
.show_mode(modestr)
.borders(true)
.focus(focused);
f.render_stateful_widget(screen, area, sstate); f.render_stateful_widget(screen, area, sstate);
if hide_cursor {
return;
}
if let Some((cx, cy)) = sstate.get_term_cursor() { if let Some((cx, cy)) = sstate.get_term_cursor() {
if let Some(c) = cursor { if let Some(c) = cursor {
let style = Style::default().fg(Color::Green); let style = Style::default().fg(Color::Green);
@@ -194,8 +341,6 @@ impl Application {
} }
f.set_cursor(cx, cy); f.set_cursor(cx, cy);
} }
store.application.load_older(msg_load_req(area));
})?; })?;
Ok(()) Ok(())
@@ -203,7 +348,8 @@ impl Application {
async fn step(&mut self) -> Result<TerminalKey, std::io::Error> { async fn step(&mut self) -> Result<TerminalKey, std::io::Error> {
loop { loop {
self.redraw(false, self.store.clone().lock().await.deref_mut())?; self.redraw(self.dirty, self.store.clone().lock().await.deref_mut())?;
self.dirty = false;
if !poll(Duration::from_secs(1))? { if !poll(Duration::from_secs(1))? {
// Redraw in case there's new messages to show. // Redraw in case there's new messages to show.
@@ -211,12 +357,25 @@ impl Application {
} }
match read()? { match read()? {
Event::Key(ke) => return Ok(ke.into()), Event::Key(ke) => {
if ke.kind == KeyEventKind::Release {
continue;
}
return Ok(ke.into());
},
Event::Mouse(_) => { Event::Mouse(_) => {
// Do nothing for now. // Do nothing for now.
}, },
Event::FocusGained | Event::FocusLost => { Event::FocusGained => {
// Do nothing for now. let mut store = self.store.lock().await;
store.application.focused = true;
self.focused = true;
},
Event::FocusLost => {
let mut store = self.store.lock().await;
store.application.focused = false;
self.focused = false;
}, },
Event::Resize(_, _) => { Event::Resize(_, _) => {
// We'll redraw for the new size next time step() is called. // We'll redraw for the new size next time step() is called.
@@ -230,7 +389,8 @@ impl Application {
match self.screen.editor_command(&act, &ctx, store.deref_mut()) { match self.screen.editor_command(&act, &ctx, store.deref_mut()) {
Ok(None) => {}, Ok(None) => {},
Ok(Some(info)) => { Ok(Some(info)) => {
self.screen.push_info(info); drop(store);
self.handle_info(info);
}, },
Err(e) => { Err(e) => {
self.screen.push_error(e); self.screen.push_error(e);
@@ -294,8 +454,7 @@ impl Application {
Action::CommandBar(act) => self.screen.command_bar(&act, &ctx)?, Action::CommandBar(act) => self.screen.command_bar(&act, &ctx)?,
Action::Macro(act) => self.bindings.macro_command(&act, &ctx, store)?, Action::Macro(act) => self.bindings.macro_command(&act, &ctx, store)?,
Action::Scroll(style) => self.screen.scroll(&style, &ctx, store)?, Action::Scroll(style) => self.screen.scroll(&style, &ctx, store)?,
Action::Suspend => self.terminal.program_suspend()?, Action::ShowInfoMessage(info) => Some(info),
Action::Tab(cmd) => self.screen.tab_command(&cmd, &ctx, store)?,
Action::Window(cmd) => self.screen.window_command(&cmd, &ctx, store)?, Action::Window(cmd) => self.screen.window_command(&cmd, &ctx, store)?,
Action::Jump(l, dir, count) => { Action::Jump(l, dir, count) => {
@@ -304,8 +463,20 @@ impl Application {
None None
}, },
Action::Suspend => {
self.terminal.program_suspend()?;
None
},
// UI actions. // UI actions.
Action::Tab(cmd) => {
if let TabAction::Close(_, _) = &cmd {
self.last_layout = self.screen.as_description().into();
}
self.screen.tab_command(&cmd, &ctx, store)?
},
Action::RedrawScreen => { Action::RedrawScreen => {
self.screen.clear_message(); self.screen.clear_message();
self.redraw(true, store)?; self.redraw(true, store)?;
@@ -321,7 +492,7 @@ impl Application {
None None
}, },
Action::Command(act) => { Action::Command(act) => {
let acts = self.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
@@ -353,13 +524,36 @@ impl Application {
ctx: ProgramContext, ctx: ProgramContext,
store: &mut ProgramStore, store: &mut ProgramStore,
) -> IambResult<EditInfo> { ) -> IambResult<EditInfo> {
if action.scribbles() {
self.dirty = true;
}
let info = match action { let info = match action {
IambAction::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();
None None
}, },
IambAction::Homeserver(act) => {
let acts = self.homeserver_command(act, ctx, store).await?;
self.action_prepend(acts);
None
},
IambAction::Keys(act) => self.keys_command(act, ctx, store).await?,
IambAction::Message(act) => { IambAction::Message(act) => {
self.screen.current_window_mut()?.message_command(act, ctx, store).await? self.screen.current_window_mut()?.message_command(act, ctx, store).await?
}, },
@@ -373,6 +567,14 @@ impl Application {
self.screen.current_window_mut()?.send_command(act, ctx, store).await? self.screen.current_window_mut()?.send_command(act, ctx, store).await?
}, },
IambAction::OpenLink(url) => {
tokio::task::spawn_blocking(move || {
return open::that(url);
});
None
},
IambAction::Verify(act, user_dev) => { IambAction::Verify(act, user_dev) => {
if let Some(sas) = store.application.verifications.get(&user_dev) { if let Some(sas) = store.application.verifications.get(&user_dev) {
self.worker.verify(act, sas.clone())? self.worker.verify(act, sas.clone())?
@@ -392,6 +594,82 @@ impl Application {
Ok(info) Ok(info)
} }
async fn homeserver_command(
&mut self,
action: HomeserverAction,
ctx: ProgramContext,
store: &mut ProgramStore,
) -> IambResult<Vec<(Action<IambInfo>, ProgramContext)>> {
match action {
HomeserverAction::CreateRoom(alias, vis, flags) => {
let client = &store.application.worker.client;
let room_id = create_room(client, alias, vis, flags).await?;
let room = IambId::Room(room_id, None);
let target = OpenTarget::Application(room);
let action = WindowAction::Switch(target);
Ok(vec![(action.into(), ctx)])
},
HomeserverAction::Logout(user, true) => {
self.worker.logout(user)?;
let flags = CloseFlags::QUIT | CloseFlags::FORCE;
let act = TabAction::Close(TabTarget::All, flags);
Ok(vec![(act.into(), ctx)])
},
HomeserverAction::Logout(user, false) => {
let msg = "Would you like to logout?";
let act = IambAction::from(HomeserverAction::Logout(user, true));
let prompt = PromptYesNo::new(msg, vec![Action::from(act)]);
let prompt = Box::new(prompt);
Err(UIError::NeedConfirm(prompt))
},
}
}
async fn keys_command(
&mut self,
action: KeysAction,
_: ProgramContext,
store: &mut ProgramStore,
) -> IambResult<EditInfo> {
let encryption = store.application.worker.client.encryption();
match action {
KeysAction::Export(path, passphrase) => {
encryption
.export_room_keys(path.into(), &passphrase, |_| true)
.await
.map_err(IambError::from)?;
Ok(Some("Successfully exported room keys".into()))
},
KeysAction::Import(path, passphrase) => {
let res = encryption
.import_room_keys(path.into(), &passphrase)
.await
.map_err(IambError::from)?;
let msg = format!("Imported {} of {} keys", res.imported_count, res.total_count);
Ok(Some(msg.into()))
},
}
}
fn handle_info(&mut self, info: InfoMessage) {
match info {
InfoMessage::Message(info) => {
self.screen.push_info(info);
},
InfoMessage::Pager(text) => {
let pager = Box::new(Pager::new(text, vec![]));
self.bindings.run_dialog(pager);
},
}
}
pub async fn run(&mut self) -> Result<(), std::io::Error> { pub async fn run(&mut self) -> Result<(), std::io::Error> {
self.terminal.clear()?; self.terminal.clear()?;
@@ -412,11 +690,18 @@ impl Application {
continue; continue;
}, },
Ok(Some(info)) => { Ok(Some(info)) => {
self.screen.push_info(info); self.handle_info(info);
// Continue processing; we'll redraw later. // Continue processing; we'll redraw later.
continue; continue;
}, },
Err(
UIError::NeedConfirm(dialog) |
UIError::EditingFailure(EditError::NeedConfirm(dialog)),
) => {
self.bindings.run_dialog(dialog);
continue;
},
Err(e) => { Err(e) => {
self.screen.push_error(e); self.screen.push_error(e);
@@ -428,6 +713,19 @@ impl Application {
} }
} }
if let Some(ref layout) = self.last_layout {
let locked = self.store.lock().await;
let path = locked.application.settings.layout_json.as_path();
path.parent().map(create_dir_all).transpose()?;
let file = File::create(path)?;
let writer = BufWriter::new(file);
if let Err(e) = serde_json::to_writer(writer, layout) {
tracing::error!("Failed to save window layout while exiting: {}", e);
}
}
crossterm::terminal::disable_raw_mode()?; crossterm::terminal::disable_raw_mode()?;
execute!(self.terminal.backend_mut(), LeaveAlternateScreen)?; execute!(self.terminal.backend_mut(), LeaveAlternateScreen)?;
self.terminal.show_cursor()?; self.terminal.show_cursor()?;
@@ -436,23 +734,59 @@ impl Application {
} }
} }
async fn login(worker: Requester, settings: &ApplicationSettings) -> IambResult<()> { fn gen_passphrase() -> String {
println!("Logging in for {}...", settings.profile.user_id); rand::thread_rng()
.sample_iter(&Alphanumeric)
.take(20)
.map(char::from)
.collect()
}
fn read_response(question: &str) -> String {
println!("{question}");
let mut input = String::new();
let _ = std::io::stdin().read_line(&mut input);
input
}
fn read_yesno(question: &str) -> Option<char> {
read_response(question).chars().next().map(|c| c.to_ascii_lowercase())
}
async fn login(worker: &Requester, settings: &ApplicationSettings) -> IambResult<()> {
if settings.session_json.is_file() { if settings.session_json.is_file() {
let file = File::open(settings.session_json.as_path())?; let session = settings.read_session(&settings.session_json)?;
let reader = BufReader::new(file); worker.login(LoginStyle::SessionRestore(session.into()))?;
let session = serde_json::from_reader(reader).map_err(IambError::from)?;
worker.login(LoginStyle::SessionRestore(session))?; return Ok(());
}
if settings.session_json_old.is_file() && !settings.sled_dir.is_dir() {
let session = settings.read_session(&settings.session_json_old)?;
worker.login(LoginStyle::SessionRestore(session.into()))?;
return Ok(()); return Ok(());
} }
loop { loop {
let password = rpassword::prompt_password("Password: ")?; let login_style =
match read_response("Please select login type: [p]assword / [s]ingle sign on")
.chars()
.next()
.map(|c| c.to_ascii_lowercase())
{
None | Some('p') => {
let password = rpassword::prompt_password("Password: ")?;
LoginStyle::Password(password)
},
Some('s') => LoginStyle::SingleSignOn,
Some(_) => {
println!("Failed to login. Please enter 'p' or 's'");
continue;
},
};
match worker.login(LoginStyle::Password(password)) { match worker.login(login_style) {
Ok(info) => { Ok(info) => {
if let Some(msg) = info { if let Some(msg) = info {
println!("{msg}"); println!("{msg}");
@@ -471,36 +805,223 @@ async fn login(worker: Requester, settings: &ApplicationSettings) -> IambResult<
} }
fn print_exit<T: Display, N>(v: T) -> N { fn print_exit<T: Display, N>(v: T) -> N {
println!("{v}"); eprintln!("{v}");
process::exit(2); process::exit(2);
} }
// We can't access the OlmMachine directly, so write the keys to a temporary
// file first, and then import them later.
async fn check_import_keys(
settings: &ApplicationSettings,
) -> IambResult<Option<(temp_dir::TempDir, String)>> {
let do_import = settings.sled_dir.is_dir() && !settings.sqlite_dir.is_dir();
if !do_import {
return Ok(None);
}
let question = format!(
"Found old sled store in {}. Would you like to export room keys from it? [y]es/[n]o",
settings.sled_dir.display()
);
loop {
match read_yesno(&question) {
Some('y') => {
break;
},
Some('n') => {
return Ok(None);
},
Some(_) | None => {
continue;
},
}
}
let keys = sled_export::export_room_keys(&settings.sled_dir).await?;
let passphrase = gen_passphrase();
println!("* Encrypting {} room keys with the passphrase {passphrase:?}...", keys.len());
let encrypted = match encrypt_room_key_export(&keys, &passphrase, 500000) {
Ok(encrypted) => encrypted,
Err(e) => {
format!("* Failed to encrypt room keys during export: {e}");
process::exit(2);
},
};
let tmpdir = TempDir::new()?;
let exported = tmpdir.child("keys");
println!("* Writing encrypted room keys to {}...", exported.display());
tokio::fs::write(&exported, &encrypted).await?;
Ok(Some((tmpdir, passphrase)))
}
async fn login_upgrade(
keydir: TempDir,
passphrase: String,
worker: &Requester,
settings: &ApplicationSettings,
store: &AsyncProgramStore,
) -> IambResult<()> {
println!(
"Please log in for {} to import the room keys into a new session",
settings.profile.user_id
);
login(worker, settings).await?;
println!("* Importing room keys...");
let exported = keydir.child("keys");
let imported = worker.client.encryption().import_room_keys(exported, &passphrase).await;
match imported {
Ok(res) => {
println!(
"* Successfully imported {} out of {} keys",
res.imported_count, res.total_count
);
let _ = keydir.cleanup();
},
Err(e) => {
println!(
"Failed to import room keys from {}/keys: {e}\n\n\
They have been encrypted with the passphrase {passphrase:?}.\
Please save them and try importing them manually instead\n",
keydir.path().display()
);
loop {
match read_yesno("Would you like to continue logging in? [y]es/[n]o") {
Some('y') => break,
Some('n') => print_exit("* Exiting..."),
Some(_) | None => continue,
}
}
},
}
println!("* Syncing...");
worker::do_first_sync(&worker.client, store)
.await
.map_err(IambError::from)?;
Ok(())
}
async fn login_normal(
worker: &Requester,
settings: &ApplicationSettings,
store: &AsyncProgramStore,
) -> IambResult<()> {
println!("* Logging in for {}...", settings.profile.user_id);
login(worker, settings).await?;
println!("* Syncing...");
worker::do_first_sync(&worker.client, store)
.await
.map_err(IambError::from)?;
Ok(())
}
/// 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.
let import_keys = check_import_keys(&settings).await?;
// Set up client state.
create_dir_all(settings.sqlite_dir.as_path())?;
let client = worker::create_client(&settings).await;
// Set up the async worker thread and global store. // Set up the async worker thread and global store.
let worker = ClientWorker::spawn(settings.clone()).await; let worker = ClientWorker::spawn(client.clone(), settings.clone()).await;
let store = ChatStore::new(worker.clone(), settings.clone()); let store = ChatStore::new(worker.clone(), settings.clone());
let store = Store::new(store); let store = Store::new(store);
let store = Arc::new(AsyncMutex::new(store)); let store = Arc::new(AsyncMutex::new(store));
worker.init(store.clone()); worker.init(store.clone());
login(worker, &settings).await.unwrap_or_else(print_exit); let res = if let Some((keydir, pass)) = import_keys {
login_upgrade(keydir, pass, &worker, &settings, &store).await
} else {
login_normal(&worker, &settings, &store).await
};
match res {
Err(UIError::Application(IambError::Matrix(e))) => {
if let Some(ErrorKind::UnknownToken { .. }) = e.client_api_error_kind() {
print_exit("Server did not recognize our API token; did you log out from this session elsewhere?")
} else {
print_exit(e)
}
},
Err(e) => print_exit(e),
Ok(()) => (),
}
// Set up the terminal for drawing, and cleanup properly on panics.
let enable_enhanced_keys = match crossterm::terminal::supports_keyboard_enhancement() {
Ok(supported) => supported,
Err(e) => {
tracing::warn!(err = %e,
"Failed to determine whether the terminal supports keyboard enhancements");
false
},
};
setup_tty(settings.profile.user_id.as_str(), enable_enhanced_keys)?;
// Make sure panics clean up the terminal properly.
let orig_hook = std::panic::take_hook(); let orig_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |panic_info| { std::panic::set_hook(Box::new(move |panic_info| {
let _ = crossterm::terminal::disable_raw_mode(); restore_tty(enable_enhanced_keys);
let _ = crossterm::execute!(stdout(), DisableBracketedPaste);
let _ = crossterm::execute!(stdout(), LeaveAlternateScreen);
let _ = crossterm::execute!(stdout(), CursorShow);
orig_hook(panic_info); orig_hook(panic_info);
process::exit(1); process::exit(1);
})); }));
// 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?;
// Clean up the terminal on exit.
restore_tty(enable_enhanced_keys);
Ok(()) Ok(())
} }
@@ -511,24 +1032,28 @@ fn main() -> IambResult<()> {
// Load configuration and set up the Matrix SDK. // Load configuration and set up the Matrix SDK.
let settings = ApplicationSettings::load(iamb).unwrap_or_else(print_exit); let settings = ApplicationSettings::load(iamb).unwrap_or_else(print_exit);
// Set umask on Unix platforms so that tokens, keys, etc. are only readable by the user.
#[cfg(unix)]
unsafe {
libc::umask(0o077);
};
// Set up the tracing subscriber so we can log client messages. // Set up the tracing subscriber so we can log client messages.
let log_prefix = format!("iamb-log-{}", settings.profile_name); let log_prefix = format!("iamb-log-{}", settings.profile_name);
let log_dir = settings.dirs.logs.as_path(); let log_dir = settings.dirs.logs.as_path();
create_dir_all(settings.matrix_dir.as_path())?;
create_dir_all(log_dir)?;
let appender = tracing_appender::rolling::daily(log_dir, log_prefix); let appender = tracing_appender::rolling::daily(log_dir, log_prefix);
let (appender, guard) = tracing_appender::non_blocking(appender); let (appender, guard) = tracing_appender::non_blocking(appender);
let subscriber = FmtSubscriber::builder() let subscriber = FmtSubscriber::builder()
.with_writer(appender) .with_writer(appender)
.with_max_level(Level::TRACE) .with_max_level(settings.tunables.log_level)
.finish(); .finish();
tracing::subscriber::set_global_default(subscriber).expect("setting default subscriber failed"); tracing::subscriber::set_global_default(subscriber).expect("setting default subscriber failed");
let rt = tokio::runtime::Builder::new_multi_thread() let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all() .enable_all()
.worker_threads(2)
.thread_name_fn(|| { .thread_name_fn(|| {
static ATOMIC_ID: AtomicUsize = AtomicUsize::new(0); static ATOMIC_ID: AtomicUsize = AtomicUsize::new(0);
let id = ATOMIC_ID.fetch_add(1, Ordering::SeqCst); let id = ATOMIC_ID.fetch_add(1, Ordering::SeqCst);

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

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,67 +1,103 @@
//! # Line Wrapping Logic
//!
//! The [TextPrinter] handles wrapping stylized text and inserting spaces for padding at the end of
//! lines to make concatenation work right (e.g., combining table cells after wrapping their
//! contents).
use std::borrow::Cow; use std::borrow::Cow;
use modalkit::tui::layout::Alignment; use ratatui::layout::Alignment;
use modalkit::tui::style::Style; use ratatui::style::Style;
use modalkit::tui::text::{Span, Spans, Text}; use ratatui::text::{Line, Span, Text};
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr; use unicode_width::UnicodeWidthStr;
use crate::util::{space_span, take_width}; use crate::util::{
replace_emojis_in_line,
replace_emojis_in_span,
replace_emojis_in_str,
space_span,
take_width,
};
/// Wrap styled text for the current terminal width.
pub struct TextPrinter<'a> { pub struct TextPrinter<'a> {
text: Text<'a>, text: Text<'a>,
width: usize, width: usize,
base_style: Style, base_style: Style,
hide_reply: bool, hide_reply: bool,
emoji_shortcodes: bool,
alignment: Alignment, alignment: Alignment,
curr_spans: Vec<Span<'a>>, curr_spans: Vec<Span<'a>>,
curr_width: usize, curr_width: usize,
literal: bool,
} }
impl<'a> TextPrinter<'a> { impl<'a> TextPrinter<'a> {
pub fn new(width: usize, base_style: Style, hide_reply: bool) -> Self { /// Create a new printer.
pub fn new(width: usize, base_style: Style, hide_reply: bool, emoji_shortcodes: bool) -> Self {
TextPrinter { TextPrinter {
text: Text::default(), text: Text::default(),
width, width,
base_style, base_style,
hide_reply, hide_reply,
emoji_shortcodes,
alignment: Alignment::Left, alignment: Alignment::Left,
curr_spans: vec![], curr_spans: vec![],
curr_width: 0, curr_width: 0,
literal: false,
} }
} }
/// Configure the alignment for each line.
pub fn align(mut self, alignment: Alignment) -> Self { pub fn align(mut self, alignment: Alignment) -> Self {
self.alignment = alignment; self.alignment = alignment;
self self
} }
/// Set whether newlines should be treated literally, or turned into spaces.
pub fn literal(mut self, literal: bool) -> Self {
self.literal = literal;
self
}
/// Indicates whether replies should be pushed to the printer.
pub fn hide_reply(&self) -> bool { pub fn hide_reply(&self) -> bool {
self.hide_reply self.hide_reply
} }
/// Indicates whether emojis should be replaced by shortcodes
pub fn emoji_shortcodes(&self) -> bool {
self.emoji_shortcodes
}
/// Indicates the current printer's width.
pub fn width(&self) -> usize { pub fn width(&self) -> usize {
self.width self.width
} }
/// Create a new printer with a smaller width.
pub fn sub(&self, indent: usize) -> Self { pub fn sub(&self, indent: usize) -> Self {
TextPrinter { TextPrinter {
text: Text::default(), text: Text::default(),
width: self.width.saturating_sub(indent), width: self.width.saturating_sub(indent),
base_style: self.base_style, base_style: self.base_style,
hide_reply: self.hide_reply, hide_reply: self.hide_reply,
emoji_shortcodes: self.emoji_shortcodes,
alignment: self.alignment, alignment: self.alignment,
curr_spans: vec![], curr_spans: vec![],
curr_width: 0, curr_width: 0,
literal: self.literal,
} }
} }
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.
pub fn commit(&mut self) { pub fn commit(&mut self) {
if self.curr_width > 0 { if self.curr_width > 0 {
self.push_break(); self.push_break();
@@ -70,9 +106,10 @@ impl<'a> TextPrinter<'a> {
fn push(&mut self) { fn push(&mut self) {
self.curr_width = 0; self.curr_width = 0;
self.text.lines.push(Spans(std::mem::take(&mut self.curr_spans))); self.text.lines.push(Line::from(std::mem::take(&mut self.curr_spans)));
} }
/// Start a new line.
pub fn push_break(&mut self) { pub fn push_break(&mut self) {
if self.curr_width == 0 && self.text.lines.is_empty() { if self.curr_width == 0 && self.text.lines.is_empty() {
// Disallow leading breaks. // Disallow leading breaks.
@@ -107,7 +144,7 @@ impl<'a> TextPrinter<'a> {
self.push(); self.push();
} }
pub fn push_str<T>(&mut self, s: T, style: Style) fn push_str_wrapped<T>(&mut self, s: T, style: Style)
where where
T: Into<Cow<'a, str>>, T: Into<Cow<'a, str>>,
{ {
@@ -140,18 +177,117 @@ impl<'a> TextPrinter<'a> {
} }
} }
pub fn push_line(&mut self, spans: Spans<'a>) { /// Push a [Span] that isn't allowed to break across lines.
self.commit(); pub fn push_span_nobreak(&mut self, mut span: Span<'a>) {
self.text.lines.push(spans); if self.emoji_shortcodes {
replace_emojis_in_span(&mut span);
}
let sw = UnicodeWidthStr::width(span.content.as_ref());
if self.curr_width + sw > self.width {
// Span doesn't fit on this line, so start a new one.
self.commit();
}
self.curr_spans.push(span);
self.curr_width += sw;
} }
pub fn push_text(&mut self, text: Text<'a>) { /// Push text with a [Style].
pub fn push_str(&mut self, s: &'a str, style: Style) {
let style = self.base_style.patch(style);
if self.width == 0 {
return;
}
for mut word in UnicodeSegmentation::split_word_bounds(s) {
if let "\n" | "\r\n" = word {
if self.literal {
self.commit();
continue;
}
// Render embedded newlines as spaces.
word = " ";
}
if !self.literal && self.curr_width == 0 && word.chars().all(char::is_whitespace) {
// Drop leading whitespace.
continue;
}
let cow = if self.emoji_shortcodes {
Cow::Owned(replace_emojis_in_str(word))
} else {
Cow::Borrowed(word)
};
let sw = UnicodeWidthStr::width(cow.as_ref());
if sw > self.width {
self.push_str_wrapped(cow, style);
continue;
}
if self.curr_width + sw > self.width {
// Word doesn't fit on this line, so start a new one.
self.commit();
if !self.literal && cow.chars().all(char::is_whitespace) {
// Drop leading whitespace.
continue;
}
}
let span = Span::styled(cow, style);
self.curr_spans.push(span);
self.curr_width += sw;
}
if self.curr_width == self.width {
// If the last bit fills the full line, start a new one.
self.push();
}
}
/// Push a [Line] into the printer.
pub fn push_line(&mut self, mut line: Line<'a>) {
self.commit(); self.commit();
if self.emoji_shortcodes {
replace_emojis_in_line(&mut line);
}
self.text.lines.push(line);
}
/// Push multiline [Text] into the printer.
pub fn push_text(&mut self, mut text: Text<'a>) {
self.commit();
if self.emoji_shortcodes {
for line in &mut text.lines {
replace_emojis_in_line(line);
}
}
self.text.lines.extend(text.lines); self.text.lines.extend(text.lines);
} }
/// Render the contents of this printer as [Text].
pub fn finish(mut self) -> Text<'a> { pub fn finish(mut self) -> Text<'a> {
self.commit(); self.commit();
self.text self.text
} }
} }
#[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");
}
}

264
src/notifications.rs Normal file
View File

@@ -0,0 +1,264 @@
use std::time::SystemTime;
use matrix_sdk::{
notification_settings::{IsEncrypted, IsOneToOne, NotificationSettings, RoomNotificationMode},
room::Room as MatrixRoom,
ruma::{
api::client::push::get_notifications::v3::Notification,
events::{room::message::MessageType, AnyMessageLikeEventContent, AnySyncTimelineEvent},
MilliSecondsSinceUnixEpoch,
RoomId,
},
Client,
};
use unicode_segmentation::UnicodeSegmentation;
use crate::{
base::{AsyncProgramStore, IambError, IambResult, ProgramStore},
config::{ApplicationSettings, NotifyVia},
};
const IAMB_XDG_NAME: &str = match option_env!("IAMB_XDG_NAME") {
None => "iamb",
Some(iamb) => iamb,
};
pub async fn register_notifications(
client: &Client,
settings: &ApplicationSettings,
store: &AsyncProgramStore,
) {
if !settings.tunables.notifications.enabled {
return;
}
let notify_via = settings.tunables.notifications.via;
let show_message = settings.tunables.notifications.show_message;
let server_settings = client.notification_settings().await;
let Some(startup_ts) = MilliSecondsSinceUnixEpoch::from_system_time(SystemTime::now()) else {
return;
};
let store = store.clone();
client
.register_notification_handler(move |notification, room: MatrixRoom, client: Client| {
let store = store.clone();
let server_settings = server_settings.clone();
async move {
let mode = global_or_room_mode(&server_settings, &room).await;
if mode == RoomNotificationMode::Mute {
return;
}
if is_visible_room(&store, room.room_id()).await {
return;
}
match parse_notification(notification, room, show_message).await {
Ok((summary, body, server_ts)) => {
if server_ts < startup_ts {
return;
}
if is_missing_mention(&body, mode, &client) {
return;
}
match notify_via {
#[cfg(feature = "desktop")]
NotifyVia::Desktop => send_notification_desktop(summary, body),
NotifyVia::Bell => send_notification_bell(&store).await,
}
},
Err(err) => {
tracing::error!("Failed to extract notification data: {err}")
},
}
}
})
.await;
}
async fn send_notification_bell(store: &AsyncProgramStore) {
let mut locked = store.lock().await;
locked.application.ring_bell = true;
}
#[cfg(feature = "desktop")]
fn send_notification_desktop(summary: String, body: Option<String>) {
let mut desktop_notification = notify_rust::Notification::new();
desktop_notification
.summary(&summary)
.appname(IAMB_XDG_NAME)
.icon(IAMB_XDG_NAME)
.action("default", "default");
if let Some(body) = body {
desktop_notification.body(&body);
}
if let Err(err) = desktop_notification.show() {
tracing::error!("Failed to send notification: {err}")
}
}
async fn global_or_room_mode(
settings: &NotificationSettings,
room: &MatrixRoom,
) -> RoomNotificationMode {
let room_mode = settings.get_user_defined_room_notification_mode(room.room_id()).await;
if let Some(mode) = room_mode {
return mode;
}
let is_one_to_one = match room.is_direct().await {
Ok(true) => IsOneToOne::Yes,
_ => IsOneToOne::No,
};
let is_encrypted = match room.is_encrypted().await {
Ok(true) => IsEncrypted::Yes,
_ => IsEncrypted::No,
};
settings
.get_default_room_notification_mode(is_encrypted, is_one_to_one)
.await
}
fn is_missing_mention(body: &Option<String>, mode: RoomNotificationMode, client: &Client) -> bool {
if let Some(body) = body {
if mode == RoomNotificationMode::MentionsAndKeywordsOnly {
let mentioned = match client.user_id() {
Some(user_id) => body.contains(user_id.localpart()),
_ => false,
};
return !mentioned;
}
}
false
}
fn is_open(locked: &mut ProgramStore, room_id: &RoomId) -> bool {
if let Some(draw_curr) = locked.application.draw_curr {
let info = locked.application.get_room_info(room_id.to_owned());
if let Some(draw_last) = info.draw_last {
return draw_last == draw_curr;
}
}
false
}
fn is_focused(locked: &ProgramStore) -> bool {
locked.application.focused
}
async fn is_visible_room(store: &AsyncProgramStore, room_id: &RoomId) -> bool {
let mut locked = store.lock().await;
is_focused(&locked) && is_open(&mut locked, room_id)
}
pub async fn parse_notification(
notification: Notification,
room: MatrixRoom,
show_body: bool,
) -> IambResult<(String, Option<String>, MilliSecondsSinceUnixEpoch)> {
let event = notification.event.deserialize().map_err(IambError::from)?;
let server_ts = event.origin_server_ts();
let sender_id = event.sender();
let sender = room.get_member_no_sync(sender_id).await.map_err(IambError::from)?;
let sender_name = sender
.as_ref()
.and_then(|m| m.display_name())
.unwrap_or_else(|| sender_id.localpart());
let summary = if let Ok(room_name) = room.display_name().await {
format!("{sender_name} in {room_name}")
} else {
sender_name.to_string()
};
let body = if show_body {
event_notification_body(
&event,
sender_name,
room.is_direct().await.map_err(IambError::from)?,
)
.map(truncate)
} else {
None
};
return Ok((summary, body, server_ts));
}
pub fn event_notification_body(
event: &AnySyncTimelineEvent,
sender_name: &str,
is_direct: bool,
) -> Option<String> {
let AnySyncTimelineEvent::MessageLike(event) = event else {
return None;
};
match event.original_content()? {
AnyMessageLikeEventContent::RoomMessage(message) => {
let body = match message.msgtype {
MessageType::Audio(_) => {
format!("{sender_name} sent an audio file.")
},
MessageType::Emote(content) => {
let message = &content.body;
format!("{sender_name}: {message}")
},
MessageType::File(_) => {
format!("{sender_name} sent a file.")
},
MessageType::Image(_) => {
format!("{sender_name} sent an image.")
},
MessageType::Location(_) => {
format!("{sender_name} sent their location.")
},
MessageType::Notice(content) => {
let message = &content.body;
format!("{sender_name}: {message}")
},
MessageType::ServerNotice(content) => {
let message = &content.body;
format!("{sender_name}: {message}")
},
MessageType::Text(content) => {
if is_direct {
content.body
} else {
let message = &content.body;
format!("{sender_name}: {message}")
}
},
MessageType::Video(_) => {
format!("{sender_name} sent a video.")
},
MessageType::VerificationRequest(_) => {
format!("{sender_name} sent a verification request.")
},
_ => {
format!("[Unknown message type: {:?}]", &message.msgtype)
},
};
Some(body)
},
AnyMessageLikeEventContent::Sticker(_) => Some(format!("{sender_name} sent a sticker.")),
_ => None,
}
}
fn truncate(s: String) -> String {
static MAX_LENGTH: usize = 100;
if s.graphemes(true).count() > MAX_LENGTH {
let truncated: String = s.graphemes(true).take(MAX_LENGTH).collect();
truncated + "..."
} else {
s
}
}

172
src/preview.rs Normal file
View File

@@ -0,0 +1,172 @@
use std::{
fs::File,
io::{Read, Write},
path::{Path, PathBuf},
};
use matrix_sdk::{
media::{MediaFormat, MediaRequest},
ruma::{
events::{
room::{
message::{MessageType, RoomMessageEventContent},
MediaSource,
},
MessageLikeEvent,
},
OwnedEventId,
OwnedRoomId,
},
Media,
};
use ratatui::layout::Rect;
use ratatui_image::Resize;
use crate::{
base::{AsyncProgramStore, ChatStore, IambError},
config::ImagePreviewSize,
message::ImageStatus,
};
pub fn source_from_event(
ev: &MessageLikeEvent<RoomMessageEventContent>,
) -> Option<(OwnedEventId, MediaSource)> {
if let MessageLikeEvent::Original(ev) = &ev {
if let MessageType::Image(c) = &ev.content.msgtype {
return Some((ev.event_id.clone(), c.source.clone()));
}
}
None
}
impl From<ImagePreviewSize> for Rect {
fn from(value: ImagePreviewSize) -> Self {
Rect::new(0, 0, value.width as _, value.height as _)
}
}
impl From<Rect> for ImagePreviewSize {
fn from(rect: Rect) -> Self {
ImagePreviewSize { width: rect.width as _, height: rect.height as _ }
}
}
/// Download and prepare the preview, and then lock the store to insert it.
pub fn spawn_insert_preview(
store: AsyncProgramStore,
room_id: OwnedRoomId,
event_id: OwnedEventId,
source: MediaSource,
media: Media,
cache_dir: PathBuf,
) {
tokio::spawn(async move {
let img = download_or_load(event_id.to_owned(), source, media, cache_dir)
.await
.map(std::io::Cursor::new)
.map(image::io::Reader::new)
.map_err(IambError::Matrix)
.and_then(|reader| reader.with_guessed_format().map_err(IambError::IOError))
.and_then(|reader| reader.decode().map_err(IambError::Image));
match img {
Err(err) => {
try_set_msg_preview_error(
&mut store.lock().await.application,
room_id,
event_id,
err,
);
},
Ok(img) => {
let mut locked = store.lock().await;
let ChatStore { rooms, picker, settings, .. } = &mut locked.application;
match picker
.as_mut()
.ok_or_else(|| IambError::Preview("Picker is empty".to_string()))
.and_then(|picker| {
Ok((
picker,
rooms
.get_or_default(room_id.clone())
.get_event_mut(&event_id)
.ok_or_else(|| {
IambError::Preview("Message not found".to_string())
})?,
settings.tunables.image_preview.clone().ok_or_else(|| {
IambError::Preview("image_preview settings not found".to_string())
})?,
))
})
.and_then(|(picker, msg, image_preview)| {
picker
.new_protocol(img, image_preview.size.into(), Resize::Fit(None))
.map_err(|err| IambError::Preview(format!("{err:?}")))
.map(|backend| (backend, msg))
}) {
Err(err) => {
try_set_msg_preview_error(&mut locked.application, room_id, event_id, err);
},
Ok((backend, msg)) => {
msg.image_preview = ImageStatus::Loaded(backend);
},
}
},
}
});
}
fn try_set_msg_preview_error(
application: &mut ChatStore,
room_id: OwnedRoomId,
event_id: OwnedEventId,
err: IambError,
) {
let rooms = &mut application.rooms;
match rooms
.get_or_default(room_id.clone())
.get_event_mut(&event_id)
.ok_or_else(|| IambError::Preview("Message not found".to_string()))
{
Ok(msg) => msg.image_preview = ImageStatus::Error(format!("{err:?}")),
Err(err) => {
tracing::error!(
"Failed to set error on msg.image_backend for event {}, room {}: {}",
event_id,
room_id,
err
)
},
}
}
async fn download_or_load(
event_id: OwnedEventId,
source: MediaSource,
media: Media,
mut cache_path: PathBuf,
) -> Result<Vec<u8>, matrix_sdk::Error> {
cache_path.push(Path::new(event_id.localpart()));
match File::open(&cache_path) {
Ok(mut f) => {
let mut buffer = Vec::new();
f.read_to_end(&mut buffer)?;
Ok(buffer)
},
Err(_) => {
media
.get_media_content(&MediaRequest { source, format: MediaFormat::File }, true)
.await
.and_then(|buffer| {
if let Err(err) =
File::create(&cache_path).and_then(|mut f| f.write_all(&buffer))
{
return Err(err.into());
}
Ok(buffer)
})
},
}
}

58
src/sled_export.rs Normal file
View File

@@ -0,0 +1,58 @@
//! # sled -> sqlite migration code
//!
//! Before the 0.0.9 release, iamb used matrix-sdk@0.6.2, which used [sled]
//! for storing information, including room keys. In matrix-sdk@0.7.0,
//! the SDK switched to using SQLite. This module takes care of opening
//! sled, exporting the inbound group sessions used for decryption,
//! and importing them into SQLite.
//!
//! This code will eventually be removed once people have been given enough
//! time to upgrade off of pre-0.0.9 versions.
//!
//! [sled]: https://docs.rs/sled/0.34.7/sled/index.html
use sled::{Config, IVec};
use std::path::Path;
use crate::base::IambError;
use matrix_sdk::crypto::olm::{ExportedRoomKey, InboundGroupSession, PickledInboundGroupSession};
#[derive(Debug, thiserror::Error)]
pub enum SledMigrationError {
#[error("sled failure: {0}")]
Sled(#[from] sled::Error),
#[error("deserialization failure: {0}")]
Deserialize(#[from] serde_json::Error),
}
fn group_session_from_slice(
(_, bytes): (IVec, IVec),
) -> Result<PickledInboundGroupSession, SledMigrationError> {
serde_json::from_slice(&bytes).map_err(SledMigrationError::from)
}
async fn export_room_keys_priv(
sled_dir: &Path,
) -> Result<Vec<ExportedRoomKey>, SledMigrationError> {
let path = sled_dir.join("matrix-sdk-state");
let store = Config::new().temporary(false).path(&path).open()?;
let inbound_groups = store.open_tree("inbound_group_sessions")?;
let mut exported = vec![];
let sessions = inbound_groups
.iter()
.map(|p| p.map_err(SledMigrationError::from).and_then(group_session_from_slice))
.collect::<Result<Vec<_>, _>>()?
.into_iter()
.filter_map(|p| InboundGroupSession::from_pickle(p).ok());
for session in sessions {
exported.push(session.export().await);
}
Ok(exported)
}
pub async fn export_room_keys(sled_dir: &Path) -> Result<Vec<ExportedRoomKey>, IambError> {
export_room_keys_priv(sled_dir).await.map_err(IambError::from)
}

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,62 +1,74 @@
//! Window for Matrix rooms
use std::borrow::Cow; use std::borrow::Cow;
use std::ffi::OsStr; use std::ffi::{OsStr, OsString};
use std::fs; use std::fs;
use std::ops::Deref; use std::ops::Deref;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use edit::edit_with_builder as external_edit;
use edit::Builder;
use modalkit::editing::store::RegisterError;
use std::process::Command;
use tokio; use tokio;
use url::Url;
use matrix_sdk::{ use matrix_sdk::{
attachment::AttachmentConfig, attachment::AttachmentConfig,
media::{MediaFormat, MediaRequest}, media::{MediaFormat, MediaRequest},
room::{Joined, Room as MatrixRoom}, room::Room as MatrixRoom,
ruma::{ ruma::{
events::reaction::{ReactionEventContent, Relation as Reaction}, events::reaction::ReactionEventContent,
events::relation::{Annotation, Replacement},
events::room::message::{ events::room::message::{
AddMentions,
ForwardThread,
MessageType, MessageType,
OriginalRoomMessageEvent, OriginalRoomMessageEvent,
Relation, Relation,
Replacement, ReplyWithinThread,
RoomMessageEventContent, RoomMessageEventContent,
TextMessageEventContent, TextMessageEventContent,
}, },
EventId, OwnedEventId,
OwnedRoomId, OwnedRoomId,
RoomId, RoomId,
}, },
RoomState,
}; };
use modalkit::{ use ratatui::{
tui::{ buffer::Buffer,
buffer::Buffer, layout::Rect,
layout::Rect, text::{Line, Span},
text::{Span, Spans}, widgets::{Paragraph, StatefulWidget, Widget},
widgets::{Paragraph, StatefulWidget, Widget},
},
widgets::textbox::{TextBox, TextBoxState},
widgets::TerminalCursor,
widgets::{PromptActions, WindowOps},
}; };
use modalkit::keybindings::dialog::{MultiChoice, MultiChoiceItem, PromptYesNo};
use modalkit_ratatui::{
textbox::{TextBox, TextBoxState},
PromptActions,
TerminalCursor,
WindowOps,
};
use modalkit::actions::{
Action,
Editable,
EditorAction,
Jumpable,
PromptAction,
Promptable,
Scrollable,
};
use modalkit::editing::{ use modalkit::editing::{
action::{ completion::CompletionList,
EditError,
EditInfo,
EditResult,
Editable,
EditorAction,
InfoMessage,
Jumpable,
PromptAction,
Promptable,
Scrollable,
UIError,
},
base::{CloseFlags, Count, MoveDir1D, PositionList, ScrollStyle, WordStyle},
context::Resolve, context::Resolve,
history::{self, HistoryList}, history::{self, HistoryList},
rope::EditRope, rope::EditRope,
}; };
use modalkit::errors::{EditError, EditResult, UIError};
use modalkit::prelude::*;
use crate::base::{ use crate::base::{
DownloadFlags, DownloadFlags,
@@ -74,11 +86,12 @@ use crate::base::{
SendAction, SendAction,
}; };
use crate::message::{Message, MessageEvent, MessageKey, MessageTimeStamp}; use crate::message::{text_to_message, Message, MessageEvent, MessageKey, MessageTimeStamp};
use crate::worker::Requester; use crate::worker::Requester;
use super::scrollback::{Scrollback, ScrollbackState}; use super::scrollback::{Scrollback, ScrollbackState};
/// State needed for rendering [Chat].
pub struct ChatState { pub struct ChatState {
room_id: OwnedRoomId, room_id: OwnedRoomId,
room: MatrixRoom, room: MatrixRoom,
@@ -95,10 +108,10 @@ pub struct ChatState {
} }
impl ChatState { impl ChatState {
pub fn new(room: MatrixRoom, store: &mut ProgramStore) -> Self { pub fn new(room: MatrixRoom, thread: Option<OwnedEventId>, store: &mut ProgramStore) -> Self {
let room_id = room.room_id().to_owned(); let room_id = room.room_id().to_owned();
let scrollback = ScrollbackState::new(room_id.clone()); let scrollback = ScrollbackState::new(room_id.clone(), thread.clone());
let id = IambBufferId::Room(room_id.clone(), RoomFocus::MessageBar); let id = IambBufferId::Room(room_id.clone(), thread, RoomFocus::MessageBar);
let ebuf = store.load_buffer(id); let ebuf = store.load_buffer(id);
let tbox = TextBoxState::new(ebuf); let tbox = TextBoxState::new(ebuf);
@@ -118,13 +131,26 @@ impl ChatState {
} }
} }
fn get_joined(&self, worker: &Requester) -> Result<Joined, IambError> { pub fn thread(&self) -> Option<&OwnedEventId> {
worker.client.get_joined_room(self.id()).ok_or(IambError::NotJoined) self.scrollback.thread()
}
fn get_joined(&self, worker: &Requester) -> Result<MatrixRoom, IambError> {
let Some(room) = worker.client.get_room(self.id()) else {
return Err(IambError::NotJoined);
};
if room.state() == RoomState::Joined {
Ok(room)
} else {
Err(IambError::NotJoined)
}
} }
fn get_reply_to<'a>(&self, info: &'a RoomInfo) -> Option<&'a OriginalRoomMessageEvent> { fn get_reply_to<'a>(&self, info: &'a RoomInfo) -> Option<&'a OriginalRoomMessageEvent> {
let thread = self.scrollback.get_thread(info)?;
let key = self.reply_to.as_ref()?; let key = self.reply_to.as_ref()?;
let msg = info.messages.get(key)?; let msg = thread.get(key)?;
if let MessageEvent::Original(ev) = &msg.event { if let MessageEvent::Original(ev) = &msg.event {
Some(ev) Some(ev)
@@ -154,66 +180,100 @@ impl ChatState {
let client = &store.application.worker.client; let client = &store.application.worker.client;
let settings = &store.application.settings; let settings = &store.application.settings;
let info = store.application.rooms.entry(self.room_id.clone()).or_default(); let info = store.application.rooms.get_or_default(self.room_id.clone());
let msg = self let msg = self.scrollback.get_mut(info).ok_or(IambError::NoSelectedMessage)?;
.scrollback
.get_mut(&mut info.messages)
.ok_or(IambError::NoSelectedMessage)?;
match act { match act {
MessageAction::Cancel => { MessageAction::Cancel(skip_confirm) => {
if skip_confirm {
self.reset();
return Ok(None);
}
self.reply_to = None; self.reply_to = None;
self.editing = None; self.editing = None;
Ok(None) let msg = "Would you like to clear the message bar?";
let act = PromptAction::Abort(false);
let prompt = PromptYesNo::new(msg, vec![Action::from(act)]);
let prompt = Box::new(prompt);
Err(UIError::NeedConfirm(prompt))
}, },
MessageAction::Download(filename, flags) => { MessageAction::Download(filename, flags) => {
if let MessageEvent::Original(ev) = &msg.event { if let MessageEvent::Original(ev) = &msg.event {
let media = client.media(); let media = client.media();
let mut filename = match filename { let mut filename = match (filename, &settings.dirs.downloads) {
Some(f) => PathBuf::from(f), (Some(f), _) => PathBuf::from(f),
None => settings.dirs.downloads.clone(), (None, Some(downloads)) => downloads.clone(),
(None, None) => return Err(IambError::NoDownloadDir.into()),
}; };
let source = match &ev.content.msgtype { let (source, msg_filename) = match &ev.content.msgtype {
MessageType::Audio(c) => { MessageType::Audio(c) => (c.source.clone(), c.body.as_str()),
if filename.is_dir() { MessageType::File(c) => {
filename.push(c.body.as_str()); (c.source.clone(), c.filename.as_deref().unwrap_or(c.body.as_str()))
},
MessageType::Image(c) => (c.source.clone(), c.body.as_str()),
MessageType::Video(c) => (c.source.clone(), c.body.as_str()),
_ => {
if !flags.contains(DownloadFlags::OPEN) {
return Err(IambError::NoAttachment.into());
} }
c.source.clone() let links = if let Some(html) = &msg.html {
html.get_links()
} else if let Ok(url) = Url::parse(&msg.event.body()) {
vec![('0', url)]
} else {
vec![]
};
if links.is_empty() {
return Err(IambError::NoAttachment.into());
}
let choices = links
.into_iter()
.map(|l| {
let url = l.1.to_string();
let act = IambAction::OpenLink(url.clone()).into();
MultiChoiceItem::new(l.0, url, vec![act])
})
.collect();
let dialog = MultiChoice::new(choices);
let err = UIError::NeedConfirm(Box::new(dialog));
return Err(err);
}, },
MessageType::File(c) => { };
if filename.is_dir() {
if let Some(name) = &c.filename { if filename.is_dir() {
filename.push(name); filename.push(msg_filename);
}
if filename.exists() && !flags.contains(DownloadFlags::FORCE) {
// Find an incrementally suffixed filename, e.g. image-2.jpg -> image-3.jpg
if let Some(stem) = filename.file_stem().and_then(OsStr::to_str) {
let ext = filename.extension();
let mut filename_incr = filename.clone();
for n in 1..=1000 {
if let Some(ext) = ext.and_then(OsStr::to_str) {
filename_incr.set_file_name(format!("{}-{}.{}", stem, n, ext));
} else { } else {
filename.push(c.body.as_str()); filename_incr.set_file_name(format!("{}-{}", stem, n));
}
if !filename_incr.exists() {
filename = filename_incr;
break;
} }
} }
}
c.source.clone() }
},
MessageType::Image(c) => {
if filename.is_dir() {
filename.push(c.body.as_str());
}
c.source.clone()
},
MessageType::Video(c) => {
if filename.is_dir() {
filename.push(c.body.as_str());
}
c.source.clone()
},
_ => {
return Err(IambError::NoAttachment.into());
},
};
if !filename.exists() || flags.contains(DownloadFlags::FORCE) { if !filename.exists() || flags.contains(DownloadFlags::FORCE) {
let req = MediaRequest { source, format: MediaFormat::File }; let req = MediaRequest { source, format: MediaFormat::File };
@@ -235,14 +295,21 @@ impl ChatState {
} }
let info = if flags.contains(DownloadFlags::OPEN) { let info = if flags.contains(DownloadFlags::OPEN) {
// open::that may not return until the spawned program closes.
let target = filename.clone().into_os_string(); let target = filename.clone().into_os_string();
tokio::task::spawn_blocking(move || open::that(target)); match open_command(
store.application.settings.tunables.open_command.as_ref(),
InfoMessage::from(format!( target,
"Attachment downloaded to {} and opened", ) {
filename.display() Ok(_) => {
)) InfoMessage::from(format!(
"Attachment downloaded to {} and opened",
filename.display()
))
},
Err(err) => {
return Err(err);
},
}
} else { } else {
InfoMessage::from(format!( InfoMessage::from(format!(
"Attachment downloaded to {}", "Attachment downloaded to {}",
@@ -285,14 +352,32 @@ impl ChatState {
}; };
self.tbox.set_text(text); self.tbox.set_text(text);
self.reply_to = msg.reply_to().and_then(|id| info.get_message_key(&id)).cloned();
self.editing = self.scrollback.get_key(info); self.editing = self.scrollback.get_key(info);
self.focus = RoomFocus::MessageBar; self.focus = RoomFocus::MessageBar;
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::EncryptedRedacted(ev) => ev.event_id.clone(),
MessageEvent::Original(ev) => ev.event_id.clone(), MessageEvent::Original(ev) => ev.event_id.clone(),
MessageEvent::Local(event_id, _) => event_id.clone(), MessageEvent::Local(event_id, _) => event_id.clone(),
MessageEvent::Redacted(_) => { MessageEvent::Redacted(_) => {
@@ -303,15 +388,33 @@ impl ChatState {
}, },
}; };
let reaction = Reaction::new(event_id, emoji); if info.user_reactions_contains(&settings.profile.user_id, &event_id, &emoji) {
let msg = format!("Youve already reacted to this message with {}", emoji);
let err = UIError::Failure(msg);
return Err(err);
}
let reaction = Annotation::new(event_id, emoji);
let msg = ReactionEventContent::new(reaction); let msg = ReactionEventContent::new(reaction);
let _ = room.send(msg, None).await.map_err(IambError::from)?; let _ = room.send(msg).await.map_err(IambError::from)?;
Ok(None) Ok(None)
}, },
MessageAction::Redact(reason) => { MessageAction::Redact(reason, skip_confirm) => {
if !skip_confirm {
let msg = "Are you sure you want to redact this message?";
let act = IambAction::Message(MessageAction::Redact(reason, true));
let prompt = PromptYesNo::new(msg, vec![Action::from(act)]);
let prompt = Box::new(prompt);
return Err(UIError::NeedConfirm(prompt));
}
let room = self.get_joined(&store.application.worker)?; let room = self.get_joined(&store.application.worker)?;
let event_id = match &msg.event { let event_id = match &msg.event {
MessageEvent::EncryptedOriginal(ev) => ev.event_id.clone(),
MessageEvent::EncryptedRedacted(ev) => ev.event_id.clone(),
MessageEvent::Original(ev) => ev.event_id.clone(), MessageEvent::Original(ev) => ev.event_id.clone(),
MessageEvent::Local(event_id, _) => event_id.clone(), MessageEvent::Local(event_id, _) => event_id.clone(),
MessageEvent::Redacted(_) => { MessageEvent::Redacted(_) => {
@@ -334,11 +437,33 @@ 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: &EventId = match &msg.event { let event_id = match &msg.event {
MessageEvent::Original(ev) => ev.event_id.as_ref(), MessageEvent::EncryptedOriginal(ev) => ev.event_id.clone(),
MessageEvent::Local(event_id, _) => event_id.as_ref(), MessageEvent::EncryptedRedacted(ev) => ev.event_id.clone(),
MessageEvent::Original(ev) => ev.event_id.clone(),
MessageEvent::Local(event_id, _) => event_id.clone(),
MessageEvent::Redacted(_) => { MessageEvent::Redacted(_) => {
let msg = "Cannot unreact to a redacted message"; let msg = "Cannot unreact to a redacted message";
let err = UIError::Failure(msg.into()); let err = UIError::Failure(msg.into());
@@ -347,7 +472,7 @@ impl ChatState {
}, },
}; };
let reactions = match info.reactions.get(event_id) { let reactions = match info.reactions.get(&event_id) {
Some(r) => r, Some(r) => r,
None => return Ok(None), None => return Ok(None),
}; };
@@ -383,43 +508,55 @@ impl ChatState {
_: ProgramContext, _: ProgramContext,
store: &mut ProgramStore, store: &mut ProgramStore,
) -> IambResult<EditInfo> { ) -> IambResult<EditInfo> {
let room = store let room = self.get_joined(&store.application.worker)?;
.application let info = store.application.rooms.get_or_default(self.id().to_owned());
.worker
.client
.get_joined_room(self.id())
.ok_or(IambError::NotJoined)?;
let info = store.application.rooms.entry(self.id().to_owned()).or_default();
let mut show_echo = true; let mut show_echo = true;
let (event_id, msg) = match act { let (event_id, msg) = match act {
SendAction::Submit => { SendAction::Submit | SendAction::SubmitFromEditor => {
let msg = self.tbox.get_text(); let msg = self.tbox.get();
if msg.is_empty() { let msg = if let SendAction::SubmitFromEditor = act {
let suffix =
store.application.settings.tunables.external_edit_file_suffix.as_str();
let edited_msg =
external_edit(msg.trim_end().to_string(), Builder::new().suffix(suffix))?
.trim_end()
.to_string();
if edited_msg.is_empty() {
return Ok(None);
}
edited_msg
} else if msg.is_blank() {
return Ok(None); return Ok(None);
} } else {
msg.trim_end().to_string()
};
let msg = TextMessageEventContent::markdown(msg); let mut msg = text_to_message(msg);
let msg = MessageType::Text(msg);
let mut msg = RoomMessageEventContent::new(msg);
if let Some((_, event_id)) = &self.editing { if let Some((_, event_id)) = &self.editing {
msg.relates_to = Some(Relation::Replacement(Replacement::new( msg.relates_to = Some(Relation::Replacement(Replacement::new(
event_id.clone(), event_id.clone(),
Box::new(msg.clone()), msg.msgtype.clone().into(),
))); )));
show_echo = false; show_echo = false;
} else if let Some(thread_root) = self.scrollback.thread() {
if let Some(m) = self.get_reply_to(info) {
msg = msg.make_for_thread(m, ReplyWithinThread::Yes, AddMentions::No);
} else if let Some(m) = info.get_thread_last(thread_root) {
msg = msg.make_for_thread(m, ReplyWithinThread::No, AddMentions::No);
} else {
// Internal state is wonky?
}
} else if let Some(m) = self.get_reply_to(info) { } else if let Some(m) = self.get_reply_to(info) {
// XXX: Switch to RoomMessageEventContent::reply() once it's stable? msg = msg.make_reply_to(m, ForwardThread::Yes, AddMentions::No);
msg = msg.make_reply_to(m);
} }
// XXX: second parameter can be a locally unique transaction id. // XXX: second parameter can be a locally unique transaction id.
// Useful for doing retries. // Useful for doing retries.
let resp = room.send(msg.clone(), None).await.map_err(IambError::from)?; let resp = room.send(msg.clone()).await.map_err(IambError::from)?;
let event_id = resp.event_id; let event_id = resp.event_id;
// Reset message bar state now that it's been sent. // Reset message bar state now that it's been sent.
@@ -439,7 +576,37 @@ impl ChatState {
let config = AttachmentConfig::new(); let config = AttachmentConfig::new();
let resp = room let resp = room
.send_attachment(name.as_ref(), &mime, bytes.as_ref(), config) .send_attachment(name.as_ref(), &mime, bytes, config)
.await
.map_err(IambError::from)?;
// Mock up the local echo message for the scrollback.
let msg = TextMessageEventContent::plain(format!("[Attached File: {name}]"));
let msg = MessageType::Text(msg);
let msg = RoomMessageEventContent::new(msg);
(resp.event_id, msg)
},
SendAction::UploadImage(width, height, bytes) => {
// Convert to png because arboard does not give us the mime type.
let bytes =
image::ImageBuffer::from_raw(width as _, height as _, bytes.into_owned())
.ok_or(IambError::Clipboard)
.and_then(|imagebuf| {
let dynimage = image::DynamicImage::ImageRgba8(imagebuf);
let bytes = Vec::<u8>::new();
let mut buff = std::io::Cursor::new(bytes);
dynimage.write_to(&mut buff, image::ImageOutputFormat::Png)?;
Ok(buff.into_inner())
})
.map_err(IambError::from)?;
let mime = mime::IMAGE_PNG;
let name = "Clipboard.png";
let config = AttachmentConfig::new();
let resp = room
.send_attachment(name.as_ref(), &mime, bytes, config)
.await .await
.map_err(IambError::from)?; .map_err(IambError::from)?;
@@ -457,7 +624,8 @@ impl ChatState {
let key = (MessageTimeStamp::LocalEcho, event_id.clone()); let key = (MessageTimeStamp::LocalEcho, event_id.clone());
let msg = MessageEvent::Local(event_id, msg.into()); let msg = MessageEvent::Local(event_id, msg.into());
let msg = Message::new(msg, user, MessageTimeStamp::LocalEcho); let msg = Message::new(msg, user, MessageTimeStamp::LocalEcho);
info.messages.insert(key, msg); let thread = self.scrollback.get_thread_mut(info);
thread.insert(key, msg);
} }
// Jump to the end of the scrollback to show the message. // Jump to the end of the scrollback to show the message.
@@ -524,12 +692,14 @@ impl WindowOps<IambInfo> for ChatState {
fn dup(&self, store: &mut ProgramStore) -> Self { fn dup(&self, store: &mut ProgramStore) -> Self {
// XXX: I want each WindowSlot to have its own shared buffer, instead of each Room; need to // XXX: I want each WindowSlot to have its own shared buffer, instead of each Room; need to
// find a good way to pass that info here so that it can be part of the content id. // find a good way to pass that info here so that it can be part of the content id.
let id = IambBufferId::Room(self.room_id.clone(), RoomFocus::MessageBar); let room_id = self.room_id.clone();
let thread = self.thread().cloned();
let id = IambBufferId::Room(room_id.clone(), thread, RoomFocus::MessageBar);
let ebuf = store.load_buffer(id); let ebuf = store.load_buffer(id);
let tbox = TextBoxState::new(ebuf); let tbox = TextBoxState::new(ebuf);
ChatState { ChatState {
room_id: self.room_id.clone(), room_id,
room: self.room.clone(), room: self.room.clone(),
tbox, tbox,
@@ -550,6 +720,21 @@ impl WindowOps<IambInfo> for ChatState {
true true
} }
fn write(
&mut self,
_: Option<&str>,
_: WriteFlags,
_: &mut ProgramStore,
) -> IambResult<EditInfo> {
// XXX: what's the right writing behaviour for a room?
// Should write send a message?
Ok(None)
}
fn get_completions(&self) -> Option<CompletionList> {
delegate!(self, w => w.get_completions())
}
fn get_cursor_word(&self, style: &WordStyle) -> Option<String> { fn get_cursor_word(&self, style: &WordStyle) -> Option<String> {
delegate!(self, w => w.get_cursor_word(style)) delegate!(self, w => w.get_cursor_word(style))
} }
@@ -570,8 +755,10 @@ impl Editable<ProgramContext, ProgramStore, IambInfo> for ChatState {
match delegate!(self, w => w.editor_command(act, ctx, store)) { match delegate!(self, w => w.editor_command(act, ctx, store)) {
res @ Ok(_) => res, res @ Ok(_) => res,
Err(EditError::WrongBuffer(IambBufferId::Room(room_id, focus))) Err(EditError::WrongBuffer(IambBufferId::Room(room_id, thread, focus)))
if room_id == self.room_id && act.is_switchable(ctx) => if room_id == self.room_id &&
thread.as_ref() == self.thread() &&
act.is_switchable(ctx) =>
{ {
// Switch focus. // Switch focus.
self.focus = focus; self.focus = focus;
@@ -579,6 +766,15 @@ impl Editable<ProgramContext, ProgramStore, IambInfo> for ChatState {
// Run command again. // Run command again.
delegate!(self, w => w.editor_command(act, ctx, store)) delegate!(self, w => w.editor_command(act, ctx, store))
}, },
Err(EditError::Register(RegisterError::ClipboardImage(data))) => {
let msg = "Do you really want to upload the image from your system clipboard?";
let send =
IambAction::Send(SendAction::UploadImage(data.width, data.height, data.bytes));
let prompt = PromptYesNo::new(msg, vec![Action::from(send)]);
let prompt = Box::new(prompt);
Err(EditError::NeedConfirm(prompt))
},
res @ Err(_) => res, res @ Err(_) => res,
} }
} }
@@ -655,13 +851,14 @@ impl PromptActions<ProgramContext, ProgramStore, IambInfo> for ChatState {
&mut self, &mut self,
dir: &MoveDir1D, dir: &MoveDir1D,
count: &Count, count: &Count,
prefixed: bool,
ctx: &ProgramContext, ctx: &ProgramContext,
_: &mut ProgramStore, _: &mut ProgramStore,
) -> EditResult<Vec<(ProgramAction, ProgramContext)>, IambInfo> { ) -> EditResult<Vec<(ProgramAction, ProgramContext)>, IambInfo> {
let count = ctx.resolve(count); let count = ctx.resolve(count);
let rope = self.tbox.get(); let rope = self.tbox.get();
let text = self.sent.recall(&rope, &mut self.sent_scrollback, *dir, count); let text = self.sent.recall(&rope, &mut self.sent_scrollback, *dir, prefixed, count);
if let Some(text) = text { if let Some(text) = text {
self.tbox.set_text(text); self.tbox.set_text(text);
@@ -679,18 +876,20 @@ impl Promptable<ProgramContext, ProgramStore, IambInfo> for ChatState {
store: &mut ProgramStore, store: &mut ProgramStore,
) -> EditResult<Vec<(ProgramAction, ProgramContext)>, IambInfo> { ) -> EditResult<Vec<(ProgramAction, ProgramContext)>, IambInfo> {
if let RoomFocus::Scrollback = self.focus { if let RoomFocus::Scrollback = self.focus {
return Ok(vec![]); return self.scrollback.prompt(act, ctx, store);
} }
match act { match act {
PromptAction::Submit => self.submit(ctx, store), PromptAction::Submit => self.submit(ctx, store),
PromptAction::Abort(empty) => self.abort(*empty, ctx, store), PromptAction::Abort(empty) => self.abort(*empty, ctx, store),
PromptAction::Recall(dir, count) => self.recall(dir, count, ctx, store), PromptAction::Recall(dir, count, prefixed) => {
_ => Err(EditError::Unimplemented("unknown prompt action".to_string())), self.recall(dir, count, *prefixed, ctx, store)
},
} }
} }
} }
/// [StatefulWidget] for Matrix rooms.
pub struct Chat<'a> { pub struct Chat<'a> {
store: &'a mut ProgramStore, store: &'a mut ProgramStore,
focused: bool, focused: bool,
@@ -711,10 +910,35 @@ impl<'a> StatefulWidget for Chat<'a> {
type State = ChatState; type State = ChatState;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
// Determine whether we have a description to show for the message bar.
let desc_spans = match (&state.editing, &state.reply_to, state.thread()) {
(None, None, None) => None,
(None, None, Some(_)) => Some(Line::from("Replying in thread")),
(Some(_), None, None) => Some(Line::from("Editing message")),
(Some(_), None, Some(_)) => Some(Line::from("Editing message in thread")),
(editing, Some(_), thread) => {
self.store.application.rooms.get(state.id()).and_then(|room| {
let msg = state.get_reply_to(room)?;
let user =
self.store.application.settings.get_user_span(msg.sender.as_ref(), room);
let prefix = match (editing.is_some(), thread.is_some()) {
(true, false) => Span::from("Editing reply to "),
(true, true) => Span::from("Editing reply in thread to "),
(false, false) => Span::from("Replying to "),
(false, true) => Span::from("Replying in thread to "),
};
let spans = Line::from(vec![prefix, user]);
spans.into()
})
},
};
// Determine the region to show each UI element.
let lines = state.tbox.has_lines(5).max(1) as u16; let lines = state.tbox.has_lines(5).max(1) as u16;
let drawh = area.height; let drawh = area.height;
let texth = lines.min(drawh).clamp(1, 5); let texth = lines.min(drawh).clamp(1, 5);
let desch = if state.reply_to.is_some() { let desch = if desc_spans.is_some() {
drawh.saturating_sub(texth).min(1) drawh.saturating_sub(texth).min(1)
} else { } else {
0 0
@@ -725,25 +949,7 @@ impl<'a> StatefulWidget for Chat<'a> {
let descarea = Rect::new(area.x, scrollarea.y + scrollh, area.width, desch); let descarea = Rect::new(area.x, scrollarea.y + scrollh, area.width, desch);
let textarea = Rect::new(area.x, descarea.y + desch, area.width, texth); let textarea = Rect::new(area.x, descarea.y + desch, area.width, texth);
let scrollback_focused = state.focus.is_scrollback() && self.focused; // Render the message bar and any description for it.
let scrollback = Scrollback::new(self.store).focus(scrollback_focused);
scrollback.render(scrollarea, buf, &mut state.scrollback);
let desc_spans = match (&state.editing, &state.reply_to) {
(None, None) => None,
(Some(_), _) => Some(Spans::from("Editing message")),
(_, Some(_)) => {
state.reply_to.as_ref().and_then(|k| {
let room = self.store.application.rooms.get(state.id())?;
let msg = room.messages.get(k)?;
let user = self.store.application.settings.get_user_span(msg.sender.as_ref());
let spans = Spans(vec![Span::from("Replying to "), user]);
spans.into()
})
},
};
if let Some(desc_spans) = desc_spans { if let Some(desc_spans) = desc_spans {
Paragraph::new(desc_spans).render(descarea, buf); Paragraph::new(desc_spans).render(descarea, buf);
} }
@@ -752,5 +958,35 @@ impl<'a> StatefulWidget for Chat<'a> {
let tbox = TextBox::new().prompt(prompt); let tbox = TextBox::new().prompt(prompt);
tbox.render(textarea, buf, &mut state.tbox); tbox.render(textarea, buf, &mut state.tbox);
// Render the message scrollback.
let scrollback_focused = state.focus.is_scrollback() && self.focused;
let scrollback = Scrollback::new(self.store)
.focus(scrollback_focused)
.room_focus(self.focused);
scrollback.render(scrollarea, buf, &mut state.scrollback);
} }
} }
fn open_command(open_command: Option<&Vec<String>>, target: OsString) -> IambResult<()> {
if let Some(mut cmd) = open_command.and_then(cmd) {
cmd.arg(target);
cmd.spawn()?;
return Ok(());
} else {
// open::that may not return until the spawned program closes.
tokio::task::spawn_blocking(move || {
return open::that(target);
});
return Ok(());
}
}
fn cmd(open_command: &Vec<String>) -> Option<Command> {
if let [program, args @ ..] = open_command.as_slice() {
let mut cmd = Command::new(program);
cmd.args(args);
return Some(cmd);
}
None
}

View File

@@ -1,55 +1,64 @@
//! # Windows for Matrix rooms and spaces
use std::collections::HashSet;
use matrix_sdk::{ use matrix_sdk::{
room::{Invited, Room as MatrixRoom}, notification_settings::RoomNotificationMode,
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,
OwnedRoomAliasId,
OwnedUserId,
RoomId, RoomId,
}, },
DisplayName, DisplayName,
RoomState as MatrixRoomState,
}; };
use modalkit::tui::{ use ratatui::{
buffer::Buffer, buffer::Buffer,
layout::{Alignment, Rect}, layout::{Alignment, Rect},
style::{Modifier as StyleModifier, Style}, style::{Modifier as StyleModifier, Style},
text::{Span, Spans, Text}, text::{Line, Span, Text},
widgets::{Paragraph, StatefulWidget, Widget}, widgets::{Paragraph, StatefulWidget, Widget},
}; };
use modalkit::{ use modalkit::actions::{
editing::action::{ Action,
Action, Editable,
EditInfo, EditorAction,
EditResult, Jumpable,
Editable, PromptAction,
EditorAction, Promptable,
Jumpable, Scrollable,
PromptAction,
Promptable,
Scrollable,
UIError,
},
editing::base::{
Axis,
CloseFlags,
Count,
MoveDir1D,
OpenTarget,
PositionList,
ScrollStyle,
WordStyle,
},
input::InputContext,
widgets::{TermOffset, TerminalCursor, WindowOps},
}; };
use modalkit::errors::{EditResult, UIError};
use modalkit::prelude::*;
use modalkit::{editing::completion::CompletionList, keybindings::dialog::PromptYesNo};
use modalkit_ratatui::{TermOffset, TerminalCursor, WindowOps};
use crate::base::{ use crate::base::{
IambAction,
IambError, IambError,
IambId, IambId,
IambInfo, IambInfo,
IambResult, IambResult,
MemberUpdateAction,
MessageAction, MessageAction,
ProgramAction, ProgramAction,
ProgramContext, ProgramContext,
@@ -62,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;
@@ -75,6 +86,38 @@ macro_rules! delegate {
}; };
} }
fn notification_mode(name: impl Into<String>) -> IambResult<RoomNotificationMode> {
let name = name.into();
let mode = match name.to_lowercase().as_str() {
"mute" => RoomNotificationMode::Mute,
"mentions" | "keywords" => RoomNotificationMode::MentionsAndKeywordsOnly,
"all" => RoomNotificationMode::AllMessages,
_ => return Err(IambError::InvalidNotificationLevel(name).into()),
};
Ok(mode)
}
fn hist_visibility_mode(name: impl Into<String>) -> IambResult<HistoryVisibility> {
let name = name.into();
let mode = match name.to_lowercase().as_str() {
"invited" => HistoryVisibility::Invited,
"joined" => HistoryVisibility::Joined,
"shared" => HistoryVisibility::Shared,
"world" | "world_readable" => HistoryVisibility::WorldReadable,
_ => return Err(IambError::InvalidHistoryVisibility(name).into()),
};
Ok(mode)
}
/// State for a Matrix room or space.
///
/// Since spaces function as special rooms within Matrix, we wrap their window state together, so
/// that operations like sending and accepting invites, opening the members window, etc., all work
/// similarly.
pub enum RoomState { pub enum RoomState {
Chat(ChatState), Chat(ChatState),
Space(SpaceState), Space(SpaceState),
@@ -95,6 +138,7 @@ impl From<SpaceState> for RoomState {
impl RoomState { impl RoomState {
pub fn new( pub fn new(
room: MatrixRoom, room: MatrixRoom,
thread: Option<OwnedEventId>,
name: DisplayName, name: DisplayName,
tags: Option<Tags>, tags: Option<Tags>,
store: &mut ProgramStore, store: &mut ProgramStore,
@@ -107,7 +151,14 @@ impl RoomState {
if room.is_space() { if room.is_space() {
SpaceState::new(room).into() SpaceState::new(room).into()
} else { } else {
ChatState::new(room, store).into() ChatState::new(room, thread, store).into()
}
}
pub fn thread(&self) -> Option<&OwnedEventId> {
match self {
RoomState::Chat(chat) => chat.thread(),
RoomState::Space(_) => None,
} }
} }
@@ -120,7 +171,7 @@ impl RoomState {
fn draw_invite( fn draw_invite(
&self, &self,
invited: Invited, invited: MatrixRoom,
area: Rect, area: Rect,
buf: &mut Buffer, buf: &mut Buffer,
store: &mut ProgramStore, store: &mut ProgramStore,
@@ -135,15 +186,16 @@ impl RoomState {
let mut invited = vec![Span::from(format!("You have been invited to join {name}"))]; let mut invited = vec![Span::from(format!("You have been invited to join {name}"))];
if let Ok(Some(inviter)) = &inviter { if let Ok(Some(inviter)) = &inviter {
let info = store.application.rooms.get_or_default(self.id().to_owned());
invited.push(Span::from(" by ")); invited.push(Span::from(" by "));
invited.push(store.application.settings.get_user_span(inviter.user_id())); invited.push(store.application.settings.get_user_span(inviter.user_id(), info));
} }
let l1 = Spans(invited); let l1 = Line::from(invited);
let l2 = Spans::from( let l2 = Line::from(
"You can run `:invite accept` or `:invite reject` to accept or reject this invitation.", "You can run `:invite accept` or `:invite reject` to accept or reject this invitation.",
); );
let text = Text { lines: vec![l1, l2] }; let text = Text::from(vec![l1, l2]);
Paragraph::new(text).alignment(Alignment::Center).render(area, buf); Paragraph::new(text).alignment(Alignment::Center).render(area, buf);
@@ -177,13 +229,21 @@ 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 {
RoomAction::InviteAccept => { RoomAction::InviteAccept => {
if let Some(room) = store.application.worker.client.get_invited_room(self.id()) { if let Some(room) = store.application.worker.client.get_room(self.id()) {
room.accept_invitation().await.map_err(IambError::from)?; let details = room.invite_details().await.map_err(IambError::from)?;
let details = details.invitee.event().original_content();
let is_direct = details.and_then(|ev| ev.is_direct).unwrap_or_default();
room.join().await.map_err(IambError::from)?;
if is_direct {
room.set_is_direct(true).await.map_err(IambError::from)?;
}
Ok(vec![]) Ok(vec![])
} else { } else {
@@ -191,8 +251,8 @@ impl RoomState {
} }
}, },
RoomAction::InviteReject => { RoomAction::InviteReject => {
if let Some(room) = store.application.worker.client.get_invited_room(self.id()) { if let Some(room) = store.application.worker.client.get_room(self.id()) {
room.reject_invitation().await.map_err(IambError::from)?; room.leave().await.map_err(IambError::from)?;
Ok(vec![]) Ok(vec![])
} else { } else {
@@ -200,7 +260,7 @@ impl RoomState {
} }
}, },
RoomAction::InviteSend(user) => { RoomAction::InviteSend(user) => {
if let Some(room) = store.application.worker.client.get_joined_room(self.id()) { if let Some(room) = store.application.worker.client.get_room(self.id()) {
room.invite_user_by_id(user.as_ref()).await.map_err(IambError::from)?; room.invite_user_by_id(user.as_ref()).await.map_err(IambError::from)?;
Ok(vec![]) Ok(vec![])
@@ -208,6 +268,65 @@ impl RoomState {
Err(IambError::NotJoined.into()) Err(IambError::NotJoined.into())
} }
}, },
RoomAction::Leave(skip_confirm) => {
if let Some(room) = store.application.worker.client.get_room(self.id()) {
if skip_confirm {
room.leave().await.map_err(IambError::from)?;
Ok(vec![])
} else {
let msg = "Do you really want to leave this room?";
let leave = IambAction::Room(RoomAction::Leave(true));
let prompt = PromptYesNo::new(msg, vec![Action::from(leave)]);
let prompt = Box::new(prompt);
Err(UIError::NeedConfirm(prompt))
}
} else {
Err(IambError::NotJoined.into())
}
},
RoomAction::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 =
@@ -216,7 +335,17 @@ impl RoomState {
width.into(), width.into(),
); );
Ok(vec![(act, cmd.context.take())]) 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
@@ -225,8 +354,13 @@ 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.into()); let ev = RoomNameEventContent::new(value);
let _ = room.send_state_event(ev).await.map_err(IambError::from)?; let _ = room.send_state_event(ev).await.map_err(IambError::from)?;
}, },
RoomField::Tag(tag) => { RoomField::Tag(tag) => {
@@ -239,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![])
@@ -250,8 +475,13 @@ 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(None); let ev = RoomNameEventContent::new("".into());
let _ = room.send_state_event(ev).await.map_err(IambError::from)?; let _ = room.send_state_event(ev).await.map_err(IambError::from)?;
}, },
RoomField::Tag(tag) => { RoomField::Tag(tag) => {
@@ -261,17 +491,161 @@ 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)])
},
} }
} }
pub fn get_title(&self, store: &mut ProgramStore) -> Spans { pub fn get_title(&self, store: &mut ProgramStore) -> Line {
let title = store.application.get_room_title(self.id()); let title = store.application.get_room_title(self.id());
let style = Style::default().add_modifier(StyleModifier::BOLD); let style = Style::default().add_modifier(StyleModifier::BOLD);
let mut spans = vec![Span::styled(title, style)]; let mut spans = vec![];
if let RoomState::Chat(chat) = self {
if chat.thread().is_some() {
spans.push("Thread in ".into());
}
}
spans.push(Span::styled(title, style));
match self.room().topic() { match self.room().topic() {
Some(desc) if !desc.is_empty() => { Some(desc) if !desc.is_empty() => {
@@ -282,7 +656,7 @@ impl RoomState {
_ => {}, _ => {},
} }
Spans(spans) Line::from(spans)
} }
pub fn focus_toggle(&mut self) { pub fn focus_toggle(&mut self) {
@@ -360,12 +734,12 @@ impl TerminalCursor for RoomState {
impl WindowOps<IambInfo> for RoomState { impl WindowOps<IambInfo> for RoomState {
fn draw(&mut self, area: Rect, buf: &mut Buffer, focused: bool, store: &mut ProgramStore) { fn draw(&mut self, area: Rect, buf: &mut Buffer, focused: bool, store: &mut ProgramStore) {
if let MatrixRoom::Invited(_) = self.room() { if self.room().state() == MatrixRoomState::Invited {
self.refresh_room(store); self.refresh_room(store);
} }
if let MatrixRoom::Invited(invited) = self.room() { if self.room().state() == MatrixRoomState::Invited {
self.draw_invite(invited.clone(), area, buf, store); self.draw_invite(self.room().clone(), area, buf, store);
} }
match self { match self {
@@ -383,10 +757,30 @@ impl WindowOps<IambInfo> for RoomState {
} }
} }
fn close(&mut self, _: CloseFlags, _: &mut ProgramStore) -> bool { fn close(&mut self, flags: CloseFlags, store: &mut ProgramStore) -> bool {
// XXX: what's the right closing behaviour for a room? match self {
// Should write send a message? RoomState::Chat(chat) => chat.close(flags, store),
true RoomState::Space(space) => space.close(flags, store),
}
}
fn write(
&mut self,
path: Option<&str>,
flags: WriteFlags,
store: &mut ProgramStore,
) -> IambResult<EditInfo> {
match self {
RoomState::Chat(chat) => chat.write(path, flags, store),
RoomState::Space(space) => space.write(path, flags, store),
}
}
fn get_completions(&self) -> Option<CompletionList> {
match self {
RoomState::Chat(chat) => chat.get_completions(),
RoomState::Space(space) => space.get_completions(),
}
} }
fn get_cursor_word(&self, style: &WordStyle) -> Option<String> { fn get_cursor_word(&self, style: &WordStyle) -> Option<String> {
@@ -403,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());
}
}

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -1,16 +1,14 @@
//! Welcome Window
use std::ops::{Deref, DerefMut}; use std::ops::{Deref, DerefMut};
use modalkit::tui::{buffer::Buffer, layout::Rect}; use ratatui::{buffer::Buffer, layout::Rect};
use modalkit::{ use modalkit_ratatui::{textbox::TextBoxState, TermOffset, TerminalCursor, WindowOps};
widgets::textbox::TextBoxState,
widgets::WindowOps,
widgets::{TermOffset, TerminalCursor},
};
use modalkit::editing::base::{CloseFlags, WordStyle}; use modalkit::editing::completion::CompletionList;
use modalkit::prelude::*;
use crate::base::{IambBufferId, IambInfo, ProgramStore}; use crate::base::{IambBufferId, IambInfo, IambResult, ProgramStore};
const WELCOME_TEXT: &str = include_str!("welcome.md"); const WELCOME_TEXT: &str = include_str!("welcome.md");
@@ -63,6 +61,19 @@ impl WindowOps<IambInfo> for WelcomeState {
self.tbox.close(flags, store) self.tbox.close(flags, store)
} }
fn write(
&mut self,
path: Option<&str>,
flags: WriteFlags,
store: &mut ProgramStore,
) -> IambResult<EditInfo> {
self.tbox.write(path, flags, store)
}
fn get_completions(&self) -> Option<CompletionList> {
self.tbox.get_completions()
}
fn get_cursor_word(&self, style: &WordStyle) -> Option<String> { fn get_cursor_word(&self, style: &WordStyle) -> Option<String> {
self.tbox.get_cursor_word(style) self.tbox.get_cursor_word(style)
} }

File diff suppressed because it is too large Load Diff