Hide cw pictures behind a summary/details (#483)

* Hide cw pictures behind a summary/details
* refactor md_to_html a bit and add cw support
* use random id for cw checkbox
This commit is contained in:
fdb-hiroshima 2019-04-06 19:20:33 +02:00 committed by GitHub
parent eabe73ddc0
commit 12c2078c89
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 239 additions and 63 deletions

View File

@ -56,53 +56,117 @@ fn to_inline(tag: Tag) -> Tag {
} }
} }
fn flatten_text<'a>(state: &mut Option<String>, evt: Event<'a>) -> Option<Vec<Event<'a>>> {
let (s, res) = match evt {
Event::Text(txt) => match state.take() {
Some(mut prev_txt) => {
prev_txt.push_str(&txt);
(Some(prev_txt), vec![])
}
None => (Some(txt.into_owned()), vec![]),
},
e => match state.take() {
Some(prev) => (None, vec![Event::Text(Cow::Owned(prev)), e]),
None => (None, vec![e]),
},
};
*state = s;
Some(res)
}
fn inline_tags<'a>(
(state, inline): &mut (Vec<Tag<'a>>, bool),
evt: Event<'a>,
) -> Option<Event<'a>> {
if *inline {
let new_evt = match evt {
Event::Start(t) => {
let tag = to_inline(t);
state.push(tag.clone());
Event::Start(tag)
}
Event::End(t) => match state.pop() {
Some(other) => Event::End(other),
None => Event::End(t),
},
e => e,
};
Some(new_evt)
} else {
Some(evt)
}
}
pub type MediaProcessor<'a> = Box<'a + Fn(i32) -> Option<(String, Option<String>)>>;
fn process_image<'a, 'b>(
evt: Event<'a>,
inline: bool,
processor: &Option<MediaProcessor<'b>>,
) -> Event<'a> {
if let Some(ref processor) = *processor {
match evt {
Event::Start(Tag::Image(id, title)) => {
if let Some((url, cw)) = id.parse::<i32>().ok().and_then(processor.as_ref()) {
if inline || cw.is_none() {
Event::Start(Tag::Image(Cow::Owned(url), title))
} else {
// there is a cw, and where are not inline
Event::Html(Cow::Owned(format!(
r#"<label for="postcontent-cw-{id}">
<input type="checkbox" id="postcontent-cw-{id}" checked="checked" class="cw-checkbox">
<span class="cw-container">
<span class="cw-text">
{cw}
</span>
<img src="{url}" alt=""#,
id = random_hex(),
cw = cw.unwrap(),
url = url
)))
}
} else {
Event::Start(Tag::Image(id, title))
}
}
Event::End(Tag::Image(id, title)) => {
if let Some((url, cw)) = id.parse::<i32>().ok().and_then(processor.as_ref()) {
if inline || cw.is_none() {
Event::End(Tag::Image(Cow::Owned(url), title))
} else {
Event::Html(Cow::Borrowed(
r#""/>
</span>
</label>"#,
))
}
} else {
Event::End(Tag::Image(id, title))
}
}
e => e,
}
} else {
evt
}
}
/// Returns (HTML, mentions, hashtags) /// Returns (HTML, mentions, hashtags)
pub fn md_to_html( pub fn md_to_html<'a>(
md: &str, md: &str,
base_url: &str, base_url: &str,
inline: bool, inline: bool,
media_processor: Option<MediaProcessor<'a>>,
) -> (String, HashSet<String>, HashSet<String>) { ) -> (String, HashSet<String>, HashSet<String>) {
let parser = Parser::new_ext(md, Options::all()); let parser = Parser::new_ext(md, Options::all());
let (parser, mentions, hashtags): (Vec<Event>, Vec<String>, Vec<String>) = parser let (parser, mentions, hashtags): (Vec<Event>, Vec<String>, Vec<String>) = parser
.scan(None, |state: &mut Option<String>, evt| { // Flatten text because pulldown_cmark break #hashtag in two individual text elements
let (s, res) = match evt { .scan(None, flatten_text)
Event::Text(txt) => match state.take() {
Some(mut prev_txt) => {
prev_txt.push_str(&txt);
(Some(prev_txt), vec![])
}
None => (Some(txt.into_owned()), vec![]),
},
e => match state.take() {
Some(prev) => (None, vec![Event::Text(Cow::Owned(prev)), e]),
None => (None, vec![e]),
},
};
*state = s;
Some(res)
})
.flat_map(IntoIterator::into_iter) .flat_map(IntoIterator::into_iter)
.map(|evt| process_image(evt, inline, &media_processor))
// Ignore headings, images, and tables if inline = true // Ignore headings, images, and tables if inline = true
.scan(vec![], |state: &mut Vec<Tag>, evt| { .scan((vec![], inline), inline_tags)
if inline {
let new_evt = match evt {
Event::Start(t) => {
let tag = to_inline(t);
state.push(tag.clone());
Event::Start(tag)
}
Event::End(t) => match state.pop() {
Some(other) => Event::End(other),
None => Event::End(t),
},
e => e,
};
Some(new_evt)
} else {
Some(evt)
}
})
.map(|evt| match evt { .map(|evt| match evt {
Event::Text(txt) => { Event::Text(txt) => {
let (evts, _, _, _, new_mentions, new_hashtags) = txt.chars().fold( let (evts, _, _, _, new_mentions, new_hashtags) = txt.chars().fold(
@ -273,7 +337,7 @@ mod tests {
for (md, mentions) in tests { for (md, mentions) in tests {
assert_eq!( assert_eq!(
md_to_html(md, "", false).1, md_to_html(md, "", false, None).1,
mentions mentions
.into_iter() .into_iter()
.map(|s| s.to_string()) .map(|s| s.to_string())
@ -298,7 +362,7 @@ mod tests {
for (md, mentions) in tests { for (md, mentions) in tests {
assert_eq!( assert_eq!(
md_to_html(md, "", false).2, md_to_html(md, "", false, None).2,
mentions mentions
.into_iter() .into_iter()
.map(|s| s.to_string()) .map(|s| s.to_string())
@ -310,11 +374,11 @@ mod tests {
#[test] #[test]
fn test_inline() { fn test_inline() {
assert_eq!( assert_eq!(
md_to_html("# Hello", "", false).0, md_to_html("# Hello", "", false, None).0,
String::from("<h1>Hello</h1>\n") String::from("<h1>Hello</h1>\n")
); );
assert_eq!( assert_eq!(
md_to_html("# Hello", "", true).0, md_to_html("# Hello", "", true, None).0,
String::from("<p>Hello</p>\n") String::from("<p>Hello</p>\n")
); );
} }

View File

@ -11,6 +11,7 @@ use std::collections::HashSet;
use comment_seers::{CommentSeers, NewCommentSeers}; use comment_seers::{CommentSeers, NewCommentSeers};
use instance::Instance; use instance::Instance;
use medias::Media;
use mentions::Mention; use mentions::Mention;
use notifications::*; use notifications::*;
use plume_common::activity_pub::{ use plume_common::activity_pub::{
@ -102,14 +103,16 @@ impl Comment {
.unwrap_or(false) .unwrap_or(false)
} }
pub fn to_activity(&self, conn: &Connection) -> Result<Note> { pub fn to_activity<'b>(&self, conn: &'b Connection) -> Result<Note> {
let author = User::get(conn, self.author_id)?;
let (html, mentions, _hashtags) = utils::md_to_html( let (html, mentions, _hashtags) = utils::md_to_html(
self.content.get().as_ref(), self.content.get().as_ref(),
&Instance::get_local(conn)?.public_domain, &Instance::get_local(conn)?.public_domain,
true, true,
Some(Media::get_media_processor(conn, vec![&author])),
); );
let author = User::get(conn, self.author_id)?;
let mut note = Note::default(); let mut note = Note::default();
let to = vec![Id::new(PUBLIC_VISIBILTY.to_string())]; let to = vec![Id::new(PUBLIC_VISIBILTY.to_string())];

View File

@ -3,6 +3,7 @@ use diesel::{self, ExpressionMethods, QueryDsl, RunQueryDsl};
use std::iter::Iterator; use std::iter::Iterator;
use ap_url; use ap_url;
use medias::Media;
use plume_common::utils::md_to_html; use plume_common::utils::md_to_html;
use safe_string::SafeString; use safe_string::SafeString;
use schema::{instances, users}; use schema::{instances, users};
@ -128,8 +129,18 @@ impl Instance {
short_description: SafeString, short_description: SafeString,
long_description: SafeString, long_description: SafeString,
) -> Result<()> { ) -> Result<()> {
let (sd, _, _) = md_to_html(short_description.as_ref(), &self.public_domain, true); let (sd, _, _) = md_to_html(
let (ld, _, _) = md_to_html(long_description.as_ref(), &self.public_domain, false); short_description.as_ref(),
&self.public_domain,
true,
Some(Media::get_media_processor(conn, vec![])),
);
let (ld, _, _) = md_to_html(
long_description.as_ref(),
&self.public_domain,
false,
Some(Media::get_media_processor(conn, vec![])),
);
diesel::update(self) diesel::update(self)
.set(( .set((
instances::name.eq(name), instances::name.eq(name),

View File

@ -5,7 +5,7 @@ use guid_create::GUID;
use reqwest; use reqwest;
use std::{fs, path::Path}; use std::{fs, path::Path};
use plume_common::activity_pub::Id; use plume_common::{activity_pub::Id, utils::MediaProcessor};
use instance::Instance; use instance::Instance;
use safe_string::SafeString; use safe_string::SafeString;
@ -124,10 +124,9 @@ impl Media {
} }
pub fn markdown(&self, conn: &Connection) -> Result<SafeString> { pub fn markdown(&self, conn: &Connection) -> Result<SafeString> {
let url = self.url(conn)?;
Ok(match self.category() { Ok(match self.category() {
MediaCategory::Image => { MediaCategory::Image => {
SafeString::new(&format!("![{}]({})", escape(&self.alt_text), url)) SafeString::new(&format!("![{}]({})", escape(&self.alt_text), self.id))
} }
MediaCategory::Audio | MediaCategory::Video => self.html(conn)?, MediaCategory::Audio | MediaCategory::Video => self.html(conn)?,
MediaCategory::Unknown => SafeString::new(""), MediaCategory::Unknown => SafeString::new(""),
@ -225,6 +224,19 @@ impl Media {
}, },
) )
} }
pub fn get_media_processor<'a>(conn: &'a Connection, user: Vec<&User>) -> MediaProcessor<'a> {
let uid = user.iter().map(|u| u.id).collect::<Vec<_>>();
Box::new(move |id| {
let media = Media::get(conn, id).ok()?;
// if owner is user or check is disabled
if uid.contains(&media.owner_id) || uid.is_empty() {
Some((media.url(conn).ok()?, media.content_warning))
} else {
None
}
})
}
} }
#[cfg(test)] #[cfg(test)]

View File

@ -207,17 +207,19 @@ impl<'a> Provider<(&'a Connection, &'a Worker, &'a Searcher, Option<i32>)> for P
let domain = &Instance::get_local(&conn) let domain = &Instance::get_local(&conn)
.map_err(|_| ApiError::NotFound("posts::update: Error getting local instance".into()))? .map_err(|_| ApiError::NotFound("posts::update: Error getting local instance".into()))?
.public_domain; .public_domain;
let (content, mentions, hashtags) = md_to_html(
query.source.clone().unwrap_or_default().clone().as_ref(),
domain,
false,
);
let author = User::get( let author = User::get(
conn, conn,
user_id.expect("<Post as Provider>::create: no user_id error"), user_id.expect("<Post as Provider>::create: no user_id error"),
) )
.map_err(|_| ApiError::NotFound("Author not found".into()))?; .map_err(|_| ApiError::NotFound("Author not found".into()))?;
let (content, mentions, hashtags) = md_to_html(
query.source.clone().unwrap_or_default().clone().as_ref(),
domain,
false,
Some(Media::get_media_processor(conn, vec![&author])),
);
let blog = match query.blog_id { let blog = match query.blog_id {
Some(x) => x, Some(x) => x,
None => { None => {
@ -757,7 +759,7 @@ impl Post {
post.license = license; post.license = license;
} }
let mut txt_hashtags = md_to_html(&post.source, "", false) let mut txt_hashtags = md_to_html(&post.source, "", false, None)
.2 .2
.into_iter() .into_iter()
.map(|s| s.to_camel_case()) .map(|s| s.to_camel_case())
@ -995,7 +997,7 @@ impl<'a> FromActivity<LicensedArticle, (&'a Connection, &'a Searcher)> for Post
} }
// save mentions and tags // save mentions and tags
let mut hashtags = md_to_html(&post.source, "", false) let mut hashtags = md_to_html(&post.source, "", false, None)
.2 .2
.into_iter() .into_iter()
.map(|s| s.to_camel_case()) .map(|s| s.to_camel_case())

View File

@ -19,7 +19,7 @@ lazy_static! {
static ref CLEAN: Builder<'static> = { static ref CLEAN: Builder<'static> = {
let mut b = Builder::new(); let mut b = Builder::new();
b.add_generic_attributes(iter::once("id")) b.add_generic_attributes(iter::once("id"))
.add_tags(&["iframe", "video", "audio"]) .add_tags(&["iframe", "video", "audio", "label", "input"])
.id_prefix(Some("postcontent-")) .id_prefix(Some("postcontent-"))
.url_relative(UrlRelative::Custom(Box::new(url_add_prefix))) .url_relative(UrlRelative::Custom(Box::new(url_add_prefix)))
.add_tag_attributes( .add_tag_attributes(
@ -27,7 +27,23 @@ lazy_static! {
["width", "height", "src", "frameborder"].iter().cloned(), ["width", "height", "src", "frameborder"].iter().cloned(),
) )
.add_tag_attributes("video", ["src", "title", "controls"].iter()) .add_tag_attributes("video", ["src", "title", "controls"].iter())
.add_tag_attributes("audio", ["src", "title", "controls"].iter()); .add_tag_attributes("audio", ["src", "title", "controls"].iter())
.add_tag_attributes("label", ["for"].iter())
.add_tag_attributes("input", ["type", "checked"].iter())
.add_allowed_classes("input", ["cw-checkbox"].iter())
.add_allowed_classes("span", ["cw-container", "cw-text"].iter())
.attribute_filter(|elem, att, val| match (elem, att) {
("input", "type") => Some("checkbox".into()),
("input", "checked") => Some("checked".into()),
("label", "for") => {
if val.starts_with("postcontent-cw-") {
Some(val.into())
} else {
None
}
}
_ => Some(val.into()),
});
b b
}; };
} }

View File

@ -209,7 +209,13 @@ impl User {
.set(( .set((
users::display_name.eq(name), users::display_name.eq(name),
users::email.eq(email), users::email.eq(email),
users::summary_html.eq(utils::md_to_html(&summary, "", false).0), users::summary_html.eq(utils::md_to_html(
&summary,
"",
false,
Some(Media::get_media_processor(conn, vec![self])),
)
.0),
users::summary.eq(summary), users::summary.eq(summary),
)) ))
.execute(conn)?; .execute(conn)?;
@ -868,7 +874,7 @@ impl NewUser {
display_name, display_name,
is_admin, is_admin,
summary: summary.to_owned(), summary: summary.to_owned(),
summary_html: SafeString::new(&utils::md_to_html(&summary, "", false).0), summary_html: SafeString::new(&utils::md_to_html(&summary, "", false, None).0),
email: Some(email), email: Some(email),
hashed_password: Some(password), hashed_password: Some(password),
instance_id: Instance::get_local(conn)?.id, instance_id: Instance::get_local(conn)?.id,

View File

@ -280,7 +280,21 @@ pub fn update(
blog.title = form.title.clone(); blog.title = form.title.clone();
blog.summary = form.summary.clone(); blog.summary = form.summary.clone();
blog.summary_html = SafeString::new(&utils::md_to_html(&form.summary, "", true).0); blog.summary_html = SafeString::new(
&utils::md_to_html(
&form.summary,
"",
true,
Some(Media::get_media_processor(
&conn,
blog.list_authors(&conn)
.expect("Couldn't get list of authors")
.iter()
.collect(),
)),
)
.0,
);
blog.icon_id = form.icon; blog.icon_id = form.icon;
blog.banner_id = form.banner; blog.banner_id = form.banner;
blog.save_changes::<Blog>(&*conn) blog.save_changes::<Blog>(&*conn)

View File

@ -15,8 +15,8 @@ use plume_common::{
utils, utils,
}; };
use plume_models::{ use plume_models::{
blogs::Blog, comments::*, db_conn::DbConn, instance::Instance, mentions::Mention, posts::Post, blogs::Blog, comments::*, db_conn::DbConn, instance::Instance, medias::Media,
safe_string::SafeString, tags::Tag, users::User, mentions::Mention, posts::Post, safe_string::SafeString, tags::Tag, users::User,
}; };
use routes::errors::ErrorPage; use routes::errors::ErrorPage;
use Worker; use Worker;
@ -49,6 +49,7 @@ pub fn create(
.expect("comments::create: local instance error") .expect("comments::create: local instance error")
.public_domain, .public_domain,
true, true,
Some(Media::get_media_processor(&conn, vec![&user])),
); );
let comm = Comment::insert( let comm = Comment::insert(
&*conn, &*conn,

View File

@ -264,6 +264,13 @@ pub fn update(
.expect("posts::update: Error getting local instance") .expect("posts::update: Error getting local instance")
.public_domain, .public_domain,
false, false,
Some(Media::get_media_processor(
&conn,
b.list_authors(&conn)
.expect("Could not get author list")
.iter()
.collect(),
)),
); );
// update publication date if when this article is no longer a draft // update publication date if when this article is no longer a draft
@ -424,6 +431,13 @@ pub fn create(
.expect("post::create: local instance error") .expect("post::create: local instance error")
.public_domain, .public_domain,
false, false,
Some(Media::get_media_processor(
&conn,
blog.list_authors(&conn)
.expect("Could not get author list")
.iter()
.collect(),
)),
); );
let searcher = rockets.searcher; let searcher = rockets.searcher;

View File

@ -322,3 +322,36 @@ main .article-meta {
right: 0px; right: 0px;
bottom: 0px; bottom: 0px;
} }
// content warning
.cw-container {
position: relative;
display: inline-block;
}
.cw-text {
display: none;
}
input[type="checkbox"].cw-checkbox {
display: none;
}
input:checked ~ .cw-container:before {
content: " ";
position: absolute;
height: 100%;
width: 100%;
background: rgba(0, 0, 0, 1);
}
input:checked ~ .cw-container > .cw-text {
display: inline;
position: absolute;
color: white;
width: 100%;
text-align: center;
top: 50%;
transform: translateY(-50%);
}