diff options
author | Colin Reeder <colin@vpzom.click> | 2020-07-22 18:40:01 -0600 |
---|---|---|
committer | Colin Reeder <colin@vpzom.click> | 2020-07-22 18:40:01 -0600 |
commit | 7edc6628ac579d7d5a19081fec53300d66c2d9ed (patch) | |
tree | e184251f6ee530a5dc3dae9b2efe1210a7768355 | |
parent | e358af5a2a4c4d3cfa0da907884a1a464c33edcd (diff) |
Notifications
-rw-r--r-- | Cargo.lock | 32 | ||||
-rw-r--r-- | Cargo.toml | 1 | ||||
-rw-r--r-- | res/lang/en.ftl | 4 | ||||
-rw-r--r-- | res/lang/eo.ftl | 4 | ||||
-rw-r--r-- | res/main.css | 9 | ||||
-rw-r--r-- | src/components/mod.rs | 119 | ||||
-rw-r--r-- | src/resp_types.rs | 55 | ||||
-rw-r--r-- | src/routes/mod.rs | 88 |
8 files changed, 279 insertions, 33 deletions
@@ -193,6 +193,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "59f5fff90fd5d971f936ad674802482ba441b6f09ba5e15fd8b39145582ca399" [[package]] +name = "futures-macro" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0b5a30a4328ab5473878237c447333c093297bded83a4983d10f4deea240d39" +dependencies = [ + "proc-macro-hack", + "proc-macro2", + "quote", + "syn", +] + +[[package]] name = "futures-sink" version = "0.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -203,6 +215,9 @@ name = "futures-task" version = "0.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bdb66b5f09e22019b1ab0830f7785bcea8e7a42148683f99214f73f8ec21a626" +dependencies = [ + "once_cell", +] [[package]] name = "futures-util" @@ -211,9 +226,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8764574ff08b701a084482c3c7031349104b07ac897393010494beaa18ce32c6" dependencies = [ "futures-core", + "futures-macro", "futures-task", "pin-project", "pin-utils", + "proc-macro-hack", + "proc-macro-nested", + "slab", ] [[package]] @@ -273,6 +292,7 @@ dependencies = [ "fallible-iterator", "fluent", "fluent-langneg", + "futures-util", "ginger", "http", "hyper", @@ -592,6 +612,12 @@ dependencies = [ ] [[package]] +name = "once_cell" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b631f7e854af39a1739f401cf34a8a013dfe09eac4fa4dba91e9768bd28168d" + +[[package]] name = "openssl" version = "0.10.29" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -751,6 +777,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e0456befd48169b9f13ef0f0ad46d492cf9d2dbb918bcf38e01eed4ce3ec5e4" [[package]] +name = "proc-macro-nested" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eba180dafb9038b050a4c280019bbedf9f2467b61e5d892dcad585bb57aadc5a" + +[[package]] name = "proc-macro2" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -27,3 +27,4 @@ fluent-langneg = "0.13.0" fluent = "0.12.0" lazy_static = "1.4.0" unic-langid = { version = "0.9.0", features = ["macros"] } +futures-util = "0.3.5" diff --git a/res/lang/en.ftl b/res/lang/en.ftl index 34430d4..2fe6728 100644 --- a/res/lang/en.ftl +++ b/res/lang/en.ftl @@ -40,7 +40,9 @@ name_prompt = Name: no_cancel = No, cancel nothing = Looks like there's nothing here. nothing_yet = Looks like there's nothing here (yet!). +notifications = Notifications on = on +on_your_post = on your post or_start = Or password_prompt = Password: post_delete_question = Delete this post? @@ -50,6 +52,7 @@ register = Register remote = Remote reply = reply reply_submit = Reply +reply_to = Reply to submit = Submit submitted = Submitted text_with_markdown = Text (markdown supported) @@ -101,3 +104,4 @@ user_remote_note = This is a remote user, information on this page may be incomp username_prompt = Username: view_at_source = View at Source view_more_comments = View More Comments +your_comment = your comment diff --git a/res/lang/eo.ftl b/res/lang/eo.ftl index cd526e9..7e8ec67 100644 --- a/res/lang/eo.ftl +++ b/res/lang/eo.ftl @@ -40,7 +40,9 @@ name_prompt = Nomo: no_cancel = Ne, nuligi nothing = Ŝajnas, ke estas nenio ĉi tie. nothing_yet = Ŝajnas, ke estas nenio ĉi tie (ĝis nun!). +notifications = Sciigoj on = sur +on_your_post = sur via poŝto or_start = Aŭ password_prompt = Pasvorto: post_delete_question = Ĉu vi volas forigi ĉi tiun poŝton? @@ -49,6 +51,7 @@ post_new = Nova Poŝto register = Registriĝi remote = Fora reply = respondi +reply_to = Respondo al reply_submit = Respondi submit = Sendi submitted = Afiŝita @@ -101,3 +104,4 @@ user_remote_note = Ĉi tiu estas fora uzanto, informo en ĉi tiu paĝo eble nepl username_prompt = Uzantnomo: view_at_source = Vidi ĉe Fonto view_more_comments = Vidi Pli da Komentoj +your_comment = via komento diff --git a/res/main.css b/res/main.css index a772c88..148a792 100644 --- a/res/main.css +++ b/res/main.css @@ -53,6 +53,15 @@ body { margin-bottom: 0; } +.notification-indicator.unread { + color: #FF8F00; +} + +.notification-item.unread { + border-left: 5px solid #FDD835; + padding-left: 5px; +} + @media (max-width: 768px) { .communitySidebar { display: none; /* TODO still show this somewhere */ diff --git a/src/components/mod.rs b/src/components/mod.rs index b7a3b39..4b5faf9 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -4,8 +4,9 @@ use std::borrow::{Borrow, Cow}; use std::collections::HashMap; use crate::resp_types::{ - RespMinimalAuthorInfo, RespMinimalCommunityInfo, RespPostCommentInfo, RespPostInfo, - RespPostListPost, RespThingComment, RespThingInfo, + RespMinimalAuthorInfo, RespMinimalCommentInfo, RespMinimalCommunityInfo, RespNotification, + RespNotificationInfo, RespPostCommentInfo, RespPostInfo, RespPostListPost, RespThingComment, + RespThingInfo, }; use crate::util::{abbreviate_link, author_is_me}; use crate::PageBaseData; @@ -34,19 +35,19 @@ pub fn Comment<'a>( { if comment.your_vote.is_some() { render::rsx! { - <form method={"POST"} action={format!("/comments/{}/unlike", comment.id)}> + <form method={"POST"} action={format!("/comments/{}/unlike", comment.as_ref().id)}> <button type={"submit"}>{lang.tr("like_undo", None)}</button> </form> } } else { render::rsx! { - <form method={"POST"} action={format!("/comments/{}/like", comment.id)}> + <form method={"POST"} action={format!("/comments/{}/like", comment.as_ref().id)}> <button type={"submit"}>{lang.tr("like", None)}</button> </form> } } } - <a href={format!("/comments/{}", comment.id)}>{lang.tr("reply", None)}</a> + <a href={format!("/comments/{}", comment.as_ref().id)}>{lang.tr("reply", None)}</a> </> }) } else { @@ -56,7 +57,7 @@ pub fn Comment<'a>( { if author_is_me(&comment.author, &base_data.login) { Some(render::rsx! { - <a href={format!("/comments/{}/delete", comment.id)}>{lang.tr("delete", None)}</a> + <a href={format!("/comments/{}/delete", comment.as_ref().id)}>{lang.tr("delete", None)}</a> }) } else { None @@ -85,7 +86,7 @@ pub fn Comment<'a>( { if comment.replies.is_none() && comment.has_replies { Some(render::rsx! { - <ul><li><a href={format!("/comments/{}", comment.id)}>{"-> "}{lang.tr("view_more_comments", None)}</a></li></ul> + <ul><li><a href={format!("/comments/{}", comment.as_ref().id)}>{"-> "}{lang.tr("view_more_comments", None)}</a></li></ul> }) } else { None @@ -123,7 +124,7 @@ pub trait HavingContent { fn content_html(&self) -> Option<&str>; } -impl<'a> HavingContent for RespPostCommentInfo<'a> { +impl<'a> HavingContent for RespMinimalCommentInfo<'a> { fn content_text(&self) -> Option<&str> { self.content_text.as_deref() } @@ -134,10 +135,19 @@ impl<'a> HavingContent for RespPostCommentInfo<'a> { impl<'a> HavingContent for RespThingComment<'a> { fn content_text(&self) -> Option<&str> { - self.content_text.as_deref() + self.base.content_text() } fn content_html(&self) -> Option<&str> { - self.content_html.as_deref() + self.base.content_html() + } +} + +impl<'a> HavingContent for RespPostCommentInfo<'a> { + fn content_text(&self) -> Option<&str> { + self.base.content_text() + } + fn content_html(&self) -> Option<&str> { + self.base.content_html() } } @@ -202,15 +212,29 @@ pub fn HTPage<'a, Children: render::Render>( </div> <div class={"right actionList"}> { - match &base_data.login { - Some(login) => Some(render::rsx! { - <a href={format!("/users/{}", login.user.id)}>{Cow::Borrowed("👤︎")}</a> - }), - None => { - Some(render::rsx! { - <a href={"/login"}>{lang.tr("login", None)}</a> - }) - } + if let Some(login) = &base_data.login { + Some(render::rsx! { + <> + <a + href={"/notifications"} + class={if login.user.has_unread_notifications { "notification-indicator unread" } else { "notification-indicator" }} + > + {"🔔︎"} + </a> + <a href={format!("/users/{}", login.user.id)}>{"👤︎"}</a> + </> + }) + } else { + None + } + } + { + if base_data.login.is_none() { + Some(render::rsx! { + <a href={"/login"}>{lang.tr("login", None)}</a> + }) + } else { + None } } </div> @@ -289,7 +313,7 @@ impl<'a> render::Render for ThingItem<'a> { (render::rsx! { <li> <small> - <a href={format!("/comments/{}", comment.id)}>{lang.tr("comment", None)}</a> + <a href={format!("/comments/{}", comment.as_ref().id)}>{lang.tr("comment", None)}</a> {" "}{lang.tr("on", None)}{" "}<a href={format!("/posts/{}", comment.post.id)}>{comment.post.title.as_ref()}</a>{":"} </small> <Content src={comment} /> @@ -413,3 +437,58 @@ pub fn BoolSubmitButton<'a>(value: bool, do_text: &'a str, done_text: &'a str) { } } } + +pub struct NotificationItem<'a> { + pub notification: &'a RespNotification<'a>, + pub lang: &'a crate::Translator, +} + +impl<'a> render::Render for NotificationItem<'a> { + fn render_into<W: std::fmt::Write>(self, writer: &mut W) -> std::fmt::Result { + let lang = self.lang; + + write!(writer, "<li class=\"notification-item")?; + if self.notification.unseen { + write!(writer, " unread")?; + } + write!(writer, "\">")?; + match &self.notification.info { + RespNotificationInfo::Unknown => { + "[unknown notification type]".render_into(writer)?; + } + RespNotificationInfo::PostReply { reply, post } => { + (render::rsx! { + <> + <a href={format!("/comments/{}", reply.id)}>{lang.tr("comment", None)}</a> + {" "}{lang.tr("on_your_post", None)}{" "}<a href={format!("/posts/{}", post.id)}>{post.title.as_ref()}</a>{":"} + <Content src={reply} /> + </> + }).render_into(writer)?; + } + RespNotificationInfo::CommentReply { + reply, + comment, + post, + } => { + (render::rsx! { + <> + {lang.tr("reply_to", None)} + {" "} + <a href={format!("/comments/{}", comment)}>{lang.tr("your_comment", None)}</a> + { + if let Some(post) = post { + Some(render::rsx! { <>{" "}{lang.tr("on", None)}{" "}<a href={format!("/posts/{}", post.id)}>{post.title.as_ref()}</a></> }) + } else { + None + } + } + {":"} + <Content src={reply} /> + </> + }).render_into(writer)?; + } + } + + write!(writer, "</li>") + } +} diff --git a/src/resp_types.rs b/src/resp_types.rs index cad9d36..77bce6c 100644 --- a/src/resp_types.rs +++ b/src/resp_types.rs @@ -45,29 +45,48 @@ pub enum RespThingInfo<'a> { } #[derive(Deserialize, Debug)] -pub struct RespThingComment<'a> { +pub struct RespMinimalCommentInfo<'a> { pub id: i64, - pub created: Cow<'a, str>, pub content_text: Option<Cow<'a, str>>, pub content_html: Option<Cow<'a, str>>, +} + +#[derive(Deserialize, Debug)] +pub struct RespThingComment<'a> { + #[serde(flatten)] + pub base: RespMinimalCommentInfo<'a>, + + pub created: Cow<'a, str>, #[serde(borrow)] pub post: RespMinimalPostInfo<'a>, } +impl<'a> AsRef<RespMinimalCommentInfo<'a>> for RespThingComment<'a> { + fn as_ref(&self) -> &RespMinimalCommentInfo<'a> { + &self.base + } +} + #[derive(Deserialize, Debug)] pub struct RespPostCommentInfo<'a> { - pub id: i64, + #[serde(flatten)] + pub base: RespMinimalCommentInfo<'a>, + #[serde(borrow)] pub author: Option<RespMinimalAuthorInfo<'a>>, pub created: Cow<'a, str>, - pub content_text: Option<Cow<'a, str>>, - pub content_html: Option<Cow<'a, str>>, pub your_vote: Option<Empty>, #[serde(borrow)] pub replies: Option<Vec<RespPostCommentInfo<'a>>>, pub has_replies: bool, } +impl<'a> AsRef<RespMinimalCommentInfo<'a>> for RespPostCommentInfo<'a> { + fn as_ref(&self) -> &RespMinimalCommentInfo<'a> { + &self.base + } +} + #[derive(Deserialize, Debug)] pub struct RespPostInfo<'a> { #[serde(flatten, borrow)] @@ -112,6 +131,7 @@ impl<'a> AsRef<RespMinimalAuthorInfo<'a>> for RespUserInfo<'a> { #[derive(Deserialize, Debug)] pub struct RespLoginInfoUser { pub id: i64, + pub has_unread_notifications: bool, } #[derive(Deserialize, Debug)] @@ -154,3 +174,28 @@ pub struct RespInstanceSoftwareInfo<'a> { pub struct RespInstanceInfo<'a> { pub software: RespInstanceSoftwareInfo<'a>, } + +#[derive(Deserialize, Debug)] +#[serde(tag = "type")] +#[serde(rename_all = "snake_case")] +pub enum RespNotificationInfo<'a> { + PostReply { + reply: RespMinimalCommentInfo<'a>, + post: RespMinimalPostInfo<'a>, + }, + CommentReply { + reply: RespMinimalCommentInfo<'a>, + comment: i64, + post: Option<RespMinimalPostInfo<'a>>, + }, + #[serde(other)] + Unknown, +} + +#[derive(Deserialize, Debug)] +pub struct RespNotification<'a> { + #[serde(flatten)] + pub info: RespNotificationInfo<'a>, + + pub unseen: bool, +} diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 57aa6b8..8f576ac 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -3,10 +3,12 @@ use std::borrow::Cow; use std::sync::Arc; use crate::components::{ - Comment, Content, HTPage, MaybeFillInput, MaybeFillTextArea, PostItem, ThingItem, UserLink, + Comment, Content, HTPage, MaybeFillInput, MaybeFillTextArea, NotificationItem, PostItem, + ThingItem, UserLink, }; use crate::resp_types::{ - RespInstanceInfo, RespPostCommentInfo, RespPostListPost, RespThingInfo, RespUserInfo, + RespInstanceInfo, RespNotification, RespPostCommentInfo, RespPostListPost, RespThingInfo, + RespUserInfo, }; use crate::util::author_is_me; use crate::PageBaseData; @@ -229,13 +231,13 @@ async fn page_comment_inner( { if comment.your_vote.is_some() { render::rsx! { - <form method={"POST"} action={format!("/comments/{}/unlike", comment.id)}> + <form method={"POST"} action={format!("/comments/{}/unlike", comment.as_ref().id)}> <button type={"submit"}>{lang.tr("like_undo", None)}</button> </form> } } else { render::rsx! { - <form method={"POST"} action={format!("/comments/{}/like", comment.id)}> + <form method={"POST"} action={format!("/comments/{}/like", comment.as_ref().id)}> <button type={"submit"}>{lang.tr("like", None)}</button> </form> } @@ -250,7 +252,7 @@ async fn page_comment_inner( { if author_is_me(&comment.author, &base_data.login) { Some(render::rsx! { - <a href={format!("/comments/{}/delete", comment.id)}>{lang.tr("delete", None)}</a> + <a href={format!("/comments/{}/delete", comment.as_ref().id)}>{lang.tr("delete", None)}</a> }) } else { None @@ -267,7 +269,7 @@ async fn page_comment_inner( { if base_data.login.is_some() { Some(render::rsx! { - <form method={"POST"} action={format!("/comments/{}/submit_reply", comment.id)}> + <form method={"POST"} action={format!("/comments/{}/submit_reply", comment.as_ref().id)}> <div> <MaybeFillTextArea values={&prev_values} name={"content_markdown"} default_value={None} /> </div> @@ -352,7 +354,7 @@ async fn page_comment_delete_inner( } }) } - <form method={"POST"} action={format!("/comments/{}/delete/confirm", comment.id)}> + <form method={"POST"} action={format!("/comments/{}/delete/confirm", comment.as_ref().id)}> { if let Some(referer) = referer { Some(render::rsx! { @@ -362,7 +364,7 @@ async fn page_comment_delete_inner( None } } - <a href={format!("/comments/{}/", comment.id)}>{lang.tr("no_cancel", None)}</a> + <a href={format!("/comments/{}/", comment.as_ref().id)}>{lang.tr("no_cancel", None)}</a> {" "} <button r#type={"submit"}>{lang.tr("delete_yes", None)}</button> </form> @@ -875,6 +877,72 @@ async fn handler_new_community_submit( } } +async fn page_notifications( + _: (), + ctx: Arc<crate::RouteContext>, + req: hyper::Request<hyper::Body>, +) -> Result<hyper::Response<hyper::Body>, crate::Error> { + use futures_util::future::TryFutureExt; + + let lang = crate::get_lang_for_req(&req); + let cookies = get_cookie_map_for_req(&req)?; + + let api_res: Result<Result<Vec<RespNotification>, _>, _> = res_to_error( + ctx.http_client + .request(for_client( + hyper::Request::get(format!( + "{}/api/unstable/users/me/notifications", + ctx.backend_host + )) + .body(Default::default())?, + req.headers(), + &cookies, + )?) + .await?, + ) + .map_err(crate::Error::from) + .and_then(|body| hyper::body::to_bytes(body).map_err(crate::Error::from)) + .await + .map(|body| serde_json::from_slice(&body)); + + let base_data = + fetch_base_data(&ctx.backend_host, &ctx.http_client, req.headers(), &cookies).await?; + + let title = lang.tr("notifications", None); + + match api_res { + Err(crate::Error::RemoteError((_, message))) => { + let mut res = html_response(render::html! { + <HTPage base_data={&base_data} lang={&lang} title={&title}> + <h1>{title.as_ref()}</h1> + <div class={"errorBox"}>{message}</div> + </HTPage> + }); + + *res.status_mut() = hyper::StatusCode::FORBIDDEN; + + Ok(res) + } + Err(other) => Err(other), + Ok(api_res) => { + let notifications = api_res?; + + Ok(html_response(render::html! { + <HTPage base_data={&base_data} lang={&lang} title={&title}> + <h1>{title.as_ref()}</h1> + <ul> + { + notifications.iter() + .map(|item| render::rsx! { <NotificationItem notification={item} lang={&lang} /> }) + .collect::<Vec<_>>() + } + </ul> + </HTPage> + })) + } + } +} + async fn page_signup( _: (), ctx: Arc<crate::RouteContext>, @@ -1349,6 +1417,10 @@ pub fn route_root() -> crate::RouteNode<()> { .with_handler_async("POST", handler_new_community_submit), ), ) + .with_child( + "notifications", + crate::RouteNode::new().with_handler_async("GET", page_notifications), + ) .with_child("posts", posts::route_posts()) .with_child( "signup", |