Compare commits

...

2 Commits

Author SHA1 Message Date
Val Lorentz 2385f33e6b Fix tests
Some checks failed
CI / lint (push) Failing after 1m4s
CI / Build and test (, 1.73.0) (push) Successful in 6m56s
CI / Build and test (, beta) (push) Successful in 7m15s
CI / Build and test (, nightly) (push) Successful in 6m45s
2023-11-25 21:24:47 +01:00
Val Lorentz f270ef25b4 Add minimal support for HTML formatting
Just bold/italic/underline for now.
2023-11-25 21:18:24 +01:00
6 changed files with 181 additions and 37 deletions

View File

@ -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"

View File

@ -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
.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!(" &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) => {
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!("&lt;-- {} left", sender),
Banned => text!("-x- {} banned 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),
KickedAndBanned => text!("<!x {} kicked and banned themselves", sender),
KickedAndBanned => text!("&lt;!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!("<!- {} kicked {}", sender, target),
Kicked => text!("&lt;!- {} kicked {}", 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),
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,
},

View File

@ -59,3 +59,19 @@ unread_message = "red"
unread_event = "bold"
# If nothing happened since you last looked at the room
uneventful = ""
# Style of usernames in chat logs
[style.user.color]
# Colors that can be assigned to users' names, picked semi-randomly
colors = [
"cyan",
"magenta",
"green",
"brown",
"lightblue",
"default",
"lightcyan",
"lightmagenta",
"lightgreen",
"blue",
]

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 components;
pub mod config;
pub mod html;
pub mod log;
pub mod mode;
pub mod plugins;

View File

@ -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;
@ -26,7 +26,7 @@ use ratatrix::components::Backlog;
use ratatrix::config::Config;
use ratatrix::widgets::Prerender;
fn config() -> Config {
fn config() -> Arc<Config> {
std::env::set_var("RATATRIX_CONFIG", PathBuf::from(".config/"));
let c = Config::new();
std::env::remove_var("RATATRIX_CONFIG");