~vpzom/hitide

7edc6628ac579d7d5a19081fec53300d66c2d9ed — Colin Reeder 1 year, 2 months ago e358af5
Notifications
M Cargo.lock => Cargo.lock +32 -0
@@ 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"

M Cargo.toml => Cargo.toml +1 -0
@@ 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"

M res/lang/en.ftl => res/lang/en.ftl +4 -0
@@ 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

M res/lang/eo.ftl => res/lang/eo.ftl +4 -0
@@ 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

M res/main.css => res/main.css +9 -0
@@ 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 */

M src/components/mod.rs => src/components/mod.rs +99 -20
@@ 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>")
    }
}

M src/resp_types.rs => src/resp_types.rs +50 -5
@@ 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,
}

M src/routes/mod.rs => src/routes/mod.rs +80 -8
@@ 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",