├── .envrc ├── .github ├── FUNDING.yml └── workflows │ ├── ci.yml │ └── binaries.yml ├── .gitignore ├── docs ├── iamb.png ├── iamb-256x256.png ├── iamb-512x512.png ├── iamb.metainfo.xml ├── iamb.svg ├── iamb.1 └── iamb.5 ├── .gitattributes ├── rust-toolchain.toml ├── iamb.desktop ├── .rustfmt.toml ├── CONTRIBUTING.md ├── config.example.toml ├── PACKAGING.md ├── src ├── windows │ ├── welcome.md │ ├── welcome.rs │ └── room │ │ ├── space.rs │ │ └── mod.rs ├── sled_export.rs ├── keybindings.rs ├── preview.rs ├── util.rs ├── tests.rs ├── message │ ├── printer.rs │ └── compose.rs └── notifications.rs ├── flake.lock ├── flake.nix ├── Cargo.toml ├── README.md └── LICENSE /.envrc: -------------------------------------------------------------------------------- 1 | use flake 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | ko_fi: ulyssam 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /result 3 | /TODO 4 | .direnv 5 | -------------------------------------------------------------------------------- /docs/iamb.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ulyssa/iamb/HEAD/docs/iamb.png -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.rs text eol=lf 2 | *.toml text eol=lf 3 | *.md text eol=lf 4 | -------------------------------------------------------------------------------- /rust-toolchain.toml: -------------------------------------------------------------------------------- 1 | [toolchain] 2 | channel = "1.88" 3 | components = [ "clippy" ] 4 | -------------------------------------------------------------------------------- /docs/iamb-256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ulyssa/iamb/HEAD/docs/iamb-256x256.png -------------------------------------------------------------------------------- /docs/iamb-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ulyssa/iamb/HEAD/docs/iamb-512x512.png -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.rustfmt.toml: -------------------------------------------------------------------------------- 1 | unstable_features = true 2 | max_width = 100 3 | fn_call_width = 88 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 | -------------------------------------------------------------------------------- /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.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | 49 | nix-flake-test: 50 | name: Flake checks ❄️ 51 | strategy: 52 | matrix: 53 | platform: [ubuntu-latest, macos-latest] 54 | runs-on: ${{ matrix.platform }} 55 | steps: 56 | - name: Checkout Code 57 | uses: actions/checkout@v4 58 | - uses: cachix/install-nix-action@v31 59 | with: 60 | github_access_token: ${{ secrets.GITHUB_TOKEN }} 61 | - uses: cachix/cachix-action@v15 62 | with: 63 | name: iamb-prs 64 | authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}' 65 | - name: Flake check 66 | run: | 67 | nix flake show 68 | nix flake check --print-build-logs 69 | 70 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "crane": { 4 | "locked": { 5 | "lastModified": 1759893430, 6 | "narHash": "sha256-yAy4otLYm9iZ+NtQwTMEbqHwswSFUbhn7x826RR6djw=", 7 | "owner": "ipetkov", 8 | "repo": "crane", 9 | "rev": "1979a2524cb8c801520bd94c38bb3d5692419d93", 10 | "type": "github" 11 | }, 12 | "original": { 13 | "owner": "ipetkov", 14 | "repo": "crane", 15 | "type": "github" 16 | } 17 | }, 18 | "fenix": { 19 | "inputs": { 20 | "nixpkgs": [ 21 | "nixpkgs" 22 | ], 23 | "rust-analyzer-src": "rust-analyzer-src" 24 | }, 25 | "locked": { 26 | "lastModified": 1760510549, 27 | "narHash": "sha256-NP+kmLMm7zSyv4Fufv+eSJXyqjLMUhUfPT6lXRlg/bU=", 28 | "owner": "nix-community", 29 | "repo": "fenix", 30 | "rev": "ef7178cf086f267113b5c48fdeb6e510729c8214", 31 | "type": "github" 32 | }, 33 | "original": { 34 | "owner": "nix-community", 35 | "repo": "fenix", 36 | "type": "github" 37 | } 38 | }, 39 | "flake-utils": { 40 | "inputs": { 41 | "systems": "systems" 42 | }, 43 | "locked": { 44 | "lastModified": 1731533236, 45 | "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", 46 | "owner": "numtide", 47 | "repo": "flake-utils", 48 | "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", 49 | "type": "github" 50 | }, 51 | "original": { 52 | "owner": "numtide", 53 | "repo": "flake-utils", 54 | "type": "github" 55 | } 56 | }, 57 | "nixpkgs": { 58 | "locked": { 59 | "lastModified": 1760284886, 60 | "narHash": "sha256-TK9Kr0BYBQ/1P5kAsnNQhmWWKgmZXwUQr4ZMjCzWf2c=", 61 | "owner": "nixos", 62 | "repo": "nixpkgs", 63 | "rev": "cf3f5c4def3c7b5f1fc012b3d839575dbe552d43", 64 | "type": "github" 65 | }, 66 | "original": { 67 | "owner": "nixos", 68 | "ref": "nixos-unstable", 69 | "repo": "nixpkgs", 70 | "type": "github" 71 | } 72 | }, 73 | "root": { 74 | "inputs": { 75 | "crane": "crane", 76 | "fenix": "fenix", 77 | "flake-utils": "flake-utils", 78 | "nixpkgs": "nixpkgs" 79 | } 80 | }, 81 | "rust-analyzer-src": { 82 | "flake": false, 83 | "locked": { 84 | "lastModified": 1760457219, 85 | "narHash": "sha256-WJOUGx42hrhmvvYcGkwea+BcJuQJLcns849OnewQqX4=", 86 | "owner": "rust-lang", 87 | "repo": "rust-analyzer", 88 | "rev": "8747cf81540bd1bbbab9ee2702f12c33aa887b46", 89 | "type": "github" 90 | }, 91 | "original": { 92 | "owner": "rust-lang", 93 | "ref": "nightly", 94 | "repo": "rust-analyzer", 95 | "type": "github" 96 | } 97 | }, 98 | "systems": { 99 | "locked": { 100 | "lastModified": 1681028828, 101 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 102 | "owner": "nix-systems", 103 | "repo": "default", 104 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 105 | "type": "github" 106 | }, 107 | "original": { 108 | "owner": "nix-systems", 109 | "repo": "default", 110 | "type": "github" 111 | } 112 | } 113 | }, 114 | "root": "root", 115 | "version": 7 116 | } 117 | -------------------------------------------------------------------------------- /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 | crane.url = "github:ipetkov/crane"; 9 | fenix = { 10 | url = "github:nix-community/fenix"; 11 | inputs.nixpkgs.follows = "nixpkgs"; 12 | }; 13 | }; 14 | 15 | outputs = 16 | { 17 | self, 18 | nixpkgs, 19 | crane, 20 | flake-utils, 21 | fenix, 22 | ... 23 | }: 24 | flake-utils.lib.eachDefaultSystem ( 25 | system: 26 | let 27 | pkgs = nixpkgs.legacyPackages.${system}; 28 | inherit (pkgs) lib; 29 | 30 | rustToolchain = fenix.packages.${system}.fromToolchainFile { 31 | file = ./rust-toolchain.toml; 32 | # When the file changes, this hash must be updated. 33 | sha256 = "sha256-Qxt8XAuaUR2OMdKbN4u8dBJOhSHxS+uS06Wl9+flVEk="; 34 | }; 35 | 36 | # Nightly toolchain for rustfmt (pinned to current flake lock) 37 | # Note that the github CI uses "current nightly" for formatting, it 's not pinned. 38 | rustNightly = fenix.packages.${system}.latest; 39 | 40 | craneLib = (crane.mkLib pkgs).overrideToolchain rustToolchain; 41 | craneLibNightly = (crane.mkLib pkgs).overrideToolchain rustNightly.toolchain; 42 | 43 | src = lib.fileset.toSource { 44 | root = ./.; 45 | fileset = lib.fileset.unions [ 46 | (craneLib.fileset.commonCargoSources ./.) 47 | ./src/windows/welcome.md 48 | ]; 49 | }; 50 | 51 | commonArgs = { 52 | inherit src; 53 | strictDeps = true; 54 | pname = "iamb"; 55 | version = self.shortRev or self.dirtyShortRev; 56 | }; 57 | 58 | # Build *just* the cargo dependencies, so we can reuse 59 | # all of that work (e.g. via cachix) when running in CI 60 | cargoArtifacts = craneLib.buildDepsOnly commonArgs; 61 | 62 | # Build the actual crate 63 | iamb = craneLib.buildPackage (commonArgs // { 64 | inherit cargoArtifacts; 65 | }); 66 | in 67 | { 68 | checks = { 69 | # Build the crate as part of `nix flake check` 70 | inherit iamb; 71 | 72 | iamb-clippy = craneLib.cargoClippy (commonArgs // { 73 | inherit cargoArtifacts; 74 | cargoClippyExtraArgs = "--all-targets -- --deny warnings"; 75 | }); 76 | 77 | iamb-fmt = craneLibNightly.cargoFmt { 78 | inherit src; 79 | }; 80 | 81 | iamb-nextest = craneLib.cargoNextest (commonArgs // { 82 | inherit cargoArtifacts; 83 | partitions = 1; 84 | partitionType = "count"; 85 | }); 86 | }; 87 | 88 | packages.default = iamb; 89 | 90 | apps.default = flake-utils.lib.mkApp { 91 | drv = iamb; 92 | }; 93 | 94 | devShells.default = craneLib.devShell { 95 | # Inherit inputs from checks 96 | checks = self.checks.${system}; 97 | 98 | packages = with pkgs; [ 99 | cargo-tarpaulin 100 | cargo-watch 101 | sqlite 102 | ]; 103 | 104 | shellHook = '' 105 | # Prepend nightly rustfmt to PATH. 106 | export PATH="${rustNightly.rustfmt}/bin:$PATH" 107 | ''; 108 | }; 109 | } 110 | ); 111 | } 112 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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.88" 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 | linkify = "0.10.0" 68 | shellexpand = "3.1.1" 69 | 70 | [dependencies.comrak] 71 | version = "0.22.0" 72 | default-features = false 73 | features = ["shortcodes"] 74 | 75 | [dependencies.notify-rust] 76 | version = "~4.10.0" 77 | default-features = false 78 | features = ["zbus", "serde"] 79 | optional = true 80 | 81 | [dependencies.modalkit] 82 | version = "0.0.24" 83 | default-features = false 84 | #git = "https://github.com/ulyssa/modalkit" 85 | #rev = "e40dbb0bfeabe4cfd08facd2acb446080a330d75" 86 | 87 | [dependencies.modalkit-ratatui] 88 | version = "0.0.24" 89 | #git = "https://github.com/ulyssa/modalkit" 90 | #rev = "e40dbb0bfeabe4cfd08facd2acb446080a330d75" 91 | 92 | [dependencies.matrix-sdk] 93 | version = "0.14.0" 94 | default-features = false 95 | features = ["e2e-encryption", "sqlite", "sso-login"] 96 | 97 | [dependencies.tokio] 98 | version = "1.24.1" 99 | features = ["macros", "net", "rt-multi-thread", "sync", "time"] 100 | 101 | [dev-dependencies] 102 | lazy_static = "1.4.0" 103 | pretty_assertions = "1.4.0" 104 | 105 | [profile.release-lto] 106 | inherits = "release" 107 | incremental = false 108 | lto = true 109 | 110 | [package.metadata.deb] 111 | section = "net" 112 | license-file = ["LICENSE", "0"] 113 | assets = [ 114 | # Binary: 115 | ["target/release/iamb", "usr/bin/iamb", "755"], 116 | # Manual pages: 117 | ["docs/iamb.1", "usr/share/man/man1/iamb.1", "644"], 118 | ["docs/iamb.5", "usr/share/man/man5/iamb.5", "644"], 119 | # Other assets: 120 | ["iamb.desktop", "usr/share/applications/iamb.desktop", "644"], 121 | ["config.example.toml", "usr/share/iamb/config.example.toml", "644"], 122 | ["docs/iamb.svg", "usr/share/icons/hicolor/scalable/apps/iamb.svg", "644"], 123 | ["docs/iamb.metainfo.xml", "usr/share/metainfo/iamb.metainfo.xml", "644"], 124 | ] 125 | 126 | [package.metadata.generate-rpm] 127 | assets = [ 128 | # Binary: 129 | { source = "target/release/iamb", dest = "/usr/bin/iamb", mode = "755" }, 130 | # Manual pages: 131 | { source = "docs/iamb.1", dest = "/usr/share/man/man1/iamb.1", mode = "644" }, 132 | { source = "docs/iamb.5", dest = "/usr/share/man/man5/iamb.5", mode = "644" }, 133 | # Other assets: 134 | { source = "iamb.desktop", dest = "/usr/share/applications/iamb.desktop", mode = "644" }, 135 | { source = "config.example.toml", dest = "/usr/share/iamb/config.example.toml", mode = "644"}, 136 | { source = "docs/iamb.svg", dest = "/usr/share/icons/hicolor/scalable/apps/iamb.svg", mode = "644"}, 137 | { source = "docs/iamb.metainfo.xml", dest = "/usr/share/metainfo/iamb.metainfo.xml", mode = "644"}, 138 | ] 139 | -------------------------------------------------------------------------------- /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 (from source) 55 | 56 | Install Rust and Cargo using [rustup], and then run from the directory 57 | containing the sources (ie: from a git clone): 58 | 59 | ``` 60 | cargo install --locked --path . 61 | ``` 62 | 63 | ## Installation (via `crates.io`) 64 | 65 | Install Rust (1.83.0 or above) and Cargo, and then run: 66 | 67 | ``` 68 | cargo install --locked iamb 69 | ``` 70 | 71 | See [Configuration](#configuration) for getting a profile set up. 72 | 73 | ## Installation (via package managers) 74 | 75 | ### Arch Linux 76 | 77 | On Arch Linux a [package](https://aur.archlinux.org/packages/iamb-git) is available in the 78 | Arch User Repositories (AUR). To install it simply run with your favorite AUR helper: 79 | 80 | ``` 81 | paru iamb-git 82 | ``` 83 | 84 | ### FreeBSD 85 | 86 | On FreeBSD a package is available from the official repositories. To install it simply run: 87 | 88 | ``` 89 | pkg install iamb 90 | ``` 91 | 92 | ### Gentoo 93 | 94 | On Gentoo, an ebuild is available from the community-managed 95 | [GURU overlay](https://wiki.gentoo.org/wiki/Project:GURU). 96 | 97 | You can enable the GURU overlay with: 98 | 99 | ``` 100 | eselect repository enable guru 101 | emerge --sync guru 102 | ``` 103 | 104 | And then install `iamb` with: 105 | 106 | ``` 107 | emerge --ask iamb 108 | ``` 109 | 110 | ### macOS 111 | 112 | On macOS a [package](https://formulae.brew.sh/formula/iamb#default) is available in Homebrew's 113 | repository. To install it simply run: 114 | 115 | ``` 116 | brew install iamb 117 | ``` 118 | 119 | ### NetBSD 120 | 121 | On NetBSD a package is available from the official repositories. To install it simply run: 122 | 123 | ``` 124 | pkgin install iamb 125 | ``` 126 | 127 | ### Nix / NixOS (flake) 128 | 129 | ``` 130 | nix profile install "github:ulyssa/iamb" 131 | ``` 132 | 133 | ### openSUSE Tumbleweed 134 | 135 | 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: 136 | 137 | ``` 138 | zypper install iamb 139 | ``` 140 | 141 | ### Snap 142 | 143 | A snap for Linux distributions which [support](https://snapcraft.io/docs/installing-snapd) the packaging system. 144 | 145 | ``` 146 | snap install iamb 147 | ``` 148 | 149 | ## License 150 | 151 | iamb is released under the [Apache License, Version 2.0]. 152 | 153 | [Apache License, Version 2.0]: https://github.com/ulyssa/iamb/blob/master/LICENSE 154 | [crates-io-iamb]: https://crates.io/crates/iamb 155 | [iamb.chat]: https://iamb.chat 156 | [well_known_entry]: https://spec.matrix.org/latest/client-server-api/#getwell-knownmatrixclient 157 | [rustup]: https://rustup.rs/ 158 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/windows/room/space.rs: -------------------------------------------------------------------------------- 1 | //! Window for Matrix spaces 2 | use std::ops::{Deref, DerefMut}; 3 | use std::str::FromStr; 4 | use std::time::{Duration, Instant}; 5 | 6 | use matrix_sdk::ruma::events::space::child::SpaceChildEventContent; 7 | use matrix_sdk::ruma::events::StateEventType; 8 | use matrix_sdk::ruma::OwnedSpaceChildOrder; 9 | use matrix_sdk::{ 10 | room::Room as MatrixRoom, 11 | ruma::{OwnedRoomId, RoomId}, 12 | }; 13 | 14 | use modalkit::prelude::{EditInfo, InfoMessage}; 15 | use ratatui::{ 16 | buffer::Buffer, 17 | layout::Rect, 18 | style::{Color, Style}, 19 | text::{Line, Span, Text}, 20 | widgets::StatefulWidget, 21 | }; 22 | 23 | use modalkit_ratatui::{ 24 | list::{List, ListState}, 25 | TermOffset, 26 | TerminalCursor, 27 | WindowOps, 28 | }; 29 | 30 | use crate::base::{ 31 | IambBufferId, 32 | IambError, 33 | IambInfo, 34 | IambResult, 35 | ProgramContext, 36 | ProgramStore, 37 | RoomFocus, 38 | SpaceAction, 39 | }; 40 | 41 | use crate::windows::{room_fields_cmp, RoomItem, RoomLikeItem}; 42 | 43 | const SPACE_HIERARCHY_DEBOUNCE: Duration = Duration::from_secs(5); 44 | 45 | /// State needed for rendering [Space]. 46 | pub struct SpaceState { 47 | room_id: OwnedRoomId, 48 | room: MatrixRoom, 49 | list: ListState, 50 | last_fetch: Option, 51 | } 52 | 53 | impl SpaceState { 54 | pub fn new(room: MatrixRoom) -> Self { 55 | let room_id = room.room_id().to_owned(); 56 | let content = IambBufferId::Room(room_id.clone(), None, RoomFocus::Scrollback); 57 | let list = ListState::new(content, vec![]); 58 | let last_fetch = None; 59 | 60 | SpaceState { room_id, room, list, last_fetch } 61 | } 62 | 63 | pub fn refresh_room(&mut self, store: &mut ProgramStore) { 64 | if let Some(room) = store.application.worker.client.get_room(self.id()) { 65 | self.room = room; 66 | } 67 | } 68 | 69 | pub fn room(&self) -> &MatrixRoom { 70 | &self.room 71 | } 72 | 73 | pub fn id(&self) -> &RoomId { 74 | &self.room_id 75 | } 76 | 77 | pub fn dup(&self, store: &mut ProgramStore) -> Self { 78 | SpaceState { 79 | room_id: self.room_id.clone(), 80 | room: self.room.clone(), 81 | list: self.list.dup(store), 82 | last_fetch: self.last_fetch, 83 | } 84 | } 85 | 86 | pub async fn space_command( 87 | &mut self, 88 | act: SpaceAction, 89 | _: ProgramContext, 90 | store: &mut ProgramStore, 91 | ) -> IambResult { 92 | match act { 93 | SpaceAction::SetChild(child_id, order, suggested) => { 94 | if !self 95 | .room 96 | .power_levels() 97 | .await 98 | .map_err(matrix_sdk::Error::from) 99 | .map_err(IambError::from)? 100 | .user_can_send_state( 101 | &store.application.settings.profile.user_id, 102 | StateEventType::SpaceChild, 103 | ) 104 | { 105 | return Err(IambError::InsufficientPermission.into()); 106 | } 107 | 108 | let via = self.room.route().await.map_err(IambError::from)?; 109 | let mut ev = SpaceChildEventContent::new(via); 110 | ev.order = order 111 | .as_deref() 112 | .map(OwnedSpaceChildOrder::from_str) 113 | .transpose() 114 | .map_err(IambError::InvalidSpaceChildOrder)?; 115 | ev.suggested = suggested; 116 | let _ = self 117 | .room 118 | .send_state_event_for_key(&child_id, ev) 119 | .await 120 | .map_err(IambError::from)?; 121 | 122 | Ok(InfoMessage::from("Space updated").into()) 123 | }, 124 | SpaceAction::RemoveChild => { 125 | let space = self.list.get().ok_or(IambError::NoSelectedRoomOrSpaceItem)?; 126 | if !self 127 | .room 128 | .power_levels() 129 | .await 130 | .map_err(matrix_sdk::Error::from) 131 | .map_err(IambError::from)? 132 | .user_can_send_state( 133 | &store.application.settings.profile.user_id, 134 | StateEventType::SpaceChild, 135 | ) 136 | { 137 | return Err(IambError::InsufficientPermission.into()); 138 | } 139 | 140 | let ev = SpaceChildEventContent::new(vec![]); 141 | let event_id = self 142 | .room 143 | .send_state_event_for_key(&space.room_id().to_owned(), ev) 144 | .await 145 | .map_err(IambError::from)?; 146 | 147 | // Fix for element (see https://github.com/element-hq/element-web/issues/29606) 148 | let _ = self 149 | .room 150 | .redact(&event_id.event_id, Some("workaround for element bug"), None) 151 | .await 152 | .map_err(IambError::from)?; 153 | 154 | Ok(InfoMessage::from("Room removed").into()) 155 | }, 156 | } 157 | } 158 | } 159 | 160 | impl TerminalCursor for SpaceState { 161 | fn get_term_cursor(&self) -> Option { 162 | self.list.get_term_cursor() 163 | } 164 | } 165 | 166 | impl Deref for SpaceState { 167 | type Target = ListState; 168 | 169 | fn deref(&self) -> &Self::Target { 170 | &self.list 171 | } 172 | } 173 | 174 | impl DerefMut for SpaceState { 175 | fn deref_mut(&mut self) -> &mut Self::Target { 176 | &mut self.list 177 | } 178 | } 179 | 180 | /// [StatefulWidget] for Matrix spaces. 181 | pub struct Space<'a> { 182 | focused: bool, 183 | store: &'a mut ProgramStore, 184 | } 185 | 186 | impl<'a> Space<'a> { 187 | pub fn new(store: &'a mut ProgramStore) -> Self { 188 | Space { focused: false, store } 189 | } 190 | 191 | pub fn focus(mut self, focused: bool) -> Self { 192 | self.focused = focused; 193 | self 194 | } 195 | } 196 | 197 | impl StatefulWidget for Space<'_> { 198 | type State = SpaceState; 199 | 200 | fn render(self, area: Rect, buffer: &mut Buffer, state: &mut Self::State) { 201 | let mut empty_message = None; 202 | let need_fetch = match state.last_fetch { 203 | Some(i) => i.elapsed() >= SPACE_HIERARCHY_DEBOUNCE, 204 | None => true, 205 | }; 206 | 207 | if need_fetch { 208 | let res = self.store.application.worker.space_members(state.room_id.clone()); 209 | 210 | match res { 211 | Ok(members) => { 212 | let mut items = members 213 | .into_iter() 214 | .filter_map(|id| { 215 | let (room, _, tags) = 216 | self.store.application.worker.get_room(id.clone()).ok()?; 217 | let room_info = std::sync::Arc::new((room, tags)); 218 | 219 | if id != state.room_id { 220 | Some(RoomItem::new(room_info, self.store)) 221 | } else { 222 | None 223 | } 224 | }) 225 | .collect::>(); 226 | let fields = &self.store.application.settings.tunables.sort.rooms; 227 | let collator = &mut self.store.application.collator; 228 | items.sort_by(|a, b| room_fields_cmp(a, b, fields, collator)); 229 | 230 | state.list.set(items); 231 | state.last_fetch = Some(Instant::now()); 232 | }, 233 | Err(e) => { 234 | let lines = vec![ 235 | Line::from("Unable to fetch space room hierarchy:"), 236 | Span::styled(e.to_string(), Style::default().fg(Color::Red)).into(), 237 | ]; 238 | 239 | empty_message = Text::from(lines).into(); 240 | }, 241 | } 242 | } 243 | 244 | let mut list = List::new(self.store).focus(self.focused); 245 | 246 | if let Some(text) = empty_message { 247 | list = list.empty_message(text); 248 | } 249 | 250 | list.render(area, buffer, &mut state.list) 251 | } 252 | } 253 | -------------------------------------------------------------------------------- /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 = 53 | RoomId::new_v1(server_name!("example.com")).to_owned(); 54 | pub static ref TEST_USER1: OwnedUserId = user_id!("@user1:example.com").to_owned(); 55 | pub static ref TEST_USER2: OwnedUserId = user_id!("@user2:example.com").to_owned(); 56 | pub static ref TEST_USER3: OwnedUserId = user_id!("@user3:example.com").to_owned(); 57 | pub static ref TEST_USER4: OwnedUserId = user_id!("@user4:example.com").to_owned(); 58 | pub static ref TEST_USER5: OwnedUserId = user_id!("@user5:example.com").to_owned(); 59 | pub static ref MSG1_EVID: OwnedEventId = EventId::new(server_name!("example.com")); 60 | pub static ref MSG2_EVID: OwnedEventId = EventId::new(server_name!("example.com")); 61 | pub static ref MSG3_EVID: OwnedEventId = 62 | event_id!("$5jRz3KfVhaUzXtVj7k:example.com").to_owned(); 63 | pub static ref MSG4_EVID: OwnedEventId = 64 | event_id!("$JP6qFV7WyXk5ZnexM3:example.com").to_owned(); 65 | pub static ref MSG5_EVID: OwnedEventId = EventId::new(server_name!("example.com")); 66 | pub static ref MSG1_KEY: MessageKey = (LocalEcho, MSG1_EVID.clone()); 67 | pub static ref MSG2_KEY: MessageKey = (OriginServer(UInt::new(1).unwrap()), MSG2_EVID.clone()); 68 | pub static ref MSG3_KEY: MessageKey = (OriginServer(UInt::new(2).unwrap()), MSG3_EVID.clone()); 69 | pub static ref MSG4_KEY: MessageKey = (OriginServer(UInt::new(2).unwrap()), MSG4_EVID.clone()); 70 | pub static ref MSG5_KEY: MessageKey = (OriginServer(UInt::new(8).unwrap()), MSG5_EVID.clone()); 71 | } 72 | 73 | pub fn user_style(user: &str) -> Style { 74 | user_style_from_color(user_color(user)) 75 | } 76 | 77 | pub fn mock_room1_message( 78 | content: RoomMessageEventContent, 79 | sender: OwnedUserId, 80 | key: MessageKey, 81 | ) -> Message { 82 | let origin_server_ts = key.0.as_millis().unwrap(); 83 | let event_id = key.1; 84 | 85 | let event = OriginalRoomMessageEvent { 86 | content, 87 | event_id, 88 | sender, 89 | origin_server_ts, 90 | room_id: TEST_ROOM1_ID.clone(), 91 | unsigned: Default::default(), 92 | }; 93 | 94 | event.into() 95 | } 96 | 97 | pub fn mock_message1() -> Message { 98 | let content = RoomMessageEventContent::text_plain("writhe"); 99 | let content = MessageEvent::Local(MSG1_EVID.clone(), content.into()); 100 | 101 | Message::new(content, TEST_USER1.clone(), MSG1_KEY.0) 102 | } 103 | 104 | pub fn mock_message2() -> Message { 105 | let content = RoomMessageEventContent::text_plain("helium"); 106 | 107 | mock_room1_message(content, TEST_USER2.clone(), MSG2_KEY.clone()) 108 | } 109 | 110 | pub fn mock_message3() -> Message { 111 | let content = RoomMessageEventContent::text_plain("this\nis\na\nmultiline\nmessage"); 112 | 113 | mock_room1_message(content, TEST_USER2.clone(), MSG3_KEY.clone()) 114 | } 115 | 116 | pub fn mock_message4() -> Message { 117 | let content = RoomMessageEventContent::text_plain("help"); 118 | 119 | mock_room1_message(content, TEST_USER1.clone(), MSG4_KEY.clone()) 120 | } 121 | 122 | pub fn mock_message5() -> Message { 123 | let content = RoomMessageEventContent::text_plain("character"); 124 | 125 | mock_room1_message(content, TEST_USER2.clone(), MSG4_KEY.clone()) 126 | } 127 | 128 | pub fn mock_keys() -> HashMap { 129 | let mut keys = HashMap::new(); 130 | 131 | keys.insert(MSG1_EVID.clone(), EventLocation::Message(None, MSG1_KEY.clone())); 132 | keys.insert(MSG2_EVID.clone(), EventLocation::Message(None, MSG2_KEY.clone())); 133 | keys.insert(MSG3_EVID.clone(), EventLocation::Message(None, MSG3_KEY.clone())); 134 | keys.insert(MSG4_EVID.clone(), EventLocation::Message(None, MSG4_KEY.clone())); 135 | keys.insert(MSG5_EVID.clone(), EventLocation::Message(None, MSG5_KEY.clone())); 136 | 137 | keys 138 | } 139 | 140 | pub fn mock_messages() -> Messages { 141 | let mut messages = Messages::main(); 142 | 143 | messages.insert(MSG1_KEY.clone(), mock_message1()); 144 | messages.insert(MSG2_KEY.clone(), mock_message2()); 145 | messages.insert(MSG3_KEY.clone(), mock_message3()); 146 | messages.insert(MSG4_KEY.clone(), mock_message4()); 147 | messages.insert(MSG5_KEY.clone(), mock_message5()); 148 | 149 | messages 150 | } 151 | 152 | pub fn mock_room() -> RoomInfo { 153 | let mut room = RoomInfo::default(); 154 | room.name = Some("Watercooler Discussion".into()); 155 | room.keys = mock_keys(); 156 | *room.get_thread_mut(None) = mock_messages(); 157 | room 158 | } 159 | 160 | pub fn mock_dirs() -> DirectoryValues { 161 | DirectoryValues { 162 | cache: PathBuf::new(), 163 | data: PathBuf::new(), 164 | logs: PathBuf::new(), 165 | downloads: None, 166 | image_previews: PathBuf::new(), 167 | } 168 | } 169 | 170 | pub fn mock_tunables() -> TunableValues { 171 | TunableValues { 172 | default_room: None, 173 | log_level: Level::INFO, 174 | message_shortcode_display: false, 175 | normal_after_send: true, 176 | reaction_display: true, 177 | reaction_shortcode_display: false, 178 | read_receipt_send: true, 179 | read_receipt_display: true, 180 | request_timeout: 120, 181 | sort: SortOverrides::default().values(), 182 | state_event_display: true, 183 | typing_notice_send: true, 184 | typing_notice_display: true, 185 | users: vec![(TEST_USER5.clone(), UserDisplayTunables { 186 | color: Some(UserColor(Color::Black)), 187 | name: Some("USER 5".into()), 188 | })] 189 | .into_iter() 190 | .collect::>(), 191 | open_command: None, 192 | external_edit_file_suffix: String::from(".md"), 193 | username_display: UserDisplayStyle::Username, 194 | message_user_color: false, 195 | mouse: Default::default(), 196 | notifications: Notifications { 197 | enabled: false, 198 | via: NotifyVia::default(), 199 | show_message: true, 200 | sound_hint: None, 201 | }, 202 | image_preview: None, 203 | user_gutter_width: 30, 204 | tabstop: 4, 205 | } 206 | } 207 | 208 | pub fn mock_settings() -> ApplicationSettings { 209 | ApplicationSettings { 210 | layout_json: PathBuf::new(), 211 | session_json: PathBuf::new(), 212 | session_json_old: PathBuf::new(), 213 | sled_dir: PathBuf::new(), 214 | sqlite_dir: PathBuf::new(), 215 | 216 | profile_name: "test".into(), 217 | profile: ProfileConfig { 218 | user_id: user_id!("@user:example.com").to_owned(), 219 | url: None, 220 | settings: None, 221 | dirs: None, 222 | layout: None, 223 | macros: None, 224 | }, 225 | tunables: mock_tunables(), 226 | dirs: mock_dirs(), 227 | layout: Default::default(), 228 | macros: HashMap::default(), 229 | } 230 | } 231 | 232 | pub async fn mock_store() -> ProgramStore { 233 | let (tx, _) = unbounded_channel(); 234 | let homeserver = Url::parse("https://localhost").unwrap(); 235 | let client = matrix_sdk::Client::new(homeserver).await.unwrap(); 236 | let worker = Requester { tx, client }; 237 | 238 | let mut store = ChatStore::new(worker, mock_settings()); 239 | 240 | // Add presence information. 241 | store.presences.get_or_default(TEST_USER1.clone()); 242 | store.presences.get_or_default(TEST_USER2.clone()); 243 | store.presences.get_or_default(TEST_USER3.clone()); 244 | store.presences.get_or_default(TEST_USER4.clone()); 245 | store.presences.get_or_default(TEST_USER5.clone()); 246 | 247 | let room_id = TEST_ROOM1_ID.clone(); 248 | let info = mock_room(); 249 | 250 | store.rooms.insert(room_id.clone(), info); 251 | store.names.insert(TEST_ROOM1_ALIAS.to_string(), room_id); 252 | 253 | ProgramStore::new(store) 254 | } 255 | -------------------------------------------------------------------------------- /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 | let tabstop = self.settings().tunables.tabstop; 220 | 221 | for mut word in UnicodeSegmentation::split_word_bounds(s) { 222 | if let "\n" | "\r\n" = word { 223 | if self.literal { 224 | self.commit(); 225 | continue; 226 | } 227 | 228 | // Render embedded newlines as spaces. 229 | word = " "; 230 | } 231 | 232 | if !self.literal && self.curr_width == 0 && word.chars().all(char::is_whitespace) { 233 | // Drop leading whitespace. 234 | continue; 235 | } 236 | 237 | let mut cow = if self.emoji_shortcodes() { 238 | Cow::Owned(replace_emojis_in_str(word)) 239 | } else { 240 | Cow::Borrowed(word) 241 | }; 242 | 243 | if cow == "\t" { 244 | let tablen = tabstop - (self.curr_width % tabstop); 245 | cow = Cow::Owned(" ".repeat(tablen)); 246 | } 247 | 248 | let sw = UnicodeWidthStr::width(cow.as_ref()); 249 | 250 | if sw > self.width { 251 | self.push_str_wrapped(cow, style); 252 | continue; 253 | } 254 | 255 | if self.curr_width + sw > self.width { 256 | // Word doesn't fit on this line, so start a new one. 257 | self.commit(); 258 | 259 | if !self.literal && cow.chars().all(char::is_whitespace) { 260 | // Drop leading whitespace. 261 | continue; 262 | } 263 | } 264 | 265 | let span = Span::styled(cow, style); 266 | self.curr_spans.push(span); 267 | self.curr_width += sw; 268 | } 269 | 270 | if self.curr_width == self.width { 271 | // If the last bit fills the full line, start a new one. 272 | self.push(); 273 | } 274 | } 275 | 276 | /// Push a [Line] into the printer. 277 | pub fn push_line(&mut self, mut line: Line<'a>) { 278 | self.commit(); 279 | if self.emoji_shortcodes() { 280 | replace_emojis_in_line(&mut line); 281 | } 282 | self.text.lines.push(line); 283 | } 284 | 285 | /// Push multiline [Text] into the printer. 286 | pub fn push_text(&mut self, mut text: Text<'a>) { 287 | self.commit(); 288 | if self.emoji_shortcodes() { 289 | for line in &mut text.lines { 290 | replace_emojis_in_line(line); 291 | } 292 | } 293 | self.text.lines.extend(text.lines); 294 | } 295 | 296 | /// Render the contents of this printer as [Text]. 297 | pub fn finish(mut self) -> Text<'a> { 298 | self.commit(); 299 | self.text 300 | } 301 | } 302 | 303 | #[cfg(test)] 304 | pub mod tests { 305 | use super::*; 306 | use crate::tests::mock_settings; 307 | 308 | #[test] 309 | fn test_push_nobreak() { 310 | let settings = mock_settings(); 311 | let mut printer = TextPrinter::new(5, Style::default(), false, &settings); 312 | printer.push_span_nobreak("hello world".into()); 313 | let text = printer.finish(); 314 | assert_eq!(text.lines.len(), 1); 315 | assert_eq!(text.lines[0].spans.len(), 1); 316 | assert_eq!(text.lines[0].spans[0].content, "hello world"); 317 | } 318 | } 319 | -------------------------------------------------------------------------------- /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 | .It Sy ":forget" 71 | Remove all left rooms from the internal database. 72 | .El 73 | 74 | .Sh "E2EE COMMANDS" 75 | .Bl -tag -width Ds 76 | .It Sy ":keys export [path] [passphrase]" 77 | Export and encrypt keys to 78 | .Pa path . 79 | .It Sy ":keys import [path] [passphrase]" 80 | Import and decrypt keys from 81 | .Pa path . 82 | .It Sy ":verify" 83 | View a list of ongoing E2EE verifications. 84 | .It Sy ":verify accept [key]" 85 | Accept a verification request. 86 | .It Sy ":verify cancel [key]" 87 | Cancel an in-progress verification. 88 | .It Sy ":verify confirm [key]" 89 | Confirm an in-progress verification. 90 | .It Sy ":verify mismatch [key]" 91 | Reject an in-progress verification due to mismatched Emoji. 92 | .It Sy ":verify request [user id]" 93 | Request a new verification with the specified user. 94 | .El 95 | 96 | .Sh "MESSAGE COMMANDS" 97 | .Bl -tag -width Ds 98 | .It Sy ":download [path]" 99 | Download an attachment from the selected message and save it to the optional path. 100 | .It Sy ":open [path]" 101 | Download and then open an attachment, or open a link in a message. 102 | .It Sy ":edit" 103 | Edit the selected message. 104 | .It Sy ":editor" 105 | Open an external 106 | .Ev $EDITOR 107 | to compose a message. 108 | .It Sy ":react [shortcode]" 109 | React to the selected message with an Emoji. 110 | .It Sy ":unreact [shortcode]" 111 | Remove your reaction from the selected message. 112 | When no arguments are given, remove all of your reactions from the message. 113 | .It Sy ":redact [reason]" 114 | Redact the selected message with the optional reason. 115 | .It Sy ":reply" 116 | Reply to the selected message. 117 | .It Sy ":cancel" 118 | Cancel the currently drafted message including replies. 119 | .It Sy ":replied" 120 | Go to the message the current message replied to. 121 | .It Sy ":upload [path]" 122 | Upload an attachment and send it to the currently selected room. 123 | .El 124 | 125 | .Sh "ROOM COMMANDS" 126 | .Bl -tag -width Ds 127 | .It Sy ":create [arguments]" 128 | Create a new room. Arguments can be 129 | .Dq ++alias=[alias] , 130 | .Dq ++public , 131 | .Dq ++space , 132 | and 133 | .Dq ++encrypted . 134 | .It Sy ":invite accept" 135 | Accept an invitation to the currently focused room. 136 | .It Sy ":invite reject" 137 | Reject an invitation to the currently focused room. 138 | .It Sy ":invite send [user]" 139 | Send an invitation to a user to join the currently focused room. 140 | .It Sy ":join [room]" 141 | Join a room or open it if you are already joined. 142 | .It Sy ":leave" 143 | Leave the currently focused room. 144 | .It Sy ":members" 145 | View a list of members of the currently focused room. 146 | .It Sy ":room name set [name]" 147 | Set the name of the currently focused room. 148 | .It Sy ":room name unset" 149 | Unset the name of the currently focused room. 150 | .It Sy ":room dm set" 151 | Mark the currently focused room as a direct message. 152 | .It Sy ":room dm unset" 153 | Mark the currently focused room as a normal room. 154 | .It Sy ":room notify set [level]" 155 | Set a notification level for the currently focused room. 156 | Valid levels are 157 | .Dq mute , 158 | .Dq mentions , 159 | .Dq keywords , 160 | and 161 | .Dq all . 162 | Note that 163 | .Dq mentions 164 | and 165 | .Dq keywords 166 | are aliases for the same behaviour. 167 | .It Sy ":room notify unset" 168 | Unset any room-level notification configuration. 169 | .It Sy ":room notify show" 170 | Show the current room-level notification configuration. 171 | If the room is using the account-level default, then this will print 172 | .Dq default . 173 | .It Sy ":room tag set [tag]" 174 | Add a tag to the currently focused room. 175 | .It Sy ":room tag unset [tag]" 176 | Remove a tag from the currently focused room. 177 | .It Sy ":room topic set [topic]" 178 | Set the topic of the currently focused room. 179 | .It Sy ":room topic unset" 180 | Unset the topic of the currently focused room. 181 | .It Sy ":room topic show" 182 | Show the topic of the currently focused room. 183 | .It Sy ":room alias set [alias]" 184 | Create and point the given alias to the room. 185 | .It Sy ":room alias unset [alias]" 186 | Delete the provided alias from the room's alternative alias list. 187 | .It Sy ":room alias show" 188 | Show alternative aliases to the room, if any are set. 189 | .It Sy ":room id show" 190 | Show the Matrix identifier for the room. 191 | .It Sy ":room canon set [alias]" 192 | Set the room's canonical alias to the one provided, and make the previous one an alternative alias. 193 | .It Sy ":room canon unset [alias]" 194 | Delete the room's canonical alias. 195 | .It Sy ":room canon show" 196 | Show the room's canonical alias, if any is set. 197 | .It Sy ":room ban [user] [reason]" 198 | Ban a user from this room with an optional reason. 199 | .It Sy ":room unban [user] [reason]" 200 | Unban a user from this room with an optional reason. 201 | .It Sy ":room kick [user] [reason]" 202 | Kick a user from this room with an optional reason. 203 | .El 204 | 205 | .Sh "SPACE COMMANDS" 206 | .Bl -tag -width Ds 207 | .It Sy ":space child set [room_id] [arguments]" 208 | Add a room to the currently focused space. 209 | .Dq ++suggested 210 | marks the room as a suggested child. 211 | .Dq ++order=[string] 212 | specifies a string by which children are lexicographically ordered. 213 | .It Sy ":space child remove" 214 | Remove the selected room from the currently focused space. 215 | .El 216 | 217 | .Sh "WINDOW COMMANDS" 218 | .Bl -tag -width Ds 219 | .It Sy ":horizontal [cmd]" 220 | Change the behaviour of the given command to be horizontal. 221 | .It Sy ":leftabove [cmd]" 222 | Change the behaviour of the given command to open before the current window. 223 | .It Sy ":only" , Sy ":on" 224 | Quit all but one window in the current tab. 225 | .It Sy ":quit" , Sy ":q" 226 | Quit a window. 227 | .It Sy ":quitall" , Sy ":qa" 228 | Quit all windows in the current tab. 229 | .It Sy ":resize" 230 | Resize a window. 231 | .It Sy ":rightbelow [cmd]" 232 | Change the behaviour of the given command to open after the current window. 233 | .It Sy ":split" , Sy ":sp" 234 | Horizontally split a window. 235 | .It Sy ":vertical [cmd]" 236 | Change the layout of the following command to be vertical. 237 | .It Sy ":vsplit" , Sy ":vsp" 238 | Vertically split a window. 239 | .El 240 | 241 | .Sh "TAB COMMANDS" 242 | .Bl -tag -width Ds 243 | .It Sy ":tab [cmd]" 244 | Run a command that opens a window in a new tab. 245 | .It Sy ":tabclose" , Sy ":tabc" 246 | Close a tab. 247 | .It Sy ":tabedit [room]" , Sy ":tabe" 248 | Open a room in a new tab. 249 | .It Sy ":tabrewind" , Sy ":tabr" 250 | Go to the first tab. 251 | .It Sy ":tablast" , Sy ":tabl" 252 | Go to the last tab. 253 | .It Sy ":tabnext" , Sy ":tabn" 254 | Go to the next tab. 255 | .It Sy ":tabonly" , Sy ":tabo" 256 | Close all but one tab. 257 | .It Sy ":tabprevious" , Sy ":tabp" 258 | Go to the preview tab. 259 | .El 260 | 261 | .Sh "SLASH COMMANDS" 262 | .Bl -tag -width Ds 263 | .It Sy "/markdown" , Sy "/md" 264 | Interpret the message body as Markdown markup. 265 | This is the default behaviour. 266 | .It Sy "/html" , Sy "/h" 267 | Send the message body as literal HTML. 268 | .It Sy "/plaintext" , Sy "/plain" , Sy "/p" 269 | Do not interpret any markup in the message body and send it as it is. 270 | .It Sy "/me" 271 | Send an emote message. 272 | .It Sy "/confetti" 273 | Produces no effect in 274 | .Nm , 275 | but will display confetti in Matrix clients that support doing so. 276 | .It Sy "/fireworks" 277 | Produces no effect in 278 | .Nm , 279 | but will display fireworks in Matrix clients that support doing so. 280 | .It Sy "/hearts" 281 | Produces no effect in 282 | .Nm , 283 | but will display floating hearts in Matrix clients that support doing so. 284 | .It Sy "/rainfall" 285 | Produces no effect in 286 | .Nm , 287 | but will display rainfall in Matrix clients that support doing so. 288 | .It Sy "/snowfall" 289 | Produces no effect in 290 | .Nm , 291 | but will display snowfall in Matrix clients that support doing so. 292 | .It Sy "/spaceinvaders" 293 | Produces no effect in 294 | .Nm , 295 | but will display aliens from Space Invaders in Matrix clients that support doing so. 296 | .El 297 | 298 | .Sh EXAMPLES 299 | .Ss Example 1: Starting with a specific profile 300 | To start with a profile named 301 | .Sy personal 302 | instead of the 303 | .Sy default_profile 304 | value: 305 | .Bd -literal -offset indent 306 | $ iamb -P personal 307 | .Ed 308 | .Ss Example 2: Using an alternate configuration directory 309 | By default, 310 | .Nm 311 | will use the XDG directories, but you may sometimes want to store 312 | your configuration elsewhere. 313 | .Bd -literal -offset indent 314 | $ iamb -C ~/src/iamb-dev/dev-config/ 315 | .Ed 316 | .Sh "REPORTING BUGS" 317 | Please report bugs in 318 | .Nm 319 | or its manual pages at 320 | .Lk https://github.com/ulyssa/iamb/issues 321 | .Sh "SEE ALSO" 322 | .Xr iamb 5 323 | .Pp 324 | Extended documentation is available online at 325 | .Lk https://iamb.chat 326 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | OwnedRoomId, 12 | RoomId, 13 | }, 14 | Client, 15 | EncryptionState, 16 | }; 17 | use unicode_segmentation::UnicodeSegmentation; 18 | 19 | use crate::{ 20 | base::{AsyncProgramStore, IambError, IambResult, ProgramStore}, 21 | config::{ApplicationSettings, NotifyVia}, 22 | }; 23 | 24 | const IAMB_XDG_NAME: &str = match option_env!("IAMB_XDG_NAME") { 25 | None => "iamb", 26 | Some(iamb) => iamb, 27 | }; 28 | 29 | /// Handle for an open notification that should be closed when the user views it. 30 | pub struct NotificationHandle( 31 | #[cfg(all(feature = "desktop", unix, not(target_os = "macos")))] 32 | Option, 33 | ); 34 | 35 | impl Drop for NotificationHandle { 36 | fn drop(&mut self) { 37 | #[cfg(all(feature = "desktop", unix, not(target_os = "macos")))] 38 | if let Some(handle) = self.0.take() { 39 | handle.close(); 40 | } 41 | } 42 | } 43 | 44 | pub async fn register_notifications( 45 | client: &Client, 46 | settings: &ApplicationSettings, 47 | store: &AsyncProgramStore, 48 | ) { 49 | if !settings.tunables.notifications.enabled { 50 | return; 51 | } 52 | let notify_via = settings.tunables.notifications.via; 53 | let show_message = settings.tunables.notifications.show_message; 54 | let sound_hint = settings.tunables.notifications.sound_hint.clone(); 55 | let server_settings = client.notification_settings().await; 56 | let Some(startup_ts) = MilliSecondsSinceUnixEpoch::from_system_time(SystemTime::now()) else { 57 | return; 58 | }; 59 | 60 | let store = store.clone(); 61 | client 62 | .register_notification_handler(move |notification, room: MatrixRoom, client: Client| { 63 | let store = store.clone(); 64 | let server_settings = server_settings.clone(); 65 | let sound_hint = sound_hint.clone(); 66 | async move { 67 | let mode = global_or_room_mode(&server_settings, &room).await; 68 | if mode == RoomNotificationMode::Mute { 69 | return; 70 | } 71 | 72 | if is_visible_room(&store, room.room_id()).await { 73 | return; 74 | } 75 | 76 | let room_id = room.room_id().to_owned(); 77 | match notification.event { 78 | RawAnySyncOrStrippedTimelineEvent::Sync(e) => { 79 | match parse_full_notification(e, room, show_message).await { 80 | Ok((summary, body, server_ts)) => { 81 | if server_ts < startup_ts { 82 | return; 83 | } 84 | 85 | if is_missing_mention(&body, mode, &client) { 86 | return; 87 | } 88 | 89 | send_notification( 90 | ¬ify_via, 91 | &summary, 92 | body.as_deref(), 93 | room_id, 94 | &store, 95 | sound_hint.as_deref(), 96 | ) 97 | .await; 98 | }, 99 | Err(err) => { 100 | tracing::error!("Failed to extract notification data: {err}") 101 | }, 102 | } 103 | }, 104 | // Stripped events may be dropped silently because they're 105 | // only relevant if we're not in a room, and we presumably 106 | // don't want notifications for rooms we're not in. 107 | RawAnySyncOrStrippedTimelineEvent::Stripped(_) => (), 108 | } 109 | } 110 | }) 111 | .await; 112 | } 113 | 114 | async fn send_notification( 115 | via: &NotifyVia, 116 | summary: &str, 117 | body: Option<&str>, 118 | room_id: OwnedRoomId, 119 | store: &AsyncProgramStore, 120 | sound_hint: Option<&str>, 121 | ) { 122 | #[cfg(feature = "desktop")] 123 | if via.desktop { 124 | send_notification_desktop(summary, body, room_id, store, sound_hint).await; 125 | } 126 | #[cfg(not(feature = "desktop"))] 127 | { 128 | let _ = (summary, body, IAMB_XDG_NAME); 129 | } 130 | 131 | if via.bell { 132 | send_notification_bell(store).await; 133 | } 134 | } 135 | 136 | async fn send_notification_bell(store: &AsyncProgramStore) { 137 | let mut locked = store.lock().await; 138 | locked.application.ring_bell = true; 139 | } 140 | 141 | #[cfg(feature = "desktop")] 142 | #[cfg_attr(target_os = "macos", allow(unused_variables))] 143 | async fn send_notification_desktop( 144 | summary: &str, 145 | body: Option<&str>, 146 | room_id: OwnedRoomId, 147 | _store: &AsyncProgramStore, 148 | sound_hint: Option<&str>, 149 | ) { 150 | let mut desktop_notification = notify_rust::Notification::new(); 151 | desktop_notification 152 | .summary(summary) 153 | .appname(IAMB_XDG_NAME) 154 | .icon(IAMB_XDG_NAME) 155 | .action("default", "default"); 156 | 157 | if let Some(sound_hint) = sound_hint { 158 | desktop_notification.sound_name(sound_hint); 159 | } 160 | 161 | #[cfg(all(unix, not(target_os = "macos")))] 162 | desktop_notification.urgency(notify_rust::Urgency::Normal); 163 | 164 | if let Some(body) = body { 165 | desktop_notification.body(body); 166 | } 167 | 168 | match desktop_notification.show() { 169 | Err(err) => tracing::error!("Failed to send notification: {err}"), 170 | Ok(handle) => { 171 | #[cfg(all(unix, not(target_os = "macos")))] 172 | _store 173 | .lock() 174 | .await 175 | .application 176 | .open_notifications 177 | .entry(room_id) 178 | .or_default() 179 | .push(NotificationHandle(Some(handle))); 180 | }, 181 | } 182 | } 183 | 184 | async fn global_or_room_mode( 185 | settings: &NotificationSettings, 186 | room: &MatrixRoom, 187 | ) -> RoomNotificationMode { 188 | let room_mode = settings.get_user_defined_room_notification_mode(room.room_id()).await; 189 | if let Some(mode) = room_mode { 190 | return mode; 191 | } 192 | let is_one_to_one = match room.is_direct().await { 193 | Ok(true) => IsOneToOne::Yes, 194 | _ => IsOneToOne::No, 195 | }; 196 | let is_encrypted = match room.latest_encryption_state().await { 197 | Ok(EncryptionState::Encrypted) => IsEncrypted::Yes, 198 | _ => IsEncrypted::No, 199 | }; 200 | settings 201 | .get_default_room_notification_mode(is_encrypted, is_one_to_one) 202 | .await 203 | } 204 | 205 | fn is_missing_mention(body: &Option, mode: RoomNotificationMode, client: &Client) -> bool { 206 | if let Some(body) = body { 207 | if mode == RoomNotificationMode::MentionsAndKeywordsOnly { 208 | let mentioned = match client.user_id() { 209 | Some(user_id) => body.contains(user_id.localpart()), 210 | _ => false, 211 | }; 212 | return !mentioned; 213 | } 214 | } 215 | false 216 | } 217 | 218 | fn is_open(locked: &mut ProgramStore, room_id: &RoomId) -> bool { 219 | if let Some(draw_curr) = locked.application.draw_curr { 220 | let info = locked.application.get_room_info(room_id.to_owned()); 221 | if let Some(draw_last) = info.draw_last { 222 | return draw_last == draw_curr; 223 | } 224 | } 225 | false 226 | } 227 | 228 | fn is_focused(locked: &ProgramStore) -> bool { 229 | locked.application.focused 230 | } 231 | 232 | async fn is_visible_room(store: &AsyncProgramStore, room_id: &RoomId) -> bool { 233 | let mut locked = store.lock().await; 234 | 235 | is_focused(&locked) && is_open(&mut locked, room_id) 236 | } 237 | 238 | pub async fn parse_full_notification( 239 | event: Raw, 240 | room: MatrixRoom, 241 | show_body: bool, 242 | ) -> IambResult<(String, Option, MilliSecondsSinceUnixEpoch)> { 243 | let event = event.deserialize().map_err(IambError::from)?; 244 | 245 | let server_ts = event.origin_server_ts(); 246 | 247 | let sender_id = event.sender(); 248 | let sender = room.get_member_no_sync(sender_id).await.map_err(IambError::from)?; 249 | 250 | let sender_name = sender 251 | .as_ref() 252 | .and_then(|m| m.display_name()) 253 | .unwrap_or_else(|| sender_id.localpart()); 254 | 255 | let summary = if let Some(room_name) = room.cached_display_name() { 256 | if room.is_direct().await.map_err(IambError::from)? && sender_name == room_name.to_string() 257 | { 258 | sender_name.to_string() 259 | } else { 260 | format!("{sender_name} in {room_name}") 261 | } 262 | } else { 263 | sender_name.to_string() 264 | }; 265 | 266 | let body = if show_body { 267 | event_notification_body(&event, sender_name).map(truncate) 268 | } else { 269 | None 270 | }; 271 | 272 | return Ok((summary, body, server_ts)); 273 | } 274 | 275 | pub fn event_notification_body(event: &AnySyncTimelineEvent, sender_name: &str) -> Option { 276 | let AnySyncTimelineEvent::MessageLike(event) = event else { 277 | return None; 278 | }; 279 | 280 | match event.original_content()? { 281 | AnyMessageLikeEventContent::RoomMessage(message) => { 282 | let body = match message.msgtype { 283 | MessageType::Audio(_) => { 284 | format!("{sender_name} sent an audio file.") 285 | }, 286 | MessageType::Emote(content) => content.body, 287 | MessageType::File(_) => { 288 | format!("{sender_name} sent a file.") 289 | }, 290 | MessageType::Image(_) => { 291 | format!("{sender_name} sent an image.") 292 | }, 293 | MessageType::Location(_) => { 294 | format!("{sender_name} sent their location.") 295 | }, 296 | MessageType::Notice(content) => content.body, 297 | MessageType::ServerNotice(content) => content.body, 298 | MessageType::Text(content) => content.body, 299 | MessageType::Video(_) => { 300 | format!("{sender_name} sent a video.") 301 | }, 302 | MessageType::VerificationRequest(_) => { 303 | format!("{sender_name} sent a verification request.") 304 | }, 305 | _ => { 306 | format!("[Unknown message type: {:?}]", &message.msgtype) 307 | }, 308 | }; 309 | Some(body) 310 | }, 311 | AnyMessageLikeEventContent::Sticker(_) => Some(format!("{sender_name} sent a sticker.")), 312 | _ => None, 313 | } 314 | } 315 | 316 | fn truncate(s: String) -> String { 317 | static MAX_LENGTH: usize = 5000; 318 | if s.graphemes(true).count() > MAX_LENGTH { 319 | let truncated: String = s.graphemes(true).take(MAX_LENGTH).collect(); 320 | truncated + "..." 321 | } else { 322 | s 323 | } 324 | } 325 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 normal_after_send 177 | Defines whether to reset input to Normal mode after sending a message. 178 | 179 | .It Sy notifications 180 | When this subsection is present, you can enable and configure push notifications. 181 | See 182 | .Sx NOTIFICATIONS 183 | for more details. 184 | 185 | .It Sy open_command 186 | Defines a custom command and its arguments to run when opening downloads instead of the default. 187 | (For example, 188 | .Sy ["my-open",\ "--file"] . ) 189 | 190 | .It Sy reaction_display 191 | Defines whether or not reactions should be shown. 192 | 193 | .It Sy reaction_shortcode_display 194 | Defines whether or not reactions should be shown as their respective shortcode. 195 | 196 | .It Sy read_receipt_send 197 | Defines whether or not read confirmations are sent. 198 | 199 | .It Sy read_receipt_display 200 | Defines whether or not read confirmations are displayed. 201 | 202 | .It Sy request_timeout 203 | Defines the maximum time per request in seconds. 204 | 205 | .It Sy sort 206 | Configures how to sort the lists shown in windows like 207 | .Sy :rooms 208 | or 209 | .Sy :members . 210 | See 211 | .Sx "SORTING LISTS" 212 | for more details. 213 | 214 | .It Sy state_event_display 215 | Defines whether the state events like joined or left are shown. 216 | 217 | .It Sy typing_notice_send 218 | Defines whether or not the typing state is sent. 219 | 220 | .It Sy typing_notice_display 221 | Defines whether or not the typing state is displayed. 222 | 223 | .It Sy user 224 | Overrides values for the specified user. 225 | See 226 | .Sx "USER OVERRIDES" 227 | for details on the format. 228 | 229 | .It Sy username_display 230 | Defines how usernames are shown for message senders. 231 | Possible values are 232 | .Dq Sy username , 233 | .Dq Sy localpart , or 234 | .Dq Sy displayname . 235 | 236 | .It Sy user_gutter_width 237 | Specify the width of the column where usernames are displayed in a room. 238 | Usernames that are too long are truncated. 239 | Defaults to 30. 240 | 241 | .It Sy tabstop 242 | Number of spaces that a counts for. 243 | Defaults to 4. 244 | .El 245 | 246 | .Ss Example 1: Avoid showing Emojis (useful for terminals w/o support) 247 | .Bd -literal -offset indent 248 | [settings] 249 | username = "username" 250 | message_shortcode_display = true 251 | reaction_shortcode_display = true 252 | .Ed 253 | 254 | .Ss Example 2: Increase request timeout to 2 minutes for a slow homeserver 255 | .Bd -literal -offset indent 256 | [settings] 257 | request_timeout = 120 258 | .Ed 259 | 260 | .Sh NOTIFICATIONS 261 | 262 | The 263 | .Sy settings.notifications 264 | subsection allows configuring how notifications for new messages behave. 265 | 266 | The available fields in this subsection are: 267 | .Bl -tag -width Ds 268 | .It Sy enabled 269 | Defaults to 270 | .Sy false . 271 | Setting this field to 272 | .Sy true 273 | enables notifications. 274 | 275 | .It Sy via 276 | Defaults to 277 | .Dq Sy desktop 278 | to use the desktop mechanism (default). 279 | Setting this field to 280 | .Dq Sy bell 281 | will use the terminal bell instead. 282 | Both can be used via 283 | .Dq Sy desktop|bell . 284 | 285 | .It Sy show_message 286 | controls whether to show the message in the desktop notification, and defaults to 287 | .Sy true . 288 | Messages are truncated beyond a small length. 289 | The notification rules are stored server side, loaded once at startup, and are currently not configurable in iamb. 290 | In other words, you can simply change the rules with another client. 291 | .El 292 | 293 | .Ss Example 1: Enable notifications with default options 294 | .Bd -literal -offset indent 295 | [settings] 296 | notifications = {} 297 | .Ed 298 | .Ss Example 2: Enable notifications using terminal bell 299 | .Bd -literal -offset indent 300 | [settings.notifications] 301 | via = "bell" 302 | show_message = false 303 | .Ed 304 | 305 | .Sh "SORTING LISTS" 306 | 307 | The 308 | .Sy settings.sort 309 | subsection allows configuring how different windows have their contents sorted. 310 | 311 | Fields available within this subsection are: 312 | .Bl -tag -width Ds 313 | .It Sy rooms 314 | How to sort the 315 | .Sy :rooms 316 | window. 317 | Defaults to 318 | .Sy ["favorite",\ "lowpriority",\ "unread",\ "name"] . 319 | .It Sy chats 320 | How to sort the 321 | .Sy :chats 322 | window. 323 | Defaults to the 324 | .Sy rooms 325 | value. 326 | .It Sy dms 327 | How to sort the 328 | .Sy :dms 329 | window. 330 | Defaults to the 331 | .Sy rooms 332 | value. 333 | .It Sy spaces 334 | How to sort the 335 | .Sy :spaces 336 | window. 337 | Defaults to the 338 | .Sy rooms 339 | value. 340 | .It Sy members 341 | How to sort the 342 | .Sy :members 343 | window. 344 | Defaults to 345 | .Sy ["power",\ "id"] . 346 | .El 347 | 348 | The available values are: 349 | .Bl -tag -width Ds 350 | .It Sy favorite 351 | Put favorite rooms before other rooms. 352 | .It Sy lowpriority 353 | Put lowpriority rooms after other rooms. 354 | .It Sy name 355 | Sort rooms by alphabetically ascending room name. 356 | .It Sy alias 357 | Sort rooms by alphabetically ascending canonical room alias. 358 | .It Sy id 359 | Sort rooms by alphabetically ascending Matrix room identifier. 360 | .It Sy unread 361 | Put unread rooms before other rooms. 362 | .It Sy recent 363 | Sort rooms by most recent message timestamp. 364 | .It Sy invite 365 | Put invites before other rooms. 366 | .El 367 | .El 368 | 369 | .Ss Example 1: Group room members by their server first 370 | .Bd -literal -offset indent 371 | [settings.sort] 372 | members = ["server", "localpart"] 373 | .Ed 374 | 375 | .Sh "USER OVERRIDES" 376 | 377 | The 378 | .Sy settings.users 379 | subsections allows overriding how specific senders are displayed. 380 | Overrides are mapped onto Matrix User IDs such as 381 | .Sy @user:matrix.org , 382 | and are typically written as inline tables containing the following keys: 383 | 384 | .Bl -tag -width Ds 385 | .It Sy name 386 | Change the display name of the user. 387 | 388 | .It Sy color 389 | Change the color the user is shown as. 390 | Possible values are: 391 | .Dq Sy black , 392 | .Dq Sy blue , 393 | .Dq Sy cyan , 394 | .Dq Sy dark-gray , 395 | .Dq Sy gray , 396 | .Dq Sy green , 397 | .Dq Sy light-blue , 398 | .Dq Sy light-cyan , 399 | .Dq Sy light-green , 400 | .Dq Sy light-magenta , 401 | .Dq Sy light-red , 402 | .Dq Sy light-yellow , 403 | .Dq Sy magenta , 404 | .Dq Sy none , 405 | .Dq Sy red , 406 | .Dq Sy white , 407 | and 408 | .Dq Sy yellow . 409 | .El 410 | 411 | .Ss Example 1: Override how @ada:example.com appears in chat 412 | .Bd -literal -offset indent 413 | [settings.users] 414 | "@ada:example.com" = { name = "Ada Lovelace", color = "light-red" } 415 | .Ed 416 | 417 | .Sh STARTUP LAYOUT 418 | 419 | The 420 | .Sy layout 421 | section allows configuring the initial set of tabs and windows to show when 422 | starting the client. 423 | 424 | .Bl -tag -width Ds 425 | .It Sy style 426 | Specifies what window layout to load when starting. 427 | Valid values are 428 | .Dq Sy restore 429 | to restore the layout from the last time the client was exited, 430 | .Dq Sy new 431 | to open a single window (uses the value of 432 | .Sy default_room 433 | if set), or 434 | .Dq Sy config 435 | to open the layout described under 436 | .Sy tabs . 437 | 438 | .It Sy tabs 439 | If 440 | .Sy style 441 | is set to 442 | .Sy config , 443 | then this value will be used to open a set of tabs and windows at startup. 444 | Each object can contain either a 445 | .Sy window 446 | key specifying a username, room identifier or room alias to show, or a 447 | .Sy split 448 | key specifying an array of window objects. 449 | .El 450 | 451 | .Ss Example 1: Show a single room every startup 452 | .Bd -literal -offset indent 453 | [settings] 454 | default_room = "#iamb-users:0x.badd.cafe" 455 | 456 | [layout] 457 | style = "new" 458 | .Ed 459 | .Ss Example 2: Show a specific layout every startup 460 | .Bd -literal -offset indent 461 | [layout] 462 | style = "config" 463 | 464 | [[layout.tabs]] 465 | window = "iamb://dms" 466 | 467 | [[layout.tabs]] 468 | window = "iamb://rooms" 469 | 470 | [[layout.tabs]] 471 | split = [ 472 | { "window" = "#iamb-users:0x.badd.cafe" }, 473 | { "window" = "#iamb-dev:0x.badd.cafe" } 474 | ] 475 | .Ed 476 | 477 | .Sh "CUSTOM KEYBINDINGS" 478 | 479 | The 480 | .Sy macros 481 | subsections allow configuring custom keybindings. 482 | Available subsections are: 483 | 484 | .Bl -tag -width Ds 485 | .It Sy insert , Sy i 486 | Map the key sequences in this section in 487 | .Sy Insert 488 | mode. 489 | 490 | .It Sy normal , Sy n 491 | Map the key sequences in this section in 492 | .Sy Normal 493 | mode. 494 | 495 | .It Sy visual , Sy v 496 | Map the key sequences in this section in 497 | .Sy Visual 498 | mode. 499 | 500 | .It Sy select 501 | Map the key sequences in this section in 502 | .Sy Select 503 | mode. 504 | 505 | .It Sy command , Sy c 506 | Map the key sequences in this section in 507 | .Sy Visual 508 | mode. 509 | 510 | .It Sy operator-pending 511 | Map the key sequences in this section in 512 | .Sy "Operator Pending" 513 | mode. 514 | .El 515 | 516 | Multiple modes can be given together by separating their names with 517 | .Dq Sy | . 518 | 519 | .Ss Example 1: Use "jj" to exit Insert mode 520 | .Bd -literal -offset indent 521 | [macros.insert] 522 | "jj" = "" 523 | .Ed 524 | 525 | .Ss Example 2: Use "V" for switching between message bar and room history 526 | .Bd -literal -offset indent 527 | [macros."normal|visual"] 528 | "V" = "m" 529 | .Ed 530 | 531 | .Sh DIRECTORIES 532 | 533 | Specifies the directories to save data in. 534 | Configured as an object under the key 535 | .Sy dirs . 536 | 537 | .Bl -tag -width Ds 538 | .It Sy cache 539 | Specifies where to store assets and temporary data in. 540 | (For example, 541 | .Sy image_preview 542 | and 543 | .Sy logs 544 | will also go in here by default.) 545 | Defaults to 546 | .Ev $XDG_CACHE_HOME/iamb . 547 | 548 | .It Sy data 549 | Specifies where to store persistent data in, such as E2EE room keys. 550 | Defaults to 551 | .Ev $XDG_DATA_HOME/iamb . 552 | 553 | .It Sy downloads 554 | Specifies where to store downloaded files. 555 | Defaults to 556 | .Ev $XDG_DOWNLOAD_DIR . 557 | 558 | .It Sy image_previews 559 | Specifies where to store automatically downloaded image previews. 560 | Defaults to 561 | .Ev ${cache}/image_preview_downloads . 562 | 563 | .It Sy logs 564 | Specifies where to store log files. 565 | Defaults to 566 | .Ev ${cache}/logs . 567 | .El 568 | .Sh FILES 569 | .Bl -tag -width Ds 570 | .It Pa ~/.config/iamb/config.toml 571 | The TOML configuration file that 572 | .Sy iamb 573 | loads by default. 574 | .It Pa ~/.config/iamb/config.json 575 | A JSON configuration file that 576 | .Sy iamb 577 | will load if the TOML one is not found. 578 | .It Pa /usr/share/iamb/config.example.toml 579 | A sample configuration file with examples of how to set different values. 580 | .El 581 | .Sh "REPORTING BUGS" 582 | Please report bugs in 583 | .Sy iamb 584 | or its manual pages at 585 | .Lk https://github.com/ulyssa/iamb/issues 586 | .Sh SEE ALSO 587 | .Xr iamb 1 588 | .Pp 589 | Extended documentation is available online at 590 | .Lk https://iamb.chat 591 | -------------------------------------------------------------------------------- /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(Box), 124 | Space(Box), 125 | } 126 | 127 | impl From for RoomState { 128 | fn from(chat: ChatState) -> Self { 129 | RoomState::Chat(Box::new(chat)) 130 | } 131 | } 132 | 133 | impl From for RoomState { 134 | fn from(space: SpaceState) -> Self { 135 | RoomState::Space(Box::new(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(Box::new(chat.dup(store))), 780 | RoomState::Space(space) => RoomState::Space(Box::new(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 | --------------------------------------------------------------------------------