Treat email and news posts as distinct types on the frontend and backend
This commit is contained in:
parent
760cec01a8
commit
a8d5617cf2
@ -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?)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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<Vec<Tag>, Server
|
||||
Ok(tags)
|
||||
}
|
||||
|
||||
pub async fn thread(pool: &PgPool, thread_id: String) -> Result<EmailThread, ServerError> {
|
||||
let id = thread_id
|
||||
pub async fn thread(pool: &PgPool, thread_id: String) -> Result<Thread, ServerError> {
|
||||
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<EmailThread, Ser
|
||||
})
|
||||
.map(|h| Url::parse(&h).ok())
|
||||
.flatten();
|
||||
let addr = r.link.as_ref().map(|link| {
|
||||
if link.contains('@') {
|
||||
link.clone()
|
||||
} else {
|
||||
if let Ok(url) = homepage.join(&link) {
|
||||
url.to_string()
|
||||
} else {
|
||||
link.clone()
|
||||
}
|
||||
}
|
||||
});
|
||||
let mut body = r.summary.unwrap_or("NO SUMMARY".to_string());
|
||||
// TODO: add site specific cleanups. For example:
|
||||
// * Grafana does <div class="image-wrapp"><img class="lazyload>"<img src="/media/...>"</img></div>
|
||||
@ -212,6 +187,10 @@ pub async fn thread(pool: &PgPool, thread_id: String) -> Result<EmailThread, Ser
|
||||
Selector::parse("span.story-byline").unwrap(),
|
||||
Selector::parse("div.p").unwrap(),
|
||||
],
|
||||
"www.smbc-comics.com".to_string() => 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<EmailThread, Ser
|
||||
body = t.transform(&link, &body).await?;
|
||||
}
|
||||
}
|
||||
let body = Body::Html(Html {
|
||||
html: body,
|
||||
content_tree: "".to_string(),
|
||||
});
|
||||
let title = clean_title(&r.title.unwrap_or("NO TITLE".to_string())).await?;
|
||||
let from = Some(Email {
|
||||
name: r.name,
|
||||
addr: addr.map(|a| a.to_string()),
|
||||
});
|
||||
Ok(EmailThread {
|
||||
let timestamp = r
|
||||
.date
|
||||
.expect("post missing date")
|
||||
.assume_utc()
|
||||
.unix_timestamp();
|
||||
Ok(Thread::News(NewsPost {
|
||||
thread_id,
|
||||
subject: title.clone(),
|
||||
messages: vec![Message {
|
||||
id,
|
||||
from,
|
||||
to: Vec::new(),
|
||||
cc: Vec::new(),
|
||||
subject: Some(title),
|
||||
timestamp: Some(
|
||||
r.date
|
||||
.expect("post missing date")
|
||||
.assume_utc()
|
||||
.unix_timestamp(),
|
||||
),
|
||||
headers: Vec::new(),
|
||||
body,
|
||||
path: "".to_string(),
|
||||
attachments: Vec::new(),
|
||||
tags,
|
||||
}],
|
||||
})
|
||||
slug,
|
||||
site,
|
||||
title,
|
||||
body,
|
||||
url: link
|
||||
.as_ref()
|
||||
.map(|url| url.to_string())
|
||||
.unwrap_or("NO URL".to_string()),
|
||||
timestamp,
|
||||
}))
|
||||
}
|
||||
pub async fn set_read_status<'ctx>(
|
||||
pool: &PgPool,
|
||||
|
||||
@ -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<EmailThread, ServerError> {
|
||||
) -> Result<Thread, ServerError> {
|
||||
// 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(
|
||||
|
||||
@ -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
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@ -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
|
||||
|
||||
@ -22,6 +22,7 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@700&display=swap" rel="stylesheet">
|
||||
<link data-trunk rel="css" href="static/site-specific.css" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
@ -362,6 +362,12 @@ pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
|
||||
open_messages,
|
||||
};
|
||||
}
|
||||
graphql::show_thread_query::ShowThreadQueryThread::NewsPost(..) => {
|
||||
model.context = Context::ThreadResult {
|
||||
thread: data.thread,
|
||||
open_messages: HashSet::new(),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
Msg::ShowThreadResult(bad) => {
|
||||
|
||||
@ -19,6 +19,10 @@ pub(super) fn view(model: &Model) -> Node<Msg> {
|
||||
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,
|
||||
|
||||
@ -13,7 +13,7 @@ use crate::{
|
||||
};
|
||||
|
||||
pub(super) fn view(model: &Model) -> Node<Msg> {
|
||||
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<Msg> {
|
||||
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,
|
||||
|
||||
@ -1075,3 +1075,107 @@ pub fn view_tags(model: &Model) -> Node<Msg> {
|
||||
]
|
||||
]
|
||||
}
|
||||
fn news_post(post: &ShowThreadQueryThreadOnNewsPost, show_icon_text: bool) -> Node<Msg> {
|
||||
// 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<Msg> {
|
||||
let from = &post.site;
|
||||
let from_detail = post.url.clone();
|
||||
let avatar: Option<String> = None;
|
||||
//let avatar: Option<String> = 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)
|
||||
})
|
||||
]
|
||||
]
|
||||
}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
use log::info;
|
||||
use seed::{prelude::*, *};
|
||||
|
||||
use crate::{
|
||||
@ -16,6 +17,10 @@ pub(super) fn view(model: &Model) -> Node<Msg> {
|
||||
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,
|
||||
|
||||
14
web/static/site-specific.css
Normal file
14
web/static/site-specific.css
Normal file
@ -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;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user