From df3148b9f57029a6b6197454f00ab0046ac92dea Mon Sep 17 00:00:00 2001 From: Ulyssa Date: Sat, 7 Oct 2023 18:24:25 -0700 Subject: [PATCH] Links should be "openable" (#43) --- src/base.rs | 7 ++ src/main.rs | 8 ++ src/message/html.rs | 228 ++++++++++++++++++++++++++++++--------- src/windows/room/chat.rs | 32 +++++- 4 files changed, 224 insertions(+), 51 deletions(-) diff --git a/src/base.rs b/src/base.rs index f4f9360..7089bb4 100644 --- a/src/base.rs +++ b/src/base.rs @@ -274,6 +274,9 @@ pub enum IambAction { /// Perform an action on the currently selected message. Message(MessageAction), + /// Open a URL. + OpenLink(String), + /// Perform an action on the currently focused room. Room(RoomAction), @@ -327,6 +330,7 @@ impl ApplicationAction for IambAction { IambAction::Homeserver(..) => SequenceStatus::Break, IambAction::Message(..) => SequenceStatus::Break, IambAction::Room(..) => SequenceStatus::Break, + IambAction::OpenLink(..) => SequenceStatus::Break, IambAction::Send(..) => SequenceStatus::Break, IambAction::ToggleScrollbackFocus => SequenceStatus::Break, IambAction::Verify(..) => SequenceStatus::Break, @@ -338,6 +342,7 @@ impl ApplicationAction for IambAction { match self { IambAction::Homeserver(..) => SequenceStatus::Atom, IambAction::Message(..) => SequenceStatus::Atom, + IambAction::OpenLink(..) => SequenceStatus::Atom, IambAction::Room(..) => SequenceStatus::Atom, IambAction::Send(..) => SequenceStatus::Atom, IambAction::ToggleScrollbackFocus => SequenceStatus::Atom, @@ -351,6 +356,7 @@ impl ApplicationAction for IambAction { IambAction::Homeserver(..) => SequenceStatus::Ignore, IambAction::Message(..) => SequenceStatus::Ignore, IambAction::Room(..) => SequenceStatus::Ignore, + IambAction::OpenLink(..) => SequenceStatus::Ignore, IambAction::Send(..) => SequenceStatus::Ignore, IambAction::ToggleScrollbackFocus => SequenceStatus::Ignore, IambAction::Verify(..) => SequenceStatus::Ignore, @@ -364,6 +370,7 @@ impl ApplicationAction for IambAction { IambAction::Message(..) => false, IambAction::Room(..) => false, IambAction::Send(..) => false, + IambAction::OpenLink(..) => false, IambAction::ToggleScrollbackFocus => false, IambAction::Verify(..) => false, IambAction::VerifyRequest(..) => false, diff --git a/src/main.rs b/src/main.rs index e62dd42..dfb3997 100644 --- a/src/main.rs +++ b/src/main.rs @@ -528,6 +528,14 @@ impl Application { self.screen.current_window_mut()?.send_command(act, ctx, store).await? }, + IambAction::OpenLink(url) => { + tokio::task::spawn_blocking(move || { + return open::that(url); + }); + + None + }, + IambAction::Verify(act, user_dev) => { if let Some(sas) = store.application.verifications.get(&user_dev) { self.worker.verify(act, sas.clone())? diff --git a/src/message/html.rs b/src/message/html.rs index b6cb69e..8790de9 100644 --- a/src/message/html.rs +++ b/src/message/html.rs @@ -15,6 +15,7 @@ use std::ops::Deref; use css_color_parser::Color as CssColor; use markup5ever_rcdom::{Handle, NodeData, RcDom}; use unicode_segmentation::UnicodeSegmentation; +use url::Url; use html5ever::{ driver::{parse_fragment, ParseOpts}, @@ -102,6 +103,12 @@ pub struct TableRow { } impl TableRow { + pub fn gather_links(&self, urls: &mut Vec<(char, Url)>) { + for (_, cell) in &self.cells { + cell.gather_links(urls); + } + } + fn columns(&self) -> usize { self.cells.len() } @@ -113,6 +120,12 @@ pub struct TableSection { } impl TableSection { + pub fn gather_links(&self, urls: &mut Vec<(char, Url)>) { + for row in &self.rows { + row.gather_links(urls); + } + } + fn columns(&self) -> usize { self.rows.iter().map(TableRow::columns).max().unwrap_or(0) } @@ -129,6 +142,12 @@ impl Table { self.sections.iter().map(TableSection::columns).max().unwrap_or(0) } + pub fn gather_links(&self, urls: &mut Vec<(char, Url)>) { + for section in &self.sections { + section.gather_links(urls); + } + } + fn to_text(&self, width: usize, style: Style) -> Text { let mut text = Text::default(); let columns = self.columns(); @@ -237,6 +256,7 @@ impl Table { /// A processed HTML element that we can render to the terminal. pub enum StyleTreeNode { + Anchor(Box, char, Url), Blockquote(Box), Break, Code(Box, Option), @@ -260,10 +280,51 @@ impl StyleTreeNode { printer.finish() } + pub fn gather_links(&self, urls: &mut Vec<(char, Url)>) { + match self { + StyleTreeNode::Anchor(_, c, url) => { + urls.push((*c, url.clone())); + }, + + StyleTreeNode::Blockquote(child) | + StyleTreeNode::Code(child, _) | + StyleTreeNode::Header(child, _) | + StyleTreeNode::Paragraph(child) | + StyleTreeNode::Pre(child) | + StyleTreeNode::Reply(child) | + StyleTreeNode::Style(child, _) => { + child.gather_links(urls); + }, + + StyleTreeNode::List(children, _) | StyleTreeNode::Sequence(children) => { + for child in children { + child.gather_links(urls); + } + }, + + StyleTreeNode::Table(table) => { + table.gather_links(urls); + }, + + StyleTreeNode::Image(_) => {}, + StyleTreeNode::Ruler => {}, + StyleTreeNode::Text(_) => {}, + StyleTreeNode::Break => {}, + } + } + pub fn print<'a>(&'a self, printer: &mut TextPrinter<'a>, style: Style) { let width = printer.width(); match self { + StyleTreeNode::Anchor(child, c, _) => { + let bold = style.add_modifier(StyleModifier::BOLD); + child.print(printer, bold); + + let link = format!("[{c}]"); + let span = Span::styled(link, style); + printer.push_span_nobreak(span); + }, StyleTreeNode::Blockquote(child) => { let mut subp = printer.sub(4); child.print(&mut subp, style); @@ -393,6 +454,16 @@ pub struct StyleTree { } impl StyleTree { + pub fn get_links(&self) -> Vec<(char, Url)> { + let mut links = Vec::new(); + + for child in &self.children { + child.gather_links(&mut links); + } + + return links; + } + pub fn to_text(&self, width: usize, style: Style, hide_reply: bool) -> Text<'_> { let mut printer = TextPrinter::new(width, style, hide_reply); @@ -404,17 +475,41 @@ impl StyleTree { } } -fn c2c(handles: &[Handle]) -> Vec { - handles.iter().flat_map(h2t).collect() +pub struct TreeGenState { + link_num: u8, } -fn c2t(handles: &[Handle]) -> Box { - let node = StyleTreeNode::Sequence(c2c(handles)); +impl TreeGenState { + fn next_link_char(&mut self) -> Option { + let num = self.link_num; + + if num < 62 { + self.link_num = num + 1; + } + + if num < 10 { + Some((num + b'0') as char) + } else if num < 36 { + Some((num - 10 + b'a') as char) + } else if num < 62 { + Some((num - 36 + b'A') as char) + } else { + None + } + } +} + +fn c2c(handles: &[Handle], state: &mut TreeGenState) -> Vec { + handles.iter().flat_map(|h| h2t(h, state)).collect() +} + +fn c2t(handles: &[Handle], state: &mut TreeGenState) -> Box { + let node = StyleTreeNode::Sequence(c2c(handles, state)); Box::new(node) } -fn get_node(hdl: &Handle, want: &str) -> Option { +fn get_node(hdl: &Handle, want: &str, state: &mut TreeGenState) -> Option { let node = hdl.deref(); if let NodeData::Element { name, .. } = &node.data { @@ -422,26 +517,26 @@ fn get_node(hdl: &Handle, want: &str) -> Option { return None; } - let c = c2c(&node.children.borrow()); + let c = c2c(&node.children.borrow(), state); return Some(StyleTreeNode::Sequence(c)); } else { return None; } } -fn li2t(hdl: &Handle) -> Option { - get_node(hdl, "li") +fn li2t(hdl: &Handle, state: &mut TreeGenState) -> Option { + get_node(hdl, "li", state) } -fn table_cell(hdl: &Handle) -> Option<(CellType, StyleTreeNode)> { - if let Some(node) = get_node(hdl, "th") { +fn table_cell(hdl: &Handle, state: &mut TreeGenState) -> Option<(CellType, StyleTreeNode)> { + if let Some(node) = get_node(hdl, "th", state) { return Some((CellType::Header, node)); } - Some((CellType::Data, get_node(hdl, "td")?)) + Some((CellType::Data, get_node(hdl, "td", state)?)) } -fn table_row(hdl: &Handle) -> Option { +fn table_row(hdl: &Handle, state: &mut TreeGenState) -> Option { let node = hdl.deref(); if let NodeData::Element { name, .. } = &node.data { @@ -449,20 +544,20 @@ fn table_row(hdl: &Handle) -> Option { return None; } - let cells = table_cells(&node.children.borrow()); + let cells = table_cells(&node.children.borrow(), state); return Some(TableRow { cells }); } else { return None; } } -fn table_section(hdl: &Handle) -> Option { +fn table_section(hdl: &Handle, state: &mut TreeGenState) -> Option { let node = hdl.deref(); if let NodeData::Element { name, .. } = &node.data { match name.local.as_ref() { "thead" | "tbody" => { - let rows = table_rows(&node.children.borrow()); + let rows = table_rows(&node.children.borrow(), state); Some(TableSection { rows }) }, @@ -473,20 +568,20 @@ fn table_section(hdl: &Handle) -> Option { } } -fn table_cells(handles: &[Handle]) -> Vec<(CellType, StyleTreeNode)> { - handles.iter().filter_map(table_cell).collect() +fn table_cells(handles: &[Handle], state: &mut TreeGenState) -> Vec<(CellType, StyleTreeNode)> { + handles.iter().filter_map(|h| table_cell(h, state)).collect() } -fn table_rows(handles: &[Handle]) -> Vec { - handles.iter().filter_map(table_row).collect() +fn table_rows(handles: &[Handle], state: &mut TreeGenState) -> Vec { + handles.iter().filter_map(|h| table_row(h, state)).collect() } -fn table_sections(handles: &[Handle]) -> Vec { - handles.iter().filter_map(table_section).collect() +fn table_sections(handles: &[Handle], state: &mut TreeGenState) -> Vec { + handles.iter().filter_map(|h| table_section(h, state)).collect() } -fn lic2t(handles: &[Handle]) -> StyleTreeChildren { - handles.iter().filter_map(li2t).collect() +fn lic2t(handles: &[Handle], state: &mut TreeGenState) -> StyleTreeChildren { + handles.iter().filter_map(|h| li2t(h, state)).collect() } fn attrs_to_alt(attrs: &[Attribute]) -> Option { @@ -501,6 +596,18 @@ fn attrs_to_alt(attrs: &[Attribute]) -> Option { return None; } +fn attrs_to_href(attrs: &[Attribute]) -> Option { + for attr in attrs { + if attr.name.local.as_ref() != "href" { + continue; + } + + return Some(attr.value.to_string()); + } + + return None; +} + fn attrs_to_language(attrs: &[Attribute]) -> Option { for attr in attrs { if attr.name.local.as_ref() != "class" { @@ -541,75 +648,95 @@ fn attrs_to_style(attrs: &[Attribute]) -> Style { return style; } -fn h2t(hdl: &Handle) -> StyleTreeChildren { +fn h2t(hdl: &Handle, state: &mut TreeGenState) -> StyleTreeChildren { let node = hdl.deref(); let tree = match &node.data { - NodeData::Document => *c2t(node.children.borrow().as_slice()), + NodeData::Document => *c2t(node.children.borrow().as_slice(), state), NodeData::Text { contents } => StyleTreeNode::Text(contents.borrow().to_string()), NodeData::Element { name, attrs, .. } => { match name.local.as_ref() { // Message that this one replies to. - "mx-reply" => StyleTreeNode::Reply(c2t(&node.children.borrow())), + "mx-reply" => StyleTreeNode::Reply(c2t(&node.children.borrow(), state)), + + // Links + "a" => { + let c = c2t(&node.children.borrow(), state); + let h = attrs_to_href(&attrs.borrow()).and_then(|u| Url::parse(&u).ok()); + + if let Some(h) = h { + if let Some(n) = state.next_link_char() { + StyleTreeNode::Anchor(c, n, h) + } else { + *c + } + } else { + *c + } + }, // Style change "b" | "strong" => { - let c = c2t(&node.children.borrow()); + let c = c2t(&node.children.borrow(), state); let s = Style::default().add_modifier(StyleModifier::BOLD); StyleTreeNode::Style(c, s) }, "font" => { - let c = c2t(&node.children.borrow()); + let c = c2t(&node.children.borrow(), state); let s = attrs_to_style(&attrs.borrow()); StyleTreeNode::Style(c, s) }, "em" | "i" => { - let c = c2t(&node.children.borrow()); + let c = c2t(&node.children.borrow(), state); let s = Style::default().add_modifier(StyleModifier::ITALIC); StyleTreeNode::Style(c, s) }, "span" => { - let c = c2t(&node.children.borrow()); + let c = c2t(&node.children.borrow(), state); let s = attrs_to_style(&attrs.borrow()); StyleTreeNode::Style(c, s) }, "del" | "strike" => { - let c = c2t(&node.children.borrow()); + let c = c2t(&node.children.borrow(), state); let s = Style::default().add_modifier(StyleModifier::CROSSED_OUT); StyleTreeNode::Style(c, s) }, "u" => { - let c = c2t(&node.children.borrow()); + let c = c2t(&node.children.borrow(), state); let s = Style::default().add_modifier(StyleModifier::UNDERLINED); StyleTreeNode::Style(c, s) }, // Lists - "ol" => StyleTreeNode::List(lic2t(&node.children.borrow()), ListStyle::Ordered), - "ul" => StyleTreeNode::List(lic2t(&node.children.borrow()), ListStyle::Unordered), + "ol" => { + StyleTreeNode::List(lic2t(&node.children.borrow(), state), ListStyle::Ordered) + }, + "ul" => { + StyleTreeNode::List(lic2t(&node.children.borrow(), state), ListStyle::Unordered) + }, // Headers - "h1" => StyleTreeNode::Header(c2t(&node.children.borrow()), 1), - "h2" => StyleTreeNode::Header(c2t(&node.children.borrow()), 2), - "h3" => StyleTreeNode::Header(c2t(&node.children.borrow()), 3), - "h4" => StyleTreeNode::Header(c2t(&node.children.borrow()), 4), - "h5" => StyleTreeNode::Header(c2t(&node.children.borrow()), 5), - "h6" => StyleTreeNode::Header(c2t(&node.children.borrow()), 6), + "h1" => StyleTreeNode::Header(c2t(&node.children.borrow(), state), 1), + "h2" => StyleTreeNode::Header(c2t(&node.children.borrow(), state), 2), + "h3" => StyleTreeNode::Header(c2t(&node.children.borrow(), state), 3), + "h4" => StyleTreeNode::Header(c2t(&node.children.borrow(), state), 4), + "h5" => StyleTreeNode::Header(c2t(&node.children.borrow(), state), 5), + "h6" => StyleTreeNode::Header(c2t(&node.children.borrow(), state), 6), // Table "table" => { - let sections = table_sections(&node.children.borrow()); + let sections = table_sections(&node.children.borrow(), state); let caption = node .children .borrow() .iter() - .find_map(|hdl| get_node(hdl, "caption")) + .find_map(|hdl| get_node(hdl, "caption", state)) .map(Box::new); let table = Table { caption, sections }; @@ -618,16 +745,16 @@ fn h2t(hdl: &Handle) -> StyleTreeChildren { // Code blocks. "code" => { - let c = c2t(&node.children.borrow()); + let c = c2t(&node.children.borrow(), state); let l = attrs_to_language(&attrs.borrow()); StyleTreeNode::Code(c, l) }, // Other text blocks. - "blockquote" => StyleTreeNode::Blockquote(c2t(&node.children.borrow())), - "div" | "p" => StyleTreeNode::Paragraph(c2t(&node.children.borrow())), - "pre" => StyleTreeNode::Pre(c2t(&node.children.borrow())), + "blockquote" => StyleTreeNode::Blockquote(c2t(&node.children.borrow(), state)), + "div" | "p" => StyleTreeNode::Paragraph(c2t(&node.children.borrow(), state)), + "pre" => StyleTreeNode::Pre(c2t(&node.children.borrow(), state)), // No children. "hr" => StyleTreeNode::Ruler, @@ -636,8 +763,8 @@ fn h2t(hdl: &Handle) -> StyleTreeChildren { "img" => StyleTreeNode::Image(attrs_to_alt(&attrs.borrow())), // These don't render in any special way. - "a" | "details" | "html" | "summary" | "sub" | "sup" => { - *c2t(&node.children.borrow()) + "details" | "html" | "summary" | "sub" | "sup" => { + *c2t(&node.children.borrow(), state) }, _ => return vec![], @@ -654,7 +781,10 @@ fn h2t(hdl: &Handle) -> StyleTreeChildren { } fn dom_to_style_tree(dom: RcDom) -> StyleTree { - StyleTree { children: h2t(&dom.document) } + let mut state = TreeGenState { link_num: 0 }; + let children = h2t(&dom.document, &mut state); + + StyleTree { children } } /// Parse an HTML document from a string. diff --git a/src/windows/room/chat.rs b/src/windows/room/chat.rs index ca2401d..ce69942 100644 --- a/src/windows/room/chat.rs +++ b/src/windows/room/chat.rs @@ -9,6 +9,7 @@ use edit::edit as external_edit; use modalkit::editing::store::RegisterError; use std::process::Command; use tokio; +use url::Url; use matrix_sdk::{ attachment::AttachmentConfig, @@ -31,7 +32,7 @@ use matrix_sdk::{ }; use modalkit::{ - input::dialog::PromptYesNo, + input::dialog::{MultiChoice, MultiChoiceItem, PromptYesNo}, tui::{ buffer::Buffer, layout::Rect, @@ -203,7 +204,34 @@ impl ChatState { MessageType::Image(c) => (c.source.clone(), c.body.as_str()), MessageType::Video(c) => (c.source.clone(), c.body.as_str()), _ => { - return Err(IambError::NoAttachment.into()); + if !flags.contains(DownloadFlags::OPEN) { + return Err(IambError::NoAttachment.into()); + } + + let links = if let Some(html) = &msg.html { + html.get_links() + } else if let Ok(url) = Url::parse(&msg.event.body()) { + vec![('0', url)] + } else { + vec![] + }; + + if links.is_empty() { + return Err(IambError::NoAttachment.into()); + } + + let choices = links + .into_iter() + .map(|l| { + let url = l.1.to_string(); + let act = IambAction::OpenLink(url.clone()).into(); + MultiChoiceItem::new(l.0, url, vec![act]) + }) + .collect(); + let dialog = MultiChoice::new(choices); + let err = UIError::NeedConfirm(Box::new(dialog)); + + return Err(err); }, };