summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorColin Reeder <colin@vpzom.click>2020-07-22 18:40:01 -0600
committerColin Reeder <colin@vpzom.click>2020-07-22 18:40:01 -0600
commit7edc6628ac579d7d5a19081fec53300d66c2d9ed (patch)
treee184251f6ee530a5dc3dae9b2efe1210a7768355
parente358af5a2a4c4d3cfa0da907884a1a464c33edcd (diff)
Notifications
-rw-r--r--Cargo.lock32
-rw-r--r--Cargo.toml1
-rw-r--r--res/lang/en.ftl4
-rw-r--r--res/lang/eo.ftl4
-rw-r--r--res/main.css9
-rw-r--r--src/components/mod.rs119
-rw-r--r--src/resp_types.rs55
-rw-r--r--src/routes/mod.rs88
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
@@ -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"
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! {
- <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",