Add minimal support for HTML formatting
Just bold/italic/underline for now.
This commit is contained in:
@ -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"
|
||||
|
@ -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<OwnedEventId>,
|
||||
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::<Self>()
|
||||
+ 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<usize>) {
|
||||
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
|
||||
let sender = escape_html(
|
||||
event
|
||||
.sender()
|
||||
.as_str()
|
||||
.strip_prefix('@')
|
||||
.expect("missing @ 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!("<!- {} kicked themselves", sender),
|
||||
Kicked => text!("<!- {} kicked themselves", sender),
|
||||
Invited => text!("-o- {} invited themselves", sender),
|
||||
KickedAndBanned => text!("<!x {} kicked and banned themselves", sender),
|
||||
KickedAndBanned => 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
|
||||
let target = escape_html(
|
||||
change
|
||||
.user_id()
|
||||
.as_str()
|
||||
.strip_prefix('@')
|
||||
.expect("missing @ 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!("<!- {} kicked {}", sender, target),
|
||||
Kicked => text!("<!- {} kicked {}", sender, target),
|
||||
Invited => text!("-o- {} invited {}", sender, target),
|
||||
KickedAndBanned => text!("<!x {} kicked and banned {}", sender, target),
|
||||
KickedAndBanned => 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,
|
||||
},
|
||||
|
79
src/html/mod.rs
Normal file
79
src/html/mod.rs
Normal 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()
|
||||
}
|
@ -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;
|
||||
|
@ -14,8 +14,8 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use color_eyre::eyre::WrapErr;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
Reference in New Issue
Block a user