diff --git a/server/src/graphql.rs b/server/src/graphql.rs index d166c19..18df894 100644 --- a/server/src/graphql.rs +++ b/server/src/graphql.rs @@ -35,6 +35,18 @@ pub struct ThreadSummary { #[derive(Debug, Union)] pub enum Thread { Email(EmailThread), + News(NewsPost), +} + +#[derive(Debug, SimpleObject)] +pub struct NewsPost { + pub thread_id: String, + pub slug: String, + pub site: String, + pub title: String, + pub body: String, + pub url: String, + pub timestamp: i64, } #[derive(Debug, SimpleObject)] @@ -374,13 +386,11 @@ impl QueryRoot { .field("contentTree") .exists(); // TODO: look at thread_id and conditionally load newsreader - Ok(Thread::Email( - if newsreader::is_newsreader_thread(&thread_id) { - newsreader::thread(pool, thread_id).await? - } else { - nm::thread(nm, thread_id, debug_content_tree).await? - }, - )) + if newsreader::is_newsreader_thread(&thread_id) { + Ok(newsreader::thread(pool, thread_id).await?) + } else { + Ok(nm::thread(nm, thread_id, debug_content_tree).await?) + } } } diff --git a/server/src/newsreader.rs b/server/src/newsreader.rs index b1d2ec1..86c7512 100644 --- a/server/src/newsreader.rs +++ b/server/src/newsreader.rs @@ -14,7 +14,7 @@ const THREAD_PREFIX: &'static str = "news:"; use crate::{ compute_offset_limit, error::ServerError, - graphql::{Body, Email, EmailThread, Html, Message, Tag, ThreadSummary}, + graphql::{Body, Email, Html, Message, NewsPost, Tag, Thread, ThreadSummary}, AddOutlink, EscapeHtml, InlineStyle, SanitizeHtml, SlurpContents, StripHtml, Transformer, }; @@ -143,33 +143,19 @@ pub async fn tags(pool: &PgPool, _needs_unread: bool) -> Result, Server Ok(tags) } -pub async fn thread(pool: &PgPool, thread_id: String) -> Result { - let id = thread_id +pub async fn thread(pool: &PgPool, thread_id: String) -> Result { + let thread_id = thread_id .strip_prefix(THREAD_PREFIX) .expect("news thread doesn't start with '{THREAD_PREFIX}'") .to_string(); - let r = sqlx::query_file!("sql/thread.sql", id) + let r = sqlx::query_file!("sql/thread.sql", thread_id) .fetch_one(pool) .await?; - let site = r.site.unwrap_or("NO TAG".to_string()); - let mut tags = vec![format!("{TAG_PREFIX}{site}")]; - if r.is_read.unwrap_or(true) { - tags.push("unread".to_string()); - }; + let slug = r.site.unwrap_or("no-slug".to_string()); + let site = r.name.unwrap_or("NO SITE".to_string()); let default_homepage = "http://no-homepage"; - let homepage = Url::parse( - &r.homepage - .map(|h| { - if h.is_empty() { - default_homepage.to_string() - } else { - h - } - }) - .unwrap_or(default_homepage.to_string()), - )?; let link = &r .link .as_ref() @@ -182,17 +168,6 @@ pub async fn thread(pool: &PgPool, thread_id: String) -> Result @@ -212,6 +187,10 @@ pub async fn thread(pool: &PgPool, thread_id: String) -> Result vec![ + Selector::parse("img#cc-comic").unwrap(), + Selector::parse("div#aftercomic img").unwrap(), + ], ], }), Box::new(AddOutlink), @@ -227,37 +206,24 @@ pub async fn thread(pool: &PgPool, thread_id: String) -> Result( pool: &PgPool, diff --git a/server/src/nm.rs b/server/src/nm.rs index 905b867..1b440c8 100644 --- a/server/src/nm.rs +++ b/server/src/nm.rs @@ -15,7 +15,7 @@ use crate::{ error::ServerError, graphql::{ Attachment, Body, DispositionType, Email, EmailThread, Header, Html, Message, PlainText, - Tag, ThreadSummary, UnhandledContentType, + Tag, Thread, ThreadSummary, UnhandledContentType, }, linkify_html, sanitize_html, }; @@ -125,7 +125,7 @@ pub async fn thread( nm: &Notmuch, thread_id: String, debug_content_tree: bool, -) -> Result { +) -> Result { // TODO(wathiede): normalize all email addresses through an address book with preferred // display names (that default to the most commonly seen name). let mut messages = Vec::new(); @@ -246,11 +246,11 @@ pub async fn thread( .next() .and_then(|m| m.subject.clone()) .unwrap_or("(NO SUBJECT)".to_string()); - Ok(EmailThread { + Ok(Thread::Email(EmailThread { thread_id, subject, messages, - }) + })) } fn email_addresses( diff --git a/web/graphql/schema.json b/web/graphql/schema.json index f9c3bae..8f9e0b3 100644 --- a/web/graphql/schema.json +++ b/web/graphql/schema.json @@ -858,6 +858,129 @@ "name": "Mutation", "possibleTypes": null }, + { + "description": null, + "enumValues": null, + "fields": [ + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "threadId", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "slug", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "site", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "title", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "body", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "url", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "timestamp", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + } + } + ], + "inputFields": null, + "interfaces": [], + "kind": "OBJECT", + "name": "NewsPost", + "possibleTypes": null + }, { "description": "Information about pagination in a connection", "enumValues": null, @@ -1234,6 +1357,11 @@ "kind": "OBJECT", "name": "EmailThread", "ofType": null + }, + { + "kind": "OBJECT", + "name": "NewsPost", + "ofType": null } ] }, diff --git a/web/graphql/show_thread.graphql b/web/graphql/show_thread.graphql index 61cb0b3..81c535e 100644 --- a/web/graphql/show_thread.graphql +++ b/web/graphql/show_thread.graphql @@ -1,5 +1,15 @@ query ShowThreadQuery($threadId: String!) { thread(threadId: $threadId) { + __typename ... on NewsPost{ + threadId + slug + site + title + body + url + timestamp + # TODO: unread + } __typename ... on EmailThread{ threadId, subject diff --git a/web/index.html b/web/index.html index e9e7f67..3ae058a 100644 --- a/web/index.html +++ b/web/index.html @@ -22,6 +22,7 @@ + diff --git a/web/src/state.rs b/web/src/state.rs index cc89f83..64e1c31 100644 --- a/web/src/state.rs +++ b/web/src/state.rs @@ -362,6 +362,12 @@ pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders) { open_messages, }; } + graphql::show_thread_query::ShowThreadQueryThread::NewsPost(..) => { + model.context = Context::ThreadResult { + thread: data.thread, + open_messages: HashSet::new(), + }; + } } } Msg::ShowThreadResult(bad) => { diff --git a/web/src/view/desktop.rs b/web/src/view/desktop.rs index 9281c75..84bc536 100644 --- a/web/src/view/desktop.rs +++ b/web/src/view/desktop.rs @@ -19,6 +19,10 @@ pub(super) fn view(model: &Model) -> Node { thread: ShowThreadQueryThread::EmailThread(thread), open_messages, } => view::thread(thread, open_messages, show_icon_text), + Context::ThreadResult { + thread: ShowThreadQueryThread::NewsPost(post), + .. + } => view::news_post(post, show_icon_text), Context::SearchResult { query, results, diff --git a/web/src/view/mobile.rs b/web/src/view/mobile.rs index 2a4a70d..9be211b 100644 --- a/web/src/view/mobile.rs +++ b/web/src/view/mobile.rs @@ -13,7 +13,7 @@ use crate::{ }; pub(super) fn view(model: &Model) -> Node { - log::info!("tablet::view"); + log::info!("mobile::view"); let show_icon_text = false; let content = match &model.context { Context::None => div![h1!["Loading"]], @@ -21,6 +21,10 @@ pub(super) fn view(model: &Model) -> Node { thread: ShowThreadQueryThread::EmailThread(thread), open_messages, } => view::thread(thread, open_messages, show_icon_text), + Context::ThreadResult { + thread: ShowThreadQueryThread::NewsPost(post), + .. + } => view::news_post(post, show_icon_text), Context::SearchResult { query, results, diff --git a/web/src/view/mod.rs b/web/src/view/mod.rs index fda614a..3533e7b 100644 --- a/web/src/view/mod.rs +++ b/web/src/view/mod.rs @@ -1075,3 +1075,107 @@ pub fn view_tags(model: &Model) -> Node { ] ] } +fn news_post(post: &ShowThreadQueryThreadOnNewsPost, show_icon_text: bool) -> Node { + // TODO(wathiede): show per-message subject if it changes significantly from top-level subject + let subject = &post.title; + set_title(subject); + let read_thread_id = post.thread_id.clone(); + let unread_thread_id = post.thread_id.clone(); + div![ + C!["thread"], + h3![C!["is-size-5"], subject], + div![ + C!["level", "is-mobile"], + div![ + C!["level-item"], + div![ + C!["buttons", "has-addons"], + button![ + C!["button", "mark-read"], + attrs! {At::Title => "Mark as read"}, + span![C!["icon", "is-small"], i![C!["far", "fa-envelope-open"]]], + IF!(show_icon_text=>span!["Read"]), + ev(Ev::Click, move |_| Msg::SetUnread(read_thread_id, false)), + ], + button![ + C!["button", "mark-unread"], + attrs! {At::Title => "Mark as unread"}, + span![C!["icon", "is-small"], i![C!["far", "fa-envelope"]]], + IF!(show_icon_text=>span!["Unread"]), + ev(Ev::Click, move |_| Msg::SetUnread(unread_thread_id, true)), + ], + ], + ], + // This would be the holder for spam buttons on emails + div![C!["level-item"], div![]] + ], + div![ + C!["message"], + div![C!["header"], render_news_post_header(&post)], + div![C!["body", format!("site-{}", post.slug)], raw![&post.body]] + ] /* TODO(wathiede): plumb in orignal id + a![ + attrs! {At::Href=>api::original(&thread_node.0.as_ref().expect("message missing").id)}, + "Original" + ], + */ + ] +} +fn render_news_post_header(post: &ShowThreadQueryThreadOnNewsPost) -> Node { + let from = &post.site; + let from_detail = post.url.clone(); + let avatar: Option = None; + //let avatar: Option = Some(String::from("https://bulma.io/images/placeholders/64x64.png")); + let id = post.thread_id.clone(); + // TODO: plumb this through + //let is_unread = has_unread(&msg.tags); + let is_unread = true; + let img = render_avatar(avatar, &from); + article![ + C!["media"], + figure![C!["media-left"], p![C!["image", "is-64x64"], img]], + div![ + C!["media-content"], + div![ + C!["content"], + p![ + strong![from], + br![], + small![ + &from_detail, + " ", + span![ + i![C!["far", "fa-clone"]], + ev(Ev::Click, move |e| { + e.stop_propagation(); + Msg::CopyToClipboard(from_detail.to_string()) + }) + ] + ], + table![tr![td![ + attrs! {At::ColSpan=>2}, + span![C!["header"], human_age(post.timestamp)] + ]]], + ], + ], + ], + div![ + C!["media-right"], + span![ + C!["read-status"], + i![C![ + "far", + if is_unread { + "fa-envelope" + } else { + "fa-envelope-open" + }, + ]] + ], + ev(Ev::Click, move |e| { + e.stop_propagation(); + Msg::SetUnread(id, !is_unread) + }) + ] + ] +} diff --git a/web/src/view/tablet.rs b/web/src/view/tablet.rs index ead555d..f780691 100644 --- a/web/src/view/tablet.rs +++ b/web/src/view/tablet.rs @@ -1,3 +1,4 @@ +use log::info; use seed::{prelude::*, *}; use crate::{ @@ -16,6 +17,10 @@ pub(super) fn view(model: &Model) -> Node { thread: ShowThreadQueryThread::EmailThread(thread), open_messages, } => view::thread(thread, open_messages, show_icon_text), + Context::ThreadResult { + thread: ShowThreadQueryThread::NewsPost(post), + .. + } => view::news_post(post, show_icon_text), Context::SearchResult { query, results, diff --git a/web/static/site-specific.css b/web/static/site-specific.css new file mode 100644 index 0000000..c7158b0 --- /dev/null +++ b/web/static/site-specific.css @@ -0,0 +1,14 @@ +.body.site-saturday-morning-breakfast-cereal { + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; +} + +.body.site-slashdot i { + display: block; + padding-left: 1em; + margin-top: 1em; + margin-bottom: 1em; + border-left: 2px solid #ddd; +}