diff --git a/Cargo.lock b/Cargo.lock index 895ee653..a37235cb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -482,10 +482,10 @@ dependencies = [ "bytes", "form_urlencoded", "futures-util", - "http", - "http-body", + "http 1.3.1", + "http-body 1.0.1", "http-body-util", - "hyper", + "hyper 1.6.0", "hyper-util", "itoa", "matchit", @@ -498,7 +498,7 @@ dependencies = [ "serde_json", "serde_path_to_error", "serde_urlencoded", - "sync_wrapper", + "sync_wrapper 1.0.2", "tokio", "tower", "tower-layer", @@ -514,13 +514,13 @@ checksum = "68464cd0412f486726fb3373129ef5d2993f90c34bc2bc1c1e9943b2f4fc7ca6" dependencies = [ "bytes", "futures-core", - "http", - "http-body", + "http 1.3.1", + "http-body 1.0.1", "http-body-util", "mime", "pin-project-lite", "rustversion", - "sync_wrapper", + "sync_wrapper 1.0.2", "tower-layer", "tower-service", "tracing", @@ -1484,6 +1484,15 @@ dependencies = [ "phf 0.11.3", ] +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "endian-type" version = "0.1.2" @@ -1983,6 +1992,25 @@ dependencies = [ "xxhash-rust", ] +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "h2" version = "0.4.10" @@ -1994,7 +2022,7 @@ dependencies = [ "fnv", "futures-core", "futures-sink", - "http", + "http 1.3.1", "indexmap", "slab", "tokio", @@ -2089,6 +2117,15 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "html-escape" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d1ad449764d627e22bfd7cd5e8868264fc9236e07c752972b4080cd351cb476" +dependencies = [ + "utf8-width", +] + [[package]] name = "html5ever" version = "0.26.0" @@ -2115,6 +2152,17 @@ dependencies = [ "match_token", ] +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + [[package]] name = "http" version = "1.3.1" @@ -2126,6 +2174,17 @@ dependencies = [ "itoa", ] +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + [[package]] name = "http-body" version = "1.0.1" @@ -2133,7 +2192,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http", + "http 1.3.1", ] [[package]] @@ -2144,8 +2203,8 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http", - "http-body", + "http 1.3.1", + "http-body 1.0.1", "pin-project-lite", ] @@ -2170,6 +2229,30 @@ dependencies = [ "libm", ] +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2 0.3.27", + "http 0.2.12", + "http-body 0.4.6", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.5.10", + "tokio", + "tower-service", + "tracing", + "want", +] + [[package]] name = "hyper" version = "1.6.0" @@ -2179,9 +2262,9 @@ dependencies = [ "bytes", "futures-channel", "futures-util", - "h2", - "http", - "http-body", + "h2 0.4.10", + "http 1.3.1", + "http-body 1.0.1", "httparse", "httpdate", "itoa", @@ -2197,8 +2280,8 @@ version = "0.27.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03a01595e11bdcec50946522c32dde3fc6914743000a68b93000965f2f02406d" dependencies = [ - "http", - "hyper", + "http 1.3.1", + "hyper 1.6.0", "hyper-util", "rustls", "rustls-pki-types", @@ -2208,6 +2291,19 @@ dependencies = [ "webpki-roots", ] +[[package]] +name = "hyper-tls" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" +dependencies = [ + "bytes", + "hyper 0.14.32", + "native-tls", + "tokio", + "tokio-native-tls", +] + [[package]] name = "hyper-tls" version = "0.6.0" @@ -2216,7 +2312,7 @@ checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" dependencies = [ "bytes", "http-body-util", - "hyper", + "hyper 1.6.0", "hyper-util", "native-tls", "tokio", @@ -2235,9 +2331,9 @@ dependencies = [ "futures-channel", "futures-core", "futures-util", - "http", - "http-body", - "hyper", + "http 1.3.1", + "http-body 1.0.1", + "hyper 1.6.0", "ipnet", "libc", "percent-encoding", @@ -2264,6 +2360,7 @@ dependencies = [ "feruca", "futures", "gethostname", + "html-escape", "html5ever 0.26.0", "humansize", "image", @@ -2284,6 +2381,7 @@ dependencies = [ "ratatui", "ratatui-image", "regex", + "reqwest 0.11.27", "rpassword", "serde", "serde_json", @@ -3081,7 +3179,7 @@ dependencies = [ "futures-core", "futures-util", "gloo-timers", - "http", + "http 1.3.1", "imbl", "indexmap", "js_int", @@ -3097,7 +3195,7 @@ dependencies = [ "percent-encoding", "pin-project-lite", "rand 0.8.5", - "reqwest", + "reqwest 0.12.18", "ruma", "serde", "serde_html_form", @@ -3574,9 +3672,9 @@ dependencies = [ "base64 0.22.1", "chrono", "getrandom 0.2.16", - "http", + "http 1.3.1", "rand 0.8.5", - "reqwest", + "reqwest 0.12.18", "serde", "serde_json", "serde_path_to_error", @@ -4530,6 +4628,46 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +[[package]] +name = "reqwest" +version = "0.11.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" +dependencies = [ + "base64 0.21.7", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2 0.3.27", + "http 0.2.12", + "http-body 0.4.6", + "hyper 0.14.32", + "hyper-tls 0.5.0", + "ipnet", + "js-sys", + "log", + "mime", + "native-tls", + "once_cell", + "percent-encoding", + "pin-project-lite", + "rustls-pemfile", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper 0.1.2", + "system-configuration", + "tokio", + "tokio-native-tls", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "winreg", +] + [[package]] name = "reqwest" version = "0.12.18" @@ -4541,13 +4679,13 @@ dependencies = [ "bytes", "futures-core", "futures-util", - "h2", - "http", - "http-body", + "h2 0.4.10", + "http 1.3.1", + "http-body 1.0.1", "http-body-util", - "hyper", + "hyper 1.6.0", "hyper-rustls", - "hyper-tls", + "hyper-tls 0.6.0", "hyper-util", "ipnet", "js-sys", @@ -4563,7 +4701,7 @@ dependencies = [ "serde", "serde_json", "serde_urlencoded", - "sync_wrapper", + "sync_wrapper 1.0.2", "tokio", "tokio-native-tls", "tokio-rustls", @@ -4679,7 +4817,7 @@ dependencies = [ "assign", "bytes", "date_header", - "http", + "http 1.3.1", "js_int", "js_option", "maplit", @@ -4704,7 +4842,7 @@ dependencies = [ "bytes", "form_urlencoded", "getrandom 0.2.16", - "http", + "http 1.3.1", "indexmap", "js-sys", "js_int", @@ -4756,7 +4894,7 @@ version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb2a705c3911870782e036a3a8b676d0166c6c93800b84f6b8b23c981f78ef08" dependencies = [ - "http", + "http 1.3.1", "js_int", "mime", "ruma-common", @@ -4893,6 +5031,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64 0.21.7", +] + [[package]] name = "rustls-pki-types" version = "1.12.0" @@ -5362,6 +5509,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -5382,6 +5535,27 @@ dependencies = [ "syn 2.0.101", ] +[[package]] +name = "system-configuration" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "system-deps" version = "6.2.2" @@ -5704,7 +5878,7 @@ dependencies = [ "futures-core", "futures-util", "pin-project-lite", - "sync_wrapper", + "sync_wrapper 1.0.2", "tokio", "tower-layer", "tower-service", @@ -5720,8 +5894,8 @@ dependencies = [ "bitflags 2.9.1", "bytes", "futures-util", - "http", - "http-body", + "http 1.3.1", + "http-body 1.0.1", "iri-string", "pin-project-lite", "tower", @@ -5986,6 +6160,12 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" +[[package]] +name = "utf8-width" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86bd8d4e895da8537e5315b8254664e6b769c4ff3db18321b297a1e7004392e3" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -6710,6 +6890,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + [[package]] name = "wit-bindgen-rt" version = "0.39.0" diff --git a/Cargo.toml b/Cargo.toml index ff5ec858..5b68dc6a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -65,6 +65,8 @@ url = {version = "^2.2.2", features = ["serde"]} edit = "0.1.4" humansize = "2.0.0" linkify = "0.10.0" +reqwest = { version = "0.11", features = ["json"] } +html-escape = "0.2" [dependencies.comrak] version = "0.22.0" diff --git a/MSC4352.md b/MSC4352.md new file mode 100644 index 00000000..eccbac46 --- /dev/null +++ b/MSC4352.md @@ -0,0 +1,112 @@ +# MSC4352: Customizable HTTPS Permalink Base URLs + +This document describes the implementation of MSC4352 in iamb, which allows customizable HTTPS permalink base URLs for Matrix links. + +## Features + +### 1. Discovery of Custom Permalink Base URL + +iamb can discover a custom permalink base URL from your homeserver's `.well-known/matrix/client` configuration: + +```json +{ + "m.permalink_base_url": "https://links.example.org" +} +``` + +During the unstable period, the key `org.matrix.msc4352.permalink_base_url` is also supported. + +### 2. `:permalink` Command + +Generate permalinks for the current room or specific events: + +``` +:permalink # Generate room permalink +:permalink event $event:example.org # Generate event permalink +``` + +The generated permalink will use: +1. Your configured custom base URL (if available) +2. Your homeserver's well-known discovered base URL (if available) +3. `https://matrix.to` as fallback + +### 3. Send-time Conversion + +When you send a message containing resolver-style HTTPS permalinks, iamb automatically: +- Preserves the original URL in the `body` field +- Converts to equivalent `matrix:` URI in the `formatted_body` `href` attribute + +This ensures compatibility with all Matrix clients while allowing custom domains. + +## Configuration + +### Enable the Feature + +Set the environment variable to enable MSC4352: + +```bash +export IAMB_MSC4352=1 +``` + +### Override Permalink Base (for testing) + +You can override the permalink base URL: + +```bash +export IAMB_MSC4352_PERMALINK_BASE=https://links.example.org +``` + +## Examples + +### Basic Usage + +1. Enable the feature: + ```bash + export IAMB_MSC4352=1 + ``` + +2. In a room, generate a permalink: + ``` + :permalink + ``` + +3. Output: `Permalink: https://links.example.org/#/!room:example.org?via=example.org (copied to clipboard)` + +### Sending Links + +When you type a message like: + +``` +Check out this room: https://links.example.org/#/%23room%3Aexample.org +``` + +The sent message will have: +- `body`: `"Check out this room: https://links.example.org/#/%23room%3Aexample.org"` +- `formatted_body`: `"Check out this room: https://links.example.org/#/%23room%3Aexample.org"` + +## Technical Details + +### Discovery Precedence + +1. **User override**: `IAMB_MSC4352_PERMALINK_BASE` environment variable +2. **Well-known discovery**: `/.well-known/matrix/client` on your homeserver +3. **Default fallback**: `https://matrix.to` + +### Supported Matrix Identifiers + +- Room aliases: `#room:example.org` +- Room IDs: `!room:example.org` +- User IDs: `@user:example.org` +- Group IDs: `+group:example.org` (historical) + +### Security + +- Only HTTPS URLs are accepted as permalink bases +- Well-known discovery requires HTTPS +- Conversion only applies to outgoing messages, not in-app navigation + +## Implementation Status + +This is an unstable implementation of MSC4352. When the MSC is accepted: +- The well-known key will change from `org.matrix.msc4352.permalink_base_url` to `m.permalink_base_url` +- The feature may be enabled by default \ No newline at end of file diff --git a/src/base.rs b/src/base.rs index bb4f491d..f0f28f7c 100644 --- a/src/base.rs +++ b/src/base.rs @@ -539,6 +539,9 @@ pub enum IambAction { /// Clear all unread messages. ClearUnreads, + + /// Generate a permalink for the current room or event. + Permalink(Option), } impl IambAction { @@ -582,6 +585,7 @@ impl ApplicationAction for IambAction { fn is_edit_sequence(&self, _: &EditContext) -> SequenceStatus { match self { IambAction::ClearUnreads => SequenceStatus::Break, + IambAction::Permalink(..) => SequenceStatus::Break, IambAction::Homeserver(..) => SequenceStatus::Break, IambAction::Keys(..) => SequenceStatus::Break, IambAction::Message(..) => SequenceStatus::Break, @@ -598,6 +602,7 @@ impl ApplicationAction for IambAction { fn is_last_action(&self, _: &EditContext) -> SequenceStatus { match self { IambAction::ClearUnreads => SequenceStatus::Atom, + IambAction::Permalink(..) => SequenceStatus::Atom, IambAction::Homeserver(..) => SequenceStatus::Atom, IambAction::Keys(..) => SequenceStatus::Atom, IambAction::Message(..) => SequenceStatus::Atom, @@ -614,6 +619,7 @@ impl ApplicationAction for IambAction { fn is_last_selection(&self, _: &EditContext) -> SequenceStatus { match self { IambAction::ClearUnreads => SequenceStatus::Ignore, + IambAction::Permalink(..) => SequenceStatus::Ignore, IambAction::Homeserver(..) => SequenceStatus::Ignore, IambAction::Keys(..) => SequenceStatus::Ignore, IambAction::Message(..) => SequenceStatus::Ignore, @@ -630,6 +636,7 @@ impl ApplicationAction for IambAction { fn is_switchable(&self, _: &EditContext) -> bool { match self { IambAction::ClearUnreads => false, + IambAction::Permalink(..) => false, IambAction::Homeserver(..) => false, IambAction::Message(..) => false, IambAction::Space(..) => false, diff --git a/src/commands.rs b/src/commands.rs index c70a2aff..5383701b 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -695,6 +695,31 @@ fn iamb_logout(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult { return Ok(step); } +fn iamb_permalink(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult { + let args = desc.arg.strings()?; + + let event_id = if args.len() == 2 && args[0] == "event" { + // Parse event ID from second argument + match args[1].parse::() { + Ok(event_id) => Some(event_id), + Err(_) => { + let msg = format!("Invalid event ID: {}", args[1]); + return Err(CommandError::Error(msg)); + } + } + } else if args.is_empty() { + // No event ID, just room permalink + None + } else { + return Err(CommandError::InvalidArgument); + }; + + let iact = IambAction::Permalink(event_id); + let step = CommandStep::Continue(iact.into(), ctx.context.clone()); + + return Ok(step); +} + fn add_iamb_commands(cmds: &mut ProgramCommands) { cmds.add_command(ProgramCommand { name: "cancel".into(), @@ -802,6 +827,11 @@ fn add_iamb_commands(cmds: &mut ProgramCommands) { aliases: vec![], f: iamb_logout, }); + cmds.add_command(ProgramCommand { + name: "permalink".into(), + aliases: vec![], + f: iamb_permalink, + }); } /// Initialize the default command state. @@ -1406,4 +1436,30 @@ mod tests { let res = cmds.input_cmd("keys import foo bar baz", ctx.clone()); assert_eq!(res, Err(CommandError::InvalidArgument)); } + + #[test] + fn test_cmd_permalink() { + let mut cmds = setup_commands(); + let ctx = EditContext::default(); + + let res = cmds.input_cmd("permalink", ctx.clone()).unwrap(); + let act = IambAction::Permalink(None); + assert_eq!(res, vec![(act.into(), ctx.clone())]); + + let res = cmds.input_cmd("permalink event $event:example.org", ctx.clone()).unwrap(); + use std::convert::TryInto; + let event_id: matrix_sdk::ruma::OwnedEventId = "$event:example.org".try_into().unwrap(); + let act = IambAction::Permalink(Some(event_id)); + assert_eq!(res, vec![(act.into(), ctx.clone())]); + + // Invalid arguments + let res = cmds.input_cmd("permalink foo", ctx.clone()); + assert_eq!(res, Err(CommandError::InvalidArgument)); + + let res = cmds.input_cmd("permalink event", ctx.clone()); + assert_eq!(res, Err(CommandError::InvalidArgument)); + + let res = cmds.input_cmd("permalink event invalid-event-id", ctx.clone()); + assert!(res.is_err()); + } } diff --git a/src/main.rs b/src/main.rs index cee2247d..67119bed 100644 --- a/src/main.rs +++ b/src/main.rs @@ -74,6 +74,7 @@ mod config; mod keybindings; mod message; mod notifications; +mod permalink; mod preview; mod sled_export; mod util; @@ -627,6 +628,9 @@ impl Application { return Err(IambError::InvalidUserId(user_id).into()); } }, + IambAction::Permalink(event_id) => { + self.permalink_command(event_id, ctx, store).await? + }, }; Ok(info) @@ -696,6 +700,92 @@ impl Application { } } + async fn permalink_command( + &mut self, + event_id: Option, + _: ProgramContext, + store: &mut ProgramStore, + ) -> IambResult { + use crate::permalink::{Msc4352Config, discover_resolver_base, effective_permalink_base, make_https_permalink_with_base}; + use matrix_sdk::ruma::MatrixToUri; + + let config = Msc4352Config::default(); + + if !config.enabled { + return Ok(Some("MSC4352 permalink feature is disabled. Set IAMB_MSC4352=1 to enable.".into())); + } + + // Get current room info + let current_room_id = match self.screen.current_window_mut()? { + IambWindow::Room(room_state) => room_state.id().to_owned(), + _ => return Ok(Some("No current room to generate permalink for".into())), + }; + + // Get room from SDK to prefer canonical alias + let room = store.application.worker.client.get_room(¤t_room_id); + + // Build MatrixToUri string + let homeserver_domain = store.application.settings.profile.user_id.server_name(); + + // Build the matrix.to URL string + let matrix_to_url = if let Some(room) = room { + if let Some(alias) = room.canonical_alias() { + // Use alias if available + if let Some(event_id) = &event_id { + format!("https://matrix.to/#/{}/{}?via={}", alias, event_id, homeserver_domain) + } else { + format!("https://matrix.to/#/{}?via={}", alias, homeserver_domain) + } + } else { + // Use room ID + if let Some(event_id) = &event_id { + format!("https://matrix.to/#/{}/{}?via={}", current_room_id, event_id, homeserver_domain) + } else { + format!("https://matrix.to/#/{}?via={}", current_room_id, homeserver_domain) + } + } + } else { + if let Some(event_id) = &event_id { + format!("https://matrix.to/#/{}/{}?via={}", current_room_id, event_id, homeserver_domain) + } else { + format!("https://matrix.to/#/{}?via={}", current_room_id, homeserver_domain) + } + }; + + // Parse into MatrixToUri + let matrix_to_uri = MatrixToUri::parse(&matrix_to_url) + .map_err(|e| IambError::InvalidRoomAlias(format!("Failed to create permalink: {}", e)))?; + + // Discover permalink base + let discovered_base = discover_resolver_base(homeserver_domain.as_str()).await.unwrap_or(None); + + let base_url = effective_permalink_base( + config.env_override.as_deref(), + discovered_base.as_deref(), + ); + + // Generate permalink + let permalink = make_https_permalink_with_base(&base_url, &matrix_to_uri); + + // Try to copy to clipboard if available + #[cfg(feature = "desktop")] + { + use modalkit::prelude::{Register, TargetShape}; + use modalkit::editing::rope::EditRope; + use modalkit::editing::store::{RegisterCell, RegisterPutFlags}; + + let cell = RegisterCell { + value: EditRope::from(permalink.clone()), + shape: TargetShape::LineWise, + }; + + let _ = store.registers.put(&Register::SelectionClipboard, cell, RegisterPutFlags::NONE); + } + + let msg = format!("Permalink: {} (copied to clipboard)", permalink); + Ok(Some(msg.into())) + } + fn handle_info(&mut self, info: InfoMessage) { match info { InfoMessage::Message(info) => { diff --git a/src/message/compose.rs b/src/message/compose.rs index 8fd6ad00..3bed767b 100644 --- a/src/message/compose.rs +++ b/src/message/compose.rs @@ -163,13 +163,40 @@ fn text_to_html(input: &str) -> Option { } fn text_to_message_content(input: String) -> TextMessageEventContent { - if let Some(html) = text_to_html(input.as_str()) { - TextMessageEventContent::html(input, html) + // First process markdown to HTML + let html = text_to_html(input.as_str()); + + // Then apply MSC4352 conversion to the HTML if enabled + let formatted_body = if let Some(html_content) = html { + if let Some(converted) = apply_msc4352_conversion(&html_content) { + Some(converted) + } else { + Some(html_content) + } + } else { + // No markdown formatting, try MSC4352 conversion on plain text + apply_msc4352_conversion(&input) + }; + + if let Some(formatted) = formatted_body { + TextMessageEventContent::html(input, formatted) } else { TextMessageEventContent::plain(input) } } +/// Apply MSC4352 permalink conversion if enabled and permalinks are found +fn apply_msc4352_conversion(input: &str) -> Option { + use crate::permalink::{Msc4352Config, linkify_outgoing_text_to_html}; + + let config = Msc4352Config::default(); + if !config.enabled { + return None; + } + + linkify_outgoing_text_to_html(input) +} + pub fn text_to_message(input: String) -> RoomMessageEventContent { let msg = parse_slash_command(input.as_str()) .and_then(|(input, slash)| slash.to_message(input)) @@ -373,4 +400,20 @@ pub mod tests { assert_eq!(content.msgtype(), "io.element.effects.space_invaders"); assert_eq!(content.body(), "hello"); } + + #[test] + fn test_msc4352_conversion() { + // Test that MSC4352 conversion preserves body text and adds matrix: hrefs + // Note: The fragment in the URL will be URL-decoded by the URL parser + let input = "Check this room: https://links.example.org/#/#room:example.org"; + + if let Some(html) = apply_msc4352_conversion(input) { + assert!(html.contains(r#", +} + +impl Default for Msc4352Config { + fn default() -> Self { + Self { + enabled: env::var("IAMB_MSC4352").map(|v| v == "1").unwrap_or(false), + env_override: env::var("IAMB_MSC4352_PERMALINK_BASE").ok(), + } + } +} + +/// Well-known client configuration for permalink discovery +#[derive(Debug, Deserialize, Serialize)] +struct WellKnownClient { + #[serde(rename = "m.permalink_base_url")] + stable_permalink_base_url: Option, + #[serde(rename = "org.matrix.msc4352.permalink_base_url")] + unstable_permalink_base_url: Option, +} + +/// Convert HTTPS permalink to matrix: URI +pub fn convert_https_permalink_to_matrix_uri(url_str: &str) -> Option { + // Parse the URL + let url = Url::parse(url_str).ok()?; + + // Check if this looks like a resolver-style permalink + let fragment = url.fragment()?; + if !fragment.starts_with('/') { + return None; + } + + // Remove the leading slash from fragment + let stripped = &fragment[1..]; + + // Convert to matrix: URI format based on the identifier type + let matrix_uri_str = if stripped.starts_with('#') { + // Room alias - Example: #room:example.org -> matrix:r/room:example.org + format!("matrix:r/{}", &stripped[1..]) + } else if stripped.starts_with('!') { + // Room ID - Example: !roomid:example.org -> matrix:roomid/!roomid:example.org + format!("matrix:roomid/{}", stripped) + } else if stripped.starts_with('@') { + // User ID - Example: @user:example.org -> matrix:u/user:example.org + format!("matrix:u/{}", &stripped[1..]) + } else { + return None; + }; + + // Add query parameters if present + if let Some(query) = url.query() { + Some(format!("{}?{}", matrix_uri_str, query)) + } else { + Some(matrix_uri_str) + } +} + +/// Discover permalink base URL from homeserver's well-known +pub async fn discover_resolver_base(homeserver_domain: &str) -> Result> { + let well_known_url = format!("https://{}/.well-known/matrix/client", homeserver_domain); + + let client = reqwest::Client::new(); + let response = client.get(&well_known_url) + .timeout(std::time::Duration::from_secs(10)) + .send() + .await + .context("Failed to fetch .well-known/matrix/client")?; + + if !response.status().is_success() { + return Ok(None); + } + + let well_known: WellKnownClient = response.json() + .await + .context("Failed to parse .well-known/matrix/client JSON")?; + + // Check stable key first, then unstable + let base_url = well_known.stable_permalink_base_url + .or(well_known.unstable_permalink_base_url); + + if let Some(url_str) = base_url { + // Validate it's a proper HTTPS URL + if let Ok(url) = Url::parse(&url_str) { + if url.scheme() == "https" { + // Return just the origin (scheme + host + optional port) + let origin = format!("{}://{}", url.scheme(), url.host_str().unwrap_or_default()); + let origin_with_port = if let Some(port) = url.port() { + format!("{}:{}", origin, port) + } else { + origin + }; + return Ok(Some(origin_with_port)); + } + } + } + + Ok(None) +} + +/// Determine effective permalink base URL using precedence rules +pub fn effective_permalink_base( + config_override: Option<&str>, + discovered_base: Option<&str>, +) -> String { + // Precedence: user override → well-known discovery → fallback to matrix.to + config_override + .or(discovered_base) + .unwrap_or("https://matrix.to") + .to_string() +} + +/// Generate HTTPS permalink using custom base URL +pub fn make_https_permalink_with_base( + base_origin: &str, + matrix_to_uri: &MatrixToUri, +) -> String { + // Get the string representation and replace the base URL + let matrix_to_str = matrix_to_uri.to_string(); + + // Replace https://matrix.to with custom base + if let Some(stripped) = matrix_to_str.strip_prefix("https://matrix.to") { + format!("{}{}", base_origin.trim_end_matches('/'), stripped) + } else { + matrix_to_str + } +} + +/// Convert outgoing text containing resolver-style permalinks to HTML with matrix: hrefs +pub fn linkify_outgoing_text_to_html(text: &str) -> Option { + // Regex to find HTTPS URLs that look like resolver-style permalinks + let permalink_regex = Regex::new(r"https://[^/\s]+/#/[^\s]*").ok()?; + + let mut found_any = false; + let mut result = String::new(); + let mut last_end = 0; + + for mat in permalink_regex.find_iter(text) { + found_any = true; + + // Add text before this match + result.push_str(&text[last_end..mat.start()]); + + let url_text = mat.as_str(); + + // Try to convert to matrix: URI + if let Some(matrix_uri) = convert_https_permalink_to_matrix_uri(url_text) { + // HTML-escape the visible URL text + let escaped_url = html_escape::encode_text(url_text); + result.push_str(&format!(r#"{}"#, matrix_uri, escaped_url)); + } else { + // If conversion fails, keep original text + result.push_str(url_text); + } + + last_end = mat.end(); + } + + if found_any { + // Add remaining text + result.push_str(&text[last_end..]); + Some(result) + } else { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_convert_https_permalink_to_matrix_uri() { + // Test standard matrix.to URL + let result = convert_https_permalink_to_matrix_uri( + "https://matrix.to/#/#room:example.org" + ); + assert!(result.is_some()); + assert!(result.unwrap().starts_with("matrix:")); + + // Test custom base URL + let result = convert_https_permalink_to_matrix_uri( + "https://links.example.org/#/#room:example.org?via=example.org" + ); + assert!(result.is_some()); + assert!(result.unwrap().starts_with("matrix:")); + } + + #[test] + fn test_linkify_outgoing_text_to_html() { + // This test only works when MSC4352 is enabled, but linkify_outgoing_text_to_html + // doesn't check the config - that's done by the caller. + // So we test the function directly. + let text = "Check out this room: https://links.example.org/#/#room:example.org"; + let result = linkify_outgoing_text_to_html(text); + + // The function should find and convert the permalink + assert!(result.is_some()); + let html = result.unwrap(); + assert!(html.contains(r#"