├── .envrc ├── .gitattributes ├── .github ├── FUNDING.yml └── workflows │ ├── binaries.yml │ └── ci.yml ├── .gitignore ├── .rustfmt.toml ├── CONTRIBUTING.md ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── PACKAGING.md ├── README.md ├── build.rs ├── config.example.toml ├── docs ├── iamb-256x256.png ├── iamb-512x512.png ├── iamb.1 ├── iamb.5 ├── iamb.metainfo.xml ├── iamb.png └── iamb.svg ├── flake.lock ├── flake.nix ├── iamb.desktop ├── rust-toolchain.toml └── src ├── base.rs ├── commands.rs ├── config.rs ├── keybindings.rs ├── main.rs ├── message ├── compose.rs ├── html.rs ├── mod.rs ├── printer.rs └── state.rs ├── notifications.rs ├── preview.rs ├── sled_export.rs ├── tests.rs ├── util.rs ├── windows ├── mod.rs ├── room │ ├── chat.rs │ ├── mod.rs │ ├── scrollback.rs │ └── space.rs ├── welcome.md └── welcome.rs └── worker.rs /.envrc: -------------------------------------------------------------------------------- 1 | use flake 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.rs text eol=lf 2 | *.toml text eol=lf 3 | *.md text eol=lf 4 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | ko_fi: ulyssam 2 | -------------------------------------------------------------------------------- /.github/workflows/binaries.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - main 5 | 6 | name: Binaries 7 | 8 | jobs: 9 | package: 10 | strategy: 11 | matrix: 12 | platform: [ubuntu-latest, windows-latest, macos-latest] 13 | arch: [x86_64, aarch64] 14 | exclude: 15 | - platform: windows-latest 16 | arch: aarch64 17 | include: 18 | - platform: ubuntu-latest 19 | arch: x86_64 20 | triple: unknown-linux-musl 21 | - platform: ubuntu-latest 22 | arch: aarch64 23 | triple: unknown-linux-gnu 24 | - platform: macos-latest 25 | triple: apple-darwin 26 | - platform: windows-latest 27 | triple: pc-windows-msvc 28 | runs-on: ${{ matrix.platform }} 29 | env: 30 | TARGET: ${{ matrix.arch }}-${{ matrix.triple }} 31 | SCCACHE_GHA_ENABLED: "true" 32 | RUSTC_WRAPPER: "sccache" 33 | steps: 34 | - name: Checkout code 35 | uses: actions/checkout@v3 36 | with: 37 | submodules: true 38 | - name: Install Rust (stable) 39 | uses: dtolnay/rust-toolchain@stable 40 | with: 41 | targets: ${{ env.TARGET }} 42 | - name: Install C cross-compilation toolchain 43 | if: matrix.platform == 'ubuntu-latest' 44 | run: | 45 | sudo apt-get update 46 | sudo apt install -f -y build-essential crossbuild-essential-arm64 musl-dev 47 | # Cross-compilation env vars for x86_64-unknown-linux-musl 48 | echo CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER=x86_64-linux-musl-gcc >> $GITHUB_ENV 49 | echo AR_x86_64_unknown_linux_musl=x86_64-linux-gnu-ar >> $GITHUB_ENV 50 | echo CC_x86_64_unknown_linux_musl=x86_64-linux-musl-gcc >> $GITHUB_ENV 51 | echo CXX_x86_64_unknown_linux_musl=x86_64-linux-gnu-g++ >> $GITHUB_ENV 52 | # Cross-compilation env vars for aarch64-unknown-linux-gnu 53 | echo CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc >> $GITHUB_ENV 54 | echo AR_aarch64_unknown_linux_gnu=aarch64-linux-gnu-ar >> $GITHUB_ENV 55 | echo CC_aarch64_unknown_linux_gnu=aarch64-linux-gnu-gcc >> $GITHUB_ENV 56 | echo CXX_aarch64_unknown_linux_gnu=aarch64-linux-gnu-g++ >> $GITHUB_ENV 57 | - name: Cache cargo registry 58 | uses: actions/cache@v3 59 | with: 60 | path: ~/.cargo/registry 61 | key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} 62 | - name: Run sccache-cache 63 | uses: mozilla-actions/sccache-action@v0.0.9 64 | - name: 'Build: binary' 65 | run: cargo +stable build --release --locked --target ${{ env.TARGET }} 66 | - name: 'Upload: binary' 67 | uses: actions/upload-artifact@v4 68 | with: 69 | name: iamb-${{ env.TARGET }}-binary 70 | path: | 71 | ./target/${{ env.TARGET }}/release/iamb 72 | ./target/${{ env.TARGET }}/release/iamb.exe 73 | - name: 'Package: deb' 74 | if: matrix.platform == 'ubuntu-latest' 75 | run: | 76 | cargo +stable install --locked cargo-deb 77 | cargo +stable deb --no-strip --target ${{ env.TARGET }} 78 | - name: 'Upload: deb' 79 | if: matrix.platform == 'ubuntu-latest' 80 | uses: actions/upload-artifact@v4 81 | with: 82 | name: iamb-${{ env.TARGET }}-deb 83 | path: ./target/${{ env.TARGET }}/debian/iamb*.deb 84 | - name: 'Package: rpm' 85 | if: matrix.platform == 'ubuntu-latest' 86 | run: | 87 | cargo +stable install --locked cargo-generate-rpm 88 | cargo +stable generate-rpm --target ${{ env.TARGET }} 89 | - name: 'Upload: rpm' 90 | if: matrix.platform == 'ubuntu-latest' 91 | uses: actions/upload-artifact@v4 92 | with: 93 | name: iamb-${{ env.TARGET }}-rpm 94 | path: ./target/${{ env.TARGET }}/generate-rpm/iamb*.rpm 95 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - main 5 | pull_request: 6 | branches: 7 | - main 8 | 9 | name: CI 10 | 11 | jobs: 12 | test: 13 | strategy: 14 | matrix: 15 | platform: [ubuntu-latest, windows-latest, macos-latest] 16 | runs-on: ${{ matrix.platform }} 17 | env: 18 | SCCACHE_GHA_ENABLED: "true" 19 | RUSTC_WRAPPER: "sccache" 20 | steps: 21 | - name: Checkout code 22 | uses: actions/checkout@v3 23 | with: 24 | submodules: true 25 | - name: Install Rust (1.83 w/ clippy) 26 | uses: dtolnay/rust-toolchain@1.83 27 | with: 28 | components: clippy 29 | - name: Install Rust (nightly w/ rustfmt) 30 | run: rustup toolchain install nightly --component rustfmt 31 | - name: Cache cargo registry 32 | uses: actions/cache@v3 33 | with: 34 | path: ~/.cargo/registry 35 | key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} 36 | - name: Run sccache-cache 37 | uses: mozilla-actions/sccache-action@v0.0.9 38 | - name: Check formatting 39 | run: cargo +nightly fmt --all -- --check 40 | - name: Check Clippy 41 | if: matrix.platform == 'ubuntu-latest' 42 | uses: giraffate/clippy-action@v1 43 | with: 44 | github_token: ${{ secrets.GITHUB_TOKEN }} 45 | reporter: 'github-check' 46 | - name: Run tests 47 | run: cargo test --locked 48 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /result 3 | /TODO 4 | .direnv 5 | -------------------------------------------------------------------------------- /.rustfmt.toml: -------------------------------------------------------------------------------- 1 | unstable_features = true 2 | max_width = 100 3 | fn_call_width = 90 4 | struct_lit_width = 50 5 | struct_variant_width = 50 6 | chain_width = 75 7 | binop_separator = "Back" 8 | force_multiline_blocks = true 9 | match_block_trailing_comma = true 10 | imports_layout = "HorizontalVertical" 11 | newline_style = "Unix" 12 | overflow_delimited_expr = true 13 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to iamb 2 | 3 | ## Building 4 | 5 | You can build `iamb` locally by using `cargo build`. 6 | 7 | ## Pull Requests 8 | 9 | When making changes to `iamb`, please make sure to: 10 | 11 | - Add new tests for fixed bugs and new features whenever possible 12 | - Add new documentation with new features 13 | 14 | If you're adding a large amount of new code, please make sure to look at a test 15 | coverage report and ensure that your tests sufficiently cover your changes. 16 | 17 | You can generate an HTML report with [cargo-tarpaulin] by running: 18 | 19 | ``` 20 | % cargo tarpaulin --avoid-cfg-tarpaulin --out html 21 | ``` 22 | 23 | ## Tests 24 | 25 | You can run the unit tests and documentation tests using `cargo test`. 26 | 27 | [cargo-tarpaulin]: https://github.com/xd009642/tarpaulin 28 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "iamb" 3 | version = "0.0.11-alpha.1" 4 | edition = "2018" 5 | authors = ["Ulyssa "] 6 | repository = "https://github.com/ulyssa/iamb" 7 | homepage = "https://iamb.chat" 8 | readme = "README.md" 9 | description = "A Matrix chat client that uses Vim keybindings" 10 | license = "Apache-2.0" 11 | exclude = [".github", "CONTRIBUTING.md"] 12 | keywords = ["matrix", "chat", "tui", "vim"] 13 | categories = ["command-line-utilities"] 14 | rust-version = "1.83" 15 | build = "build.rs" 16 | 17 | [features] 18 | default = ["bundled", "desktop"] 19 | bundled = ["matrix-sdk/bundled-sqlite", "rustls-tls"] 20 | desktop = ["dep:notify-rust", "modalkit/clipboard"] 21 | native-tls = ["matrix-sdk/native-tls"] 22 | rustls-tls = ["matrix-sdk/rustls-tls"] 23 | 24 | [build-dependencies.vergen] 25 | version = "8" 26 | default-features = false 27 | features = ["build", "git", "gitcl",] 28 | 29 | [dependencies] 30 | anyhow = "1.0" 31 | bitflags = "^2.3" 32 | chrono = "0.4" 33 | clap = {version = "~4.3", features = ["derive"]} 34 | css-color-parser = "0.1.2" 35 | dirs = "4.0.0" 36 | emojis = "0.5" 37 | feruca = "0.10.1" 38 | futures = "0.3" 39 | gethostname = "0.4.1" 40 | html5ever = "0.26.0" 41 | image = "^0.25.6" 42 | libc = "0.2" 43 | markup5ever_rcdom = "0.2.0" 44 | mime = "^0.3.16" 45 | mime_guess = "^2.0.4" 46 | nom = "7.0.0" 47 | open = "3.2.0" 48 | rand = "0.8.5" 49 | ratatui = "0.29.0" 50 | ratatui-image = { version = "~8.0.1", features = ["serde"] } 51 | regex = "^1.5" 52 | rpassword = "^7.2" 53 | serde = "^1.0" 54 | serde_json = "^1.0" 55 | sled = "0.34.7" 56 | temp-dir = "0.1.12" 57 | thiserror = "^1.0.37" 58 | toml = "^0.8.12" 59 | tracing = "~0.1.36" 60 | tracing-appender = "~0.2.2" 61 | tracing-subscriber = "0.3.16" 62 | unicode-segmentation = "^1.7" 63 | unicode-width = "0.1.10" 64 | url = {version = "^2.2.2", features = ["serde"]} 65 | edit = "0.1.4" 66 | humansize = "2.0.0" 67 | 68 | [dependencies.comrak] 69 | version = "0.22.0" 70 | default-features = false 71 | features = ["shortcodes"] 72 | 73 | [dependencies.notify-rust] 74 | version = "~4.10.0" 75 | default-features = false 76 | features = ["zbus", "serde"] 77 | optional = true 78 | 79 | [dependencies.modalkit] 80 | version = "0.0.21" 81 | default-features = false 82 | #git = "https://github.com/ulyssa/modalkit" 83 | #rev = "e40dbb0bfeabe4cfd08facd2acb446080a330d75" 84 | 85 | [dependencies.modalkit-ratatui] 86 | version = "0.0.21" 87 | #git = "https://github.com/ulyssa/modalkit" 88 | #rev = "e40dbb0bfeabe4cfd08facd2acb446080a330d75" 89 | 90 | [dependencies.matrix-sdk] 91 | version = "0.10.0" 92 | default-features = false 93 | features = ["e2e-encryption", "sqlite", "sso-login"] 94 | 95 | [dependencies.tokio] 96 | version = "1.24.1" 97 | features = ["macros", "net", "rt-multi-thread", "sync", "time"] 98 | 99 | [dev-dependencies] 100 | lazy_static = "1.4.0" 101 | pretty_assertions = "1.4.0" 102 | 103 | [profile.release-lto] 104 | inherits = "release" 105 | incremental = false 106 | lto = true 107 | 108 | [package.metadata.deb] 109 | section = "net" 110 | license-file = ["LICENSE", "0"] 111 | assets = [ 112 | # Binary: 113 | ["target/release/iamb", "usr/bin/iamb", "755"], 114 | # Manual pages: 115 | ["docs/iamb.1", "usr/share/man/man1/iamb.1", "644"], 116 | ["docs/iamb.5", "usr/share/man/man5/iamb.5", "644"], 117 | # Other assets: 118 | ["iamb.desktop", "usr/share/applications/iamb.desktop", "644"], 119 | ["config.example.toml", "usr/share/iamb/config.example.toml", "644"], 120 | ["docs/iamb.svg", "usr/share/icons/hicolor/scalable/apps/iamb.svg", "644"], 121 | ["docs/iamb.metainfo.xml", "usr/share/metainfo/iamb.metainfo.xml", "644"], 122 | ] 123 | 124 | [package.metadata.generate-rpm] 125 | assets = [ 126 | # Binary: 127 | { source = "target/release/iamb", dest = "/usr/bin/iamb", mode = "755" }, 128 | # Manual pages: 129 | { source = "docs/iamb.1", dest = "/usr/share/man/man1/iamb.1", mode = "644" }, 130 | { source = "docs/iamb.5", dest = "/usr/share/man/man5/iamb.5", mode = "644" }, 131 | # Other assets: 132 | { source = "iamb.desktop", dest = "/usr/share/applications/iamb.desktop", mode = "644" }, 133 | { source = "config.example.toml", dest = "/usr/share/iamb/config.example.toml", mode = "644"}, 134 | { source = "docs/iamb.svg", dest = "/usr/share/icons/hicolor/scalable/apps/iamb.svg", mode = "644"}, 135 | { source = "docs/iamb.metainfo.xml", dest = "/usr/share/metainfo/iamb.metainfo.xml", mode = "644"}, 136 | ] 137 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2024 Ulyssa Mello 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /PACKAGING.md: -------------------------------------------------------------------------------- 1 | # Notes For Package Maintainers 2 | 3 | ## Linking Against System Packages 4 | 5 | The default Cargo features for __iamb__ will bundle SQLite and use [rustls] for 6 | TLS. Package maintainers may want to link against the system's native SQLite 7 | and TLS libraries instead. To do so, you'll want to build without the default 8 | features and specify that it should build with `native-tls`: 9 | 10 | ``` 11 | % cargo build --release --no-default-features --features=native-tls 12 | ``` 13 | 14 | ## Enabling LTO 15 | 16 | Enabling LTO can result in smaller binaries. There is a separate profile to 17 | enable it when building: 18 | 19 | ``` 20 | % cargo build --profile release-lto 21 | ``` 22 | 23 | Note that this [can fail][ring-lto] in some build environments if both Clang 24 | and GCC are present. 25 | 26 | ## Documentation 27 | 28 | In addition to the compiled binary, there are other files in the repo that 29 | you'll want to install as part of a package: 30 | 31 | 32 | | Repository Path | Installed Path (may vary per OS) | 33 | | ----------------------- | ----------------------------------------------- | 34 | | /iamb.desktop | /usr/share/applications/iamb.desktop | 35 | | /config.example.toml | /usr/share/iamb/config.example.toml | 36 | | /docs/iamb-256x256.png | /usr/share/icons/hicolor/256x256/apps/iamb.png | 37 | | /docs/iamb-512x512.png | /usr/share/icons/hicolor/512x512/apps/iamb.png | 38 | | /docs/iamb.svg | /usr/share/icons/hicolor/scalable/apps/iamb.svg | 39 | | /docs/iamb.1 | /usr/share/man/man1/iamb.1 | 40 | | /docs/iamb.5 | /usr/share/man/man5/iamb.5 | 41 | | /docs/iamb.metainfo.xml | /usr/share/metainfo/iamb.metainfo.xml | 42 | 43 | [ring-lto]: https://github.com/briansmith/ring/issues/1444 44 | [rustls]: https://crates.io/crates/rustls 45 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

3 | 4 | [![Build Status](https://github.com/ulyssa/iamb/actions/workflows/ci.yml/badge.svg)](https://github.com/ulyssa/iamb/actions?query=workflow%3ACI+) 5 | [![License: Apache 2.0](https://img.shields.io/crates/l/iamb.svg?logo=apache)][crates-io-iamb] 6 | [![#iamb:0x.badd.cafe](https://img.shields.io/badge/matrix-%23iamb:0x.badd.cafe-blue)](https://matrix.to/#/#iamb:0x.badd.cafe) 7 | [![Latest Version](https://img.shields.io/crates/v/iamb.svg?logo=rust)][crates-io-iamb] 8 | [![iamb](https://snapcraft.io/iamb/badge.svg)](https://snapcraft.io/iamb) 9 | 10 | ![Example Usage](https://iamb.chat/static/images/iamb-demo.gif) 11 | 12 |
13 | 14 | ## About 15 | 16 | `iamb` is a Matrix client for the terminal that uses Vim keybindings. It includes support for: 17 | 18 | - Threads, spaces, E2EE, and read receipts 19 | - Image previews in terminals that support it (sixels, Kitty, and iTerm2), or using pixelated blocks for those that don't 20 | - Notifications via terminal bell or desktop environment 21 | - Send Markdown, HTML or plaintext messages 22 | - Creating, joining, and leaving rooms 23 | - Sending and accepting room invitations 24 | - Editing, redacting, and reacting to messages 25 | - Custom keybindings 26 | - Multiple profiles 27 | 28 | _You may want to [see this page as it was when the latest version was published][crates-io-iamb]._ 29 | 30 | ## Documentation 31 | 32 | You can find documentation for installing, configuring, and using iamb on its 33 | website, [iamb.chat]. 34 | 35 | ## Configuration 36 | 37 | You can create a basic configuration in `$CONFIG_DIR/iamb/config.toml` that looks like: 38 | 39 | ```toml 40 | [profiles."example.com"] 41 | user_id = "@user:example.com" 42 | ``` 43 | 44 | If you homeserver is located on a different domain than the server part of the 45 | `user_id` and you don't have a [`/.well-known`][well_known_entry] entry, then 46 | you can explicitly specify the homeserver URL to use: 47 | 48 | ```toml 49 | [profiles."example.com"] 50 | url = "https://example.com" 51 | user_id = "@user:example.com" 52 | ``` 53 | 54 | ## Installation (via `crates.io`) 55 | 56 | Install Rust (1.83.0 or above) and Cargo, and then run: 57 | 58 | ``` 59 | cargo install --locked iamb 60 | ``` 61 | 62 | See [Configuration](#configuration) for getting a profile set up. 63 | 64 | ## Installation (via package managers) 65 | 66 | ### Arch Linux 67 | 68 | On Arch Linux a [package](https://aur.archlinux.org/packages/iamb-git) is available in the 69 | Arch User Repositories (AUR). To install it simply run with your favorite AUR helper: 70 | 71 | ``` 72 | paru iamb-git 73 | ``` 74 | 75 | ### FreeBSD 76 | 77 | On FreeBSD a package is available from the official repositories. To install it simply run: 78 | 79 | ``` 80 | pkg install iamb 81 | ``` 82 | 83 | ### Gentoo 84 | 85 | On Gentoo, an ebuild is available from the community-managed 86 | [GURU overlay](https://wiki.gentoo.org/wiki/Project:GURU). 87 | 88 | You can enable the GURU overlay with: 89 | 90 | ``` 91 | eselect repository enable guru 92 | emerge --sync guru 93 | ``` 94 | 95 | And then install `iamb` with: 96 | 97 | ``` 98 | emerge --ask iamb 99 | ``` 100 | 101 | ### macOS 102 | 103 | On macOS a [package](https://formulae.brew.sh/formula/iamb#default) is available in Homebrew's 104 | repository. To install it simply run: 105 | 106 | ``` 107 | brew install iamb 108 | ``` 109 | 110 | ### NetBSD 111 | 112 | On NetBSD a package is available from the official repositories. To install it simply run: 113 | 114 | ``` 115 | pkgin install iamb 116 | ``` 117 | 118 | ### Nix / NixOS (flake) 119 | 120 | ``` 121 | nix profile install "github:ulyssa/iamb" 122 | ``` 123 | 124 | ### openSUSE Tumbleweed 125 | 126 | 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: 127 | 128 | ``` 129 | zypper install iamb 130 | ``` 131 | 132 | ### Snap 133 | 134 | A snap for Linux distributions which [support](https://snapcraft.io/docs/installing-snapd) the packaging system. 135 | 136 | ``` 137 | snap install iamb 138 | ``` 139 | 140 | ## License 141 | 142 | iamb is released under the [Apache License, Version 2.0]. 143 | 144 | [Apache License, Version 2.0]: https://github.com/ulyssa/iamb/blob/master/LICENSE 145 | [crates-io-iamb]: https://crates.io/crates/iamb 146 | [iamb.chat]: https://iamb.chat 147 | [well_known_entry]: https://spec.matrix.org/latest/client-server-api/#getwell-knownmatrixclient 148 | -------------------------------------------------------------------------------- /build.rs: -------------------------------------------------------------------------------- 1 | use std::error::Error; 2 | 3 | use vergen::EmitBuilder; 4 | 5 | fn main() -> Result<(), Box> { 6 | EmitBuilder::builder().git_sha(true).emit()?; 7 | 8 | Ok(()) 9 | } 10 | -------------------------------------------------------------------------------- /config.example.toml: -------------------------------------------------------------------------------- 1 | default_profile = "default" 2 | 3 | [profiles.default] 4 | user_id = "@user:matrix.org" 5 | url = "https://matrix.org" 6 | 7 | [settings] 8 | default_room = "#iamb-users:0x.badd.cafe" 9 | external_edit_file_suffix = ".md" 10 | log_level = "warn" 11 | message_shortcode_display = false 12 | open_command = ["my-open", "--file"] 13 | reaction_display = true 14 | reaction_shortcode_display = false 15 | read_receipt_display = true 16 | read_receipt_send = true 17 | request_timeout = 10000 18 | typing_notice_display = true 19 | typing_notice_send = true 20 | user_gutter_width = 30 21 | username_display = "username" 22 | 23 | [settings.image_preview] 24 | protocol.type = "sixel" 25 | size = { "width" = 66, "height" = 10 } 26 | 27 | [settings.sort] 28 | rooms = ["favorite", "lowpriority", "unread", "name"] 29 | members = ["power", "id"] 30 | 31 | [settings.users] 32 | "@user:matrix.org" = { "name" = "John Doe", "color" = "magenta" } 33 | 34 | [layout] 35 | style = "config" 36 | 37 | [[layout.tabs]] 38 | window = "iamb://dms" 39 | 40 | [[layout.tabs]] 41 | window = "iamb://rooms" 42 | 43 | [[layout.tabs]] 44 | split = [ 45 | { "window" = "#iamb-users:0x.badd.cafe" }, 46 | { "window" = "#iamb-dev:0x.badd.cafe" } 47 | ] 48 | 49 | [macros.insert] 50 | "jj" = "" 51 | 52 | [macros."normal|visual"] 53 | "V" = "m" 54 | 55 | [dirs] 56 | cache = "/home/user/.cache/iamb/" 57 | logs = "/home/user/.local/share/iamb/logs/" 58 | downloads = "/home/user/Downloads/" 59 | -------------------------------------------------------------------------------- /docs/iamb-256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ulyssa/iamb/33d3407694c9dece0f3e59aa577eda98c9384ea1/docs/iamb-256x256.png -------------------------------------------------------------------------------- /docs/iamb-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ulyssa/iamb/33d3407694c9dece0f3e59aa577eda98c9384ea1/docs/iamb-512x512.png -------------------------------------------------------------------------------- /docs/iamb.1: -------------------------------------------------------------------------------- 1 | .\" iamb(1) manual page 2 | .\" 3 | .\" This manual page is written using the mdoc(7) macros. For more 4 | .\" information, see . 5 | .\" 6 | .\" You can preview this file with: 7 | .\" $ man ./docs/iamb.1 8 | .Dd Mar 24, 2024 9 | .Dt IAMB 1 10 | .Os 11 | .Sh NAME 12 | .Nm iamb 13 | .Nd a terminal-based client for Matrix for the Vim addict 14 | .Sh SYNOPSIS 15 | .Nm 16 | .Op Fl hV 17 | .Op Fl P Ar profile 18 | .Op Fl C Ar dir 19 | .Sh DESCRIPTION 20 | .Nm 21 | is a client for the Matrix communication protocol. 22 | It provides a terminal user interface with familiar Vim keybindings, and 23 | includes support for multiple profiles, threads, spaces, notifications, 24 | reactions, custom keybindings, and more. 25 | .Pp 26 | This manual page includes a quick rundown of the available commands in 27 | .Nm . 28 | For example usage and a full description of each one and its arguments, please 29 | refer to the full documentation online. 30 | .Sh OPTIONS 31 | .Bl -tag -width Ds 32 | .It Fl P , Fl Fl profile 33 | The profile to start 34 | .Nm 35 | with. 36 | If this flag is not specified, 37 | then it defaults to using 38 | .Sy default_profile 39 | (see 40 | .Xr iamb 5 ) . 41 | .It Fl C , Fl Fl config-directory 42 | Path to the directory the configuration file is located in. 43 | .It Fl h , Fl Fl help 44 | Show the help text and quit. 45 | .It Fl V , Fl Fl version 46 | Show the current 47 | .Nm 48 | version and quit. 49 | .El 50 | 51 | .Sh "GENERAL COMMANDS" 52 | .Bl -tag -width Ds 53 | .It Sy ":chats" 54 | View a list of joined rooms and direct messages. 55 | .It Sy ":dms" 56 | View a list of direct messages. 57 | .It Sy ":logout [user id]" 58 | Log out of 59 | .Nm . 60 | .It Sy ":rooms" 61 | View a list of joined rooms. 62 | .It Sy ":spaces" 63 | View a list of joined spaces. 64 | .It Sy ":unreads" 65 | View a list of unread rooms. 66 | .It Sy ":unreads clear" 67 | Mark all rooms as read. 68 | .It Sy ":welcome" 69 | View the startup Welcome window. 70 | .El 71 | 72 | .Sh "E2EE COMMANDS" 73 | .Bl -tag -width Ds 74 | .It Sy ":keys export [path] [passphrase]" 75 | Export and encrypt keys to 76 | .Pa path . 77 | .It Sy ":keys import [path] [passphrase]" 78 | Import and decrypt keys from 79 | .Pa path . 80 | .It Sy ":verify" 81 | View a list of ongoing E2EE verifications. 82 | .It Sy ":verify accept [key]" 83 | Accept a verification request. 84 | .It Sy ":verify cancel [key]" 85 | Cancel an in-progress verification. 86 | .It Sy ":verify confirm [key]" 87 | Confirm an in-progress verification. 88 | .It Sy ":verify mismatch [key]" 89 | Reject an in-progress verification due to mismatched Emoji. 90 | .It Sy ":verify request [user id]" 91 | Request a new verification with the specified user. 92 | .El 93 | 94 | .Sh "MESSAGE COMMANDS" 95 | .Bl -tag -width Ds 96 | .It Sy ":download [path]" 97 | Download an attachment from the selected message and save it to the optional path. 98 | .It Sy ":open [path]" 99 | Download and then open an attachment, or open a link in a message. 100 | .It Sy ":edit" 101 | Edit the selected message. 102 | .It Sy ":editor" 103 | Open an external 104 | .Ev $EDITOR 105 | to compose a message. 106 | .It Sy ":react [shortcode]" 107 | React to the selected message with an Emoji. 108 | .It Sy ":unreact [shortcode]" 109 | Remove your reaction from the selected message. 110 | When no arguments are given, remove all of your reactions from the message. 111 | .It Sy ":redact [reason]" 112 | Redact the selected message with the optional reason. 113 | .It Sy ":reply" 114 | Reply to the selected message. 115 | .It Sy ":cancel" 116 | Cancel the currently drafted message including replies. 117 | .It Sy ":unreads clear" 118 | Mark all unread rooms as read. 119 | .It Sy ":upload [path]" 120 | Upload an attachment and send it to the currently selected room. 121 | .El 122 | 123 | .Sh "ROOM COMMANDS" 124 | .Bl -tag -width Ds 125 | .It Sy ":create [arguments]" 126 | Create a new room. Arguments can be 127 | .Dq ++alias=[alias] , 128 | .Dq ++public , 129 | .Dq ++space , 130 | and 131 | .Dq ++encrypted . 132 | .It Sy ":invite accept" 133 | Accept an invitation to the currently focused room. 134 | .It Sy ":invite reject" 135 | Reject an invitation to the currently focused room. 136 | .It Sy ":invite send [user]" 137 | Send an invitation to a user to join the currently focused room. 138 | .It Sy ":join [room]" 139 | Join a room or open it if you are already joined. 140 | .It Sy ":leave" 141 | Leave the currently focused room. 142 | .It Sy ":members" 143 | View a list of members of the currently focused room. 144 | .It Sy ":room name set [name]" 145 | Set the name of the currently focused room. 146 | .It Sy ":room name unset" 147 | Unset the name of the currently focused room. 148 | .It Sy ":room dm set" 149 | Mark the currently focused room as a direct message. 150 | .It Sy ":room dm unset" 151 | Mark the currently focused room as a normal room. 152 | .It Sy ":room notify set [level]" 153 | Set a notification level for the currently focused room. 154 | Valid levels are 155 | .Dq mute , 156 | .Dq mentions , 157 | .Dq keywords , 158 | and 159 | .Dq all . 160 | Note that 161 | .Dq mentions 162 | and 163 | .Dq keywords 164 | are aliases for the same behaviour. 165 | .It Sy ":room notify unset" 166 | Unset any room-level notification configuration. 167 | .It Sy ":room notify show" 168 | Show the current room-level notification configuration. 169 | If the room is using the account-level default, then this will print 170 | .Dq default . 171 | .It Sy ":room tag set [tag]" 172 | Add a tag to the currently focused room. 173 | .It Sy ":room tag unset [tag]" 174 | Remove a tag from the currently focused room. 175 | .It Sy ":room topic set [topic]" 176 | Set the topic of the currently focused room. 177 | .It Sy ":room topic unset" 178 | Unset the topic of the currently focused room. 179 | .It Sy ":room topic show" 180 | Show the topic of the currently focused room. 181 | .It Sy ":room alias set [alias]" 182 | Create and point the given alias to the room. 183 | .It Sy ":room alias unset [alias]" 184 | Delete the provided alias from the room's alternative alias list. 185 | .It Sy ":room alias show" 186 | Show alternative aliases to the room, if any are set. 187 | .It Sy ":room id show" 188 | Show the Matrix identifier for the room. 189 | .It Sy ":room canon set [alias]" 190 | Set the room's canonical alias to the one provided, and make the previous one an alternative alias. 191 | .It Sy ":room canon unset [alias]" 192 | Delete the room's canonical alias. 193 | .It Sy ":room canon show" 194 | Show the room's canonical alias, if any is set. 195 | .It Sy ":room ban [user] [reason]" 196 | Ban a user from this room with an optional reason. 197 | .It Sy ":room unban [user] [reason]" 198 | Unban a user from this room with an optional reason. 199 | .It Sy ":room kick [user] [reason]" 200 | Kick a user from this room with an optional reason. 201 | .El 202 | 203 | .Sh "SPACE COMMANDS" 204 | .Bl -tag -width Ds 205 | .It Sy ":space child set [room_id] [arguments]" 206 | Add a room to the currently focused space. 207 | .Dq ++suggested 208 | marks the room as a suggested child. 209 | .Dq ++order=[string] 210 | specifies a string by which children are lexicographically ordered. 211 | .It Sy ":space child remove" 212 | Remove the selected room from the currently focused space. 213 | .El 214 | 215 | .Sh "WINDOW COMMANDS" 216 | .Bl -tag -width Ds 217 | .It Sy ":horizontal [cmd]" 218 | Change the behaviour of the given command to be horizontal. 219 | .It Sy ":leftabove [cmd]" 220 | Change the behaviour of the given command to open before the current window. 221 | .It Sy ":only" , Sy ":on" 222 | Quit all but one window in the current tab. 223 | .It Sy ":quit" , Sy ":q" 224 | Quit a window. 225 | .It Sy ":quitall" , Sy ":qa" 226 | Quit all windows in the current tab. 227 | .It Sy ":resize" 228 | Resize a window. 229 | .It Sy ":rightbelow [cmd]" 230 | Change the behaviour of the given command to open after the current window. 231 | .It Sy ":split" , Sy ":sp" 232 | Horizontally split a window. 233 | .It Sy ":vertical [cmd]" 234 | Change the layout of the following command to be vertical. 235 | .It Sy ":vsplit" , Sy ":vsp" 236 | Vertically split a window. 237 | .El 238 | 239 | .Sh "TAB COMMANDS" 240 | .Bl -tag -width Ds 241 | .It Sy ":tab [cmd]" 242 | Run a command that opens a window in a new tab. 243 | .It Sy ":tabclose" , Sy ":tabc" 244 | Close a tab. 245 | .It Sy ":tabedit [room]" , Sy ":tabe" 246 | Open a room in a new tab. 247 | .It Sy ":tabrewind" , Sy ":tabr" 248 | Go to the first tab. 249 | .It Sy ":tablast" , Sy ":tabl" 250 | Go to the last tab. 251 | .It Sy ":tabnext" , Sy ":tabn" 252 | Go to the next tab. 253 | .It Sy ":tabonly" , Sy ":tabo" 254 | Close all but one tab. 255 | .It Sy ":tabprevious" , Sy ":tabp" 256 | Go to the preview tab. 257 | .El 258 | 259 | .Sh "SLASH COMMANDS" 260 | .Bl -tag -width Ds 261 | .It Sy "/markdown" , Sy "/md" 262 | Interpret the message body as Markdown markup. 263 | This is the default behaviour. 264 | .It Sy "/html" , Sy "/h" 265 | Send the message body as literal HTML. 266 | .It Sy "/plaintext" , Sy "/plain" , Sy "/p" 267 | Do not interpret any markup in the message body and send it as it is. 268 | .It Sy "/me" 269 | Send an emote message. 270 | .It Sy "/confetti" 271 | Produces no effect in 272 | .Nm , 273 | but will display confetti in Matrix clients that support doing so. 274 | .It Sy "/fireworks" 275 | Produces no effect in 276 | .Nm , 277 | but will display fireworks in Matrix clients that support doing so. 278 | .It Sy "/hearts" 279 | Produces no effect in 280 | .Nm , 281 | but will display floating hearts in Matrix clients that support doing so. 282 | .It Sy "/rainfall" 283 | Produces no effect in 284 | .Nm , 285 | but will display rainfall in Matrix clients that support doing so. 286 | .It Sy "/snowfall" 287 | Produces no effect in 288 | .Nm , 289 | but will display snowfall in Matrix clients that support doing so. 290 | .It Sy "/spaceinvaders" 291 | Produces no effect in 292 | .Nm , 293 | but will display aliens from Space Invaders in Matrix clients that support doing so. 294 | .El 295 | 296 | .Sh EXAMPLES 297 | .Ss Example 1: Starting with a specific profile 298 | To start with a profile named 299 | .Sy personal 300 | instead of the 301 | .Sy default_profile 302 | value: 303 | .Bd -literal -offset indent 304 | $ iamb -P personal 305 | .Ed 306 | .Ss Example 2: Using an alternate configuration directory 307 | By default, 308 | .Nm 309 | will use the XDG directories, but you may sometimes want to store 310 | your configuration elsewhere. 311 | .Bd -literal -offset indent 312 | $ iamb -C ~/src/iamb-dev/dev-config/ 313 | .Ed 314 | .Sh "REPORTING BUGS" 315 | Please report bugs in 316 | .Nm 317 | or its manual pages at 318 | .Lk https://github.com/ulyssa/iamb/issues 319 | .Sh "SEE ALSO" 320 | .Xr iamb 5 321 | .Pp 322 | Extended documentation is available online at 323 | .Lk https://iamb.chat 324 | -------------------------------------------------------------------------------- /docs/iamb.5: -------------------------------------------------------------------------------- 1 | .\" iamb(7) manual page 2 | .\" 3 | .\" This manual page is written using the mdoc(7) macros. For more 4 | .\" information, see . 5 | .\" 6 | .\" You can preview this file with: 7 | .\" $ man ./docs/iamb.1 8 | .Dd Mar 24, 2024 9 | .Dt IAMB 5 10 | .Os 11 | .Sh NAME 12 | .Nm config.toml 13 | .Nd configuration file for 14 | .Sy iamb 15 | .Sh DESCRIPTION 16 | Configuration must be placed under 17 | .Pa ~/.config/iamb/ 18 | and named 19 | .Nm . 20 | (If 21 | .Ev $XDG_CONFIG_HOME 22 | is set, then 23 | .Sy iamb 24 | will look for a directory named 25 | .Pa iamb 26 | there instead.) 27 | .Pp 28 | Example configuration usually comes bundled with your installation and can 29 | typically be found in 30 | .Pa /usr/share/iamb . 31 | .Pp 32 | As implied by the filename, the configuration is formatted in TOML. 33 | It's structure and fields are described below. 34 | .Sh CONFIGURATION 35 | These options are sections at the top-level of the file. 36 | .Bl -tag -width Ds 37 | .It Sy profiles 38 | A map of profile names containing per-account information. 39 | See 40 | .Sx PROFILES . 41 | .It Sy default_profile 42 | The name of the default profile to connect to, unless overwritten by a 43 | commandline switch. 44 | It should be one of the names defined in the 45 | .Sy profiles 46 | section. 47 | .It Sy settings 48 | Overwrite general settings for 49 | .Sy iamb . 50 | See 51 | .Sx SETTINGS 52 | for a description of possible values. 53 | .It Sy layout 54 | Configure the default window layout to use when starting 55 | .Sy iamb . 56 | See 57 | .Sx "STARTUP LAYOUT" 58 | for more information on how to configure this object. 59 | .It Sy macros 60 | Map keybindings to other keybindings. 61 | See 62 | .Sx "CUSTOM KEYBINDINGS" 63 | for how to configure this object. 64 | .It Sy dirs 65 | Configure the directories to use for data, logs, and more. 66 | See 67 | .Sx DIRECTORIES 68 | for the possible values you can set in this object. 69 | .El 70 | .Sh PROFILES 71 | These options are configured as fields in the 72 | .Sy profiles 73 | object. 74 | .Bl -tag -width Ds 75 | .It Sy user_id 76 | The user ID to use when connecting to the server. 77 | For example "user" in "@user:matrix.org". 78 | .It Sy url 79 | The URL of the user's server. 80 | (For example "https://matrix.org" for "@user:matrix.org".) 81 | This is only needed when the server does not have a 82 | .Pa /.well-known/matrix/client 83 | entry. 84 | .El 85 | .Pp 86 | In addition to the above fields, you can also reuse the following fields to set 87 | per-profile overrides of their global values: 88 | .Bl -bullet -offset indent -width 1m 89 | .It 90 | .Sy dirs 91 | .It 92 | .Sy layout 93 | .It 94 | .Sy macros 95 | .It 96 | .Sy settings 97 | .El 98 | .Ss Example 1: A single profile 99 | .Bd -literal -offset indent 100 | [profiles.personal] 101 | user_id = "@user:matrix.org" 102 | .Ed 103 | .Ss Example 2: Two profiles with a default 104 | In the following example, there are two profiles, 105 | .Dq personal 106 | (set to be the default) and 107 | .Dq work . 108 | The 109 | .Dq work 110 | profile has an explicit URL set for its homeserver. 111 | .Bd -literal -offset indent 112 | default_profile = "personal" 113 | 114 | [profiles.personal] 115 | user_id = "@user:matrix.org" 116 | 117 | [profiles.work] 118 | user_id = "@user:example.com" 119 | url = "https://matrix.example.com" 120 | .Ed 121 | .Sh SETTINGS 122 | These options are configured as an object under the 123 | .Sy settings 124 | key and can be overridden as described in 125 | .Sx PROFILES . 126 | .Bl -tag -width Ds 127 | 128 | .It Sy external_edit_file_suffix 129 | Suffix to append to temporary file names when using the :editor command. Defaults to .md. 130 | 131 | .It Sy default_room 132 | The room to show by default instead of the 133 | .Sy :welcome 134 | window. 135 | 136 | .It Sy image_preview 137 | Enable image previews and configure it. 138 | An empty object will enable the feature with default settings, omitting it will disable the feature. 139 | The available fields in this object are: 140 | .Bl -tag -width Ds 141 | .It Sy size 142 | An optional object with 143 | .Sy width 144 | and 145 | .Sy height 146 | fields to specify the preview size in cells. 147 | Defaults to 66 and 10. 148 | .It Sy protocol 149 | An optional object to override settings that will normally be guessed automatically: 150 | .Bl -tag -width Ds 151 | .It Sy type 152 | An optional string set to one of the protocol types: 153 | .Dq Sy sixel , 154 | .Dq Sy kitty , and 155 | .Dq Sy halfblocks . 156 | .It Sy font_size 157 | An optional list of two numbers representing font width and height in pixels. 158 | .El 159 | .El 160 | .It Sy log_level 161 | Specifies the lowest log level that should be shown. 162 | Possible values are: 163 | .Dq Sy trace , 164 | .Dq Sy debug , 165 | .Dq Sy info , 166 | .Dq Sy warn , and 167 | .Dq Sy error . 168 | 169 | .It Sy message_shortcode_display 170 | Defines whether or not Emoji characters in messages should be replaced by their 171 | respective shortcodes. 172 | 173 | .It Sy message_user_color 174 | Defines whether or not the message body is colored like the username. 175 | 176 | .It Sy notifications 177 | When this subsection is present, you can enable and configure push notifications. 178 | See 179 | .Sx NOTIFICATIONS 180 | for more details. 181 | 182 | .It Sy open_command 183 | Defines a custom command and its arguments to run when opening downloads instead of the default. 184 | (For example, 185 | .Sy ["my-open",\ "--file"] . ) 186 | 187 | .It Sy reaction_display 188 | Defines whether or not reactions should be shown. 189 | 190 | .It Sy reaction_shortcode_display 191 | Defines whether or not reactions should be shown as their respective shortcode. 192 | 193 | .It Sy read_receipt_send 194 | Defines whether or not read confirmations are sent. 195 | 196 | .It Sy read_receipt_display 197 | Defines whether or not read confirmations are displayed. 198 | 199 | .It Sy request_timeout 200 | Defines the maximum time per request in seconds. 201 | 202 | .It Sy sort 203 | Configures how to sort the lists shown in windows like 204 | .Sy :rooms 205 | or 206 | .Sy :members . 207 | See 208 | .Sx "SORTING LISTS" 209 | for more details. 210 | 211 | .It Sy typing_notice_send 212 | Defines whether or not the typing state is sent. 213 | 214 | .It Sy typing_notice_display 215 | Defines whether or not the typing state is displayed. 216 | 217 | .It Sy user 218 | Overrides values for the specified user. 219 | See 220 | .Sx "USER OVERRIDES" 221 | for details on the format. 222 | 223 | .It Sy username_display 224 | Defines how usernames are shown for message senders. 225 | Possible values are 226 | .Dq Sy username , 227 | .Dq Sy localpart , or 228 | .Dq Sy displayname . 229 | 230 | .It Sy user_gutter_width 231 | Specify the width of the column where usernames are displayed in a room. 232 | Usernames that are too long are truncated. 233 | Defaults to 30. 234 | .El 235 | 236 | .Ss Example 1: Avoid showing Emojis (useful for terminals w/o support) 237 | .Bd -literal -offset indent 238 | [settings] 239 | username = "username" 240 | message_shortcode_display = true 241 | reaction_shortcode_display = true 242 | .Ed 243 | 244 | .Ss Example 2: Increase request timeout to 2 minutes for a slow homeserver 245 | .Bd -literal -offset indent 246 | [settings] 247 | request_timeout = 120 248 | .Ed 249 | 250 | .Sh NOTIFICATIONS 251 | 252 | The 253 | .Sy settings.notifications 254 | subsection allows configuring how notifications for new messages behave. 255 | 256 | The available fields in this subsection are: 257 | .Bl -tag -width Ds 258 | .It Sy enabled 259 | Defaults to 260 | .Sy false . 261 | Setting this field to 262 | .Sy true 263 | enables notifications. 264 | 265 | .It Sy via 266 | Defaults to 267 | .Dq Sy desktop 268 | to use the desktop mechanism (default). 269 | Setting this field to 270 | .Dq Sy bell 271 | will use the terminal bell instead. 272 | Both can be used via 273 | .Dq Sy desktop|bell . 274 | 275 | .It Sy show_message 276 | controls whether to show the message in the desktop notification, and defaults to 277 | .Sy true . 278 | Messages are truncated beyond a small length. 279 | The notification rules are stored server side, loaded once at startup, and are currently not configurable in iamb. 280 | In other words, you can simply change the rules with another client. 281 | .El 282 | 283 | .Ss Example 1: Enable notifications with default options 284 | .Bd -literal -offset indent 285 | [settings] 286 | notifications = {} 287 | .Ed 288 | .Ss Example 2: Enable notifications using terminal bell 289 | .Bd -literal -offset indent 290 | [settings.notifications] 291 | via = "bell" 292 | show_message = false 293 | .Ed 294 | 295 | .Sh "SORTING LISTS" 296 | 297 | The 298 | .Sy settings.sort 299 | subsection allows configuring how different windows have their contents sorted. 300 | 301 | Fields available within this subsection are: 302 | .Bl -tag -width Ds 303 | .It Sy rooms 304 | How to sort the 305 | .Sy :rooms 306 | window. 307 | Defaults to 308 | .Sy ["favorite",\ "lowpriority",\ "unread",\ "name"] . 309 | .It Sy chats 310 | How to sort the 311 | .Sy :chats 312 | window. 313 | Defaults to the 314 | .Sy rooms 315 | value. 316 | .It Sy dms 317 | How to sort the 318 | .Sy :dms 319 | window. 320 | Defaults to the 321 | .Sy rooms 322 | value. 323 | .It Sy spaces 324 | How to sort the 325 | .Sy :spaces 326 | window. 327 | Defaults to the 328 | .Sy rooms 329 | value. 330 | .It Sy members 331 | How to sort the 332 | .Sy :members 333 | window. 334 | Defaults to 335 | .Sy ["power",\ "id"] . 336 | .El 337 | 338 | The available values are: 339 | .Bl -tag -width Ds 340 | .It Sy favorite 341 | Put favorite rooms before other rooms. 342 | .It Sy lowpriority 343 | Put lowpriority rooms after other rooms. 344 | .It Sy name 345 | Sort rooms by alphabetically ascending room name. 346 | .It Sy alias 347 | Sort rooms by alphabetically ascending canonical room alias. 348 | .It Sy id 349 | Sort rooms by alphabetically ascending Matrix room identifier. 350 | .It Sy unread 351 | Put unread rooms before other rooms. 352 | .It Sy recent 353 | Sort rooms by most recent message timestamp. 354 | .It Sy invite 355 | Put invites before other rooms. 356 | .El 357 | .El 358 | 359 | .Ss Example 1: Group room members by their server first 360 | .Bd -literal -offset indent 361 | [settings.sort] 362 | members = ["server", "localpart"] 363 | .Ed 364 | 365 | .Sh "USER OVERRIDES" 366 | 367 | The 368 | .Sy settings.users 369 | subsections allows overriding how specific senders are displayed. 370 | Overrides are mapped onto Matrix User IDs such as 371 | .Sy @user:matrix.org , 372 | and are typically written as inline tables containing the following keys: 373 | 374 | .Bl -tag -width Ds 375 | .It Sy name 376 | Change the display name of the user. 377 | 378 | .It Sy color 379 | Change the color the user is shown as. 380 | Possible values are: 381 | .Dq Sy black , 382 | .Dq Sy blue , 383 | .Dq Sy cyan , 384 | .Dq Sy dark-gray , 385 | .Dq Sy gray , 386 | .Dq Sy green , 387 | .Dq Sy light-blue , 388 | .Dq Sy light-cyan , 389 | .Dq Sy light-green , 390 | .Dq Sy light-magenta , 391 | .Dq Sy light-red , 392 | .Dq Sy light-yellow , 393 | .Dq Sy magenta , 394 | .Dq Sy none , 395 | .Dq Sy red , 396 | .Dq Sy white , 397 | and 398 | .Dq Sy yellow . 399 | .El 400 | 401 | .Ss Example 1: Override how @ada:example.com appears in chat 402 | .Bd -literal -offset indent 403 | [settings.users] 404 | "@ada:example.com" = { name = "Ada Lovelace", color = "light-red" } 405 | .Ed 406 | 407 | .Sh STARTUP LAYOUT 408 | 409 | The 410 | .Sy layout 411 | section allows configuring the initial set of tabs and windows to show when 412 | starting the client. 413 | 414 | .Bl -tag -width Ds 415 | .It Sy style 416 | Specifies what window layout to load when starting. 417 | Valid values are 418 | .Dq Sy restore 419 | to restore the layout from the last time the client was exited, 420 | .Dq Sy new 421 | to open a single window (uses the value of 422 | .Sy default_room 423 | if set), or 424 | .Dq Sy config 425 | to open the layout described under 426 | .Sy tabs . 427 | 428 | .It Sy tabs 429 | If 430 | .Sy style 431 | is set to 432 | .Sy config , 433 | then this value will be used to open a set of tabs and windows at startup. 434 | Each object can contain either a 435 | .Sy window 436 | key specifying a username, room identifier or room alias to show, or a 437 | .Sy split 438 | key specifying an array of window objects. 439 | .El 440 | 441 | .Ss Example 1: Show a single room every startup 442 | .Bd -literal -offset indent 443 | [settings] 444 | default_room = "#iamb-users:0x.badd.cafe" 445 | 446 | [layout] 447 | style = "new" 448 | .Ed 449 | .Ss Example 2: Show a specific layout every startup 450 | .Bd -literal -offset indent 451 | [layout] 452 | style = "config" 453 | 454 | [[layout.tabs]] 455 | window = "iamb://dms" 456 | 457 | [[layout.tabs]] 458 | window = "iamb://rooms" 459 | 460 | [[layout.tabs]] 461 | split = [ 462 | { "window" = "#iamb-users:0x.badd.cafe" }, 463 | { "window" = "#iamb-dev:0x.badd.cafe" } 464 | ] 465 | .Ed 466 | 467 | .Sh "CUSTOM KEYBINDINGS" 468 | 469 | The 470 | .Sy macros 471 | subsections allow configuring custom keybindings. 472 | Available subsections are: 473 | 474 | .Bl -tag -width Ds 475 | .It Sy insert , Sy i 476 | Map the key sequences in this section in 477 | .Sy Insert 478 | mode. 479 | 480 | .It Sy normal , Sy n 481 | Map the key sequences in this section in 482 | .Sy Normal 483 | mode. 484 | 485 | .It Sy visual , Sy v 486 | Map the key sequences in this section in 487 | .Sy Visual 488 | mode. 489 | 490 | .It Sy select 491 | Map the key sequences in this section in 492 | .Sy Select 493 | mode. 494 | 495 | .It Sy command , Sy c 496 | Map the key sequences in this section in 497 | .Sy Visual 498 | mode. 499 | 500 | .It Sy operator-pending 501 | Map the key sequences in this section in 502 | .Sy "Operator Pending" 503 | mode. 504 | .El 505 | 506 | Multiple modes can be given together by separating their names with 507 | .Dq Sy | . 508 | 509 | .Ss Example 1: Use "jj" to exit Insert mode 510 | .Bd -literal -offset indent 511 | [macros.insert] 512 | "jj" = "" 513 | .Ed 514 | 515 | .Ss Example 2: Use "V" for switching between message bar and room history 516 | .Bd -literal -offset indent 517 | [macros."normal|visual"] 518 | "V" = "m" 519 | .Ed 520 | 521 | .Sh DIRECTORIES 522 | 523 | Specifies the directories to save data in. 524 | Configured as an object under the key 525 | .Sy dirs . 526 | 527 | .Bl -tag -width Ds 528 | .It Sy cache 529 | Specifies where to store assets and temporary data in. 530 | (For example, 531 | .Sy image_preview 532 | and 533 | .Sy logs 534 | will also go in here by default.) 535 | Defaults to 536 | .Ev $XDG_CACHE_HOME/iamb . 537 | 538 | .It Sy data 539 | Specifies where to store persistent data in, such as E2EE room keys. 540 | Defaults to 541 | .Ev $XDG_DATA_HOME/iamb . 542 | 543 | .It Sy downloads 544 | Specifies where to store downloaded files. 545 | Defaults to 546 | .Ev $XDG_DOWNLOAD_DIR . 547 | 548 | .It Sy image_previews 549 | Specifies where to store automatically downloaded image previews. 550 | Defaults to 551 | .Ev ${cache}/image_preview_downloads . 552 | 553 | .It Sy logs 554 | Specifies where to store log files. 555 | Defaults to 556 | .Ev ${cache}/logs . 557 | .El 558 | .Sh FILES 559 | .Bl -tag -width Ds 560 | .It Pa ~/.config/iamb/config.toml 561 | The TOML configuration file that 562 | .Sy iamb 563 | loads by default. 564 | .It Pa ~/.config/iamb/config.json 565 | A JSON configuration file that 566 | .Sy iamb 567 | will load if the TOML one is not found. 568 | .It Pa /usr/share/iamb/config.example.toml 569 | A sample configuration file with examples of how to set different values. 570 | .El 571 | .Sh "REPORTING BUGS" 572 | Please report bugs in 573 | .Sy iamb 574 | or its manual pages at 575 | .Lk https://github.com/ulyssa/iamb/issues 576 | .Sh SEE ALSO 577 | .Xr iamb 1 578 | .Pp 579 | Extended documentation is available online at 580 | .Lk https://iamb.chat 581 | -------------------------------------------------------------------------------- /docs/iamb.metainfo.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | chat.iamb.iamb 4 | 5 | iamb 6 | A terminal Matrix client for Vim addicts 7 | https://iamb.chat 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | Ulyssa 16 | 17 | 18 | Ulyssa 19 | CC-BY-SA-4.0 20 | Apache-2.0 21 | 22 | 23 | intense 24 | 25 | 26 | 27 | 28 | https://iamb.chat/static/images/metainfo-screenshot.png 29 | Example screenshot of room and lists of rooms, spaces and members within iamb 30 | 31 | 32 | 33 | 34 |

35 | iamb is a client for the Matrix communication protocol. It provides a 36 | terminal user interface with familiar Vim keybindings, and includes 37 | support for multiple profiles, threads, spaces, notifications, 38 | reactions, custom keybindings, and more. 39 |

40 |
41 | 42 | iamb.desktop 43 | 44 | 45 | Network 46 | Chat 47 | 48 | 49 | 50 | iamb 51 | 52 |
53 | -------------------------------------------------------------------------------- /docs/iamb.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ulyssa/iamb/33d3407694c9dece0f3e59aa577eda98c9384ea1/docs/iamb.png -------------------------------------------------------------------------------- /docs/iamb.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 19 | 39 | 41 | 47 | 53 | 54 | 58 | 66 | 70 | 77 | 84 | 91 | 96 | 100 | 104 | 105 | 110 | 114 | 118 | 122 | 126 | 127 | 128 | 129 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-utils": { 4 | "inputs": { 5 | "systems": "systems" 6 | }, 7 | "locked": { 8 | "lastModified": 1731533236, 9 | "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", 10 | "owner": "numtide", 11 | "repo": "flake-utils", 12 | "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", 13 | "type": "github" 14 | }, 15 | "original": { 16 | "owner": "numtide", 17 | "repo": "flake-utils", 18 | "type": "github" 19 | } 20 | }, 21 | "nixpkgs": { 22 | "locked": { 23 | "lastModified": 1736883708, 24 | "narHash": "sha256-uQ+NQ0/xYU0N1CnXsa2zghgNaOPxWpMJXSUJJ9W7140=", 25 | "owner": "nixos", 26 | "repo": "nixpkgs", 27 | "rev": "eb62e6aa39ea67e0b8018ba8ea077efe65807dc8", 28 | "type": "github" 29 | }, 30 | "original": { 31 | "owner": "nixos", 32 | "ref": "nixos-unstable", 33 | "repo": "nixpkgs", 34 | "type": "github" 35 | } 36 | }, 37 | "nixpkgs_2": { 38 | "locked": { 39 | "lastModified": 1736320768, 40 | "narHash": "sha256-nIYdTAiKIGnFNugbomgBJR+Xv5F1ZQU+HfaBqJKroC0=", 41 | "owner": "NixOS", 42 | "repo": "nixpkgs", 43 | "rev": "4bc9c909d9ac828a039f288cf872d16d38185db8", 44 | "type": "github" 45 | }, 46 | "original": { 47 | "owner": "NixOS", 48 | "ref": "nixpkgs-unstable", 49 | "repo": "nixpkgs", 50 | "type": "github" 51 | } 52 | }, 53 | "root": { 54 | "inputs": { 55 | "flake-utils": "flake-utils", 56 | "nixpkgs": "nixpkgs", 57 | "rust-overlay": "rust-overlay" 58 | } 59 | }, 60 | "rust-overlay": { 61 | "inputs": { 62 | "nixpkgs": "nixpkgs_2" 63 | }, 64 | "locked": { 65 | "lastModified": 1736994333, 66 | "narHash": "sha256-v4Jrok5yXsZ6dwj2+2uo5cSyUi9fBTurHqHvNHLT1XA=", 67 | "owner": "oxalica", 68 | "repo": "rust-overlay", 69 | "rev": "848db855cb9e88785996e961951659570fc58814", 70 | "type": "github" 71 | }, 72 | "original": { 73 | "owner": "oxalica", 74 | "repo": "rust-overlay", 75 | "type": "github" 76 | } 77 | }, 78 | "systems": { 79 | "locked": { 80 | "lastModified": 1681028828, 81 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 82 | "owner": "nix-systems", 83 | "repo": "default", 84 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 85 | "type": "github" 86 | }, 87 | "original": { 88 | "owner": "nix-systems", 89 | "repo": "default", 90 | "type": "github" 91 | } 92 | } 93 | }, 94 | "root": "root", 95 | "version": 7 96 | } 97 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "iamb"; 3 | nixConfig.bash-prompt = "\[nix-develop\]$ "; 4 | 5 | inputs = { 6 | nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; 7 | flake-utils.url = "github:numtide/flake-utils"; 8 | rust-overlay.url = "github:oxalica/rust-overlay"; 9 | }; 10 | 11 | outputs = { self, nixpkgs, flake-utils, rust-overlay, ... }: 12 | flake-utils.lib.eachDefaultSystem (system: 13 | let 14 | # We only need the nightly overlay in the devShell because .rs files are formatted with nightly. 15 | overlays = [ (import rust-overlay) ]; 16 | pkgs = import nixpkgs { inherit system overlays; }; 17 | rustNightly = pkgs.rust-bin.nightly."2024-12-12".default; 18 | in 19 | with pkgs; 20 | { 21 | packages.default = rustPlatform.buildRustPackage { 22 | pname = "iamb"; 23 | version = self.shortRev or self.dirtyShortRev; 24 | src = ./.; 25 | cargoLock = { 26 | lockFile = ./Cargo.lock; 27 | }; 28 | nativeBuildInputs = [ pkg-config ]; 29 | buildInputs = [ openssl ] ++ lib.optionals stdenv.isDarwin 30 | (with darwin.apple_sdk.frameworks; [ AppKit Security Cocoa ]); 31 | }; 32 | 33 | devShell = mkShell { 34 | buildInputs = [ 35 | (rustNightly.override { 36 | extensions = [ "rust-src" "rust-analyzer-preview" "rustfmt" "clippy" ]; 37 | }) 38 | pkg-config 39 | cargo-tarpaulin 40 | cargo-watch 41 | sqlite 42 | ]; 43 | }; 44 | }); 45 | } 46 | -------------------------------------------------------------------------------- /iamb.desktop: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Categories=Network;InstantMessaging;Chat; 3 | Comment=A Matrix client for Vim addicts 4 | Exec=iamb 5 | GenericName=Matrix Client 6 | Keywords=Matrix;matrix.org;chat;communications;talk; 7 | Name=iamb 8 | Icon=iamb 9 | StartupNotify=false 10 | Terminal=true 11 | TryExec=iamb 12 | Type=Application 13 | -------------------------------------------------------------------------------- /rust-toolchain.toml: -------------------------------------------------------------------------------- 1 | [toolchain] 2 | channel = "1.83" 3 | components = [ "clippy" ] 4 | -------------------------------------------------------------------------------- /src/keybindings.rs: -------------------------------------------------------------------------------- 1 | //! # Default Keybindings 2 | //! 3 | //! The keybindings are set up here. We define some iamb-specific keybindings, but the default Vim 4 | //! keys come from [modalkit::env::vim::keybindings]. 5 | use modalkit::{ 6 | actions::{InsertTextAction, MacroAction, WindowAction}, 7 | env::vim::keybindings::{InputStep, VimBindings}, 8 | env::vim::VimMode, 9 | env::CommonKeyClass, 10 | key::TerminalKey, 11 | keybindings::{EdgeEvent, EdgeRepeat, InputBindings}, 12 | prelude::*, 13 | }; 14 | 15 | use crate::base::{IambAction, IambInfo, Keybindings, MATRIX_ID_WORD}; 16 | use crate::config::{ApplicationSettings, Keys}; 17 | 18 | pub type IambStep = InputStep; 19 | 20 | fn once(key: &TerminalKey) -> (EdgeRepeat, EdgeEvent) { 21 | (EdgeRepeat::Once, EdgeEvent::Key(*key)) 22 | } 23 | 24 | /// Initialize the default keybinding state. 25 | pub fn setup_keybindings() -> Keybindings { 26 | let mut ism = Keybindings::empty(); 27 | 28 | let vim = VimBindings::default() 29 | .submit_on_enter() 30 | .cursor_open(MATRIX_ID_WORD.clone()); 31 | 32 | vim.setup(&mut ism); 33 | 34 | let ctrl_w = "".parse::().unwrap(); 35 | let ctrl_m = "".parse::().unwrap(); 36 | let ctrl_z = "".parse::().unwrap(); 37 | let key_m_lc = "m".parse::().unwrap(); 38 | let key_z_lc = "z".parse::().unwrap(); 39 | let shift_enter = "".parse::().unwrap(); 40 | 41 | let cwz = vec![once(&ctrl_w), once(&key_z_lc)]; 42 | let cwcz = vec![once(&ctrl_w), once(&ctrl_z)]; 43 | let zoom = IambStep::new() 44 | .actions(vec![WindowAction::ZoomToggle.into()]) 45 | .goto(VimMode::Normal); 46 | 47 | ism.add_mapping(VimMode::Normal, &cwz, &zoom); 48 | ism.add_mapping(VimMode::Visual, &cwz, &zoom); 49 | ism.add_mapping(VimMode::Normal, &cwcz, &zoom); 50 | ism.add_mapping(VimMode::Visual, &cwcz, &zoom); 51 | 52 | let cwm = vec![once(&ctrl_w), once(&key_m_lc)]; 53 | let cwcm = vec![once(&ctrl_w), once(&ctrl_m)]; 54 | let stoggle = IambStep::new() 55 | .actions(vec![IambAction::ToggleScrollbackFocus.into()]) 56 | .goto(VimMode::Normal); 57 | ism.add_mapping(VimMode::Normal, &cwm, &stoggle); 58 | ism.add_mapping(VimMode::Visual, &cwm, &stoggle); 59 | ism.add_mapping(VimMode::Normal, &cwcm, &stoggle); 60 | ism.add_mapping(VimMode::Visual, &cwcm, &stoggle); 61 | 62 | let shift_enter = vec![once(&shift_enter)]; 63 | let newline = IambStep::new().actions(vec![InsertTextAction::Type( 64 | Char::Single('\n').into(), 65 | MoveDir1D::Previous, 66 | 1.into(), 67 | ) 68 | .into()]); 69 | ism.add_mapping(VimMode::Insert, &cwm, &newline); 70 | ism.add_mapping(VimMode::Insert, &shift_enter, &newline); 71 | 72 | ism 73 | } 74 | 75 | impl InputBindings for ApplicationSettings { 76 | fn setup(&self, bindings: &mut Keybindings) { 77 | for (modes, keys) in &self.macros { 78 | for (Keys(input, _), Keys(_, run)) in keys { 79 | let act = MacroAction::Run(run.clone(), Count::Contextual); 80 | let step = IambStep::new().actions(vec![act.into()]); 81 | let input = input.iter().map(once).collect::>(); 82 | 83 | for mode in &modes.0 { 84 | bindings.add_mapping(*mode, &input, &step); 85 | } 86 | } 87 | } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | //! # iamb 2 | //! 3 | //! The iamb client loops over user input and commands, and turns them into actions, [some of 4 | //! which][IambAction] are specific to iamb, and [some of which][Action] come from [modalkit]. When 5 | //! adding new functionality, you will usually want to extend [IambAction] or one of its variants 6 | //! (like [RoomAction][base::RoomAction]), and then add an appropriate [command][commands] or 7 | //! [keybinding][keybindings]. 8 | //! 9 | //! For more complicated changes, you may need to update [the async worker thread][worker], which 10 | //! handles background Matrix tasks with [matrix-rust-sdk][matrix_sdk]. 11 | //! 12 | //! Most rendering logic lives under the [windows] module, but [Matrix messages][message] have 13 | //! their own module. 14 | #![allow(clippy::manual_range_contains)] 15 | #![allow(clippy::needless_return)] 16 | #![allow(clippy::result_large_err)] 17 | #![allow(clippy::bool_assert_comparison)] 18 | use std::collections::VecDeque; 19 | use std::convert::TryFrom; 20 | use std::fmt::Display; 21 | use std::fs::{create_dir_all, File}; 22 | use std::io::{stdout, BufWriter, Stdout, Write}; 23 | use std::ops::DerefMut; 24 | use std::process; 25 | use std::sync::atomic::{AtomicUsize, Ordering}; 26 | use std::sync::Arc; 27 | use std::time::{Duration, Instant}; 28 | 29 | use clap::Parser; 30 | use matrix_sdk::crypto::encrypt_room_key_export; 31 | use matrix_sdk::ruma::api::client::error::ErrorKind; 32 | use matrix_sdk::ruma::OwnedUserId; 33 | use modalkit::keybindings::InputBindings; 34 | use rand::{distributions::Alphanumeric, Rng}; 35 | use temp_dir::TempDir; 36 | use tokio::sync::Mutex as AsyncMutex; 37 | use tracing_subscriber::FmtSubscriber; 38 | 39 | use modalkit::crossterm::{ 40 | self, 41 | cursor::Show as CursorShow, 42 | event::{ 43 | poll, 44 | read, 45 | DisableBracketedPaste, 46 | DisableFocusChange, 47 | DisableMouseCapture, 48 | EnableBracketedPaste, 49 | EnableFocusChange, 50 | EnableMouseCapture, 51 | Event, 52 | KeyEventKind, 53 | KeyboardEnhancementFlags, 54 | MouseEventKind, 55 | PopKeyboardEnhancementFlags, 56 | PushKeyboardEnhancementFlags, 57 | }, 58 | execute, 59 | terminal::{EnterAlternateScreen, LeaveAlternateScreen, SetTitle}, 60 | }; 61 | 62 | use ratatui::{ 63 | backend::CrosstermBackend, 64 | layout::Rect, 65 | style::{Color, Style}, 66 | text::Span, 67 | widgets::Paragraph, 68 | Terminal, 69 | }; 70 | 71 | mod base; 72 | mod commands; 73 | mod config; 74 | mod keybindings; 75 | mod message; 76 | mod notifications; 77 | mod preview; 78 | mod sled_export; 79 | mod util; 80 | mod windows; 81 | mod worker; 82 | 83 | #[cfg(test)] 84 | mod tests; 85 | 86 | use crate::{ 87 | base::{ 88 | AsyncProgramStore, 89 | ChatStore, 90 | HomeserverAction, 91 | IambAction, 92 | IambError, 93 | IambId, 94 | IambInfo, 95 | IambResult, 96 | KeysAction, 97 | ProgramAction, 98 | ProgramContext, 99 | ProgramStore, 100 | }, 101 | config::{ApplicationSettings, Iamb}, 102 | windows::IambWindow, 103 | worker::{create_room, ClientWorker, LoginStyle, Requester}, 104 | }; 105 | 106 | use modalkit::{ 107 | actions::{ 108 | Action, 109 | Commandable, 110 | Editable, 111 | EditorAction, 112 | InsertTextAction, 113 | Jumpable, 114 | Promptable, 115 | Scrollable, 116 | TabAction, 117 | TabContainer, 118 | TabCount, 119 | WindowAction, 120 | WindowContainer, 121 | }, 122 | editing::{context::Resolve, key::KeyManager, store::Store}, 123 | errors::{EditError, UIError}, 124 | key::TerminalKey, 125 | keybindings::{ 126 | dialog::{Pager, PromptYesNo}, 127 | BindingMachine, 128 | }, 129 | prelude::*, 130 | ui::FocusList, 131 | }; 132 | 133 | use modalkit_ratatui::{ 134 | cmdbar::CommandBarState, 135 | screen::{Screen, ScreenState, TabbedLayoutDescription}, 136 | windows::{WindowLayoutDescription, WindowLayoutState}, 137 | TerminalCursor, 138 | TerminalExtOps, 139 | Window, 140 | }; 141 | 142 | fn config_tab_to_desc( 143 | layout: config::WindowLayout, 144 | store: &mut ProgramStore, 145 | ) -> IambResult> { 146 | let desc = match layout { 147 | config::WindowLayout::Window { window } => { 148 | let ChatStore { names, worker, .. } = &mut store.application; 149 | 150 | let window = match window { 151 | config::WindowPath::UserId(user_id) => { 152 | let name = user_id.to_string(); 153 | let room_id = worker.join_room(name.clone())?; 154 | names.insert(name, room_id.clone()); 155 | IambId::Room(room_id, None) 156 | }, 157 | config::WindowPath::RoomId(room_id) => IambId::Room(room_id, None), 158 | config::WindowPath::AliasId(alias) => { 159 | let name = alias.to_string(); 160 | let room_id = worker.join_room(name.clone())?; 161 | names.insert(name, room_id.clone()); 162 | IambId::Room(room_id, None) 163 | }, 164 | config::WindowPath::Window(id) => id, 165 | }; 166 | 167 | WindowLayoutDescription::Window { window, length: None } 168 | }, 169 | config::WindowLayout::Split { split } => { 170 | let children = split 171 | .into_iter() 172 | .map(|child| config_tab_to_desc(child, store)) 173 | .collect::>>()?; 174 | 175 | WindowLayoutDescription::Split { children, length: None } 176 | }, 177 | }; 178 | 179 | Ok(desc) 180 | } 181 | 182 | fn restore_layout( 183 | area: Rect, 184 | settings: &ApplicationSettings, 185 | store: &mut ProgramStore, 186 | ) -> IambResult>> { 187 | let layout = std::fs::read(&settings.layout_json)?; 188 | let tabs: TabbedLayoutDescription = 189 | serde_json::from_slice(&layout).map_err(IambError::from)?; 190 | tabs.to_layout(area.into(), store) 191 | } 192 | 193 | fn setup_screen( 194 | settings: ApplicationSettings, 195 | store: &mut ProgramStore, 196 | ) -> IambResult> { 197 | let cmd = CommandBarState::new(store); 198 | let dims = crossterm::terminal::size()?; 199 | let area = Rect::new(0, 0, dims.0, dims.1); 200 | 201 | match settings.layout { 202 | config::Layout::Restore => { 203 | match restore_layout(area, &settings, store) { 204 | Ok(tabs) => { 205 | return Ok(ScreenState::from_list(tabs, cmd)); 206 | }, 207 | Err(e) => { 208 | // Log the issue with restoring and then continue. 209 | tracing::warn!(err = %e, "Failed to restore layout from disk"); 210 | }, 211 | } 212 | }, 213 | config::Layout::New => {}, 214 | config::Layout::Config { tabs } => { 215 | let mut list = FocusList::default(); 216 | 217 | for tab in tabs.into_iter() { 218 | let tab = config_tab_to_desc(tab, store)?; 219 | let tab = tab.to_layout(area.into(), store)?; 220 | list.push(tab); 221 | } 222 | 223 | return Ok(ScreenState::from_list(list, cmd)); 224 | }, 225 | } 226 | 227 | let win = settings 228 | .tunables 229 | .default_room 230 | .and_then(|room| IambWindow::find(room, store).ok()) 231 | .or_else(|| IambWindow::open(IambId::Welcome, store).ok()) 232 | .unwrap(); 233 | 234 | return Ok(ScreenState::new(win, cmd)); 235 | } 236 | 237 | /// The main application state and event loop. 238 | struct Application { 239 | /// Terminal backend. 240 | terminal: Terminal>, 241 | 242 | /// State for the Matrix client, editing, etc. 243 | store: AsyncProgramStore, 244 | 245 | /// UI state (open tabs, command bar, etc.) to use when rendering. 246 | screen: ScreenState, 247 | 248 | /// Handle to communicate synchronously with the Matrix worker task. 249 | worker: Requester, 250 | 251 | /// Mapped keybindings. 252 | bindings: KeyManager, 253 | 254 | /// Pending actions to run. 255 | actstack: VecDeque<(ProgramAction, ProgramContext)>, 256 | 257 | /// Whether or not the terminal is currently focused. 258 | focused: bool, 259 | 260 | /// The tab layout before the last executed [TabAction]. 261 | last_layout: Option>, 262 | 263 | /// Whether we need to do a full redraw (e.g., after running a subprocess). 264 | dirty: bool, 265 | } 266 | 267 | impl Application { 268 | pub async fn new( 269 | settings: ApplicationSettings, 270 | store: AsyncProgramStore, 271 | ) -> IambResult { 272 | let backend = CrosstermBackend::new(stdout()); 273 | let terminal = Terminal::new(backend)?; 274 | 275 | let mut bindings = crate::keybindings::setup_keybindings(); 276 | settings.setup(&mut bindings); 277 | let bindings = KeyManager::new(bindings); 278 | 279 | let mut locked = store.lock().await; 280 | let screen = setup_screen(settings, locked.deref_mut())?; 281 | 282 | let worker = locked.application.worker.clone(); 283 | 284 | drop(locked); 285 | 286 | let actstack = VecDeque::new(); 287 | 288 | Ok(Application { 289 | store, 290 | worker, 291 | terminal, 292 | bindings, 293 | actstack, 294 | screen, 295 | focused: true, 296 | last_layout: None, 297 | dirty: true, 298 | }) 299 | } 300 | 301 | fn redraw(&mut self, full: bool, store: &mut ProgramStore) -> Result<(), std::io::Error> { 302 | let bindings = &mut self.bindings; 303 | let focused = self.focused; 304 | let sstate = &mut self.screen; 305 | let term = &mut self.terminal; 306 | 307 | if store.application.ring_bell { 308 | store.application.ring_bell = term.backend_mut().write_all(&[7]).is_err(); 309 | } 310 | 311 | if full { 312 | term.clear()?; 313 | } 314 | 315 | term.draw(|f| { 316 | let area = f.area(); 317 | 318 | let modestr = bindings.show_mode(); 319 | let cursor = bindings.get_cursor_indicator(); 320 | let dialogstr = bindings.show_dialog(area.height as usize, area.width as usize); 321 | 322 | // Don't show terminal cursor when we show a dialog. 323 | let hide_cursor = !dialogstr.is_empty(); 324 | 325 | store.application.draw_curr = Some(Instant::now()); 326 | let screen = Screen::new(store) 327 | .show_dialog(dialogstr) 328 | .show_mode(modestr) 329 | .borders(true) 330 | .focus(focused); 331 | f.render_stateful_widget(screen, area, sstate); 332 | 333 | if hide_cursor { 334 | return; 335 | } 336 | 337 | if let Some((cx, cy)) = sstate.get_term_cursor() { 338 | if let Some(c) = cursor { 339 | let style = Style::default().fg(Color::Green); 340 | let span = Span::styled(c.to_string(), style); 341 | let para = Paragraph::new(span); 342 | let inner = Rect::new(cx, cy, 1, 1); 343 | f.render_widget(para, inner) 344 | } 345 | f.set_cursor_position((cx, cy)); 346 | } 347 | })?; 348 | 349 | Ok(()) 350 | } 351 | 352 | async fn step(&mut self) -> Result { 353 | loop { 354 | self.redraw(self.dirty, self.store.clone().lock().await.deref_mut())?; 355 | self.dirty = false; 356 | 357 | if !poll(Duration::from_secs(1))? { 358 | // Redraw in case there's new messages to show. 359 | continue; 360 | } 361 | 362 | match read()? { 363 | Event::Key(ke) => { 364 | if ke.kind == KeyEventKind::Release { 365 | continue; 366 | } 367 | 368 | return Ok(ke.into()); 369 | }, 370 | Event::Mouse(me) => { 371 | let dir = match me.kind { 372 | MouseEventKind::ScrollUp => MoveDir2D::Up, 373 | MouseEventKind::ScrollDown => MoveDir2D::Down, 374 | MouseEventKind::ScrollLeft => MoveDir2D::Left, 375 | MouseEventKind::ScrollRight => MoveDir2D::Right, 376 | _ => continue, 377 | }; 378 | 379 | let size = ScrollSize::Cell; 380 | let style = ScrollStyle::Direction2D(dir, size, 1.into()); 381 | let ctx = ProgramContext::default(); 382 | let mut store = self.store.lock().await; 383 | 384 | match self.screen.scroll(&style, &ctx, store.deref_mut()) { 385 | Ok(None) => {}, 386 | Ok(Some(info)) => { 387 | drop(store); 388 | self.handle_info(info); 389 | }, 390 | Err(e) => { 391 | self.screen.push_error(e); 392 | }, 393 | } 394 | }, 395 | Event::FocusGained => { 396 | let mut store = self.store.lock().await; 397 | store.application.focused = true; 398 | self.focused = true; 399 | }, 400 | Event::FocusLost => { 401 | let mut store = self.store.lock().await; 402 | store.application.focused = false; 403 | self.focused = false; 404 | }, 405 | Event::Resize(_, _) => { 406 | // We'll redraw for the new size next time step() is called. 407 | }, 408 | Event::Paste(s) => { 409 | let act = InsertTextAction::Transcribe(s, MoveDir1D::Previous, 1.into()); 410 | let act = EditorAction::from(act); 411 | let ctx = ProgramContext::default(); 412 | let mut store = self.store.lock().await; 413 | 414 | match self.screen.editor_command(&act, &ctx, store.deref_mut()) { 415 | Ok(None) => {}, 416 | Ok(Some(info)) => { 417 | drop(store); 418 | self.handle_info(info); 419 | }, 420 | Err(e) => { 421 | self.screen.push_error(e); 422 | }, 423 | } 424 | }, 425 | } 426 | } 427 | } 428 | 429 | fn action_prepend(&mut self, acts: Vec<(ProgramAction, ProgramContext)>) { 430 | let mut acts = VecDeque::from(acts); 431 | acts.append(&mut self.actstack); 432 | self.actstack = acts; 433 | } 434 | 435 | fn action_pop(&mut self, keyskip: bool) -> Option<(ProgramAction, ProgramContext)> { 436 | if let res @ Some(_) = self.actstack.pop_front() { 437 | return res; 438 | } 439 | 440 | if keyskip { 441 | return None; 442 | } else { 443 | return self.bindings.pop(); 444 | } 445 | } 446 | 447 | async fn action_run( 448 | &mut self, 449 | action: ProgramAction, 450 | ctx: ProgramContext, 451 | store: &mut ProgramStore, 452 | ) -> IambResult { 453 | let info = match action { 454 | // Do nothing. 455 | Action::NoOp => None, 456 | 457 | Action::Editor(act) => { 458 | match self.screen.editor_command(&act, &ctx, store) { 459 | Ok(info) => info, 460 | Err(EditError::WrongBuffer(content)) if act.is_switchable(&ctx) => { 461 | // Switch to the right window. 462 | if let Some(winid) = content.to_window() { 463 | let open = OpenTarget::Application(winid); 464 | let open = WindowAction::Switch(open); 465 | let _ = self.screen.window_command(&open, &ctx, store)?; 466 | 467 | // Run command again. 468 | self.screen.editor_command(&act, &ctx, store)? 469 | } else { 470 | return Err(EditError::WrongBuffer(content).into()); 471 | } 472 | }, 473 | Err(err) => return Err(err.into()), 474 | } 475 | }, 476 | 477 | // Simple delegations. 478 | Action::Application(act) => self.iamb_run(act, ctx, store).await?, 479 | Action::CommandBar(act) => self.screen.command_bar(&act, &ctx)?, 480 | Action::Macro(act) => self.bindings.macro_command(&act, &ctx, store)?, 481 | Action::Scroll(style) => self.screen.scroll(&style, &ctx, store)?, 482 | Action::ShowInfoMessage(info) => Some(info), 483 | Action::Window(cmd) => self.screen.window_command(&cmd, &ctx, store)?, 484 | 485 | Action::Jump(l, dir, count) => { 486 | let count = ctx.resolve(&count); 487 | let _ = self.screen.jump(l, dir, count, &ctx)?; 488 | 489 | None 490 | }, 491 | Action::Suspend => { 492 | self.terminal.program_suspend()?; 493 | 494 | None 495 | }, 496 | 497 | // UI actions. 498 | Action::Tab(cmd) => { 499 | if let TabAction::Close(_, _) = &cmd { 500 | self.last_layout = self.screen.as_description().into(); 501 | } 502 | 503 | self.screen.tab_command(&cmd, &ctx, store)? 504 | }, 505 | Action::RedrawScreen => { 506 | self.screen.clear_message(); 507 | self.redraw(true, store)?; 508 | 509 | None 510 | }, 511 | 512 | // Actions that create more Actions. 513 | Action::Prompt(act) => { 514 | let acts = self.screen.prompt(&act, &ctx, store)?; 515 | self.action_prepend(acts); 516 | 517 | None 518 | }, 519 | Action::Command(act) => { 520 | let acts = store.application.cmds.command(&act, &ctx, &mut store.registers)?; 521 | self.action_prepend(acts); 522 | 523 | None 524 | }, 525 | Action::Repeat(rt) => { 526 | self.bindings.repeat(rt, Some(ctx)); 527 | 528 | None 529 | }, 530 | 531 | // Unimplemented. 532 | Action::KeywordLookup => { 533 | // XXX: implement 534 | None 535 | }, 536 | 537 | _ => { 538 | // XXX: log unhandled actions? print message? 539 | None 540 | }, 541 | }; 542 | 543 | return Ok(info); 544 | } 545 | 546 | async fn iamb_run( 547 | &mut self, 548 | action: IambAction, 549 | ctx: ProgramContext, 550 | store: &mut ProgramStore, 551 | ) -> IambResult { 552 | if action.scribbles() { 553 | self.dirty = true; 554 | } 555 | 556 | let info = match action { 557 | IambAction::ClearUnreads => { 558 | let user_id = &store.application.settings.profile.user_id; 559 | 560 | for room_id in store.application.sync_info.chats() { 561 | if let Some(room) = store.application.rooms.get_mut(room_id) { 562 | room.fully_read(user_id); 563 | } 564 | } 565 | 566 | None 567 | }, 568 | 569 | IambAction::ToggleScrollbackFocus => { 570 | self.screen.current_window_mut()?.focus_toggle(); 571 | 572 | None 573 | }, 574 | 575 | IambAction::Homeserver(act) => { 576 | let acts = self.homeserver_command(act, ctx, store).await?; 577 | self.action_prepend(acts); 578 | 579 | None 580 | }, 581 | IambAction::Keys(act) => self.keys_command(act, ctx, store).await?, 582 | IambAction::Message(act) => { 583 | self.screen.current_window_mut()?.message_command(act, ctx, store).await? 584 | }, 585 | IambAction::Space(act) => { 586 | self.screen.current_window_mut()?.space_command(act, ctx, store).await? 587 | }, 588 | IambAction::Room(act) => { 589 | let acts = self.screen.current_window_mut()?.room_command(act, ctx, store).await?; 590 | self.action_prepend(acts); 591 | 592 | None 593 | }, 594 | IambAction::Send(act) => { 595 | self.screen.current_window_mut()?.send_command(act, ctx, store).await? 596 | }, 597 | 598 | IambAction::OpenLink(url) => { 599 | tokio::task::spawn_blocking(move || { 600 | return open::that(url); 601 | }); 602 | 603 | None 604 | }, 605 | 606 | IambAction::Verify(act, user_dev) => { 607 | if let Some(sas) = store.application.verifications.get(&user_dev) { 608 | self.worker.verify(act, sas.clone())? 609 | } else { 610 | return Err(IambError::InvalidVerificationId(user_dev).into()); 611 | } 612 | }, 613 | IambAction::VerifyRequest(user_id) => { 614 | if let Ok(user_id) = OwnedUserId::try_from(user_id.as_str()) { 615 | self.worker.verify_request(user_id)? 616 | } else { 617 | return Err(IambError::InvalidUserId(user_id).into()); 618 | } 619 | }, 620 | }; 621 | 622 | Ok(info) 623 | } 624 | 625 | async fn homeserver_command( 626 | &mut self, 627 | action: HomeserverAction, 628 | ctx: ProgramContext, 629 | store: &mut ProgramStore, 630 | ) -> IambResult, ProgramContext)>> { 631 | match action { 632 | HomeserverAction::CreateRoom(alias, vis, flags) => { 633 | let client = &store.application.worker.client; 634 | let room_id = create_room(client, alias, vis, flags).await?; 635 | let room = IambId::Room(room_id, None); 636 | let target = OpenTarget::Application(room); 637 | let action = WindowAction::Switch(target); 638 | 639 | Ok(vec![(action.into(), ctx)]) 640 | }, 641 | HomeserverAction::Logout(user, true) => { 642 | self.worker.logout(user)?; 643 | let flags = CloseFlags::QUIT | CloseFlags::FORCE; 644 | let act = TabAction::Close(TabTarget::All, flags); 645 | 646 | Ok(vec![(act.into(), ctx)]) 647 | }, 648 | HomeserverAction::Logout(user, false) => { 649 | let msg = "Would you like to logout?"; 650 | let act = IambAction::from(HomeserverAction::Logout(user, true)); 651 | let prompt = PromptYesNo::new(msg, vec![Action::from(act)]); 652 | let prompt = Box::new(prompt); 653 | 654 | Err(UIError::NeedConfirm(prompt)) 655 | }, 656 | } 657 | } 658 | 659 | async fn keys_command( 660 | &mut self, 661 | action: KeysAction, 662 | _: ProgramContext, 663 | store: &mut ProgramStore, 664 | ) -> IambResult { 665 | let encryption = store.application.worker.client.encryption(); 666 | 667 | match action { 668 | KeysAction::Export(path, passphrase) => { 669 | encryption 670 | .export_room_keys(path.into(), &passphrase, |_| true) 671 | .await 672 | .map_err(IambError::from)?; 673 | 674 | Ok(Some("Successfully exported room keys".into())) 675 | }, 676 | KeysAction::Import(path, passphrase) => { 677 | let res = encryption 678 | .import_room_keys(path.into(), &passphrase) 679 | .await 680 | .map_err(IambError::from)?; 681 | 682 | let msg = format!("Imported {} of {} keys", res.imported_count, res.total_count); 683 | 684 | Ok(Some(msg.into())) 685 | }, 686 | } 687 | } 688 | 689 | fn handle_info(&mut self, info: InfoMessage) { 690 | match info { 691 | InfoMessage::Message(info) => { 692 | self.screen.push_info(info); 693 | }, 694 | InfoMessage::Pager(text) => { 695 | let pager = Box::new(Pager::new(text, vec![])); 696 | self.bindings.run_dialog(pager); 697 | }, 698 | } 699 | } 700 | 701 | pub async fn run(&mut self) -> Result<(), std::io::Error> { 702 | self.terminal.clear()?; 703 | 704 | let store = self.store.clone(); 705 | 706 | while self.screen.tabs() != 0 { 707 | let key = self.step().await?; 708 | 709 | self.bindings.input_key(key); 710 | 711 | let mut locked = store.lock().await; 712 | let mut keyskip = false; 713 | 714 | while let Some((action, ctx)) = self.action_pop(keyskip) { 715 | match self.action_run(action, ctx, locked.deref_mut()).await { 716 | Ok(None) => { 717 | // Continue processing. 718 | continue; 719 | }, 720 | Ok(Some(info)) => { 721 | self.handle_info(info); 722 | 723 | // Continue processing; we'll redraw later. 724 | continue; 725 | }, 726 | Err( 727 | UIError::NeedConfirm(dialog) | 728 | UIError::EditingFailure(EditError::NeedConfirm(dialog)), 729 | ) => { 730 | self.bindings.run_dialog(dialog); 731 | continue; 732 | }, 733 | Err(e) => { 734 | self.screen.push_error(e); 735 | 736 | // Skip processing any more keypress Actions until the next key. 737 | keyskip = true; 738 | continue; 739 | }, 740 | } 741 | } 742 | } 743 | 744 | if let Some(ref layout) = self.last_layout { 745 | let locked = self.store.lock().await; 746 | let path = locked.application.settings.layout_json.as_path(); 747 | path.parent().map(create_dir_all).transpose()?; 748 | 749 | let file = File::create(path)?; 750 | let writer = BufWriter::new(file); 751 | 752 | if let Err(e) = serde_json::to_writer(writer, layout) { 753 | tracing::error!("Failed to save window layout while exiting: {}", e); 754 | } 755 | } 756 | 757 | crossterm::terminal::disable_raw_mode()?; 758 | execute!(self.terminal.backend_mut(), LeaveAlternateScreen)?; 759 | self.terminal.show_cursor()?; 760 | 761 | return Ok(()); 762 | } 763 | } 764 | 765 | fn gen_passphrase() -> String { 766 | rand::thread_rng() 767 | .sample_iter(&Alphanumeric) 768 | .take(20) 769 | .map(char::from) 770 | .collect() 771 | } 772 | 773 | fn read_response(question: &str) -> String { 774 | println!("{question}"); 775 | let mut input = String::new(); 776 | let _ = std::io::stdin().read_line(&mut input); 777 | input 778 | } 779 | 780 | fn read_yesno(question: &str) -> Option { 781 | read_response(question).chars().next().map(|c| c.to_ascii_lowercase()) 782 | } 783 | 784 | async fn login(worker: &Requester, settings: &ApplicationSettings) -> IambResult<()> { 785 | if settings.session_json.is_file() { 786 | let session = settings.read_session(&settings.session_json)?; 787 | worker.login(LoginStyle::SessionRestore(session.into()))?; 788 | 789 | return Ok(()); 790 | } 791 | 792 | if settings.session_json_old.is_file() && !settings.sled_dir.is_dir() { 793 | let session = settings.read_session(&settings.session_json_old)?; 794 | worker.login(LoginStyle::SessionRestore(session.into()))?; 795 | 796 | return Ok(()); 797 | } 798 | 799 | loop { 800 | let login_style = 801 | match read_response("Please select login type: [p]assword / [s]ingle sign on") 802 | .chars() 803 | .next() 804 | .map(|c| c.to_ascii_lowercase()) 805 | { 806 | None | Some('p') => { 807 | let password = rpassword::prompt_password("Password: ")?; 808 | LoginStyle::Password(password) 809 | }, 810 | Some('s') => LoginStyle::SingleSignOn, 811 | Some(_) => { 812 | println!("Failed to login. Please enter 'p' or 's'"); 813 | continue; 814 | }, 815 | }; 816 | 817 | match worker.login(login_style) { 818 | Ok(info) => { 819 | if let Some(msg) = info { 820 | println!("{msg}"); 821 | } 822 | 823 | break; 824 | }, 825 | Err(err) => { 826 | println!("Failed to login: {err}"); 827 | continue; 828 | }, 829 | } 830 | } 831 | 832 | Ok(()) 833 | } 834 | 835 | fn print_exit(v: T) -> N { 836 | eprintln!("{v}"); 837 | process::exit(2); 838 | } 839 | 840 | // We can't access the OlmMachine directly, so write the keys to a temporary 841 | // file first, and then import them later. 842 | async fn check_import_keys( 843 | settings: &ApplicationSettings, 844 | ) -> IambResult> { 845 | let do_import = settings.sled_dir.is_dir() && !settings.sqlite_dir.is_dir(); 846 | 847 | if !do_import { 848 | return Ok(None); 849 | } 850 | 851 | let question = format!( 852 | "Found old sled store in {}. Would you like to export room keys from it? [y]es/[n]o", 853 | settings.sled_dir.display() 854 | ); 855 | 856 | loop { 857 | match read_yesno(&question) { 858 | Some('y') => { 859 | break; 860 | }, 861 | Some('n') => { 862 | return Ok(None); 863 | }, 864 | Some(_) | None => { 865 | continue; 866 | }, 867 | } 868 | } 869 | 870 | let keys = sled_export::export_room_keys(&settings.sled_dir).await?; 871 | let passphrase = gen_passphrase(); 872 | 873 | println!("* Encrypting {} room keys with the passphrase {passphrase:?}...", keys.len()); 874 | 875 | let encrypted = match encrypt_room_key_export(&keys, &passphrase, 500000) { 876 | Ok(encrypted) => encrypted, 877 | Err(e) => { 878 | println!("* Failed to encrypt room keys during export: {e}"); 879 | process::exit(2); 880 | }, 881 | }; 882 | 883 | let tmpdir = TempDir::new()?; 884 | let exported = tmpdir.child("keys"); 885 | 886 | println!("* Writing encrypted room keys to {}...", exported.display()); 887 | tokio::fs::write(&exported, &encrypted).await?; 888 | 889 | Ok(Some((tmpdir, passphrase))) 890 | } 891 | 892 | async fn login_upgrade( 893 | keydir: TempDir, 894 | passphrase: String, 895 | worker: &Requester, 896 | settings: &ApplicationSettings, 897 | store: &AsyncProgramStore, 898 | ) -> IambResult<()> { 899 | println!( 900 | "Please log in for {} to import the room keys into a new session", 901 | settings.profile.user_id 902 | ); 903 | 904 | login(worker, settings).await?; 905 | 906 | println!("* Importing room keys..."); 907 | 908 | let exported = keydir.child("keys"); 909 | let imported = worker.client.encryption().import_room_keys(exported, &passphrase).await; 910 | 911 | match imported { 912 | Ok(res) => { 913 | println!( 914 | "* Successfully imported {} out of {} keys", 915 | res.imported_count, res.total_count 916 | ); 917 | let _ = keydir.cleanup(); 918 | }, 919 | Err(e) => { 920 | println!( 921 | "Failed to import room keys from {}/keys: {e}\n\n\ 922 | They have been encrypted with the passphrase {passphrase:?}.\ 923 | Please save them and try importing them manually instead\n", 924 | keydir.path().display() 925 | ); 926 | 927 | loop { 928 | match read_yesno("Would you like to continue logging in? [y]es/[n]o") { 929 | Some('y') => break, 930 | Some('n') => print_exit("* Exiting..."), 931 | Some(_) | None => continue, 932 | } 933 | } 934 | }, 935 | } 936 | 937 | println!("* Syncing..."); 938 | worker::do_first_sync(&worker.client, store) 939 | .await 940 | .map_err(IambError::from)?; 941 | 942 | Ok(()) 943 | } 944 | 945 | async fn login_normal( 946 | worker: &Requester, 947 | settings: &ApplicationSettings, 948 | store: &AsyncProgramStore, 949 | ) -> IambResult<()> { 950 | println!("* Logging in for {}...", settings.profile.user_id); 951 | login(worker, settings).await?; 952 | println!("* Syncing..."); 953 | worker::do_first_sync(&worker.client, store) 954 | .await 955 | .map_err(IambError::from)?; 956 | Ok(()) 957 | } 958 | 959 | /// Set up the terminal for drawing the TUI, and getting additional info. 960 | fn setup_tty(settings: &ApplicationSettings, enable_enhanced_keys: bool) -> std::io::Result<()> { 961 | let title = format!("iamb ({})", settings.profile.user_id.as_str()); 962 | 963 | // Enable raw mode and enter the alternate screen. 964 | crossterm::terminal::enable_raw_mode()?; 965 | crossterm::execute!(stdout(), EnterAlternateScreen)?; 966 | 967 | if enable_enhanced_keys { 968 | // Enable the Kitty keyboard enhancement protocol for improved keypresses. 969 | crossterm::queue!( 970 | stdout(), 971 | PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES) 972 | )?; 973 | } 974 | 975 | if settings.tunables.mouse.enabled { 976 | crossterm::execute!(stdout(), EnableMouseCapture)?; 977 | } 978 | 979 | crossterm::execute!(stdout(), EnableBracketedPaste, EnableFocusChange, SetTitle(title)) 980 | } 981 | 982 | // Do our best to reverse what we did in setup_tty() when we exit or crash. 983 | fn restore_tty(enable_enhanced_keys: bool, enable_mouse: bool) { 984 | if enable_enhanced_keys { 985 | let _ = crossterm::queue!(stdout(), PopKeyboardEnhancementFlags); 986 | } 987 | 988 | if enable_mouse { 989 | let _ = crossterm::queue!(stdout(), DisableMouseCapture); 990 | } 991 | 992 | let _ = crossterm::execute!( 993 | stdout(), 994 | DisableBracketedPaste, 995 | DisableFocusChange, 996 | LeaveAlternateScreen, 997 | CursorShow, 998 | ); 999 | 1000 | let _ = crossterm::terminal::disable_raw_mode(); 1001 | } 1002 | 1003 | async fn run(settings: ApplicationSettings) -> IambResult<()> { 1004 | // Get old keys the first time we run w/ the upgraded SDK. 1005 | let import_keys = check_import_keys(&settings).await?; 1006 | 1007 | // Set up client state. 1008 | create_dir_all(settings.sqlite_dir.as_path())?; 1009 | let client = worker::create_client(&settings).await; 1010 | 1011 | // Set up the async worker thread and global store. 1012 | let worker = ClientWorker::spawn(client.clone(), settings.clone()).await; 1013 | let store = ChatStore::new(worker.clone(), settings.clone()); 1014 | let store = Store::new(store); 1015 | let store = Arc::new(AsyncMutex::new(store)); 1016 | worker.init(store.clone()); 1017 | 1018 | let res = if let Some((keydir, pass)) = import_keys { 1019 | login_upgrade(keydir, pass, &worker, &settings, &store).await 1020 | } else { 1021 | login_normal(&worker, &settings, &store).await 1022 | }; 1023 | 1024 | match res { 1025 | Err(UIError::Application(IambError::Matrix(e))) => { 1026 | if let Some(ErrorKind::UnknownToken { .. }) = e.client_api_error_kind() { 1027 | print_exit("Server did not recognize our API token; did you log out from this session elsewhere?") 1028 | } else { 1029 | print_exit(e) 1030 | } 1031 | }, 1032 | Err(e) => print_exit(e), 1033 | Ok(()) => (), 1034 | } 1035 | 1036 | // Set up the terminal for drawing, and cleanup properly on panics. 1037 | let enable_enhanced_keys = match crossterm::terminal::supports_keyboard_enhancement() { 1038 | Ok(supported) => supported, 1039 | Err(e) => { 1040 | tracing::warn!(err = %e, 1041 | "Failed to determine whether the terminal supports keyboard enhancements"); 1042 | false 1043 | }, 1044 | }; 1045 | setup_tty(&settings, enable_enhanced_keys)?; 1046 | 1047 | let orig_hook = std::panic::take_hook(); 1048 | let enable_mouse = settings.tunables.mouse.enabled; 1049 | std::panic::set_hook(Box::new(move |panic_info| { 1050 | restore_tty(enable_enhanced_keys, enable_mouse); 1051 | orig_hook(panic_info); 1052 | process::exit(1); 1053 | })); 1054 | 1055 | // And finally, start running the terminal UI. 1056 | let mut application = Application::new(settings, store).await?; 1057 | application.run().await?; 1058 | 1059 | // Clean up the terminal on exit. 1060 | restore_tty(enable_enhanced_keys, enable_mouse); 1061 | 1062 | Ok(()) 1063 | } 1064 | 1065 | fn main() -> IambResult<()> { 1066 | // Parse command-line flags. 1067 | let iamb = Iamb::parse(); 1068 | 1069 | // Load configuration and set up the Matrix SDK. 1070 | let settings = ApplicationSettings::load(iamb).unwrap_or_else(print_exit); 1071 | 1072 | // Set umask on Unix platforms so that tokens, keys, etc. are only readable by the user. 1073 | #[cfg(unix)] 1074 | unsafe { 1075 | libc::umask(0o077); 1076 | }; 1077 | 1078 | // Set up the tracing subscriber so we can log client messages. 1079 | let log_prefix = format!("iamb-log-{}", settings.profile_name); 1080 | let log_dir = settings.dirs.logs.as_path(); 1081 | 1082 | let appender = tracing_appender::rolling::daily(log_dir, log_prefix); 1083 | let (appender, guard) = tracing_appender::non_blocking(appender); 1084 | 1085 | let subscriber = FmtSubscriber::builder() 1086 | .with_writer(appender) 1087 | .with_max_level(settings.tunables.log_level) 1088 | .finish(); 1089 | tracing::subscriber::set_global_default(subscriber).expect("setting default subscriber failed"); 1090 | 1091 | let rt = tokio::runtime::Builder::new_multi_thread() 1092 | .enable_all() 1093 | .worker_threads(2) 1094 | .thread_name_fn(|| { 1095 | static ATOMIC_ID: AtomicUsize = AtomicUsize::new(0); 1096 | let id = ATOMIC_ID.fetch_add(1, Ordering::SeqCst); 1097 | format!("iamb-worker-{id}") 1098 | }) 1099 | .build() 1100 | .unwrap(); 1101 | 1102 | rt.block_on(async move { run(settings).await })?; 1103 | 1104 | drop(guard); 1105 | process::exit(0); 1106 | } 1107 | -------------------------------------------------------------------------------- /src/message/compose.rs: -------------------------------------------------------------------------------- 1 | //! Code for converting composed messages into content to send to the homeserver. 2 | use comrak::{markdown_to_html, ComrakOptions}; 3 | use nom::{ 4 | branch::alt, 5 | bytes::complete::tag, 6 | character::complete::space0, 7 | combinator::value, 8 | IResult, 9 | }; 10 | 11 | use matrix_sdk::ruma::events::room::message::{ 12 | EmoteMessageEventContent, 13 | MessageType, 14 | RoomMessageEventContent, 15 | TextMessageEventContent, 16 | }; 17 | 18 | #[derive(Clone, Debug, Default)] 19 | enum SlashCommand { 20 | /// Send an emote message. 21 | Emote, 22 | 23 | /// Send a message as literal HTML. 24 | Html, 25 | 26 | /// Send a message without parsing any markup. 27 | Plaintext, 28 | 29 | /// Send a Markdown message (the default message markup). 30 | #[default] 31 | Markdown, 32 | 33 | /// Send a message with confetti effects in clients that show them. 34 | Confetti, 35 | 36 | /// Send a message with fireworks effects in clients that show them. 37 | Fireworks, 38 | 39 | /// Send a message with heart effects in clients that show them. 40 | Hearts, 41 | 42 | /// Send a message with rainfall effects in clients that show them. 43 | Rainfall, 44 | 45 | /// Send a message with snowfall effects in clients that show them. 46 | Snowfall, 47 | 48 | /// Send a message with heart effects in clients that show them. 49 | SpaceInvaders, 50 | } 51 | 52 | impl SlashCommand { 53 | fn to_message(&self, input: &str) -> anyhow::Result { 54 | let msgtype = match self { 55 | SlashCommand::Emote => { 56 | let msg = if let Some(html) = text_to_html(input) { 57 | EmoteMessageEventContent::html(input, html) 58 | } else { 59 | EmoteMessageEventContent::plain(input) 60 | }; 61 | 62 | MessageType::Emote(msg) 63 | }, 64 | SlashCommand::Html => { 65 | let msg = TextMessageEventContent::html(input, input); 66 | MessageType::Text(msg) 67 | }, 68 | SlashCommand::Plaintext => { 69 | let msg = TextMessageEventContent::plain(input); 70 | MessageType::Text(msg) 71 | }, 72 | SlashCommand::Markdown => { 73 | let msg = text_to_message_content(input.to_string()); 74 | MessageType::Text(msg) 75 | }, 76 | SlashCommand::Confetti => { 77 | MessageType::new("nic.custom.confetti", input.into(), Default::default())? 78 | }, 79 | SlashCommand::Fireworks => { 80 | MessageType::new("nic.custom.fireworks", input.into(), Default::default())? 81 | }, 82 | SlashCommand::Hearts => { 83 | MessageType::new("io.element.effect.hearts", input.into(), Default::default())? 84 | }, 85 | SlashCommand::Rainfall => { 86 | MessageType::new("io.element.effect.rainfall", input.into(), Default::default())? 87 | }, 88 | SlashCommand::Snowfall => { 89 | MessageType::new("io.element.effect.snowfall", input.into(), Default::default())? 90 | }, 91 | SlashCommand::SpaceInvaders => { 92 | MessageType::new( 93 | "io.element.effects.space_invaders", 94 | input.into(), 95 | Default::default(), 96 | )? 97 | }, 98 | }; 99 | 100 | Ok(msgtype) 101 | } 102 | } 103 | 104 | fn parse_slash_command_inner(input: &str) -> IResult<&str, SlashCommand> { 105 | let (input, _) = space0(input)?; 106 | let (input, slash) = alt(( 107 | value(SlashCommand::Emote, tag("/me ")), 108 | value(SlashCommand::Html, tag("/h ")), 109 | value(SlashCommand::Html, tag("/html ")), 110 | value(SlashCommand::Plaintext, tag("/p ")), 111 | value(SlashCommand::Plaintext, tag("/plain ")), 112 | value(SlashCommand::Plaintext, tag("/plaintext ")), 113 | value(SlashCommand::Markdown, tag("/md ")), 114 | value(SlashCommand::Markdown, tag("/markdown ")), 115 | value(SlashCommand::Confetti, tag("/confetti ")), 116 | value(SlashCommand::Fireworks, tag("/fireworks ")), 117 | value(SlashCommand::Hearts, tag("/hearts ")), 118 | value(SlashCommand::Rainfall, tag("/rainfall ")), 119 | value(SlashCommand::Snowfall, tag("/snowfall ")), 120 | value(SlashCommand::SpaceInvaders, tag("/spaceinvaders ")), 121 | ))(input)?; 122 | let (input, _) = space0(input)?; 123 | 124 | Ok((input, slash)) 125 | } 126 | 127 | fn parse_slash_command(input: &str) -> anyhow::Result<(&str, SlashCommand)> { 128 | match parse_slash_command_inner(input) { 129 | Ok(input) => Ok(input), 130 | Err(e) => Err(anyhow::anyhow!("Failed to parse slash command: {e}")), 131 | } 132 | } 133 | 134 | /// Check whether this character is not used for markup in Markdown. 135 | /// 136 | /// Markdown uses just about every ASCII punctuation symbol in some way, especially 137 | /// once autolinking is involved, so we really just check whether it's non-punctuation or 138 | /// single/double quotations. 139 | fn not_markdown_char(c: char) -> bool { 140 | if !c.is_ascii_punctuation() { 141 | return true; 142 | } 143 | 144 | matches!(c, '"' | '\'') 145 | } 146 | 147 | /// Check whether the input actually needs to be processed as Markdown. 148 | fn not_markdown(input: &str) -> bool { 149 | input.chars().all(not_markdown_char) 150 | } 151 | 152 | fn text_to_html(input: &str) -> Option { 153 | if not_markdown(input) { 154 | return None; 155 | } 156 | 157 | let mut options = ComrakOptions::default(); 158 | options.extension.autolink = true; 159 | options.extension.shortcodes = true; 160 | options.extension.strikethrough = true; 161 | options.render.hardbreaks = true; 162 | markdown_to_html(input, &options).into() 163 | } 164 | 165 | fn text_to_message_content(input: String) -> TextMessageEventContent { 166 | if let Some(html) = text_to_html(input.as_str()) { 167 | TextMessageEventContent::html(input, html) 168 | } else { 169 | TextMessageEventContent::plain(input) 170 | } 171 | } 172 | 173 | pub fn text_to_message(input: String) -> RoomMessageEventContent { 174 | let msg = parse_slash_command(input.as_str()) 175 | .and_then(|(input, slash)| slash.to_message(input)) 176 | .unwrap_or_else(|_| MessageType::Text(text_to_message_content(input))); 177 | 178 | RoomMessageEventContent::new(msg) 179 | } 180 | 181 | #[cfg(test)] 182 | pub mod tests { 183 | use super::*; 184 | 185 | #[test] 186 | fn test_markdown_autolink() { 187 | let input = "http://example.com\n"; 188 | let content = text_to_message_content(input.into()); 189 | assert_eq!(content.body, input); 190 | assert_eq!( 191 | content.formatted.unwrap().body, 192 | "

http://example.com

\n" 193 | ); 194 | 195 | let input = "www.example.com\n"; 196 | let content = text_to_message_content(input.into()); 197 | assert_eq!(content.body, input); 198 | assert_eq!( 199 | content.formatted.unwrap().body, 200 | "

www.example.com

\n" 201 | ); 202 | 203 | let input = "See docs (they're at https://iamb.chat)\n"; 204 | let content = text_to_message_content(input.into()); 205 | assert_eq!(content.body, input); 206 | assert_eq!( 207 | content.formatted.unwrap().body, 208 | "

See docs (they're at https://iamb.chat)

\n" 209 | ); 210 | } 211 | 212 | #[test] 213 | fn test_markdown_message() { 214 | let input = "**bold**\n"; 215 | let content = text_to_message_content(input.into()); 216 | assert_eq!(content.body, input); 217 | assert_eq!(content.formatted.unwrap().body, "

bold

\n"); 218 | 219 | let input = "*emphasis*\n"; 220 | let content = text_to_message_content(input.into()); 221 | assert_eq!(content.body, input); 222 | assert_eq!(content.formatted.unwrap().body, "

emphasis

\n"); 223 | 224 | let input = "`code`\n"; 225 | let content = text_to_message_content(input.into()); 226 | assert_eq!(content.body, input); 227 | assert_eq!(content.formatted.unwrap().body, "

code

\n"); 228 | 229 | let input = "```rust\nconst A: usize = 1;\n```\n"; 230 | let content = text_to_message_content(input.into()); 231 | assert_eq!(content.body, input); 232 | assert_eq!( 233 | content.formatted.unwrap().body, 234 | "
const A: usize = 1;\n
\n" 235 | ); 236 | 237 | let input = ":heart:\n"; 238 | let content = text_to_message_content(input.into()); 239 | assert_eq!(content.body, input); 240 | assert_eq!(content.formatted.unwrap().body, "

\u{2764}\u{FE0F}

\n"); 241 | 242 | let input = "para *1*\n\npara _2_\n"; 243 | let content = text_to_message_content(input.into()); 244 | assert_eq!(content.body, input); 245 | assert_eq!( 246 | content.formatted.unwrap().body, 247 | "

para 1

\n

para 2

\n" 248 | ); 249 | 250 | let input = "line 1\nline ~~2~~\n"; 251 | let content = text_to_message_content(input.into()); 252 | assert_eq!(content.body, input); 253 | assert_eq!(content.formatted.unwrap().body, "

line 1
\nline 2

\n"); 254 | 255 | let input = "# Heading\n## Subheading\n\ntext\n"; 256 | let content = text_to_message_content(input.into()); 257 | assert_eq!(content.body, input); 258 | assert_eq!( 259 | content.formatted.unwrap().body, 260 | "

Heading

\n

Subheading

\n

text

\n" 261 | ); 262 | } 263 | 264 | #[test] 265 | fn test_markdown_headers() { 266 | let input = "hello\n=====\n"; 267 | let content = text_to_message_content(input.into()); 268 | assert_eq!(content.body, input); 269 | assert_eq!(content.formatted.unwrap().body, "

hello

\n"); 270 | 271 | let input = "hello\n-----\n"; 272 | let content = text_to_message_content(input.into()); 273 | assert_eq!(content.body, input); 274 | assert_eq!(content.formatted.unwrap().body, "

hello

\n"); 275 | } 276 | 277 | #[test] 278 | fn test_markdown_lists() { 279 | let input = "- A\n- B\n- C\n"; 280 | let content = text_to_message_content(input.into()); 281 | assert_eq!(content.body, input); 282 | assert_eq!( 283 | content.formatted.unwrap().body, 284 | "
    \n
  • A
  • \n
  • B
  • \n
  • C
  • \n
\n" 285 | ); 286 | 287 | let input = "1) A\n2) B\n3) C\n"; 288 | let content = text_to_message_content(input.into()); 289 | assert_eq!(content.body, input); 290 | assert_eq!( 291 | content.formatted.unwrap().body, 292 | "
    \n
  1. A
  2. \n
  3. B
  4. \n
  5. C
  6. \n
\n" 293 | ); 294 | } 295 | 296 | #[test] 297 | fn test_no_markdown_conversion_on_simple_text() { 298 | let input = "para 1\n\npara 2\n"; 299 | let content = text_to_message_content(input.into()); 300 | assert_eq!(content.body, input); 301 | assert!(content.formatted.is_none()); 302 | 303 | let input = "line 1\nline 2\n"; 304 | let content = text_to_message_content(input.into()); 305 | assert_eq!(content.body, input); 306 | assert!(content.formatted.is_none()); 307 | 308 | let input = "isn't markdown\n"; 309 | let content = text_to_message_content(input.into()); 310 | assert_eq!(content.body, input); 311 | assert!(content.formatted.is_none()); 312 | 313 | let input = "\"scare quotes\"\n"; 314 | let content = text_to_message_content(input.into()); 315 | assert_eq!(content.body, input); 316 | assert!(content.formatted.is_none()); 317 | } 318 | 319 | #[test] 320 | fn text_to_message_slash_commands() { 321 | let MessageType::Text(content) = text_to_message("/html bold".into()).msgtype else { 322 | panic!("Expected MessageType::Text"); 323 | }; 324 | assert_eq!(content.body, "bold"); 325 | assert_eq!(content.formatted.unwrap().body, "bold"); 326 | 327 | let MessageType::Text(content) = text_to_message("/h bold".into()).msgtype else { 328 | panic!("Expected MessageType::Text"); 329 | }; 330 | assert_eq!(content.body, "bold"); 331 | assert_eq!(content.formatted.unwrap().body, "bold"); 332 | 333 | let MessageType::Text(content) = text_to_message("/plain bold".into()).msgtype 334 | else { 335 | panic!("Expected MessageType::Text"); 336 | }; 337 | assert_eq!(content.body, "bold"); 338 | assert!(content.formatted.is_none(), "{:?}", content.formatted); 339 | 340 | let MessageType::Text(content) = text_to_message("/p bold".into()).msgtype else { 341 | panic!("Expected MessageType::Text"); 342 | }; 343 | assert_eq!(content.body, "bold"); 344 | assert!(content.formatted.is_none(), "{:?}", content.formatted); 345 | 346 | let MessageType::Emote(content) = text_to_message("/me *bold*".into()).msgtype else { 347 | panic!("Expected MessageType::Emote"); 348 | }; 349 | assert_eq!(content.body, "*bold*"); 350 | assert_eq!(content.formatted.unwrap().body, "

bold

\n"); 351 | 352 | let content = text_to_message("/confetti hello".into()).msgtype; 353 | assert_eq!(content.msgtype(), "nic.custom.confetti"); 354 | assert_eq!(content.body(), "hello"); 355 | 356 | let content = text_to_message("/fireworks hello".into()).msgtype; 357 | assert_eq!(content.msgtype(), "nic.custom.fireworks"); 358 | assert_eq!(content.body(), "hello"); 359 | 360 | let content = text_to_message("/hearts hello".into()).msgtype; 361 | assert_eq!(content.msgtype(), "io.element.effect.hearts"); 362 | assert_eq!(content.body(), "hello"); 363 | 364 | let content = text_to_message("/rainfall hello".into()).msgtype; 365 | assert_eq!(content.msgtype(), "io.element.effect.rainfall"); 366 | assert_eq!(content.body(), "hello"); 367 | 368 | let content = text_to_message("/snowfall hello".into()).msgtype; 369 | assert_eq!(content.msgtype(), "io.element.effect.snowfall"); 370 | assert_eq!(content.body(), "hello"); 371 | 372 | let content = text_to_message("/spaceinvaders hello".into()).msgtype; 373 | assert_eq!(content.msgtype(), "io.element.effects.space_invaders"); 374 | assert_eq!(content.body(), "hello"); 375 | } 376 | } 377 | -------------------------------------------------------------------------------- /src/message/printer.rs: -------------------------------------------------------------------------------- 1 | //! # Line Wrapping Logic 2 | //! 3 | //! The [TextPrinter] handles wrapping stylized text and inserting spaces for padding at the end of 4 | //! lines to make concatenation work right (e.g., combining table cells after wrapping their 5 | //! contents). 6 | use std::borrow::Cow; 7 | 8 | use ratatui::layout::Alignment; 9 | use ratatui::style::Style; 10 | use ratatui::text::{Line, Span, Text}; 11 | use unicode_segmentation::UnicodeSegmentation; 12 | use unicode_width::UnicodeWidthStr; 13 | 14 | use crate::config::{ApplicationSettings, TunableValues}; 15 | use crate::util::{ 16 | replace_emojis_in_line, 17 | replace_emojis_in_span, 18 | replace_emojis_in_str, 19 | space_span, 20 | take_width, 21 | }; 22 | 23 | /// Wrap styled text for the current terminal width. 24 | pub struct TextPrinter<'a> { 25 | text: Text<'a>, 26 | width: usize, 27 | base_style: Style, 28 | hide_reply: bool, 29 | 30 | alignment: Alignment, 31 | curr_spans: Vec>, 32 | curr_width: usize, 33 | literal: bool, 34 | 35 | pub(super) settings: &'a ApplicationSettings, 36 | } 37 | 38 | impl<'a> TextPrinter<'a> { 39 | /// Create a new printer. 40 | pub fn new( 41 | width: usize, 42 | base_style: Style, 43 | hide_reply: bool, 44 | settings: &'a ApplicationSettings, 45 | ) -> Self { 46 | TextPrinter { 47 | text: Text::default(), 48 | width, 49 | base_style, 50 | hide_reply, 51 | 52 | alignment: Alignment::Left, 53 | curr_spans: vec![], 54 | curr_width: 0, 55 | literal: false, 56 | settings, 57 | } 58 | } 59 | 60 | /// Configure the alignment for each line. 61 | pub fn align(mut self, alignment: Alignment) -> Self { 62 | self.alignment = alignment; 63 | self 64 | } 65 | 66 | /// Set whether newlines should be treated literally, or turned into spaces. 67 | pub fn literal(mut self, literal: bool) -> Self { 68 | self.literal = literal; 69 | self 70 | } 71 | 72 | /// Indicates whether replies should be pushed to the printer. 73 | pub fn hide_reply(&self) -> bool { 74 | self.hide_reply 75 | } 76 | 77 | /// Indicates whether emojis should be replaced by shortcodes 78 | pub fn emoji_shortcodes(&self) -> bool { 79 | self.tunables().message_shortcode_display 80 | } 81 | 82 | pub fn settings(&self) -> &ApplicationSettings { 83 | self.settings 84 | } 85 | 86 | pub fn tunables(&self) -> &TunableValues { 87 | &self.settings.tunables 88 | } 89 | 90 | /// Indicates the current printer's width. 91 | pub fn width(&self) -> usize { 92 | self.width 93 | } 94 | 95 | /// Create a new printer with a smaller width. 96 | pub fn sub(&self, indent: usize) -> Self { 97 | TextPrinter { 98 | text: Text::default(), 99 | width: self.width.saturating_sub(indent), 100 | base_style: self.base_style, 101 | hide_reply: self.hide_reply, 102 | 103 | alignment: self.alignment, 104 | curr_spans: vec![], 105 | curr_width: 0, 106 | literal: self.literal, 107 | settings: self.settings, 108 | } 109 | } 110 | 111 | fn remaining(&self) -> usize { 112 | self.width.saturating_sub(self.curr_width) 113 | } 114 | 115 | /// If there is any text on the current line, start a new one. 116 | pub fn commit(&mut self) { 117 | if self.curr_width > 0 { 118 | self.push_break(); 119 | } 120 | } 121 | 122 | fn push(&mut self) { 123 | self.curr_width = 0; 124 | self.text.lines.push(Line::from(std::mem::take(&mut self.curr_spans))); 125 | } 126 | 127 | /// Start a new line. 128 | pub fn push_break(&mut self) { 129 | if self.curr_width == 0 && self.text.lines.is_empty() { 130 | // Disallow leading breaks. 131 | return; 132 | } 133 | 134 | let remaining = self.remaining(); 135 | 136 | if remaining > 0 { 137 | match self.alignment { 138 | Alignment::Left => { 139 | let tspan = space_span(remaining, self.base_style); 140 | self.curr_spans.push(tspan); 141 | }, 142 | Alignment::Center => { 143 | let trailing = remaining / 2; 144 | let leading = remaining - trailing; 145 | 146 | let tspan = space_span(trailing, self.base_style); 147 | let lspan = space_span(leading, self.base_style); 148 | 149 | self.curr_spans.push(tspan); 150 | self.curr_spans.insert(0, lspan); 151 | }, 152 | Alignment::Right => { 153 | let lspan = space_span(remaining, self.base_style); 154 | self.curr_spans.insert(0, lspan); 155 | }, 156 | } 157 | } 158 | 159 | self.push(); 160 | } 161 | 162 | fn push_str_wrapped(&mut self, s: T, style: Style) 163 | where 164 | T: Into>, 165 | { 166 | let style = self.base_style.patch(style); 167 | let mut cow = s.into(); 168 | 169 | loop { 170 | let sw = UnicodeWidthStr::width(cow.as_ref()); 171 | 172 | if self.curr_width + sw <= self.width { 173 | // The text fits within the current line. 174 | self.curr_spans.push(Span::styled(cow, style)); 175 | self.curr_width += sw; 176 | break; 177 | } 178 | 179 | // Take a leading portion of the text that fits in the line. 180 | let ((s0, w), s1) = take_width(cow, self.remaining()); 181 | cow = s1; 182 | 183 | self.curr_spans.push(Span::styled(s0, style)); 184 | self.curr_width += w; 185 | 186 | self.commit(); 187 | } 188 | 189 | if self.curr_width == self.width { 190 | // If the last bit fills the full line, start a new one. 191 | self.push(); 192 | } 193 | } 194 | 195 | /// Push a [Span] that isn't allowed to break across lines. 196 | pub fn push_span_nobreak(&mut self, mut span: Span<'a>) { 197 | if self.emoji_shortcodes() { 198 | replace_emojis_in_span(&mut span); 199 | } 200 | let sw = UnicodeWidthStr::width(span.content.as_ref()); 201 | 202 | if self.curr_width + sw > self.width { 203 | // Span doesn't fit on this line, so start a new one. 204 | self.commit(); 205 | } 206 | 207 | self.curr_spans.push(span); 208 | self.curr_width += sw; 209 | } 210 | 211 | /// Push text with a [Style]. 212 | pub fn push_str(&mut self, s: &'a str, style: Style) { 213 | let style = self.base_style.patch(style); 214 | 215 | if self.width == 0 { 216 | return; 217 | } 218 | 219 | for mut word in UnicodeSegmentation::split_word_bounds(s) { 220 | if let "\n" | "\r\n" = word { 221 | if self.literal { 222 | self.commit(); 223 | continue; 224 | } 225 | 226 | // Render embedded newlines as spaces. 227 | word = " "; 228 | } 229 | 230 | if !self.literal && self.curr_width == 0 && word.chars().all(char::is_whitespace) { 231 | // Drop leading whitespace. 232 | continue; 233 | } 234 | 235 | let cow = if self.emoji_shortcodes() { 236 | Cow::Owned(replace_emojis_in_str(word)) 237 | } else { 238 | Cow::Borrowed(word) 239 | }; 240 | let sw = UnicodeWidthStr::width(cow.as_ref()); 241 | 242 | if sw > self.width { 243 | self.push_str_wrapped(cow, style); 244 | continue; 245 | } 246 | 247 | if self.curr_width + sw > self.width { 248 | // Word doesn't fit on this line, so start a new one. 249 | self.commit(); 250 | 251 | if !self.literal && cow.chars().all(char::is_whitespace) { 252 | // Drop leading whitespace. 253 | continue; 254 | } 255 | } 256 | 257 | let span = Span::styled(cow, style); 258 | self.curr_spans.push(span); 259 | self.curr_width += sw; 260 | } 261 | 262 | if self.curr_width == self.width { 263 | // If the last bit fills the full line, start a new one. 264 | self.push(); 265 | } 266 | } 267 | 268 | /// Push a [Line] into the printer. 269 | pub fn push_line(&mut self, mut line: Line<'a>) { 270 | self.commit(); 271 | if self.emoji_shortcodes() { 272 | replace_emojis_in_line(&mut line); 273 | } 274 | self.text.lines.push(line); 275 | } 276 | 277 | /// Push multiline [Text] into the printer. 278 | pub fn push_text(&mut self, mut text: Text<'a>) { 279 | self.commit(); 280 | if self.emoji_shortcodes() { 281 | for line in &mut text.lines { 282 | replace_emojis_in_line(line); 283 | } 284 | } 285 | self.text.lines.extend(text.lines); 286 | } 287 | 288 | /// Render the contents of this printer as [Text]. 289 | pub fn finish(mut self) -> Text<'a> { 290 | self.commit(); 291 | self.text 292 | } 293 | } 294 | 295 | #[cfg(test)] 296 | pub mod tests { 297 | use super::*; 298 | use crate::tests::mock_settings; 299 | 300 | #[test] 301 | fn test_push_nobreak() { 302 | let settings = mock_settings(); 303 | let mut printer = TextPrinter::new(5, Style::default(), false, &settings); 304 | printer.push_span_nobreak("hello world".into()); 305 | let text = printer.finish(); 306 | assert_eq!(text.lines.len(), 1); 307 | assert_eq!(text.lines[0].spans.len(), 1); 308 | assert_eq!(text.lines[0].spans[0].content, "hello world"); 309 | } 310 | } 311 | -------------------------------------------------------------------------------- /src/notifications.rs: -------------------------------------------------------------------------------- 1 | use std::time::SystemTime; 2 | 3 | use matrix_sdk::{ 4 | deserialized_responses::RawAnySyncOrStrippedTimelineEvent, 5 | notification_settings::{IsEncrypted, IsOneToOne, NotificationSettings, RoomNotificationMode}, 6 | room::Room as MatrixRoom, 7 | ruma::{ 8 | events::{room::message::MessageType, AnyMessageLikeEventContent, AnySyncTimelineEvent}, 9 | serde::Raw, 10 | MilliSecondsSinceUnixEpoch, 11 | RoomId, 12 | }, 13 | Client, 14 | }; 15 | use unicode_segmentation::UnicodeSegmentation; 16 | 17 | use crate::{ 18 | base::{AsyncProgramStore, IambError, IambResult, ProgramStore}, 19 | config::{ApplicationSettings, NotifyVia}, 20 | }; 21 | 22 | const IAMB_XDG_NAME: &str = match option_env!("IAMB_XDG_NAME") { 23 | None => "iamb", 24 | Some(iamb) => iamb, 25 | }; 26 | 27 | pub async fn register_notifications( 28 | client: &Client, 29 | settings: &ApplicationSettings, 30 | store: &AsyncProgramStore, 31 | ) { 32 | if !settings.tunables.notifications.enabled { 33 | return; 34 | } 35 | let notify_via = settings.tunables.notifications.via; 36 | let show_message = settings.tunables.notifications.show_message; 37 | let server_settings = client.notification_settings().await; 38 | let Some(startup_ts) = MilliSecondsSinceUnixEpoch::from_system_time(SystemTime::now()) else { 39 | return; 40 | }; 41 | 42 | let store = store.clone(); 43 | client 44 | .register_notification_handler(move |notification, room: MatrixRoom, client: Client| { 45 | let store = store.clone(); 46 | let server_settings = server_settings.clone(); 47 | async move { 48 | let mode = global_or_room_mode(&server_settings, &room).await; 49 | if mode == RoomNotificationMode::Mute { 50 | return; 51 | } 52 | 53 | if is_visible_room(&store, room.room_id()).await { 54 | return; 55 | } 56 | 57 | match notification.event { 58 | RawAnySyncOrStrippedTimelineEvent::Sync(e) => { 59 | match parse_full_notification(e, room, show_message).await { 60 | Ok((summary, body, server_ts)) => { 61 | if server_ts < startup_ts { 62 | return; 63 | } 64 | 65 | if is_missing_mention(&body, mode, &client) { 66 | return; 67 | } 68 | 69 | send_notification(¬ify_via, &store, &summary, body.as_deref()) 70 | .await; 71 | }, 72 | Err(err) => { 73 | tracing::error!("Failed to extract notification data: {err}") 74 | }, 75 | } 76 | }, 77 | // Stripped events may be dropped silently because they're 78 | // only relevant if we're not in a room, and we presumably 79 | // don't want notifications for rooms we're not in. 80 | RawAnySyncOrStrippedTimelineEvent::Stripped(_) => (), 81 | } 82 | } 83 | }) 84 | .await; 85 | } 86 | 87 | async fn send_notification( 88 | via: &NotifyVia, 89 | store: &AsyncProgramStore, 90 | summary: &str, 91 | body: Option<&str>, 92 | ) { 93 | #[cfg(feature = "desktop")] 94 | if via.desktop { 95 | send_notification_desktop(summary, body); 96 | } 97 | #[cfg(not(feature = "desktop"))] 98 | { 99 | let _ = (summary, body, IAMB_XDG_NAME); 100 | } 101 | 102 | if via.bell { 103 | send_notification_bell(store).await; 104 | } 105 | } 106 | 107 | async fn send_notification_bell(store: &AsyncProgramStore) { 108 | let mut locked = store.lock().await; 109 | locked.application.ring_bell = true; 110 | } 111 | 112 | #[cfg(feature = "desktop")] 113 | fn send_notification_desktop(summary: &str, body: Option<&str>) { 114 | let mut desktop_notification = notify_rust::Notification::new(); 115 | desktop_notification 116 | .summary(summary) 117 | .appname(IAMB_XDG_NAME) 118 | .icon(IAMB_XDG_NAME) 119 | .action("default", "default"); 120 | 121 | #[cfg(all(unix, not(target_os = "macos")))] 122 | desktop_notification.urgency(notify_rust::Urgency::Normal); 123 | 124 | if let Some(body) = body { 125 | desktop_notification.body(body); 126 | } 127 | 128 | if let Err(err) = desktop_notification.show() { 129 | tracing::error!("Failed to send notification: {err}") 130 | } 131 | } 132 | 133 | async fn global_or_room_mode( 134 | settings: &NotificationSettings, 135 | room: &MatrixRoom, 136 | ) -> RoomNotificationMode { 137 | let room_mode = settings.get_user_defined_room_notification_mode(room.room_id()).await; 138 | if let Some(mode) = room_mode { 139 | return mode; 140 | } 141 | let is_one_to_one = match room.is_direct().await { 142 | Ok(true) => IsOneToOne::Yes, 143 | _ => IsOneToOne::No, 144 | }; 145 | let is_encrypted = match room.is_encrypted().await { 146 | Ok(true) => IsEncrypted::Yes, 147 | _ => IsEncrypted::No, 148 | }; 149 | settings 150 | .get_default_room_notification_mode(is_encrypted, is_one_to_one) 151 | .await 152 | } 153 | 154 | fn is_missing_mention(body: &Option, mode: RoomNotificationMode, client: &Client) -> bool { 155 | if let Some(body) = body { 156 | if mode == RoomNotificationMode::MentionsAndKeywordsOnly { 157 | let mentioned = match client.user_id() { 158 | Some(user_id) => body.contains(user_id.localpart()), 159 | _ => false, 160 | }; 161 | return !mentioned; 162 | } 163 | } 164 | false 165 | } 166 | 167 | fn is_open(locked: &mut ProgramStore, room_id: &RoomId) -> bool { 168 | if let Some(draw_curr) = locked.application.draw_curr { 169 | let info = locked.application.get_room_info(room_id.to_owned()); 170 | if let Some(draw_last) = info.draw_last { 171 | return draw_last == draw_curr; 172 | } 173 | } 174 | false 175 | } 176 | 177 | fn is_focused(locked: &ProgramStore) -> bool { 178 | locked.application.focused 179 | } 180 | 181 | async fn is_visible_room(store: &AsyncProgramStore, room_id: &RoomId) -> bool { 182 | let mut locked = store.lock().await; 183 | 184 | is_focused(&locked) && is_open(&mut locked, room_id) 185 | } 186 | 187 | pub async fn parse_full_notification( 188 | event: Raw, 189 | room: MatrixRoom, 190 | show_body: bool, 191 | ) -> IambResult<(String, Option, MilliSecondsSinceUnixEpoch)> { 192 | let event = event.deserialize().map_err(IambError::from)?; 193 | 194 | let server_ts = event.origin_server_ts(); 195 | 196 | let sender_id = event.sender(); 197 | let sender = room.get_member_no_sync(sender_id).await.map_err(IambError::from)?; 198 | 199 | let sender_name = sender 200 | .as_ref() 201 | .and_then(|m| m.display_name()) 202 | .unwrap_or_else(|| sender_id.localpart()); 203 | 204 | let summary = if let Some(room_name) = room.cached_display_name() { 205 | if room.is_direct().await.map_err(IambError::from)? && sender_name == room_name.to_string() 206 | { 207 | sender_name.to_string() 208 | } else { 209 | format!("{sender_name} in {room_name}") 210 | } 211 | } else { 212 | sender_name.to_string() 213 | }; 214 | 215 | let body = if show_body { 216 | event_notification_body(&event, sender_name).map(truncate) 217 | } else { 218 | None 219 | }; 220 | 221 | return Ok((summary, body, server_ts)); 222 | } 223 | 224 | pub fn event_notification_body(event: &AnySyncTimelineEvent, sender_name: &str) -> Option { 225 | let AnySyncTimelineEvent::MessageLike(event) = event else { 226 | return None; 227 | }; 228 | 229 | match event.original_content()? { 230 | AnyMessageLikeEventContent::RoomMessage(message) => { 231 | let body = match message.msgtype { 232 | MessageType::Audio(_) => { 233 | format!("{sender_name} sent an audio file.") 234 | }, 235 | MessageType::Emote(content) => content.body, 236 | MessageType::File(_) => { 237 | format!("{sender_name} sent a file.") 238 | }, 239 | MessageType::Image(_) => { 240 | format!("{sender_name} sent an image.") 241 | }, 242 | MessageType::Location(_) => { 243 | format!("{sender_name} sent their location.") 244 | }, 245 | MessageType::Notice(content) => content.body, 246 | MessageType::ServerNotice(content) => content.body, 247 | MessageType::Text(content) => content.body, 248 | MessageType::Video(_) => { 249 | format!("{sender_name} sent a video.") 250 | }, 251 | MessageType::VerificationRequest(_) => { 252 | format!("{sender_name} sent a verification request.") 253 | }, 254 | _ => { 255 | format!("[Unknown message type: {:?}]", &message.msgtype) 256 | }, 257 | }; 258 | Some(body) 259 | }, 260 | AnyMessageLikeEventContent::Sticker(_) => Some(format!("{sender_name} sent a sticker.")), 261 | _ => None, 262 | } 263 | } 264 | 265 | fn truncate(s: String) -> String { 266 | static MAX_LENGTH: usize = 5000; 267 | if s.graphemes(true).count() > MAX_LENGTH { 268 | let truncated: String = s.graphemes(true).take(MAX_LENGTH).collect(); 269 | truncated + "..." 270 | } else { 271 | s 272 | } 273 | } 274 | -------------------------------------------------------------------------------- /src/preview.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | fs::File, 3 | io::{Read, Write}, 4 | path::{Path, PathBuf}, 5 | }; 6 | 7 | use matrix_sdk::{ 8 | media::{MediaFormat, MediaRequestParameters}, 9 | ruma::{ 10 | events::{ 11 | room::{ 12 | message::{MessageType, RoomMessageEventContent}, 13 | MediaSource, 14 | }, 15 | MessageLikeEvent, 16 | }, 17 | OwnedEventId, 18 | OwnedRoomId, 19 | }, 20 | Media, 21 | }; 22 | use ratatui::layout::Rect; 23 | use ratatui_image::Resize; 24 | 25 | use crate::{ 26 | base::{AsyncProgramStore, ChatStore, IambError}, 27 | config::ImagePreviewSize, 28 | message::ImageStatus, 29 | }; 30 | 31 | pub fn source_from_event( 32 | ev: &MessageLikeEvent, 33 | ) -> Option<(OwnedEventId, MediaSource)> { 34 | if let MessageLikeEvent::Original(ev) = &ev { 35 | if let MessageType::Image(c) = &ev.content.msgtype { 36 | return Some((ev.event_id.clone(), c.source.clone())); 37 | } 38 | } 39 | None 40 | } 41 | 42 | impl From for Rect { 43 | fn from(value: ImagePreviewSize) -> Self { 44 | Rect::new(0, 0, value.width as _, value.height as _) 45 | } 46 | } 47 | impl From for ImagePreviewSize { 48 | fn from(rect: Rect) -> Self { 49 | ImagePreviewSize { width: rect.width as _, height: rect.height as _ } 50 | } 51 | } 52 | 53 | /// Download and prepare the preview, and then lock the store to insert it. 54 | pub fn spawn_insert_preview( 55 | store: AsyncProgramStore, 56 | room_id: OwnedRoomId, 57 | event_id: OwnedEventId, 58 | source: MediaSource, 59 | media: Media, 60 | cache_dir: PathBuf, 61 | ) { 62 | tokio::spawn(async move { 63 | let img = download_or_load(event_id.to_owned(), source, media, cache_dir) 64 | .await 65 | .map(std::io::Cursor::new) 66 | .map(image::ImageReader::new) 67 | .map_err(IambError::Matrix) 68 | .and_then(|reader| reader.with_guessed_format().map_err(IambError::IOError)) 69 | .and_then(|reader| reader.decode().map_err(IambError::Image)); 70 | 71 | match img { 72 | Err(err) => { 73 | try_set_msg_preview_error( 74 | &mut store.lock().await.application, 75 | room_id, 76 | event_id, 77 | err, 78 | ); 79 | }, 80 | Ok(img) => { 81 | let mut locked = store.lock().await; 82 | let ChatStore { rooms, picker, settings, .. } = &mut locked.application; 83 | 84 | match picker 85 | .as_mut() 86 | .ok_or_else(|| IambError::Preview("Picker is empty".to_string())) 87 | .and_then(|picker| { 88 | Ok(( 89 | picker, 90 | rooms 91 | .get_or_default(room_id.clone()) 92 | .get_event_mut(&event_id) 93 | .ok_or_else(|| { 94 | IambError::Preview("Message not found".to_string()) 95 | })?, 96 | settings.tunables.image_preview.clone().ok_or_else(|| { 97 | IambError::Preview("image_preview settings not found".to_string()) 98 | })?, 99 | )) 100 | }) 101 | .and_then(|(picker, msg, image_preview)| { 102 | picker 103 | .new_protocol(img, image_preview.size.into(), Resize::Fit(None)) 104 | .map_err(|err| IambError::Preview(format!("{err:?}"))) 105 | .map(|backend| (backend, msg)) 106 | }) { 107 | Err(err) => { 108 | try_set_msg_preview_error(&mut locked.application, room_id, event_id, err); 109 | }, 110 | Ok((backend, msg)) => { 111 | msg.image_preview = ImageStatus::Loaded(backend); 112 | }, 113 | } 114 | }, 115 | } 116 | }); 117 | } 118 | 119 | fn try_set_msg_preview_error( 120 | application: &mut ChatStore, 121 | room_id: OwnedRoomId, 122 | event_id: OwnedEventId, 123 | err: IambError, 124 | ) { 125 | let rooms = &mut application.rooms; 126 | 127 | match rooms 128 | .get_or_default(room_id.clone()) 129 | .get_event_mut(&event_id) 130 | .ok_or_else(|| IambError::Preview("Message not found".to_string())) 131 | { 132 | Ok(msg) => msg.image_preview = ImageStatus::Error(format!("{err:?}")), 133 | Err(err) => { 134 | tracing::error!( 135 | "Failed to set error on msg.image_backend for event {}, room {}: {}", 136 | event_id, 137 | room_id, 138 | err 139 | ) 140 | }, 141 | } 142 | } 143 | 144 | async fn download_or_load( 145 | event_id: OwnedEventId, 146 | source: MediaSource, 147 | media: Media, 148 | mut cache_path: PathBuf, 149 | ) -> Result, matrix_sdk::Error> { 150 | cache_path.push(Path::new(event_id.localpart())); 151 | 152 | match File::open(&cache_path) { 153 | Ok(mut f) => { 154 | let mut buffer = Vec::new(); 155 | f.read_to_end(&mut buffer)?; 156 | Ok(buffer) 157 | }, 158 | Err(_) => { 159 | media 160 | .get_media_content( 161 | &MediaRequestParameters { source, format: MediaFormat::File }, 162 | true, 163 | ) 164 | .await 165 | .and_then(|buffer| { 166 | if let Err(err) = 167 | File::create(&cache_path).and_then(|mut f| f.write_all(&buffer)) 168 | { 169 | return Err(err.into()); 170 | } 171 | Ok(buffer) 172 | }) 173 | }, 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /src/sled_export.rs: -------------------------------------------------------------------------------- 1 | //! # sled -> sqlite migration code 2 | //! 3 | //! Before the 0.0.9 release, iamb used matrix-sdk@0.6.2, which used [sled] 4 | //! for storing information, including room keys. In matrix-sdk@0.7.0, 5 | //! the SDK switched to using SQLite. This module takes care of opening 6 | //! sled, exporting the inbound group sessions used for decryption, 7 | //! and importing them into SQLite. 8 | //! 9 | //! This code will eventually be removed once people have been given enough 10 | //! time to upgrade off of pre-0.0.9 versions. 11 | //! 12 | //! [sled]: https://docs.rs/sled/0.34.7/sled/index.html 13 | use sled::{Config, IVec}; 14 | use std::path::Path; 15 | 16 | use crate::base::IambError; 17 | use matrix_sdk::crypto::olm::{ExportedRoomKey, InboundGroupSession, PickledInboundGroupSession}; 18 | 19 | #[derive(Debug, thiserror::Error)] 20 | pub enum SledMigrationError { 21 | #[error("sled failure: {0}")] 22 | Sled(#[from] sled::Error), 23 | 24 | #[error("deserialization failure: {0}")] 25 | Deserialize(#[from] serde_json::Error), 26 | } 27 | 28 | fn group_session_from_slice( 29 | (_, bytes): (IVec, IVec), 30 | ) -> Result { 31 | serde_json::from_slice(&bytes).map_err(SledMigrationError::from) 32 | } 33 | 34 | async fn export_room_keys_priv( 35 | sled_dir: &Path, 36 | ) -> Result, SledMigrationError> { 37 | let path = sled_dir.join("matrix-sdk-state"); 38 | let store = Config::new().temporary(false).path(&path).open()?; 39 | let inbound_groups = store.open_tree("inbound_group_sessions")?; 40 | 41 | let mut exported = vec![]; 42 | let sessions = inbound_groups 43 | .iter() 44 | .map(|p| p.map_err(SledMigrationError::from).and_then(group_session_from_slice)) 45 | .collect::, _>>()? 46 | .into_iter() 47 | .filter_map(|p| InboundGroupSession::from_pickle(p).ok()); 48 | 49 | for session in sessions { 50 | exported.push(session.export().await); 51 | } 52 | 53 | Ok(exported) 54 | } 55 | 56 | pub async fn export_room_keys(sled_dir: &Path) -> Result, IambError> { 57 | export_room_keys_priv(sled_dir).await.map_err(IambError::from) 58 | } 59 | -------------------------------------------------------------------------------- /src/tests.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | use std::path::PathBuf; 3 | 4 | use matrix_sdk::ruma::{ 5 | event_id, 6 | events::room::message::{OriginalRoomMessageEvent, RoomMessageEventContent}, 7 | server_name, 8 | user_id, 9 | EventId, 10 | OwnedEventId, 11 | OwnedRoomId, 12 | OwnedUserId, 13 | RoomId, 14 | UInt, 15 | }; 16 | 17 | use lazy_static::lazy_static; 18 | use ratatui::style::{Color, Style}; 19 | use tokio::sync::mpsc::unbounded_channel; 20 | use tracing::Level; 21 | use url::Url; 22 | 23 | use crate::{ 24 | base::{ChatStore, EventLocation, ProgramStore, RoomInfo}, 25 | config::{ 26 | user_color, 27 | user_style_from_color, 28 | ApplicationSettings, 29 | DirectoryValues, 30 | Notifications, 31 | NotifyVia, 32 | ProfileConfig, 33 | SortOverrides, 34 | TunableValues, 35 | UserColor, 36 | UserDisplayStyle, 37 | UserDisplayTunables, 38 | }, 39 | message::{ 40 | Message, 41 | MessageEvent, 42 | MessageKey, 43 | MessageTimeStamp::{LocalEcho, OriginServer}, 44 | Messages, 45 | }, 46 | worker::Requester, 47 | }; 48 | 49 | const TEST_ROOM1_ALIAS: &str = "#room1:example.com"; 50 | 51 | lazy_static! { 52 | pub static ref TEST_ROOM1_ID: OwnedRoomId = RoomId::new(server_name!("example.com")).to_owned(); 53 | pub static ref TEST_USER1: OwnedUserId = user_id!("@user1:example.com").to_owned(); 54 | pub static ref TEST_USER2: OwnedUserId = user_id!("@user2:example.com").to_owned(); 55 | pub static ref TEST_USER3: OwnedUserId = user_id!("@user3:example.com").to_owned(); 56 | pub static ref TEST_USER4: OwnedUserId = user_id!("@user4:example.com").to_owned(); 57 | pub static ref TEST_USER5: OwnedUserId = user_id!("@user5:example.com").to_owned(); 58 | pub static ref MSG1_EVID: OwnedEventId = EventId::new(server_name!("example.com")); 59 | pub static ref MSG2_EVID: OwnedEventId = EventId::new(server_name!("example.com")); 60 | pub static ref MSG3_EVID: OwnedEventId = 61 | event_id!("$5jRz3KfVhaUzXtVj7k:example.com").to_owned(); 62 | pub static ref MSG4_EVID: OwnedEventId = 63 | event_id!("$JP6qFV7WyXk5ZnexM3:example.com").to_owned(); 64 | pub static ref MSG5_EVID: OwnedEventId = EventId::new(server_name!("example.com")); 65 | pub static ref MSG1_KEY: MessageKey = (LocalEcho, MSG1_EVID.clone()); 66 | pub static ref MSG2_KEY: MessageKey = (OriginServer(UInt::new(1).unwrap()), MSG2_EVID.clone()); 67 | pub static ref MSG3_KEY: MessageKey = (OriginServer(UInt::new(2).unwrap()), MSG3_EVID.clone()); 68 | pub static ref MSG4_KEY: MessageKey = (OriginServer(UInt::new(2).unwrap()), MSG4_EVID.clone()); 69 | pub static ref MSG5_KEY: MessageKey = (OriginServer(UInt::new(8).unwrap()), MSG5_EVID.clone()); 70 | } 71 | 72 | pub fn user_style(user: &str) -> Style { 73 | user_style_from_color(user_color(user)) 74 | } 75 | 76 | pub fn mock_room1_message( 77 | content: RoomMessageEventContent, 78 | sender: OwnedUserId, 79 | key: MessageKey, 80 | ) -> Message { 81 | let origin_server_ts = key.0.as_millis().unwrap(); 82 | let event_id = key.1; 83 | 84 | let event = OriginalRoomMessageEvent { 85 | content, 86 | event_id, 87 | sender, 88 | origin_server_ts, 89 | room_id: TEST_ROOM1_ID.clone(), 90 | unsigned: Default::default(), 91 | }; 92 | 93 | event.into() 94 | } 95 | 96 | pub fn mock_message1() -> Message { 97 | let content = RoomMessageEventContent::text_plain("writhe"); 98 | let content = MessageEvent::Local(MSG1_EVID.clone(), content.into()); 99 | 100 | Message::new(content, TEST_USER1.clone(), MSG1_KEY.0) 101 | } 102 | 103 | pub fn mock_message2() -> Message { 104 | let content = RoomMessageEventContent::text_plain("helium"); 105 | 106 | mock_room1_message(content, TEST_USER2.clone(), MSG2_KEY.clone()) 107 | } 108 | 109 | pub fn mock_message3() -> Message { 110 | let content = RoomMessageEventContent::text_plain("this\nis\na\nmultiline\nmessage"); 111 | 112 | mock_room1_message(content, TEST_USER2.clone(), MSG3_KEY.clone()) 113 | } 114 | 115 | pub fn mock_message4() -> Message { 116 | let content = RoomMessageEventContent::text_plain("help"); 117 | 118 | mock_room1_message(content, TEST_USER1.clone(), MSG4_KEY.clone()) 119 | } 120 | 121 | pub fn mock_message5() -> Message { 122 | let content = RoomMessageEventContent::text_plain("character"); 123 | 124 | mock_room1_message(content, TEST_USER2.clone(), MSG4_KEY.clone()) 125 | } 126 | 127 | pub fn mock_keys() -> HashMap { 128 | let mut keys = HashMap::new(); 129 | 130 | keys.insert(MSG1_EVID.clone(), EventLocation::Message(None, MSG1_KEY.clone())); 131 | keys.insert(MSG2_EVID.clone(), EventLocation::Message(None, MSG2_KEY.clone())); 132 | keys.insert(MSG3_EVID.clone(), EventLocation::Message(None, MSG3_KEY.clone())); 133 | keys.insert(MSG4_EVID.clone(), EventLocation::Message(None, MSG4_KEY.clone())); 134 | keys.insert(MSG5_EVID.clone(), EventLocation::Message(None, MSG5_KEY.clone())); 135 | 136 | keys 137 | } 138 | 139 | pub fn mock_messages() -> Messages { 140 | let mut messages = Messages::main(); 141 | 142 | messages.insert(MSG1_KEY.clone(), mock_message1()); 143 | messages.insert(MSG2_KEY.clone(), mock_message2()); 144 | messages.insert(MSG3_KEY.clone(), mock_message3()); 145 | messages.insert(MSG4_KEY.clone(), mock_message4()); 146 | messages.insert(MSG5_KEY.clone(), mock_message5()); 147 | 148 | messages 149 | } 150 | 151 | pub fn mock_room() -> RoomInfo { 152 | let mut room = RoomInfo::default(); 153 | room.name = Some("Watercooler Discussion".into()); 154 | room.keys = mock_keys(); 155 | *room.get_thread_mut(None) = mock_messages(); 156 | room 157 | } 158 | 159 | pub fn mock_dirs() -> DirectoryValues { 160 | DirectoryValues { 161 | cache: PathBuf::new(), 162 | data: PathBuf::new(), 163 | logs: PathBuf::new(), 164 | downloads: None, 165 | image_previews: PathBuf::new(), 166 | } 167 | } 168 | 169 | pub fn mock_tunables() -> TunableValues { 170 | TunableValues { 171 | default_room: None, 172 | log_level: Level::INFO, 173 | message_shortcode_display: false, 174 | reaction_display: true, 175 | reaction_shortcode_display: false, 176 | read_receipt_send: true, 177 | read_receipt_display: true, 178 | request_timeout: 120, 179 | sort: SortOverrides::default().values(), 180 | state_event_display: true, 181 | typing_notice_send: true, 182 | typing_notice_display: true, 183 | users: vec![(TEST_USER5.clone(), UserDisplayTunables { 184 | color: Some(UserColor(Color::Black)), 185 | name: Some("USER 5".into()), 186 | })] 187 | .into_iter() 188 | .collect::>(), 189 | open_command: None, 190 | external_edit_file_suffix: String::from(".md"), 191 | username_display: UserDisplayStyle::Username, 192 | message_user_color: false, 193 | mouse: Default::default(), 194 | notifications: Notifications { 195 | enabled: false, 196 | via: NotifyVia::default(), 197 | show_message: true, 198 | }, 199 | image_preview: None, 200 | user_gutter_width: 30, 201 | } 202 | } 203 | 204 | pub fn mock_settings() -> ApplicationSettings { 205 | ApplicationSettings { 206 | layout_json: PathBuf::new(), 207 | session_json: PathBuf::new(), 208 | session_json_old: PathBuf::new(), 209 | sled_dir: PathBuf::new(), 210 | sqlite_dir: PathBuf::new(), 211 | 212 | profile_name: "test".into(), 213 | profile: ProfileConfig { 214 | user_id: user_id!("@user:example.com").to_owned(), 215 | url: None, 216 | settings: None, 217 | dirs: None, 218 | layout: None, 219 | macros: None, 220 | }, 221 | tunables: mock_tunables(), 222 | dirs: mock_dirs(), 223 | layout: Default::default(), 224 | macros: HashMap::default(), 225 | } 226 | } 227 | 228 | pub async fn mock_store() -> ProgramStore { 229 | let (tx, _) = unbounded_channel(); 230 | let homeserver = Url::parse("https://localhost").unwrap(); 231 | let client = matrix_sdk::Client::new(homeserver).await.unwrap(); 232 | let worker = Requester { tx, client }; 233 | 234 | let mut store = ChatStore::new(worker, mock_settings()); 235 | 236 | // Add presence information. 237 | store.presences.get_or_default(TEST_USER1.clone()); 238 | store.presences.get_or_default(TEST_USER2.clone()); 239 | store.presences.get_or_default(TEST_USER3.clone()); 240 | store.presences.get_or_default(TEST_USER4.clone()); 241 | store.presences.get_or_default(TEST_USER5.clone()); 242 | 243 | let room_id = TEST_ROOM1_ID.clone(); 244 | let info = mock_room(); 245 | 246 | store.rooms.insert(room_id.clone(), info); 247 | store.names.insert(TEST_ROOM1_ALIAS.to_string(), room_id); 248 | 249 | ProgramStore::new(store) 250 | } 251 | -------------------------------------------------------------------------------- /src/util.rs: -------------------------------------------------------------------------------- 1 | //! # Utility functions 2 | use std::borrow::Cow; 3 | 4 | use unicode_segmentation::UnicodeSegmentation; 5 | use unicode_width::UnicodeWidthStr; 6 | 7 | use ratatui::style::Style; 8 | use ratatui::text::{Line, Span, Text}; 9 | 10 | pub fn split_cow(cow: Cow<'_, str>, idx: usize) -> (Cow<'_, str>, Cow<'_, str>) { 11 | match cow { 12 | Cow::Borrowed(s) => { 13 | let s1 = Cow::Borrowed(&s[idx..]); 14 | let s0 = Cow::Borrowed(&s[..idx]); 15 | 16 | (s0, s1) 17 | }, 18 | Cow::Owned(mut s) => { 19 | let s1 = Cow::Owned(s.split_off(idx)); 20 | let s0 = Cow::Owned(s); 21 | 22 | (s0, s1) 23 | }, 24 | } 25 | } 26 | 27 | pub fn take_width(s: Cow<'_, str>, width: usize) -> ((Cow<'_, str>, usize), Cow<'_, str>) { 28 | // Find where to split the line. 29 | let mut w = 0; 30 | 31 | let idx = UnicodeSegmentation::grapheme_indices(s.as_ref(), true) 32 | .find_map(|(i, g)| { 33 | let gw = UnicodeWidthStr::width(g); 34 | if w + gw > width { 35 | Some(i) 36 | } else { 37 | w += gw; 38 | None 39 | } 40 | }) 41 | .unwrap_or(s.len()); 42 | 43 | let (s0, s1) = split_cow(s, idx); 44 | 45 | ((s0, w), s1) 46 | } 47 | 48 | pub struct WrappedLinesIterator<'a> { 49 | iter: std::vec::IntoIter>, 50 | curr: Option>, 51 | width: usize, 52 | } 53 | 54 | impl<'a> WrappedLinesIterator<'a> { 55 | fn new(input: T, width: usize) -> Self 56 | where 57 | T: Into>, 58 | { 59 | let width = width.max(2); 60 | 61 | let cows: Vec> = match input.into() { 62 | Cow::Borrowed(s) => s.lines().map(Cow::Borrowed).collect(), 63 | Cow::Owned(s) => s.lines().map(ToOwned::to_owned).map(Cow::Owned).collect(), 64 | }; 65 | 66 | WrappedLinesIterator { iter: cows.into_iter(), curr: None, width } 67 | } 68 | } 69 | 70 | impl<'a> Iterator for WrappedLinesIterator<'a> { 71 | type Item = (Cow<'a, str>, usize); 72 | 73 | fn next(&mut self) -> Option { 74 | if self.curr.is_none() { 75 | self.curr = self.iter.next(); 76 | } 77 | 78 | if let Some(s) = self.curr.take() { 79 | let width = UnicodeWidthStr::width(s.as_ref()); 80 | 81 | if width <= self.width { 82 | return Some((s, width)); 83 | } else { 84 | let (prefix, s1) = take_width(s, self.width); 85 | self.curr = Some(s1); 86 | return Some(prefix); 87 | } 88 | } else { 89 | return None; 90 | } 91 | } 92 | } 93 | 94 | pub fn wrap<'a, T>(input: T, width: usize) -> WrappedLinesIterator<'a> 95 | where 96 | T: Into>, 97 | { 98 | WrappedLinesIterator::new(input, width) 99 | } 100 | 101 | pub fn wrapped_text<'a, T>(s: T, width: usize, style: Style) -> Text<'a> 102 | where 103 | T: Into>, 104 | { 105 | let mut text = Text::default(); 106 | 107 | for (line, w) in wrap(s, width) { 108 | let space = space_span(width.saturating_sub(w), style); 109 | let spans = Line::from(vec![Span::styled(line, style), space]); 110 | 111 | text.lines.push(spans); 112 | } 113 | 114 | return text; 115 | } 116 | 117 | pub fn space(width: usize) -> String { 118 | " ".repeat(width) 119 | } 120 | 121 | pub fn space_span(width: usize, style: Style) -> Span<'static> { 122 | Span::styled(space(width), style) 123 | } 124 | 125 | pub fn space_text(width: usize, style: Style) -> Text<'static> { 126 | space_span(width, style).into() 127 | } 128 | 129 | pub fn join_cell_text<'a>(texts: Vec<(Text<'a>, usize)>, join: Span<'a>, style: Style) -> Text<'a> { 130 | let height = texts.iter().map(|t| t.0.height()).max().unwrap_or(0); 131 | let mut text = Text::from(vec![Line::from(vec![join.clone()]); height]); 132 | 133 | for (mut t, w) in texts.into_iter() { 134 | for i in 0..height { 135 | if let Some(line) = t.lines.get_mut(i) { 136 | text.lines[i].spans.append(&mut line.spans); 137 | } else { 138 | text.lines[i].spans.push(space_span(w, style)); 139 | } 140 | 141 | text.lines[i].spans.push(join.clone()); 142 | } 143 | } 144 | 145 | text 146 | } 147 | 148 | fn replace_emoji_in_grapheme(grapheme: &str) -> String { 149 | emojis::get(grapheme) 150 | .and_then(|emoji| emoji.shortcode()) 151 | .map(|shortcode| format!(":{shortcode}:")) 152 | .unwrap_or_else(|| grapheme.to_owned()) 153 | } 154 | 155 | pub fn replace_emojis_in_str(s: &str) -> String { 156 | let graphemes = s.graphemes(true); 157 | graphemes.map(replace_emoji_in_grapheme).collect() 158 | } 159 | 160 | pub fn replace_emojis_in_span(span: &mut Span) { 161 | span.content = Cow::Owned(replace_emojis_in_str(span.content.as_ref())) 162 | } 163 | 164 | pub fn replace_emojis_in_line(line: &mut Line) { 165 | for span in &mut line.spans { 166 | replace_emojis_in_span(span); 167 | } 168 | } 169 | 170 | #[cfg(test)] 171 | pub mod tests { 172 | use super::*; 173 | 174 | #[test] 175 | fn test_wrapped_lines_ascii() { 176 | let s = "hello world!\nabcdefghijklmnopqrstuvwxyz\ngoodbye"; 177 | 178 | let mut iter = wrap(s, 100); 179 | assert_eq!(iter.next(), Some((Cow::Borrowed("hello world!"), 12))); 180 | assert_eq!(iter.next(), Some((Cow::Borrowed("abcdefghijklmnopqrstuvwxyz"), 26))); 181 | assert_eq!(iter.next(), Some((Cow::Borrowed("goodbye"), 7))); 182 | assert_eq!(iter.next(), None); 183 | 184 | let mut iter = wrap(s, 5); 185 | assert_eq!(iter.next(), Some((Cow::Borrowed("hello"), 5))); 186 | assert_eq!(iter.next(), Some((Cow::Borrowed(" worl"), 5))); 187 | assert_eq!(iter.next(), Some((Cow::Borrowed("d!"), 2))); 188 | assert_eq!(iter.next(), Some((Cow::Borrowed("abcde"), 5))); 189 | assert_eq!(iter.next(), Some((Cow::Borrowed("fghij"), 5))); 190 | assert_eq!(iter.next(), Some((Cow::Borrowed("klmno"), 5))); 191 | assert_eq!(iter.next(), Some((Cow::Borrowed("pqrst"), 5))); 192 | assert_eq!(iter.next(), Some((Cow::Borrowed("uvwxy"), 5))); 193 | assert_eq!(iter.next(), Some((Cow::Borrowed("z"), 1))); 194 | assert_eq!(iter.next(), Some((Cow::Borrowed("goodb"), 5))); 195 | assert_eq!(iter.next(), Some((Cow::Borrowed("ye"), 2))); 196 | assert_eq!(iter.next(), None); 197 | } 198 | 199 | #[test] 200 | fn test_wrapped_lines_unicode() { 201 | let s = "CHICKEN"; 202 | 203 | let mut iter = wrap(s, 14); 204 | assert_eq!(iter.next(), Some((Cow::Borrowed(s), 14))); 205 | assert_eq!(iter.next(), None); 206 | 207 | let mut iter = wrap(s, 5); 208 | assert_eq!(iter.next(), Some((Cow::Borrowed("CH"), 4))); 209 | assert_eq!(iter.next(), Some((Cow::Borrowed("IC"), 4))); 210 | assert_eq!(iter.next(), Some((Cow::Borrowed("KE"), 4))); 211 | assert_eq!(iter.next(), Some((Cow::Borrowed("N"), 2))); 212 | assert_eq!(iter.next(), None); 213 | } 214 | } 215 | -------------------------------------------------------------------------------- /src/windows/room/mod.rs: -------------------------------------------------------------------------------- 1 | //! # Windows for Matrix rooms and spaces 2 | use std::collections::HashSet; 3 | 4 | use matrix_sdk::{ 5 | notification_settings::RoomNotificationMode, 6 | room::Room as MatrixRoom, 7 | ruma::{ 8 | api::client::{ 9 | alias::{ 10 | create_alias::v3::Request as CreateAliasRequest, 11 | delete_alias::v3::Request as DeleteAliasRequest, 12 | }, 13 | error::ErrorKind as ClientApiErrorKind, 14 | }, 15 | events::{ 16 | room::{ 17 | canonical_alias::RoomCanonicalAliasEventContent, 18 | history_visibility::{HistoryVisibility, RoomHistoryVisibilityEventContent}, 19 | name::RoomNameEventContent, 20 | topic::RoomTopicEventContent, 21 | }, 22 | tag::{TagInfo, Tags}, 23 | }, 24 | OwnedEventId, 25 | OwnedRoomAliasId, 26 | OwnedUserId, 27 | RoomId, 28 | }, 29 | RoomDisplayName, 30 | RoomState as MatrixRoomState, 31 | }; 32 | 33 | use ratatui::{ 34 | buffer::Buffer, 35 | layout::{Alignment, Rect}, 36 | style::{Modifier as StyleModifier, Style}, 37 | text::{Line, Span, Text}, 38 | widgets::{Paragraph, StatefulWidget, Widget}, 39 | }; 40 | 41 | use modalkit::actions::{ 42 | Action, 43 | Editable, 44 | EditorAction, 45 | Jumpable, 46 | PromptAction, 47 | Promptable, 48 | Scrollable, 49 | }; 50 | use modalkit::errors::{EditResult, UIError}; 51 | use modalkit::prelude::*; 52 | use modalkit::{editing::completion::CompletionList, keybindings::dialog::PromptYesNo}; 53 | use modalkit_ratatui::{TermOffset, TerminalCursor, WindowOps}; 54 | 55 | use crate::base::{ 56 | IambAction, 57 | IambError, 58 | IambId, 59 | IambInfo, 60 | IambResult, 61 | MemberUpdateAction, 62 | MessageAction, 63 | ProgramAction, 64 | ProgramContext, 65 | ProgramStore, 66 | RoomAction, 67 | RoomField, 68 | SendAction, 69 | SpaceAction, 70 | }; 71 | 72 | use self::chat::ChatState; 73 | use self::space::{Space, SpaceState}; 74 | 75 | use std::convert::TryFrom; 76 | 77 | mod chat; 78 | mod scrollback; 79 | mod space; 80 | 81 | macro_rules! delegate { 82 | ($s: expr, $id: ident => $e: expr) => { 83 | match $s { 84 | RoomState::Chat($id) => $e, 85 | RoomState::Space($id) => $e, 86 | } 87 | }; 88 | } 89 | 90 | fn notification_mode(name: impl Into) -> IambResult { 91 | let name = name.into(); 92 | 93 | let mode = match name.to_lowercase().as_str() { 94 | "mute" => RoomNotificationMode::Mute, 95 | "mentions" | "keywords" => RoomNotificationMode::MentionsAndKeywordsOnly, 96 | "all" => RoomNotificationMode::AllMessages, 97 | _ => return Err(IambError::InvalidNotificationLevel(name).into()), 98 | }; 99 | 100 | Ok(mode) 101 | } 102 | 103 | fn hist_visibility_mode(name: impl Into) -> IambResult { 104 | let name = name.into(); 105 | 106 | let mode = match name.to_lowercase().as_str() { 107 | "invited" => HistoryVisibility::Invited, 108 | "joined" => HistoryVisibility::Joined, 109 | "shared" => HistoryVisibility::Shared, 110 | "world" | "world_readable" => HistoryVisibility::WorldReadable, 111 | _ => return Err(IambError::InvalidHistoryVisibility(name).into()), 112 | }; 113 | 114 | Ok(mode) 115 | } 116 | 117 | /// State for a Matrix room or space. 118 | /// 119 | /// Since spaces function as special rooms within Matrix, we wrap their window state together, so 120 | /// that operations like sending and accepting invites, opening the members window, etc., all work 121 | /// similarly. 122 | pub enum RoomState { 123 | Chat(ChatState), 124 | Space(SpaceState), 125 | } 126 | 127 | impl From for RoomState { 128 | fn from(chat: ChatState) -> Self { 129 | RoomState::Chat(chat) 130 | } 131 | } 132 | 133 | impl From for RoomState { 134 | fn from(space: SpaceState) -> Self { 135 | RoomState::Space(space) 136 | } 137 | } 138 | 139 | impl RoomState { 140 | pub fn new( 141 | room: MatrixRoom, 142 | thread: Option, 143 | name: RoomDisplayName, 144 | tags: Option, 145 | store: &mut ProgramStore, 146 | ) -> Self { 147 | let room_id = room.room_id().to_owned(); 148 | let info = store.application.get_room_info(room_id); 149 | info.name = name.to_string().into(); 150 | info.tags = tags; 151 | 152 | if room.is_space() { 153 | SpaceState::new(room).into() 154 | } else { 155 | ChatState::new(room, thread, store).into() 156 | } 157 | } 158 | 159 | pub fn thread(&self) -> Option<&OwnedEventId> { 160 | match self { 161 | RoomState::Chat(chat) => chat.thread(), 162 | RoomState::Space(_) => None, 163 | } 164 | } 165 | 166 | pub fn refresh_room(&mut self, store: &mut ProgramStore) { 167 | match self { 168 | RoomState::Chat(chat) => chat.refresh_room(store), 169 | RoomState::Space(space) => space.refresh_room(store), 170 | } 171 | } 172 | 173 | fn draw_invite( 174 | &self, 175 | invited: MatrixRoom, 176 | area: Rect, 177 | buf: &mut Buffer, 178 | store: &mut ProgramStore, 179 | ) { 180 | let inviter = store.application.worker.get_inviter(invited.clone()); 181 | 182 | let name = match invited.canonical_alias() { 183 | Some(alias) => alias.to_string(), 184 | None => format!("{:?}", store.application.get_room_title(self.id())), 185 | }; 186 | 187 | let mut invited = vec![Span::from(format!("You have been invited to join {name}"))]; 188 | 189 | if let Ok(Some(inviter)) = &inviter { 190 | let info = store.application.rooms.get_or_default(self.id().to_owned()); 191 | invited.push(Span::from(" by ")); 192 | invited.push(store.application.settings.get_user_span(inviter.user_id(), info)); 193 | } 194 | 195 | let l1 = Line::from(invited); 196 | let l2 = Line::from( 197 | "You can run `:invite accept` or `:invite reject` to accept or reject this invitation.", 198 | ); 199 | let text = Text::from(vec![l1, l2]); 200 | 201 | Paragraph::new(text).alignment(Alignment::Center).render(area, buf); 202 | 203 | return; 204 | } 205 | 206 | pub async fn message_command( 207 | &mut self, 208 | act: MessageAction, 209 | ctx: ProgramContext, 210 | store: &mut ProgramStore, 211 | ) -> IambResult { 212 | match self { 213 | RoomState::Chat(chat) => chat.message_command(act, ctx, store).await, 214 | RoomState::Space(_) => Err(IambError::NoSelectedMessage.into()), 215 | } 216 | } 217 | 218 | pub async fn space_command( 219 | &mut self, 220 | act: SpaceAction, 221 | ctx: ProgramContext, 222 | store: &mut ProgramStore, 223 | ) -> IambResult { 224 | match self { 225 | RoomState::Space(space) => space.space_command(act, ctx, store).await, 226 | RoomState::Chat(_) => Err(IambError::NoSelectedSpace.into()), 227 | } 228 | } 229 | 230 | pub async fn send_command( 231 | &mut self, 232 | act: SendAction, 233 | ctx: ProgramContext, 234 | store: &mut ProgramStore, 235 | ) -> IambResult { 236 | match self { 237 | RoomState::Chat(chat) => chat.send_command(act, ctx, store).await, 238 | RoomState::Space(_) => Err(IambError::NoSelectedRoom.into()), 239 | } 240 | } 241 | 242 | pub async fn room_command( 243 | &mut self, 244 | act: RoomAction, 245 | ctx: ProgramContext, 246 | store: &mut ProgramStore, 247 | ) -> IambResult, ProgramContext)>> { 248 | match act { 249 | RoomAction::InviteAccept => { 250 | if let Some(room) = store.application.worker.client.get_room(self.id()) { 251 | let details = room.invite_details().await.map_err(IambError::from)?; 252 | let details = details.invitee.event().original_content(); 253 | let is_direct = details.and_then(|ev| ev.is_direct).unwrap_or_default(); 254 | 255 | room.join().await.map_err(IambError::from)?; 256 | 257 | if is_direct { 258 | room.set_is_direct(true).await.map_err(IambError::from)?; 259 | } 260 | 261 | Ok(vec![]) 262 | } else { 263 | Err(IambError::NotInvited.into()) 264 | } 265 | }, 266 | RoomAction::InviteReject => { 267 | if let Some(room) = store.application.worker.client.get_room(self.id()) { 268 | room.leave().await.map_err(IambError::from)?; 269 | 270 | Ok(vec![]) 271 | } else { 272 | Err(IambError::NotInvited.into()) 273 | } 274 | }, 275 | RoomAction::InviteSend(user) => { 276 | if let Some(room) = store.application.worker.client.get_room(self.id()) { 277 | room.invite_user_by_id(user.as_ref()).await.map_err(IambError::from)?; 278 | 279 | Ok(vec![]) 280 | } else { 281 | Err(IambError::NotJoined.into()) 282 | } 283 | }, 284 | RoomAction::Leave(skip_confirm) => { 285 | if let Some(room) = store.application.worker.client.get_room(self.id()) { 286 | if skip_confirm { 287 | room.leave().await.map_err(IambError::from)?; 288 | 289 | Ok(vec![]) 290 | } else { 291 | let msg = "Do you really want to leave this room?"; 292 | let leave = IambAction::Room(RoomAction::Leave(true)); 293 | let prompt = PromptYesNo::new(msg, vec![Action::from(leave)]); 294 | let prompt = Box::new(prompt); 295 | 296 | Err(UIError::NeedConfirm(prompt)) 297 | } 298 | } else { 299 | Err(IambError::NotJoined.into()) 300 | } 301 | }, 302 | RoomAction::MemberUpdate(mua, user, reason, skip_confirm) => { 303 | let Some(room) = store.application.worker.client.get_room(self.id()) else { 304 | return Err(IambError::NotJoined.into()); 305 | }; 306 | 307 | let Ok(user_id) = OwnedUserId::try_from(user.as_str()) else { 308 | let err = IambError::InvalidUserId(user); 309 | 310 | return Err(err.into()); 311 | }; 312 | 313 | if !skip_confirm { 314 | let msg = format!("Do you really want to {mua} {user} from this room?"); 315 | let act = RoomAction::MemberUpdate(mua, user, reason, true); 316 | let act = IambAction::from(act); 317 | let prompt = PromptYesNo::new(msg, vec![Action::from(act)]); 318 | let prompt = Box::new(prompt); 319 | 320 | return Err(UIError::NeedConfirm(prompt)); 321 | } 322 | 323 | match mua { 324 | MemberUpdateAction::Ban => { 325 | room.ban_user(&user_id, reason.as_deref()) 326 | .await 327 | .map_err(IambError::from)?; 328 | }, 329 | MemberUpdateAction::Unban => { 330 | room.unban_user(&user_id, reason.as_deref()) 331 | .await 332 | .map_err(IambError::from)?; 333 | }, 334 | MemberUpdateAction::Kick => { 335 | room.kick_user(&user_id, reason.as_deref()) 336 | .await 337 | .map_err(IambError::from)?; 338 | }, 339 | } 340 | 341 | Ok(vec![]) 342 | }, 343 | RoomAction::Members(mut cmd) => { 344 | let width = Count::Exact(30); 345 | let act = 346 | cmd.default_axis(Axis::Vertical).default_relation(MoveDir1D::Next).window( 347 | OpenTarget::Application(IambId::MemberList(self.id().to_owned())), 348 | width.into(), 349 | ); 350 | 351 | Ok(vec![(act, cmd.context.clone())]) 352 | }, 353 | RoomAction::SetDirect(is_direct) => { 354 | let room = store 355 | .application 356 | .get_joined_room(self.id()) 357 | .ok_or(UIError::Application(IambError::NotJoined))?; 358 | 359 | room.set_is_direct(is_direct).await.map_err(IambError::from)?; 360 | 361 | Ok(vec![]) 362 | }, 363 | RoomAction::Set(field, value) => { 364 | let room = store 365 | .application 366 | .get_joined_room(self.id()) 367 | .ok_or(UIError::Application(IambError::NotJoined))?; 368 | 369 | match field { 370 | RoomField::History => { 371 | let visibility = hist_visibility_mode(value)?; 372 | let ev = RoomHistoryVisibilityEventContent::new(visibility); 373 | let _ = room.send_state_event(ev).await.map_err(IambError::from)?; 374 | }, 375 | RoomField::Name => { 376 | let ev = RoomNameEventContent::new(value); 377 | let _ = room.send_state_event(ev).await.map_err(IambError::from)?; 378 | }, 379 | RoomField::Tag(tag) => { 380 | let mut info = TagInfo::new(); 381 | info.order = Some(1.0); 382 | 383 | let _ = room.set_tag(tag, info).await.map_err(IambError::from)?; 384 | }, 385 | RoomField::Topic => { 386 | let ev = RoomTopicEventContent::new(value); 387 | let _ = room.send_state_event(ev).await.map_err(IambError::from)?; 388 | }, 389 | RoomField::NotificationMode => { 390 | let mode = notification_mode(value)?; 391 | let client = &store.application.worker.client; 392 | let notifications = client.notification_settings().await; 393 | 394 | notifications 395 | .set_room_notification_mode(self.id(), mode) 396 | .await 397 | .map_err(IambError::from)?; 398 | }, 399 | RoomField::CanonicalAlias => { 400 | let client = &mut store.application.worker.client; 401 | 402 | let Ok(orai) = OwnedRoomAliasId::try_from(value.as_str()) else { 403 | let err = IambError::InvalidRoomAlias(value); 404 | 405 | return Err(err.into()); 406 | }; 407 | 408 | let mut alt_aliases = 409 | room.alt_aliases().into_iter().collect::>(); 410 | let canonical_old = room.canonical_alias(); 411 | 412 | // If the room's alias is already that, ignore it 413 | if canonical_old.as_ref() == Some(&orai) { 414 | let msg = format!("The canonical room alias is already {orai}"); 415 | 416 | return Ok(vec![(Action::ShowInfoMessage(msg.into()), ctx)]); 417 | } 418 | 419 | // Try creating the room alias on the server. 420 | let alias_create_req = 421 | CreateAliasRequest::new(orai.clone(), room.room_id().into()); 422 | if let Err(e) = client.send(alias_create_req).await { 423 | if let Some(ClientApiErrorKind::Unknown) = e.client_api_error_kind() { 424 | // Ignore when it already exists. 425 | } else { 426 | return Err(IambError::from(e).into()); 427 | } 428 | } 429 | 430 | // Demote the previous one to an alt alias. 431 | alt_aliases.extend(canonical_old); 432 | 433 | // At this point the room alias definitely exists, and we can update the 434 | // state event. 435 | let mut ev = RoomCanonicalAliasEventContent::new(); 436 | ev.alias = Some(orai); 437 | ev.alt_aliases = alt_aliases.into_iter().collect(); 438 | let _ = room.send_state_event(ev).await.map_err(IambError::from)?; 439 | }, 440 | RoomField::Alias(alias) => { 441 | let client = &mut store.application.worker.client; 442 | 443 | let Ok(orai) = OwnedRoomAliasId::try_from(alias.as_str()) else { 444 | let err = IambError::InvalidRoomAlias(alias); 445 | 446 | return Err(err.into()); 447 | }; 448 | 449 | let mut alt_aliases = 450 | room.alt_aliases().into_iter().collect::>(); 451 | let canonical = room.canonical_alias(); 452 | 453 | if alt_aliases.contains(&orai) || canonical.as_ref() == Some(&orai) { 454 | let msg = format!("The alias {orai} already maps to this room"); 455 | 456 | return Ok(vec![(Action::ShowInfoMessage(msg.into()), ctx)]); 457 | } else { 458 | alt_aliases.insert(orai.clone()); 459 | } 460 | 461 | // If the room alias does not exist on the server, create it 462 | let alias_create_req = CreateAliasRequest::new(orai, room.room_id().into()); 463 | if let Err(e) = client.send(alias_create_req).await { 464 | if let Some(ClientApiErrorKind::Unknown) = e.client_api_error_kind() { 465 | // Ignore when it already exists. 466 | } else { 467 | return Err(IambError::from(e).into()); 468 | } 469 | } 470 | 471 | // And add it to the aliases in the state event. 472 | let mut ev = RoomCanonicalAliasEventContent::new(); 473 | ev.alias = canonical; 474 | ev.alt_aliases = alt_aliases.into_iter().collect(); 475 | let _ = room.send_state_event(ev).await.map_err(IambError::from)?; 476 | }, 477 | RoomField::Aliases => { 478 | // This never happens, aliases is only used for showing 479 | }, 480 | RoomField::Id => { 481 | // This never happens, id is only used for showing 482 | }, 483 | } 484 | 485 | Ok(vec![]) 486 | }, 487 | RoomAction::Unset(field) => { 488 | let room = store 489 | .application 490 | .get_joined_room(self.id()) 491 | .ok_or(UIError::Application(IambError::NotJoined))?; 492 | 493 | match field { 494 | RoomField::History => { 495 | let visibility = HistoryVisibility::Joined; 496 | let ev = RoomHistoryVisibilityEventContent::new(visibility); 497 | let _ = room.send_state_event(ev).await.map_err(IambError::from)?; 498 | }, 499 | RoomField::Name => { 500 | let ev = RoomNameEventContent::new("".into()); 501 | let _ = room.send_state_event(ev).await.map_err(IambError::from)?; 502 | }, 503 | RoomField::Tag(tag) => { 504 | let _ = room.remove_tag(tag).await.map_err(IambError::from)?; 505 | }, 506 | RoomField::Topic => { 507 | let ev = RoomTopicEventContent::new("".into()); 508 | let _ = room.send_state_event(ev).await.map_err(IambError::from)?; 509 | }, 510 | RoomField::NotificationMode => { 511 | let client = &store.application.worker.client; 512 | let notifications = client.notification_settings().await; 513 | 514 | notifications 515 | .delete_user_defined_room_rules(self.id()) 516 | .await 517 | .map_err(IambError::from)?; 518 | }, 519 | RoomField::CanonicalAlias => { 520 | let Some(alias_to_destroy) = room.canonical_alias() else { 521 | let msg = "This room has no canonical alias to unset"; 522 | 523 | return Ok(vec![(Action::ShowInfoMessage(msg.into()), ctx)]); 524 | }; 525 | 526 | // Remove the canonical alias from the state event. 527 | let mut ev = RoomCanonicalAliasEventContent::new(); 528 | ev.alias = None; 529 | ev.alt_aliases = room.alt_aliases(); 530 | let _ = room.send_state_event(ev).await.map_err(IambError::from)?; 531 | 532 | // And then unmap it on the server. 533 | let del_req = DeleteAliasRequest::new(alias_to_destroy); 534 | let _ = store 535 | .application 536 | .worker 537 | .client 538 | .send(del_req) 539 | .await 540 | .map_err(IambError::from)?; 541 | }, 542 | RoomField::Alias(alias) => { 543 | let Ok(orai) = OwnedRoomAliasId::try_from(alias.as_str()) else { 544 | let err = IambError::InvalidRoomAlias(alias); 545 | 546 | return Err(err.into()); 547 | }; 548 | 549 | let alt_aliases = room.alt_aliases(); 550 | let canonical = room.canonical_alias(); 551 | 552 | if !alt_aliases.contains(&orai) && canonical.as_ref() != Some(&orai) { 553 | let msg = format!("The alias {orai:?} isn't mapped to this room"); 554 | 555 | return Ok(vec![(Action::ShowInfoMessage(msg.into()), ctx)]); 556 | } 557 | 558 | // Remove the alias from the state event if it's in it. 559 | let mut ev = RoomCanonicalAliasEventContent::new(); 560 | ev.alias = canonical.filter(|canon| canon != &orai); 561 | ev.alt_aliases = alt_aliases; 562 | ev.alt_aliases.retain(|in_orai| in_orai != &orai); 563 | let _ = room.send_state_event(ev).await.map_err(IambError::from)?; 564 | 565 | // And then unmap it on the server. 566 | let del_req = DeleteAliasRequest::new(orai); 567 | let _ = store 568 | .application 569 | .worker 570 | .client 571 | .send(del_req) 572 | .await 573 | .map_err(IambError::from)?; 574 | }, 575 | RoomField::Aliases => { 576 | // This will not happen, you cannot unset all aliases 577 | }, 578 | RoomField::Id => { 579 | // This never happens, id is only used for showing 580 | }, 581 | } 582 | 583 | Ok(vec![]) 584 | }, 585 | RoomAction::Show(field) => { 586 | let room = store 587 | .application 588 | .get_joined_room(self.id()) 589 | .ok_or(UIError::Application(IambError::NotJoined))?; 590 | 591 | let msg = match field { 592 | RoomField::History => { 593 | let visibility = room.history_visibility(); 594 | let visibility = visibility.as_ref().map(|v| v.as_str()); 595 | format!("Room history visibility: {}", visibility.unwrap_or("")) 596 | }, 597 | RoomField::Id => { 598 | let id = room.room_id(); 599 | format!("Room identifier: {id}") 600 | }, 601 | RoomField::Name => { 602 | match room.name() { 603 | None => "Room has no name".into(), 604 | Some(name) => format!("Room name: {name:?}"), 605 | } 606 | }, 607 | RoomField::Topic => { 608 | match room.topic() { 609 | None => "Room has no topic".into(), 610 | Some(topic) => format!("Room topic: {topic:?}"), 611 | } 612 | }, 613 | RoomField::NotificationMode => { 614 | let client = &store.application.worker.client; 615 | let notifications = client.notification_settings().await; 616 | let mode = 617 | notifications.get_user_defined_room_notification_mode(self.id()).await; 618 | 619 | let level = match mode { 620 | Some(RoomNotificationMode::Mute) => "mute", 621 | Some(RoomNotificationMode::MentionsAndKeywordsOnly) => "keywords", 622 | Some(RoomNotificationMode::AllMessages) => "all", 623 | None => "default", 624 | }; 625 | 626 | format!("Room notification level: {level:?}") 627 | }, 628 | RoomField::Aliases => { 629 | let aliases = room 630 | .alt_aliases() 631 | .iter() 632 | .map(OwnedRoomAliasId::to_string) 633 | .collect::>(); 634 | 635 | if aliases.is_empty() { 636 | "No alternative aliases in room".into() 637 | } else { 638 | format!("Alternative aliases: {}.", aliases.join(", ")) 639 | } 640 | }, 641 | RoomField::CanonicalAlias => { 642 | match room.canonical_alias() { 643 | None => "No canonical alias for room".into(), 644 | Some(can) => format!("Canonical alias: {can}"), 645 | } 646 | }, 647 | RoomField::Tag(_) => "Cannot currently show value for a tag".into(), 648 | RoomField::Alias(_) => { 649 | "Cannot show a single alias; use `:room aliases show` instead.".into() 650 | }, 651 | }; 652 | 653 | let msg = InfoMessage::Pager(msg); 654 | let act = Action::ShowInfoMessage(msg); 655 | 656 | Ok(vec![(act, ctx)]) 657 | }, 658 | } 659 | } 660 | 661 | pub fn get_title(&self, store: &mut ProgramStore) -> Line { 662 | let title = store.application.get_room_title(self.id()); 663 | let style = Style::default().add_modifier(StyleModifier::BOLD); 664 | let mut spans = vec![]; 665 | 666 | if let RoomState::Chat(chat) = self { 667 | if chat.thread().is_some() { 668 | spans.push("Thread in ".into()); 669 | } 670 | } 671 | 672 | spans.push(Span::styled(title, style)); 673 | 674 | match self.room().topic() { 675 | Some(desc) if !desc.is_empty() => { 676 | spans.push(" (".into()); 677 | spans.push(desc.into()); 678 | spans.push(")".into()); 679 | }, 680 | _ => {}, 681 | } 682 | 683 | Line::from(spans) 684 | } 685 | 686 | pub fn focus_toggle(&mut self) { 687 | match self { 688 | RoomState::Chat(chat) => chat.focus_toggle(), 689 | RoomState::Space(_) => return, 690 | } 691 | } 692 | 693 | pub fn room(&self) -> &MatrixRoom { 694 | match self { 695 | RoomState::Chat(chat) => chat.room(), 696 | RoomState::Space(space) => space.room(), 697 | } 698 | } 699 | 700 | pub fn id(&self) -> &RoomId { 701 | match self { 702 | RoomState::Chat(chat) => chat.id(), 703 | RoomState::Space(space) => space.id(), 704 | } 705 | } 706 | } 707 | 708 | impl Editable for RoomState { 709 | fn editor_command( 710 | &mut self, 711 | act: &EditorAction, 712 | ctx: &ProgramContext, 713 | store: &mut ProgramStore, 714 | ) -> EditResult { 715 | delegate!(self, w => w.editor_command(act, ctx, store)) 716 | } 717 | } 718 | 719 | impl Jumpable for RoomState { 720 | fn jump( 721 | &mut self, 722 | list: PositionList, 723 | dir: MoveDir1D, 724 | count: usize, 725 | ctx: &ProgramContext, 726 | ) -> IambResult { 727 | delegate!(self, w => w.jump(list, dir, count, ctx)) 728 | } 729 | } 730 | 731 | impl Scrollable for RoomState { 732 | fn scroll( 733 | &mut self, 734 | style: &ScrollStyle, 735 | ctx: &ProgramContext, 736 | store: &mut ProgramStore, 737 | ) -> EditResult { 738 | delegate!(self, w => w.scroll(style, ctx, store)) 739 | } 740 | } 741 | 742 | impl Promptable for RoomState { 743 | fn prompt( 744 | &mut self, 745 | act: &PromptAction, 746 | ctx: &ProgramContext, 747 | store: &mut ProgramStore, 748 | ) -> EditResult, IambInfo> { 749 | delegate!(self, w => w.prompt(act, ctx, store)) 750 | } 751 | } 752 | 753 | impl TerminalCursor for RoomState { 754 | fn get_term_cursor(&self) -> Option { 755 | delegate!(self, w => w.get_term_cursor()) 756 | } 757 | } 758 | 759 | impl WindowOps for RoomState { 760 | fn draw(&mut self, area: Rect, buf: &mut Buffer, focused: bool, store: &mut ProgramStore) { 761 | if self.room().state() == MatrixRoomState::Invited { 762 | self.refresh_room(store); 763 | } 764 | 765 | if self.room().state() == MatrixRoomState::Invited { 766 | self.draw_invite(self.room().clone(), area, buf, store); 767 | } 768 | 769 | match self { 770 | RoomState::Chat(chat) => chat.draw(area, buf, focused, store), 771 | RoomState::Space(space) => { 772 | Space::new(store).focus(focused).render(area, buf, space); 773 | }, 774 | } 775 | } 776 | 777 | fn dup(&self, store: &mut ProgramStore) -> Self { 778 | match self { 779 | RoomState::Chat(chat) => RoomState::Chat(chat.dup(store)), 780 | RoomState::Space(space) => RoomState::Space(space.dup(store)), 781 | } 782 | } 783 | 784 | fn close(&mut self, flags: CloseFlags, store: &mut ProgramStore) -> bool { 785 | match self { 786 | RoomState::Chat(chat) => chat.close(flags, store), 787 | RoomState::Space(space) => space.close(flags, store), 788 | } 789 | } 790 | 791 | fn write( 792 | &mut self, 793 | path: Option<&str>, 794 | flags: WriteFlags, 795 | store: &mut ProgramStore, 796 | ) -> IambResult { 797 | match self { 798 | RoomState::Chat(chat) => chat.write(path, flags, store), 799 | RoomState::Space(space) => space.write(path, flags, store), 800 | } 801 | } 802 | 803 | fn get_completions(&self) -> Option { 804 | match self { 805 | RoomState::Chat(chat) => chat.get_completions(), 806 | RoomState::Space(space) => space.get_completions(), 807 | } 808 | } 809 | 810 | fn get_cursor_word(&self, style: &WordStyle) -> Option { 811 | match self { 812 | RoomState::Chat(chat) => chat.get_cursor_word(style), 813 | RoomState::Space(space) => space.get_cursor_word(style), 814 | } 815 | } 816 | 817 | fn get_selected_word(&self) -> Option { 818 | match self { 819 | RoomState::Chat(chat) => chat.get_selected_word(), 820 | RoomState::Space(space) => space.get_selected_word(), 821 | } 822 | } 823 | } 824 | 825 | #[cfg(test)] 826 | mod tests { 827 | use super::*; 828 | 829 | #[test] 830 | fn test_parse_room_notification_level() { 831 | let tests = vec![ 832 | ("mute", RoomNotificationMode::Mute), 833 | ("mentions", RoomNotificationMode::MentionsAndKeywordsOnly), 834 | ("keywords", RoomNotificationMode::MentionsAndKeywordsOnly), 835 | ("all", RoomNotificationMode::AllMessages), 836 | ]; 837 | 838 | for (input, expect) in tests { 839 | let res = notification_mode(input).unwrap(); 840 | assert_eq!(expect, res); 841 | } 842 | 843 | assert!(notification_mode("invalid").is_err()); 844 | assert!(notification_mode("not a level").is_err()); 845 | assert!(notification_mode("@user:example.com").is_err()); 846 | } 847 | } 848 | -------------------------------------------------------------------------------- /src/windows/room/space.rs: -------------------------------------------------------------------------------- 1 | //! Window for Matrix spaces 2 | use std::ops::{Deref, DerefMut}; 3 | use std::time::{Duration, Instant}; 4 | 5 | use matrix_sdk::ruma::events::space::child::SpaceChildEventContent; 6 | use matrix_sdk::ruma::events::StateEventType; 7 | use matrix_sdk::{ 8 | room::Room as MatrixRoom, 9 | ruma::{OwnedRoomId, RoomId}, 10 | }; 11 | 12 | use modalkit::prelude::{EditInfo, InfoMessage}; 13 | use ratatui::{ 14 | buffer::Buffer, 15 | layout::Rect, 16 | style::{Color, Style}, 17 | text::{Line, Span, Text}, 18 | widgets::StatefulWidget, 19 | }; 20 | 21 | use modalkit_ratatui::{ 22 | list::{List, ListState}, 23 | TermOffset, 24 | TerminalCursor, 25 | WindowOps, 26 | }; 27 | 28 | use crate::base::{ 29 | IambBufferId, 30 | IambError, 31 | IambInfo, 32 | IambResult, 33 | ProgramContext, 34 | ProgramStore, 35 | RoomFocus, 36 | SpaceAction, 37 | }; 38 | 39 | use crate::windows::{room_fields_cmp, RoomItem, RoomLikeItem}; 40 | 41 | const SPACE_HIERARCHY_DEBOUNCE: Duration = Duration::from_secs(5); 42 | 43 | /// State needed for rendering [Space]. 44 | pub struct SpaceState { 45 | room_id: OwnedRoomId, 46 | room: MatrixRoom, 47 | list: ListState, 48 | last_fetch: Option, 49 | } 50 | 51 | impl SpaceState { 52 | pub fn new(room: MatrixRoom) -> Self { 53 | let room_id = room.room_id().to_owned(); 54 | let content = IambBufferId::Room(room_id.clone(), None, RoomFocus::Scrollback); 55 | let list = ListState::new(content, vec![]); 56 | let last_fetch = None; 57 | 58 | SpaceState { room_id, room, list, last_fetch } 59 | } 60 | 61 | pub fn refresh_room(&mut self, store: &mut ProgramStore) { 62 | if let Some(room) = store.application.worker.client.get_room(self.id()) { 63 | self.room = room; 64 | } 65 | } 66 | 67 | pub fn room(&self) -> &MatrixRoom { 68 | &self.room 69 | } 70 | 71 | pub fn id(&self) -> &RoomId { 72 | &self.room_id 73 | } 74 | 75 | pub fn dup(&self, store: &mut ProgramStore) -> Self { 76 | SpaceState { 77 | room_id: self.room_id.clone(), 78 | room: self.room.clone(), 79 | list: self.list.dup(store), 80 | last_fetch: self.last_fetch, 81 | } 82 | } 83 | 84 | pub async fn space_command( 85 | &mut self, 86 | act: SpaceAction, 87 | _: ProgramContext, 88 | store: &mut ProgramStore, 89 | ) -> IambResult { 90 | match act { 91 | SpaceAction::SetChild(child_id, order, suggested) => { 92 | if !self 93 | .room 94 | .can_user_send_state( 95 | &store.application.settings.profile.user_id, 96 | StateEventType::SpaceChild, 97 | ) 98 | .await 99 | .map_err(IambError::from)? 100 | { 101 | return Err(IambError::InsufficientPermission.into()); 102 | } 103 | 104 | let via = self.room.route().await.map_err(IambError::from)?; 105 | let mut ev = SpaceChildEventContent::new(via); 106 | ev.order = order; 107 | ev.suggested = suggested; 108 | let _ = self 109 | .room 110 | .send_state_event_for_key(&child_id, ev) 111 | .await 112 | .map_err(IambError::from)?; 113 | 114 | Ok(InfoMessage::from("Space updated").into()) 115 | }, 116 | SpaceAction::RemoveChild => { 117 | let space = self.list.get().ok_or(IambError::NoSelectedRoomOrSpaceItem)?; 118 | if !self 119 | .room 120 | .can_user_send_state( 121 | &store.application.settings.profile.user_id, 122 | StateEventType::SpaceChild, 123 | ) 124 | .await 125 | .map_err(IambError::from)? 126 | { 127 | return Err(IambError::InsufficientPermission.into()); 128 | } 129 | 130 | let ev = SpaceChildEventContent::new(vec![]); 131 | let event_id = self 132 | .room 133 | .send_state_event_for_key(&space.room_id().to_owned(), ev) 134 | .await 135 | .map_err(IambError::from)?; 136 | 137 | // Fix for element (see https://github.com/element-hq/element-web/issues/29606) 138 | let _ = self 139 | .room 140 | .redact(&event_id.event_id, Some("workaround for element bug"), None) 141 | .await 142 | .map_err(IambError::from)?; 143 | 144 | Ok(InfoMessage::from("Room removed").into()) 145 | }, 146 | } 147 | } 148 | } 149 | 150 | impl TerminalCursor for SpaceState { 151 | fn get_term_cursor(&self) -> Option { 152 | self.list.get_term_cursor() 153 | } 154 | } 155 | 156 | impl Deref for SpaceState { 157 | type Target = ListState; 158 | 159 | fn deref(&self) -> &Self::Target { 160 | &self.list 161 | } 162 | } 163 | 164 | impl DerefMut for SpaceState { 165 | fn deref_mut(&mut self) -> &mut Self::Target { 166 | &mut self.list 167 | } 168 | } 169 | 170 | /// [StatefulWidget] for Matrix spaces. 171 | pub struct Space<'a> { 172 | focused: bool, 173 | store: &'a mut ProgramStore, 174 | } 175 | 176 | impl<'a> Space<'a> { 177 | pub fn new(store: &'a mut ProgramStore) -> Self { 178 | Space { focused: false, store } 179 | } 180 | 181 | pub fn focus(mut self, focused: bool) -> Self { 182 | self.focused = focused; 183 | self 184 | } 185 | } 186 | 187 | impl StatefulWidget for Space<'_> { 188 | type State = SpaceState; 189 | 190 | fn render(self, area: Rect, buffer: &mut Buffer, state: &mut Self::State) { 191 | let mut empty_message = None; 192 | let need_fetch = match state.last_fetch { 193 | Some(i) => i.elapsed() >= SPACE_HIERARCHY_DEBOUNCE, 194 | None => true, 195 | }; 196 | 197 | if need_fetch { 198 | let res = self.store.application.worker.space_members(state.room_id.clone()); 199 | 200 | match res { 201 | Ok(members) => { 202 | let mut items = members 203 | .into_iter() 204 | .filter_map(|id| { 205 | let (room, _, tags) = 206 | self.store.application.worker.get_room(id.clone()).ok()?; 207 | let room_info = std::sync::Arc::new((room, tags)); 208 | 209 | if id != state.room_id { 210 | Some(RoomItem::new(room_info, self.store)) 211 | } else { 212 | None 213 | } 214 | }) 215 | .collect::>(); 216 | let fields = &self.store.application.settings.tunables.sort.rooms; 217 | let collator = &mut self.store.application.collator; 218 | items.sort_by(|a, b| room_fields_cmp(a, b, fields, collator)); 219 | 220 | state.list.set(items); 221 | state.last_fetch = Some(Instant::now()); 222 | }, 223 | Err(e) => { 224 | let lines = vec![ 225 | Line::from("Unable to fetch space room hierarchy:"), 226 | Span::styled(e.to_string(), Style::default().fg(Color::Red)).into(), 227 | ]; 228 | 229 | empty_message = Text::from(lines).into(); 230 | }, 231 | } 232 | } 233 | 234 | let mut list = List::new(self.store).focus(self.focused); 235 | 236 | if let Some(text) = empty_message { 237 | list = list.empty_message(text); 238 | } 239 | 240 | list.render(area, buffer, &mut state.list) 241 | } 242 | } 243 | -------------------------------------------------------------------------------- /src/windows/welcome.md: -------------------------------------------------------------------------------- 1 | # Welcome to iamb! 2 | 3 | ## Useful Keybindings 4 | 5 | - `` will send a typed message 6 | - `^V^J` can be used in Insert mode to enter a newline without submitting 7 | - `O`/`o` can be used to insert blank lines before and after the cursor line 8 | - `^Wm` can be used to toggle whether the message bar or scrollback is selected 9 | - `^Wz` can be used to toggle whether the current window takes up the full screen 10 | 11 | ## Room Commands 12 | 13 | - `:dms` will open a list of direct messages 14 | - `:rooms` will open a list of joined rooms 15 | - `:chats` will open a list containing both direct messages and rooms 16 | - `:members` will open a list of members for the currently focused room or space 17 | - `:spaces` will open a list of joined spaces 18 | - `:join` can be used to switch to join a new room or start a direct message 19 | - `:split` and `:vsplit` can be used to open rooms in a new window 20 | 21 | ## Verification Commands 22 | 23 | The `:verify` command has several different subcommands for working with 24 | verification requests. When used without any arguments, it will take you to a 25 | list of current verifications, where you can see and compare the Emoji. 26 | 27 | The different subcommands are: 28 | 29 | - `:verify request USERNAME` will send a verification request to a user 30 | - `:verify confirm USERNAME/DEVICE` will confirm a verification 31 | - `:verify mismatch USERNAME/DEVICE` will cancel a verification where the Emoji don't match 32 | - `:verify cancel USERNAME/DEVICE` will cancel a verification 33 | 34 | ## Other Useful Commands 35 | 36 | - `:welcome` will take you back to this screen 37 | 38 | ## Additional Configuration 39 | 40 | You can customize iamb in your `$CONFIG_DIR/iamb/config.toml` file, where 41 | `$CONFIG_DIR` is your system's per-user configuration directory. For example, 42 | this is typically `~/.config/iamb/config.toml` on systems that use the XDG 43 | Base Directory Specification. 44 | 45 | See the manual pages or for more details on how to 46 | further configure or use iamb. 47 | -------------------------------------------------------------------------------- /src/windows/welcome.rs: -------------------------------------------------------------------------------- 1 | //! Welcome Window 2 | use std::ops::{Deref, DerefMut}; 3 | 4 | use ratatui::{buffer::Buffer, layout::Rect}; 5 | 6 | use modalkit_ratatui::{textbox::TextBoxState, TermOffset, TerminalCursor, WindowOps}; 7 | 8 | use modalkit::editing::completion::CompletionList; 9 | use modalkit::prelude::*; 10 | 11 | use crate::base::{IambBufferId, IambInfo, IambResult, ProgramStore}; 12 | 13 | const WELCOME_TEXT: &str = include_str!("welcome.md"); 14 | 15 | pub struct WelcomeState { 16 | tbox: TextBoxState, 17 | } 18 | 19 | impl WelcomeState { 20 | pub fn new(store: &mut ProgramStore) -> Self { 21 | let buf = store.buffers.load_str(IambBufferId::Welcome, WELCOME_TEXT); 22 | let mut tbox = TextBoxState::new(buf); 23 | tbox.set_readonly(true); 24 | 25 | WelcomeState { tbox } 26 | } 27 | } 28 | 29 | impl Deref for WelcomeState { 30 | type Target = TextBoxState; 31 | 32 | fn deref(&self) -> &Self::Target { 33 | return &self.tbox; 34 | } 35 | } 36 | 37 | impl DerefMut for WelcomeState { 38 | fn deref_mut(&mut self) -> &mut Self::Target { 39 | return &mut self.tbox; 40 | } 41 | } 42 | 43 | impl TerminalCursor for WelcomeState { 44 | fn get_term_cursor(&self) -> Option { 45 | self.tbox.get_term_cursor() 46 | } 47 | } 48 | 49 | impl WindowOps for WelcomeState { 50 | fn draw(&mut self, area: Rect, buf: &mut Buffer, focused: bool, store: &mut ProgramStore) { 51 | self.tbox.draw(area, buf, focused, store) 52 | } 53 | 54 | fn dup(&self, store: &mut ProgramStore) -> Self { 55 | let tbox = self.tbox.dup(store); 56 | 57 | WelcomeState { tbox } 58 | } 59 | 60 | fn close(&mut self, flags: CloseFlags, store: &mut ProgramStore) -> bool { 61 | self.tbox.close(flags, store) 62 | } 63 | 64 | fn write( 65 | &mut self, 66 | path: Option<&str>, 67 | flags: WriteFlags, 68 | store: &mut ProgramStore, 69 | ) -> IambResult { 70 | self.tbox.write(path, flags, store) 71 | } 72 | 73 | fn get_completions(&self) -> Option { 74 | self.tbox.get_completions() 75 | } 76 | 77 | fn get_cursor_word(&self, style: &WordStyle) -> Option { 78 | self.tbox.get_cursor_word(style) 79 | } 80 | 81 | fn get_selected_word(&self) -> Option { 82 | self.tbox.get_selected_word() 83 | } 84 | } 85 | --------------------------------------------------------------------------------