From 7edc6628ac579d7d5a19081fec53300d66c2d9ed Mon Sep 17 00:00:00 2001 From: Colin Reeder Date: Wed, 22 Jul 2020 18:40:01 -0600 Subject: Notifications --- Cargo.lock | 32 ++++++++++++++ Cargo.toml | 1 + res/lang/en.ftl | 4 ++ res/lang/eo.ftl | 4 ++ res/main.css | 9 ++++ src/components/mod.rs | 119 +++++++++++++++++++++++++++++++++++++++++--------- src/resp_types.rs | 55 ++++++++++++++++++++--- src/routes/mod.rs | 88 +++++++++++++++++++++++++++++++++---- 8 files changed, 279 insertions(+), 33 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 212857b..92cc743 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -192,6 +192,18 @@ version = "0.3.5" 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" @@ -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", @@ -591,6 +611,12 @@ dependencies = [ "autocfg", ] +[[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" @@ -750,6 +776,12 @@ version = "0.5.16" 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" diff --git a/Cargo.toml b/Cargo.toml index ff705b9..3351b62 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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! { -
+
} } else { render::rsx! { -
+
} } } - {lang.tr("reply", None)} + {lang.tr("reply", None)} }) } else { @@ -56,7 +57,7 @@ pub fn Comment<'a>( { if author_is_me(&comment.author, &base_data.login) { Some(render::rsx! { - {lang.tr("delete", None)} + {lang.tr("delete", None)} }) } else { None @@ -85,7 +86,7 @@ pub fn Comment<'a>( { if comment.replies.is_none() && comment.has_replies { Some(render::rsx! { - + }) } 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>(
{ - match &base_data.login { - Some(login) => Some(render::rsx! { - {Cow::Borrowed("👤︎")} - }), - None => { - Some(render::rsx! { - {lang.tr("login", None)} - }) - } + if let Some(login) = &base_data.login { + Some(render::rsx! { + <> + + {"🔔︎"} + + {"👤︎"} + + }) + } else { + None + } + } + { + if base_data.login.is_none() { + Some(render::rsx! { + {lang.tr("login", None)} + }) + } else { + None } }
@@ -289,7 +313,7 @@ impl<'a> render::Render for ThingItem<'a> { (render::rsx! {
  • - {lang.tr("comment", None)} + {lang.tr("comment", None)} {" "}{lang.tr("on", None)}{" "}{comment.post.title.as_ref()}{":"} @@ -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(self, writer: &mut W) -> std::fmt::Result { + let lang = self.lang; + + write!(writer, "
  • ")?; + match &self.notification.info { + RespNotificationInfo::Unknown => { + "[unknown notification type]".render_into(writer)?; + } + RespNotificationInfo::PostReply { reply, post } => { + (render::rsx! { + <> + {lang.tr("comment", None)} + {" "}{lang.tr("on_your_post", None)}{" "}{post.title.as_ref()}{":"} + + + }).render_into(writer)?; + } + RespNotificationInfo::CommentReply { + reply, + comment, + post, + } => { + (render::rsx! { + <> + {lang.tr("reply_to", None)} + {" "} + {lang.tr("your_comment", None)} + { + if let Some(post) = post { + Some(render::rsx! { <>{" "}{lang.tr("on", None)}{" "}{post.title.as_ref()} }) + } else { + None + } + } + {":"} + + + }).render_into(writer)?; + } + } + + write!(writer, "
  • ") + } +} 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>, pub content_html: Option>, +} + +#[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> 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>, pub created: Cow<'a, str>, - pub content_text: Option>, - pub content_html: Option>, pub your_vote: Option, #[serde(borrow)] pub replies: Option>>, pub has_replies: bool, } +impl<'a> AsRef> 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> 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>, + }, + #[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! { -
    +
    } } else { render::rsx! { -
    +
    } @@ -250,7 +252,7 @@ async fn page_comment_inner( { if author_is_me(&comment.author, &base_data.login) { Some(render::rsx! { - {lang.tr("delete", None)} + {lang.tr("delete", None)} }) } else { None @@ -267,7 +269,7 @@ async fn page_comment_inner( { if base_data.login.is_some() { Some(render::rsx! { -
    +
    @@ -352,7 +354,7 @@ async fn page_comment_delete_inner( } }) } - + { if let Some(referer) = referer { Some(render::rsx! { @@ -362,7 +364,7 @@ async fn page_comment_delete_inner( None } } - {lang.tr("no_cancel", None)} + {lang.tr("no_cancel", None)} {" "}
    @@ -875,6 +877,72 @@ async fn handler_new_community_submit( } } +async fn page_notifications( + _: (), + ctx: Arc, + req: hyper::Request, +) -> Result, 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, _>, _> = 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! { + +

    {title.as_ref()}

    +
    {message}
    +
    + }); + + *res.status_mut() = hyper::StatusCode::FORBIDDEN; + + Ok(res) + } + Err(other) => Err(other), + Ok(api_res) => { + let notifications = api_res?; + + Ok(html_response(render::html! { + +

    {title.as_ref()}

    +
      + { + notifications.iter() + .map(|item| render::rsx! { }) + .collect::>() + } +
    +
    + })) + } + } +} + async fn page_signup( _: (), ctx: Arc, @@ -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", -- cgit v1.2.3