Add minimal support for HTML formatting

Just bold/italic/underline for now.
This commit is contained in:
2023-11-25 21:18:24 +01:00
parent f4f134870b
commit f270ef25b4
5 changed files with 164 additions and 36 deletions

View File

@ -40,6 +40,11 @@ better-panic = "0.3.0"
color-eyre = "0.6.2" color-eyre = "0.6.2"
human-panic = "1.2.0" human-panic = "1.2.0"
# Formatting
html5ever = "0.26.0"
markup5ever_rcdom = "0.2.0"
html-escape = "0.2.13"
# Internal # Internal
enum_dispatch = "0.3.12" enum_dispatch = "0.3.12"
inventory = "0.3" inventory = "0.3"

View File

@ -30,9 +30,13 @@ use matrix_sdk::async_trait;
use matrix_sdk::deserialized_responses::SyncOrStrippedState; use matrix_sdk::deserialized_responses::SyncOrStrippedState;
use matrix_sdk::room::ParentSpace; use matrix_sdk::room::ParentSpace;
use matrix_sdk::ruma::events::fully_read::FullyReadEventContent; 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::space::child::SpaceChildEventContent;
use matrix_sdk::ruma::events::RoomAccountDataEvent; use matrix_sdk::ruma::events::RoomAccountDataEvent;
use matrix_sdk::ruma::events::SyncStateEvent; use matrix_sdk::ruma::events::SyncStateEvent;
use matrix_sdk::ruma::html::{HtmlSanitizerMode, RemoveReplyFallback};
use matrix_sdk::ruma::{OwnedEventId, OwnedRoomId, RoomId}; use matrix_sdk::ruma::{OwnedEventId, OwnedRoomId, RoomId};
use matrix_sdk::sync::UnreadNotificationsCount; use matrix_sdk::sync::UnreadNotificationsCount;
use matrix_sdk::{Client, DisplayName, Room, RoomInfo}; use matrix_sdk::{Client, DisplayName, Room, RoomInfo};
@ -48,6 +52,7 @@ use tokio::sync::oneshot;
use super::{Buffer, BufferId, BufferItem, BufferItemContent, BufferSortKey, FullyReadStatus}; use super::{Buffer, BufferId, BufferItem, BufferItemContent, BufferSortKey, FullyReadStatus};
use crate::config::Config; use crate::config::Config;
use crate::html::{escape_html, format_html};
use crate::widgets::Prerender; use crate::widgets::Prerender;
/// Like [`BufferItemContent`] but owned. /// Like [`BufferItemContent`] but owned.
@ -56,7 +61,7 @@ pub enum OwnedBufferItemContent {
Text { Text {
event_id: Option<OwnedEventId>, event_id: Option<OwnedEventId>,
is_message: bool, is_message: bool,
text: String, text: Text<'static>,
}, },
Divider(String), Divider(String),
Empty, Empty,
@ -66,17 +71,20 @@ impl DynamicUsage for OwnedBufferItemContent {
fn dynamic_usage(&self) -> usize { fn dynamic_usage(&self) -> usize {
std::mem::size_of::<Self>() std::mem::size_of::<Self>()
+ match self { + match self {
OwnedBufferItemContent::Text { text: s, .. } | OwnedBufferItemContent::Divider(s) => { OwnedBufferItemContent::Text { text, .. } => {
s.dynamic_usage() text.width() * text.height() * 4 // FIXME: rough approx
}, },
OwnedBufferItemContent::Divider(s) => s.dynamic_usage(),
OwnedBufferItemContent::Empty => 0, OwnedBufferItemContent::Empty => 0,
} }
} }
fn dynamic_usage_bounds(&self) -> (usize, Option<usize>) { fn dynamic_usage_bounds(&self) -> (usize, Option<usize>) {
let (min, max) = match self { let (min, max) = match self {
OwnedBufferItemContent::Text { text: s, .. } | OwnedBufferItemContent::Divider(s) => { OwnedBufferItemContent::Text { text, .. } => {
s.dynamic_usage_bounds() 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)), OwnedBufferItemContent::Empty => (0, Some(0)),
}; };
match max { match max {
@ -170,7 +178,7 @@ impl SingleClientRoomBuffer {
OwnedBufferItemContent::Text { OwnedBufferItemContent::Text {
event_id: event.event_id().map(ToOwned::to_owned), event_id: event.event_id().map(ToOwned::to_owned),
is_message: false, is_message: false,
text: format!($($tokens)*) text: format_html(&format!($($tokens)*))
} }
} }
} }
@ -181,21 +189,54 @@ impl SingleClientRoomBuffer {
OwnedBufferItemContent::Text { OwnedBufferItemContent::Text {
event_id: event.event_id().map(ToOwned::to_owned), event_id: event.event_id().map(ToOwned::to_owned),
is_message: true, is_message: true,
text: format!($($tokens)*) text: format_html(&format!($($tokens)*))
} }
} }
} }
let sender = event let sender = escape_html(
event
.sender() .sender()
.as_str() .as_str()
.strip_prefix('@') .strip_prefix('@')
.expect("missing @ prefix"); .expect("missing @ prefix"),
);
match event.content() { match event.content() {
Message(message) => msg!(" <{}> {}", sender, message.body().replace('\n', "\n ")), Message(message) => match message.msgtype() {
RedactedMessage => msg!("xx <{}> [redacted]", sender), MessageType::Text(TextMessageEventContent {
Sticker(sticker) => msg!("st <{}> {}", sender, sticker.content().body), formatted: Some(formatted),
UnableToDecrypt(_) => text!("xx <{}> [unable to decrypt]", sender), ..
}) => {
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!(" &lt;{}> {}", sender, formatted.body)
},
MessageType::Text(TextMessageEventContent { body, .. }) => {
msg!(" &lt;{}> {}", sender, escape_html(body))
},
_ =>
// Fallback to text body
{
msg!(
" &lt;{}> {}",
sender,
escape_html(&message.body().replace('\n', "\n "))
)
},
},
RedactedMessage => msg!("xx &lt;{}> [redacted]", sender),
Sticker(sticker) => msg!(
"st &lt;{}> {}",
sender,
escape_html(&sticker.content().body)
),
UnableToDecrypt(_) => text!("xx &lt;{}> [unable to decrypt]", sender),
MembershipChange(change) => { MembershipChange(change) => {
use matrix_sdk_ui::timeline::MembershipChange::*; use matrix_sdk_ui::timeline::MembershipChange::*;
if change.user_id() == event.sender() { if change.user_id() == event.sender() {
@ -209,12 +250,12 @@ impl SingleClientRoomBuffer {
sender sender
), ),
Joined => text!("--> {} joined", sender), Joined => text!("--> {} joined", sender),
Left => text!("<-- {} left", sender), Left => text!("&lt;-- {} left", sender),
Banned => text!("-x- {} banned themselves", sender), Banned => text!("-x- {} banned themselves", sender),
Unbanned => text!("-x- {} unbanned themselves", sender), Unbanned => text!("-x- {} unbanned themselves", sender),
Kicked => text!("<!- {} kicked themselves", sender), Kicked => text!("&lt;!- {} kicked themselves", sender),
Invited => text!("-o- {} invited themselves", sender), Invited => text!("-o- {} invited themselves", sender),
KickedAndBanned => text!("<!x {} kicked and banned themselves", sender), KickedAndBanned => text!("&lt;!x {} kicked and banned themselves", sender),
InvitationAccepted => text!("-o> {} accepted an invite", sender), InvitationAccepted => text!("-o> {} accepted an invite", sender),
InvitationRejected => text!("-ox {} rejected an invite", sender), InvitationRejected => text!("-ox {} rejected an invite", sender),
InvitationRevoked => text!("--x {} revoked an invite", sender), InvitationRevoked => text!("--x {} revoked an invite", sender),
@ -245,11 +286,13 @@ impl SingleClientRoomBuffer {
), ),
} }
} else { } else {
let target = change let target = escape_html(
change
.user_id() .user_id()
.as_str() .as_str()
.strip_prefix('@') .strip_prefix('@')
.expect("missing @ prefix"); .expect("missing @ prefix"),
);
let Some(change_kind) = change.change() else { let Some(change_kind) = change.change() else {
return text!("--- {} made incomprehensible changes to {}", sender, target); return text!("--- {} made incomprehensible changes to {}", sender, target);
}; };
@ -268,9 +311,9 @@ impl SingleClientRoomBuffer {
), ),
Banned => text!("-x- {} banned {}", sender, target), Banned => text!("-x- {} banned {}", sender, target),
Unbanned => text!("-x- {} unbanned {}", sender, target), Unbanned => text!("-x- {} unbanned {}", sender, target),
Kicked => text!("<!- {} kicked {}", sender, target), Kicked => text!("&lt;!- {} kicked {}", sender, target),
Invited => text!("-o- {} invited {}", sender, target), Invited => text!("-o- {} invited {}", sender, target),
KickedAndBanned => text!("<!x {} kicked and banned {}", sender, target), KickedAndBanned => text!("&lt;!x {} kicked and banned {}", sender, target),
InvitationAccepted => text!("-o> {} accepted an invite to {}", sender, target), InvitationAccepted => text!("-o> {} accepted an invite to {}", sender, target),
InvitationRejected => text!("-ox {} rejected an invite to {}", sender, target), InvitationRejected => text!("-ox {} rejected an invite to {}", sender, target),
InvitationRevoked => text!("--x {} revoked an invite to {}", sender, target), InvitationRevoked => text!("--x {} revoked an invite to {}", sender, target),
@ -295,16 +338,16 @@ impl SingleClientRoomBuffer {
text!( text!(
"--- {} changed {}: {:?}", "--- {} changed {}: {:?}",
sender, sender,
state.state_key(), escape_html(state.state_key()),
state.content() state.content() // TODO: escape html
) )
} }
}, },
FailedToParseMessageLike { event_type, error } => text!( FailedToParseMessageLike { event_type, error } => text!(
"xxx {} sent a {} message that made matrix-sdk-ui error: {:?}", "xxx {} sent a {} message that made matrix-sdk-ui error: {:?}",
sender, sender,
event_type, escape_html(&event_type.to_string()),
error error // TODO: escape html
), ),
FailedToParseState { FailedToParseState {
event_type, event_type,
@ -314,9 +357,9 @@ impl SingleClientRoomBuffer {
text!( text!(
"xxx {} made a {} change to {} that made matrix-sdk-ui error: {:?}", "xxx {} made a {} change to {} that made matrix-sdk-ui error: {:?}",
sender, sender,
event_type, escape_html(&event_type.to_string()),
state_key, escape_html(state_key),
error error // TODO: escape html
) )
}, },
Poll(_) => text!("-?- {} acted on a poll", sender), Poll(_) => text!("-?- {} acted on a poll", sender),
@ -448,7 +491,7 @@ impl RoomBuffer {
OwnedBufferItemContent::Text { OwnedBufferItemContent::Text {
event_id: None, event_id: None,
is_message: false, is_message: false,
text: format!("Initial item: {:#?}", item), text: Text::raw(format!("Initial item: {:#?}", item)),
}, },
Prerender::new(), Prerender::new(),
) )
@ -591,7 +634,7 @@ impl Buffer for RoomBuffer {
.rev() .rev()
.map(|(line_id, line, prerender)| BufferItem { .map(|(line_id, line, prerender)| BufferItem {
content: match line { 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::Divider(text) => BufferItemContent::Divider(Text::raw(text)),
OwnedBufferItemContent::Empty => BufferItemContent::Empty, OwnedBufferItemContent::Empty => BufferItemContent::Empty,
}, },

79
src/html/mod.rs Normal file
View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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: ?Sized + AsRef<str>>(s: &S) -> Cow<'_, str> {
encode_text_minimal(s)
}
#[derive(Clone, Debug)]
struct FormatState {
style: Style,
}
fn format_tree(tree: Rc<Node>, state: FormatState, spans: &mut Vec<Span<'static>>) {
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()
}

View File

@ -9,6 +9,7 @@ pub mod cli;
pub mod commands; pub mod commands;
pub mod components; pub mod components;
pub mod config; pub mod config;
pub mod html;
pub mod log; pub mod log;
pub mod mode; pub mod mode;
pub mod plugins; pub mod plugins;

View File

@ -14,8 +14,8 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
use std::sync::Arc;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Arc;
use color_eyre::eyre::WrapErr; use color_eyre::eyre::WrapErr;
use pretty_assertions::assert_eq; use pretty_assertions::assert_eq;