Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
262 changes: 226 additions & 36 deletions Cargo.lock

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
112 changes: 112 additions & 0 deletions MSC4352.md
Original file line number Diff line number Diff line change
@@ -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: <a href=\"matrix:r/room:example.org\">https://links.example.org/#/%23room%3Aexample.org</a>"`

## 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
7 changes: 7 additions & 0 deletions src/base.rs
Original file line number Diff line number Diff line change
Expand Up @@ -539,6 +539,9 @@ pub enum IambAction {

/// Clear all unread messages.
ClearUnreads,

/// Generate a permalink for the current room or event.
Permalink(Option<OwnedEventId>),
}

impl IambAction {
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand Down
56 changes: 56 additions & 0 deletions src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<matrix_sdk::ruma::OwnedEventId>() {
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(),
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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());
}
}
90 changes: 90 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ mod config;
mod keybindings;
mod message;
mod notifications;
mod permalink;
mod preview;
mod sled_export;
mod util;
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -696,6 +700,92 @@ impl Application {
}
}

async fn permalink_command(
&mut self,
event_id: Option<matrix_sdk::ruma::OwnedEventId>,
_: ProgramContext,
store: &mut ProgramStore,
) -> IambResult<EditInfo> {
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(&current_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) => {
Expand Down
Loading