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"
|
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"
|
||||||
|
@ -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!(" <{}> {}", 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) => {
|
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!("<-- {} 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!("<!- {} 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!("<!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!("<!- {} 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!("<!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
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 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;
|
||||||
|
@ -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;
|
||||||
|
Reference in New Issue
Block a user