From f270ef25b4f07bf267149cf7fe958b281ac7f4be Mon Sep 17 00:00:00 2001 From: Val Lorentz Date: Sat, 25 Nov 2023 21:18:24 +0100 Subject: [PATCH] Add minimal support for HTML formatting Just bold/italic/underline for now. --- Cargo.toml | 5 ++ src/buffers/room.rs | 113 +++++++++++++++++++++++++----------- src/html/mod.rs | 79 +++++++++++++++++++++++++ src/lib.rs | 1 + tests/components/backlog.rs | 2 +- 5 files changed, 164 insertions(+), 36 deletions(-) create mode 100644 src/html/mod.rs diff --git a/Cargo.toml b/Cargo.toml index 955aa98..93de915 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,6 +40,11 @@ better-panic = "0.3.0" color-eyre = "0.6.2" human-panic = "1.2.0" +# Formatting +html5ever = "0.26.0" +markup5ever_rcdom = "0.2.0" +html-escape = "0.2.13" + # Internal enum_dispatch = "0.3.12" inventory = "0.3" diff --git a/src/buffers/room.rs b/src/buffers/room.rs index 8024008..93ea62a 100644 --- a/src/buffers/room.rs +++ b/src/buffers/room.rs @@ -30,9 +30,13 @@ use matrix_sdk::async_trait; use matrix_sdk::deserialized_responses::SyncOrStrippedState; use matrix_sdk::room::ParentSpace; use matrix_sdk::ruma::events::fully_read::FullyReadEventContent; +use matrix_sdk::ruma::events::room::message::{ + FormattedBody, MessageFormat, MessageType, TextMessageEventContent, +}; use matrix_sdk::ruma::events::space::child::SpaceChildEventContent; use matrix_sdk::ruma::events::RoomAccountDataEvent; use matrix_sdk::ruma::events::SyncStateEvent; +use matrix_sdk::ruma::html::{HtmlSanitizerMode, RemoveReplyFallback}; use matrix_sdk::ruma::{OwnedEventId, OwnedRoomId, RoomId}; use matrix_sdk::sync::UnreadNotificationsCount; use matrix_sdk::{Client, DisplayName, Room, RoomInfo}; @@ -48,6 +52,7 @@ use tokio::sync::oneshot; use super::{Buffer, BufferId, BufferItem, BufferItemContent, BufferSortKey, FullyReadStatus}; use crate::config::Config; +use crate::html::{escape_html, format_html}; use crate::widgets::Prerender; /// Like [`BufferItemContent`] but owned. @@ -56,7 +61,7 @@ pub enum OwnedBufferItemContent { Text { event_id: Option, is_message: bool, - text: String, + text: Text<'static>, }, Divider(String), Empty, @@ -66,17 +71,20 @@ impl DynamicUsage for OwnedBufferItemContent { fn dynamic_usage(&self) -> usize { std::mem::size_of::() + match self { - OwnedBufferItemContent::Text { text: s, .. } | OwnedBufferItemContent::Divider(s) => { - s.dynamic_usage() + OwnedBufferItemContent::Text { text, .. } => { + text.width() * text.height() * 4 // FIXME: rough approx }, + OwnedBufferItemContent::Divider(s) => s.dynamic_usage(), OwnedBufferItemContent::Empty => 0, } } fn dynamic_usage_bounds(&self) -> (usize, Option) { let (min, max) = match self { - OwnedBufferItemContent::Text { text: s, .. } | OwnedBufferItemContent::Divider(s) => { - s.dynamic_usage_bounds() + OwnedBufferItemContent::Text { text, .. } => { + let area = text.width() * text.height(); + (area, Some(area * 12)) // FIXME: rough approx }, + OwnedBufferItemContent::Divider(s) => s.dynamic_usage_bounds(), OwnedBufferItemContent::Empty => (0, Some(0)), }; match max { @@ -170,7 +178,7 @@ impl SingleClientRoomBuffer { OwnedBufferItemContent::Text { event_id: event.event_id().map(ToOwned::to_owned), is_message: false, - text: format!($($tokens)*) + text: format_html(&format!($($tokens)*)) } } } @@ -181,21 +189,54 @@ impl SingleClientRoomBuffer { OwnedBufferItemContent::Text { event_id: event.event_id().map(ToOwned::to_owned), is_message: true, - text: format!($($tokens)*) + text: format_html(&format!($($tokens)*)) } } } - let sender = event - .sender() - .as_str() - .strip_prefix('@') - .expect("missing @ prefix"); + let sender = escape_html( + event + .sender() + .as_str() + .strip_prefix('@') + .expect("missing @ prefix"), + ); match event.content() { - Message(message) => msg!(" <{}> {}", sender, message.body().replace('\n', "\n ")), - RedactedMessage => msg!("xx <{}> [redacted]", sender), - Sticker(sticker) => msg!("st <{}> {}", sender, sticker.content().body), - UnableToDecrypt(_) => text!("xx <{}> [unable to decrypt]", sender), + Message(message) => match message.msgtype() { + MessageType::Text(TextMessageEventContent { + formatted: Some(formatted), + .. + }) => { + let mut formatted = formatted.to_owned(); + formatted.sanitize_html(HtmlSanitizerMode::Strict, RemoveReplyFallback::No); + assert_eq!( + formatted.format, + MessageFormat::Html, + "FormattedBody::sanitize_html set type to {:?} instead of Html", + formatted.format + ); + msg!(" <{}> {}", sender, formatted.body) + }, + MessageType::Text(TextMessageEventContent { body, .. }) => { + msg!(" <{}> {}", sender, escape_html(body)) + }, + _ => + // Fallback to text body + { + msg!( + " <{}> {}", + sender, + escape_html(&message.body().replace('\n', "\n ")) + ) + }, + }, + RedactedMessage => msg!("xx <{}> [redacted]", sender), + Sticker(sticker) => msg!( + "st <{}> {}", + sender, + escape_html(&sticker.content().body) + ), + UnableToDecrypt(_) => text!("xx <{}> [unable to decrypt]", sender), MembershipChange(change) => { use matrix_sdk_ui::timeline::MembershipChange::*; if change.user_id() == event.sender() { @@ -209,12 +250,12 @@ impl SingleClientRoomBuffer { sender ), Joined => text!("--> {} joined", sender), - Left => text!("<-- {} left", sender), + Left => text!("<-- {} left", sender), Banned => text!("-x- {} banned themselves", sender), Unbanned => text!("-x- {} unbanned themselves", sender), - Kicked => text!(" text!("<!- {} kicked themselves", sender), Invited => text!("-o- {} invited themselves", sender), - KickedAndBanned => text!(" text!("<!x {} kicked and banned themselves", sender), InvitationAccepted => text!("-o> {} accepted an invite", sender), InvitationRejected => text!("-ox {} rejected an invite", sender), InvitationRevoked => text!("--x {} revoked an invite", sender), @@ -245,11 +286,13 @@ impl SingleClientRoomBuffer { ), } } else { - let target = change - .user_id() - .as_str() - .strip_prefix('@') - .expect("missing @ prefix"); + let target = escape_html( + change + .user_id() + .as_str() + .strip_prefix('@') + .expect("missing @ prefix"), + ); let Some(change_kind) = change.change() else { return text!("--- {} made incomprehensible changes to {}", sender, target); }; @@ -268,9 +311,9 @@ impl SingleClientRoomBuffer { ), Banned => text!("-x- {} banned {}", sender, target), Unbanned => text!("-x- {} unbanned {}", sender, target), - Kicked => text!(" text!("<!- {} kicked {}", sender, target), Invited => text!("-o- {} invited {}", sender, target), - KickedAndBanned => text!(" text!("<!x {} kicked and banned {}", sender, target), InvitationAccepted => text!("-o> {} accepted an invite to {}", sender, target), InvitationRejected => text!("-ox {} rejected an invite to {}", sender, target), InvitationRevoked => text!("--x {} revoked an invite to {}", sender, target), @@ -295,16 +338,16 @@ impl SingleClientRoomBuffer { text!( "--- {} changed {}: {:?}", sender, - state.state_key(), - state.content() + escape_html(state.state_key()), + state.content() // TODO: escape html ) } }, FailedToParseMessageLike { event_type, error } => text!( "xxx {} sent a {} message that made matrix-sdk-ui error: {:?}", sender, - event_type, - error + escape_html(&event_type.to_string()), + error // TODO: escape html ), FailedToParseState { event_type, @@ -314,9 +357,9 @@ impl SingleClientRoomBuffer { text!( "xxx {} made a {} change to {} that made matrix-sdk-ui error: {:?}", sender, - event_type, - state_key, - error + escape_html(&event_type.to_string()), + escape_html(state_key), + error // TODO: escape html ) }, Poll(_) => text!("-?- {} acted on a poll", sender), @@ -448,7 +491,7 @@ impl RoomBuffer { OwnedBufferItemContent::Text { event_id: None, is_message: false, - text: format!("Initial item: {:#?}", item), + text: Text::raw(format!("Initial item: {:#?}", item)), }, Prerender::new(), ) @@ -591,7 +634,7 @@ impl Buffer for RoomBuffer { .rev() .map(|(line_id, line, prerender)| BufferItem { content: match line { - OwnedBufferItemContent::Text { text, .. } => BufferItemContent::Text(Text::raw(text)), + OwnedBufferItemContent::Text { text, .. } => BufferItemContent::Text(text.clone()), OwnedBufferItemContent::Divider(text) => BufferItemContent::Divider(Text::raw(text)), OwnedBufferItemContent::Empty => BufferItemContent::Empty, }, diff --git a/src/html/mod.rs b/src/html/mod.rs new file mode 100644 index 0000000..e970321 --- /dev/null +++ b/src/html/mod.rs @@ -0,0 +1,79 @@ +/* + * Copyright (C) 2023 Valentin Lorentz + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License version 3, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +use std::borrow::Cow; +use std::rc::Rc; + +use html5ever::driver::parse_fragment; +use html5ever::interface::QualName; +use html5ever::tendril::TendrilSink; +use html5ever::{local_name, namespace_url, ns}; +use html_escape::encode_text_minimal; +use markup5ever_rcdom::{Handle, Node, NodeData, RcDom}; +use ratatui::style::{Style, Stylize}; +use ratatui::text::{Line, Span, Text}; + +pub fn escape_html>(s: &S) -> Cow<'_, str> { + encode_text_minimal(s) +} + +#[derive(Clone, Debug)] +struct FormatState { + style: Style, +} + +fn format_tree(tree: Rc, state: FormatState, spans: &mut Vec>) { + use markup5ever_rcdom::NodeData::*; + let state = match &tree.data { + Document | Doctype { .. } | Comment { .. } | ProcessingInstruction { .. } => state, + Text { contents } => { + spans.push(Span { + content: Cow::Owned(contents.clone().into_inner().into()), + style: state.style, + }); + state + }, + Element { name: QualName { ns: ns!(html), local: name, .. }, attrs, .. } => match name.as_ref() { + "em" | "i" => FormatState { style: state.style.italic(), ..state }, + "strong" | "b" => FormatState { style: state.style.bold(), ..state }, + "u" => FormatState { style: state.style.underlined(), ..state }, + _ => state, + }, + Element { .. } => state, // Element not in the HTML namespace + }; + + for subtree in tree.children.borrow().iter() { + format_tree(subtree.clone(), state.clone(), spans); + } +} + +pub fn format_html(s: &str) -> Text<'static> { + let tree = parse_fragment( + RcDom::default(), + Default::default(), + QualName::new(None, ns!(html), local_name!("body")), + Vec::new(), + ) + .one(s) + .document; + let mut spans = Vec::new(); + let state = FormatState { + style: Style::default(), + }; + format_tree(tree, state, &mut spans); + let line: Line<'static> = spans.into(); + line.into() +} diff --git a/src/lib.rs b/src/lib.rs index 990978a..625753c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,6 +9,7 @@ pub mod cli; pub mod commands; pub mod components; pub mod config; +pub mod html; pub mod log; pub mod mode; pub mod plugins; diff --git a/tests/components/backlog.rs b/tests/components/backlog.rs index 96ef5e8..008c735 100644 --- a/tests/components/backlog.rs +++ b/tests/components/backlog.rs @@ -14,8 +14,8 @@ * along with this program. If not, see . */ -use std::sync::Arc; use std::path::PathBuf; +use std::sync::Arc; use color_eyre::eyre::WrapErr; use pretty_assertions::assert_eq;