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