~vpzom/lotide

2abe9f354855afe7edd0064529b2fd3872995b65 — Colin Reeder a month ago 5471de1
Initial work on new replies API / pagination
4 files changed, 347 insertions(+), 61 deletions(-)

M src/main.rs
M src/routes/api/comments.rs
M src/routes/api/mod.rs
M src/routes/api/posts.rs
M src/main.rs => src/main.rs +1 -1
@@ 90,7 90,7 @@ impl Into<activitystreams::primitives::OneOrMany<activitystreams::base::AnyBase>

pub type ParamSlice<'a> = &'a [&'a (dyn tokio_postgres::types::ToSql + Sync)];

#[derive(Serialize, Default)]
#[derive(Serialize, Default, Clone, Copy)]
pub struct Empty {}

pub struct Pineapple {

M src/routes/api/comments.rs => src/routes/api/comments.rs +61 -15
@@ 1,6 1,6 @@
use super::{
    JustURL, MaybeIncludeYour, RespAvatarInfo, RespMinimalAuthorInfo, RespMinimalCommentInfo,
    RespMinimalPostInfo, RespPostCommentInfo,
    JustURL, MaybeIncludeYour, RespAvatarInfo, RespList, RespMinimalAuthorInfo,
    RespMinimalCommentInfo, RespMinimalPostInfo, RespPostCommentInfo,
};
use crate::{CommentLocalID, CommunityLocalID, PostLocalID, UserLocalID};
use serde_derive::{Deserialize, Serialize};


@@ 39,7 39,7 @@ async fn route_unstable_comments_get(

    let (row, your_vote) = futures::future::try_join(
        db.query_opt(
            "SELECT reply.author, reply.post, reply.content_text, reply.created, reply.local, reply.content_html, person.username, person.local, person.ap_id, post.title, reply.deleted, reply.parent, person.avatar, reply.attachment_href, (SELECT COUNT(*) FROM reply_like WHERE reply = reply.id) FROM reply INNER JOIN post ON (reply.post = post.id) LEFT OUTER JOIN person ON (reply.author = person.id) WHERE reply.id = $1",
            "SELECT reply.author, reply.post, reply.content_text, reply.created, reply.local, reply.content_html, person.username, person.local, person.ap_id, post.title, reply.deleted, reply.parent, person.avatar, reply.attachment_href, (SELECT COUNT(*) FROM reply_like WHERE reply = reply.id), EXISTS(SELECT 1 FROM reply AS r2 WHERE r2.parent = reply.id) FROM reply INNER JOIN post ON (reply.post = post.id) LEFT OUTER JOIN person ON (reply.author = person.id) WHERE reply.id = $1",
            &[&comment_id],
        )
        .map_err(crate::Error::from),


@@ 96,12 96,6 @@ async fn route_unstable_comments_get(
                None => None,
            };

            let replies =
                super::get_comments_replies(&[comment_id], include_your_for, 3, &db, &ctx)
                    .await?
                    .remove(&comment_id)
                    .unwrap_or_else(Vec::new);

            let output = RespCommentInfo {
                base: RespPostCommentInfo {
                    base: RespMinimalCommentInfo {


@@ 123,8 117,11 @@ async fn route_unstable_comments_get(
                    created: created.to_rfc3339(),
                    deleted: row.get(10),
                    local: row.get(4),
                    has_replies: !replies.is_empty(),
                    replies: Some(replies),
                    replies: if row.get(15) {
                        None
                    } else {
                        Some(RespList::empty())
                    },
                    score: row.get(14),
                    your_vote,
                },


@@ 406,10 403,10 @@ async fn route_unstable_comments_likes_list(
        })
        .collect::<Vec<_>>();

    let body = serde_json::json!({
        "items": likes,
        "next_page": next_page,
    });
    let body = RespList {
        items: (&likes).into(),
        next_page: next_page.as_deref().map(Cow::Borrowed),
    };

    crate::json_response(&body)
}


@@ 516,6 513,54 @@ async fn route_unstable_comments_unlike(
    Ok(crate::empty_response())
}

async fn route_unstable_comments_replies_list(
    params: (CommentLocalID,),
    ctx: Arc<crate::RouteContext>,
    req: hyper::Request<hyper::Body>,
) -> Result<hyper::Response<hyper::Body>, crate::Error> {
    let (comment_id,) = params;

    #[derive(Deserialize)]
    struct RepliesListQuery {
        #[serde(default)]
        include_your: bool,
        #[serde(default = "super::default_replies_depth")]
        depth: u8,
        #[serde(default = "super::default_replies_limit")]
        limit: u8,
    }

    let query: RepliesListQuery = serde_urlencoded::from_str(req.uri().query().unwrap_or(""))?;

    let db = ctx.db_pool.get().await?;

    let include_your_for = if query.include_your {
        let user = crate::require_login(&req, &db).await?;
        Some(user)
    } else {
        None
    };

    let (replies, next_page) = super::get_comments_replies(
        &[comment_id],
        include_your_for,
        query.depth,
        query.limit,
        &db,
        &ctx,
    )
    .await?
    .remove(&comment_id)
    .unwrap_or_else(|| (Vec::new(), None));

    let body = RespList {
        items: (&replies).into(),
        next_page: next_page.as_deref().map(Cow::Borrowed),
    };

    crate::json_response(&body)
}

async fn route_unstable_comments_replies_create(
    params: (CommentLocalID,),
    ctx: Arc<crate::RouteContext>,


@@ 595,6 640,7 @@ pub fn route_comments() -> crate::RouteNode<()> {
            .with_child(
                "replies",
                crate::RouteNode::new()
                    .with_handler_async("GET", route_unstable_comments_replies_list)
                    .with_handler_async("POST", route_unstable_comments_replies_create),
            )
            .with_child(

M src/routes/api/mod.rs => src/routes/api/mod.rs +185 -22
@@ 22,6 22,52 @@ lazy_static::lazy_static! {
    };
}

#[derive(Debug)]
struct InvalidNumber58;

fn parse_number_58(src: &str) -> Result<i64, InvalidNumber58> {
    let mut buf = [0; 8];
    match bs58::decode(src).into(&mut buf) {
        Err(_) => Err(InvalidNumber58),
        Ok(count) => {
            if count == 8 {
                Ok(i64::from_be_bytes(buf))
            } else {
                Err(InvalidNumber58)
            }
        }
    }
}

fn format_number_58(src: i64) -> String {
    bs58::encode(src.to_be_bytes()).into_string()
}

struct ValueConsumer<'a> {
    targets: Vec<&'a mut Option<Box<dyn tokio_postgres::types::ToSql + Send + Sync>>>,
    start_idx: usize,
    used: usize,
}

impl<'a> ValueConsumer<'a> {
    fn push(&mut self, value: impl tokio_postgres::types::ToSql + Sync + Send + 'static) -> usize {
        *self.targets[self.used] = Some(Box::new(value));
        self.used += 1;

        self.start_idx + self.used
    }
}

struct InvalidPage;
impl InvalidPage {
    fn to_user_error(self) -> crate::Error {
        crate::Error::UserError(crate::simple_response(
            hyper::StatusCode::BAD_REQUEST,
            "Invalid page",
        ))
    }
}

#[derive(Deserialize)]
#[serde(rename_all = "snake_case")]
enum SortType {


@@ 43,6 89,76 @@ impl SortType {
            SortType::New => "reply.created DESC",
        }
    }

    pub fn handle_page(
        &self,
        page: Option<&str>,
        mut value_out: ValueConsumer,
    ) -> Result<(Option<String>, Option<String>), InvalidPage> {
        match page {
            None => Ok((None, None)),
            Some(page) => match self {
                SortType::Hot => {
                    let page: i64 = parse_number_58(page).map_err(|_| InvalidPage)?;
                    let idx = value_out.push(page);
                    Ok((None, Some(format!(" OFFSET ${}", idx))))
                }
                SortType::New => {
                    let page: (chrono::DateTime<chrono::offset::FixedOffset>, i64) = {
                        let mut spl = page.split(',');

                        let ts = spl.next().ok_or(InvalidPage)?;
                        let u = spl.next().ok_or(InvalidPage)?;
                        if spl.next().is_some() {
                            return Err(InvalidPage);
                        } else {
                            use chrono::TimeZone;

                            let ts: i64 = ts.parse().map_err(|_| InvalidPage)?;
                            let u: i64 = u.parse().map_err(|_| InvalidPage)?;

                            let ts = chrono::offset::Utc.timestamp_nanos(ts);

                            (ts.into(), u)
                        }
                    };

                    let idx1 = value_out.push(page.0);
                    let idx2 = value_out.push(page.1);

                    Ok((
                        Some(format!(
                            " AND created < ${0} OR (created = ${0} AND id <= ${1})",
                            idx1, idx2
                        )),
                        None,
                    ))
                }
            },
        }
    }

    fn get_next_comments_page(
        &self,
        comment: RespPostCommentInfo,
        limit: u8,
        current_page: Option<&str>,
    ) -> String {
        match self {
            SortType::Hot => format_number_58(
                i64::from(limit)
                    + match current_page {
                        None => 0,
                        Some(current_page) => parse_number_58(current_page).unwrap(),
                    },
            ),
            SortType::New => {
                let ts: chrono::DateTime<chrono::offset::FixedOffset> =
                    comment.created.parse().unwrap();
                format!("{},{}", ts, comment.base.id)
            }
        }
    }
}

#[derive(Serialize)]


@@ 56,12 172,27 @@ struct MaybeIncludeYour {
    pub include_your: bool,
}

#[derive(Serialize)]
#[derive(Serialize, Clone)]
struct RespList<'a, T: serde::Serialize + ToOwned + Clone> {
    items: Cow<'a, [T]>,
    next_page: Option<Cow<'a, str>>,
}

impl<'a, T: serde::Serialize + ToOwned + Clone> RespList<'a, T> {
    pub fn empty() -> Self {
        Self {
            items: Cow::Borrowed(&[]),
            next_page: None,
        }
    }
}

#[derive(Serialize, Clone)]
struct RespAvatarInfo<'a> {
    url: Cow<'a, str>,
}

#[derive(Serialize)]
#[derive(Serialize, Clone)]
struct RespMinimalAuthorInfo<'a> {
    id: UserLocalID,
    username: Cow<'a, str>,


@@ 80,7 211,7 @@ struct RespLoginUserInfo<'a> {
    has_unread_notifications: bool,
}

#[derive(Serialize)]
#[derive(Serialize, Clone)]
struct JustUser<'a> {
    user: RespMinimalAuthorInfo<'a>,
}


@@ 118,7 249,7 @@ struct RespPostListPost<'a> {
    your_vote: Option<Option<crate::Empty>>,
}

#[derive(Serialize)]
#[derive(Serialize, Clone)]
struct RespMinimalCommentInfo<'a> {
    id: CommentLocalID,
    content_text: Option<Cow<'a, str>>,


@@ 126,12 257,12 @@ struct RespMinimalCommentInfo<'a> {
    content_html_safe: Option<String>,
}

#[derive(Serialize)]
#[derive(Serialize, Clone)]
struct JustURL<'a> {
    url: Cow<'a, str>,
}

#[derive(Serialize)]
#[derive(Serialize, Clone)]
struct RespPostCommentInfo<'a> {
    #[serde(flatten)]
    base: RespMinimalCommentInfo<'a>,


@@ 141,13 272,21 @@ struct RespPostCommentInfo<'a> {
    created: String,
    deleted: bool,
    local: bool,
    replies: Option<Vec<RespPostCommentInfo<'a>>>,
    has_replies: bool,
    replies: Option<RespList<'a, RespPostCommentInfo<'a>>>,
    score: i64,
    #[serde(skip_serializing_if = "Option::is_none")]
    your_vote: Option<Option<crate::Empty>>,
}

impl<'a> RespPostCommentInfo<'a> {
    fn has_replies(&self) -> Option<bool> {
        match &self.replies {
            None => None,
            Some(list) => Some(!list.items.is_empty()),
        }
    }
}

#[derive(Serialize)]
#[serde(tag = "type")]
enum RespThingInfo<'a> {


@@ 170,6 309,14 @@ enum RespThingInfo<'a> {
    },
}

pub fn default_replies_depth() -> u8 {
    3
}

pub fn default_replies_limit() -> u8 {
    30
}

pub fn route_api() -> crate::RouteNode<()> {
    crate::RouteNode::new()
        .with_child(


@@ 651,6 798,7 @@ async fn apply_comments_replies<'a, T>(
    comments: &mut Vec<(T, RespPostCommentInfo<'a>)>,
    include_your_for: Option<UserLocalID>,
    depth: u8,
    limit: u8,
    db: &tokio_postgres::Client,
    ctx: &'a crate::BaseContext,
) -> Result<(), crate::Error> {


@@ 660,12 808,16 @@ async fn apply_comments_replies<'a, T>(
        .collect::<Vec<_>>();
    if depth > 0 {
        let mut replies =
            get_comments_replies_box(&ids, include_your_for, depth - 1, db, ctx).await?;
            get_comments_replies_box(&ids, include_your_for, depth - 1, limit, db, ctx).await?;

        for (_, comment) in comments.iter_mut() {
            let current = replies.remove(&comment.base.id).unwrap_or_else(Vec::new);
            comment.has_replies = !current.is_empty();
            comment.replies = Some(current);
            let (current, next_page) = replies
                .remove(&comment.base.id)
                .unwrap_or_else(|| (Vec::new(), None));
            comment.replies = Some(RespList {
                items: current.into(),
                next_page: next_page.map(From::from),
            });
        }
    } else {
        use futures::stream::TryStreamExt;


@@ 684,11 836,15 @@ async fn apply_comments_replies<'a, T>(
            .await?;

        for (_, comment) in comments.iter_mut() {
            comment.has_replies = with_replies.contains(&comment.base.id);
            comment.replies = if with_replies.contains(&comment.base.id) {
                None
            } else {
                Some(RespList::empty())
            };
        }
    }

    comments.retain(|(_, comment)| !comment.deleted || comment.has_replies);
    comments.retain(|(_, comment)| !comment.deleted || comment.has_replies() != Some(false));

    Ok(())
}


@@ 697,13 853,14 @@ fn get_comments_replies_box<'a: 'b, 'b>(
    parents: &'b [CommentLocalID],
    include_your_for: Option<UserLocalID>,
    depth: u8,
    limit: u8,
    db: &'b tokio_postgres::Client,
    ctx: &'a crate::BaseContext,
) -> std::pin::Pin<
    Box<
        dyn Future<
                Output = Result<
                    HashMap<CommentLocalID, Vec<RespPostCommentInfo<'a>>>,
                    HashMap<CommentLocalID, (Vec<RespPostCommentInfo<'a>>, Option<String>)>,
                    crate::Error,
                >,
            > + Send


@@ 714,6 871,7 @@ fn get_comments_replies_box<'a: 'b, 'b>(
        parents,
        include_your_for,
        depth,
        limit,
        db,
        ctx,
    ))


@@ 723,12 881,13 @@ async fn get_comments_replies<'a>(
    parents: &[CommentLocalID],
    include_your_for: Option<UserLocalID>,
    depth: u8,
    limit: u8,
    db: &tokio_postgres::Client,
    ctx: &'a crate::BaseContext,
) -> Result<HashMap<CommentLocalID, Vec<RespPostCommentInfo<'a>>>, crate::Error> {
) -> Result<HashMap<CommentLocalID, (Vec<RespPostCommentInfo<'a>>, Option<String>)>, crate::Error> {
    use futures::TryStreamExt;

    let sql1 = "SELECT reply.id, reply.author, reply.content_text, reply.created, reply.parent, reply.content_html, person.username, person.local, person.ap_id, reply.deleted, person.avatar, reply.attachment_href, reply.local, (SELECT COUNT(*) FROM reply_like WHERE reply = reply.id)";
    let sql1 = "SELECT result.* FROM UNNEST($1::BIGINT[]) JOIN LATERAL (SELECT reply.id, reply.author, reply.content_text, reply.created, reply.parent, reply.content_html, person.username, person.local, person.ap_id, reply.deleted, person.avatar, reply.attachment_href, reply.local, (SELECT COUNT(*) FROM reply_like WHERE reply = reply.id)";
    let (sql2, values): (_, Vec<&(dyn tokio_postgres::types::ToSql + Sync)>) =
        if include_your_for.is_some() {
            (


@@ 738,7 897,7 @@ async fn get_comments_replies<'a>(
        } else {
            ("", vec![&parents])
        };
    let sql3 = " FROM reply LEFT OUTER JOIN person ON (person.id = reply.author) WHERE parent = ANY($1::BIGINT[]) ORDER BY hot_rank((SELECT COUNT(*) FROM reply_like WHERE reply = reply.id AND person != reply.author), reply.created) DESC";
    let sql3 = " FROM reply LEFT OUTER JOIN person ON (person.id = reply.author) WHERE parent = unnest ORDER BY hot_rank((SELECT COUNT(*) FROM reply_like WHERE reply = reply.id AND person != reply.author), reply.created) DESC) AS result ON TRUE";

    let sql: &str = &format!("{}{}{}", sql1, sql2, sql3);



@@ 795,8 954,7 @@ async fn get_comments_replies<'a>(
                    created: created.to_rfc3339(),
                    deleted: row.get(9),
                    local: row.get(12),
                    replies: None,
                    has_replies: false,
                    replies: Some(RespList::empty()),
                    score: row.get(13),
                    your_vote: match include_your_for {
                        None => None,


@@ 812,11 970,16 @@ async fn get_comments_replies<'a>(
        .try_collect()
        .await?;

    apply_comments_replies(&mut comments, include_your_for, depth, db, &ctx).await?;
    apply_comments_replies(&mut comments, include_your_for, depth, limit, db, &ctx).await?;

    let mut result = HashMap::new();
    for (parent, comment) in comments {
        result.entry(parent).or_insert_with(Vec::new).push(comment);
        let entry = result.entry(parent).or_insert_with(|| (Vec::new(), None));
        if entry.0.len() < limit.into() {
            entry.0.push(comment);
        } else {
            entry.1 = Some(limit.to_string());
        }
    }

    Ok(result)

M src/routes/api/posts.rs => src/routes/api/posts.rs +100 -23
@@ 1,6 1,6 @@
use super::{
    JustURL, RespAvatarInfo, RespMinimalAuthorInfo, RespMinimalCommentInfo,
    RespMinimalCommunityInfo, RespPostCommentInfo, RespPostListPost,
    JustURL, RespAvatarInfo, RespList, RespMinimalAuthorInfo, RespMinimalCommentInfo,
    RespMinimalCommunityInfo, RespPostCommentInfo, RespPostListPost, ValueConsumer,
};
use crate::{CommentLocalID, CommunityLocalID, PostLocalID, UserLocalID};
use serde_derive::{Deserialize, Serialize};


@@ 13,24 13,55 @@ async fn get_post_comments<'a>(
    post_id: PostLocalID,
    include_your_for: Option<UserLocalID>,
    sort: super::SortType,
    limit: u8,
    page: Option<&'a str>,
    db: &tokio_postgres::Client,
    ctx: &'a crate::BaseContext,
) -> Result<Vec<RespPostCommentInfo<'a>>, crate::Error> {
) -> Result<(Vec<RespPostCommentInfo<'a>>, Option<String>), crate::Error> {
    use futures::TryStreamExt;

    let limit_i = i64::from(limit) + 1;

    let sql1 = "SELECT reply.id, reply.author, reply.content_text, reply.created, reply.content_html, person.username, person.local, person.ap_id, reply.deleted, person.avatar, attachment_href, reply.local, (SELECT COUNT(*) FROM reply_like WHERE reply = reply.id)";
    let (sql2, values): (_, Vec<&(dyn tokio_postgres::types::ToSql + Sync)>) =
    let (sql2, mut values): (_, Vec<&(dyn tokio_postgres::types::ToSql + Sync)>) =
        if include_your_for.is_some() {
            (
                ", EXISTS(SELECT 1 FROM reply_like WHERE reply = reply.id AND person = $2)",
                vec![&post_id, &include_your_for],
                ", EXISTS(SELECT 1 FROM reply_like WHERE reply = reply.id AND person = $3)",
                vec![&post_id, &limit_i, &include_your_for],
            )
        } else {
            ("", vec![&post_id])
            ("", vec![&post_id, &limit_i])
        };
    let sql3 = format!(" FROM reply LEFT OUTER JOIN person ON (person.id = reply.author) WHERE post=$1 AND parent IS NULL ORDER BY {}", sort.comment_sort_sql());
    let mut sql3 = "FROM reply LEFT OUTER JOIN person ON (person.id = reply.author) WHERE post=$1 AND parent IS NULL ".to_owned();
    let mut sql4 = format!("ORDER BY {} LIMIT $2", sort.comment_sort_sql());

    let mut con1 = None;
    let mut con2 = None;
    let (page_part1, page_part2) = sort
        .handle_page(
            page,
            ValueConsumer {
                targets: vec![&mut con1, &mut con2],
                start_idx: values.len(),
                used: 0,
            },
        )
        .map_err(super::InvalidPage::to_user_error)?;
    if let Some(value) = &con1 {
        values.push(value.as_ref());
        if let Some(value) = &con2 {
            values.push(value.as_ref());
        }
    }

    if let Some(part) = page_part1 {
        sql3.push_str(&part);
    }
    if let Some(part) = page_part2 {
        sql4.push_str(&part);
    }

    let sql: &str = &format!("{}{}{}", sql1, sql2, sql3);
    let sql: &str = &format!("{}{}{}{}", sql1, sql2, sql3, sql4);

    let stream = crate::query_stream(db, sql, &values[..]).await?;



@@ 84,8 115,7 @@ async fn get_post_comments<'a>(
                    created: created.to_rfc3339(),
                    deleted: row.get(8),
                    local: row.get(11),
                    replies: None,
                    has_replies: false,
                    replies: Some(RespList::empty()),
                    score: row.get(12),
                    your_vote: match include_your_for {
                        None => None,


@@ 101,9 131,18 @@ async fn get_post_comments<'a>(
        .try_collect()
        .await?;

    super::apply_comments_replies(&mut comments, include_your_for, 2, db, &ctx).await?;
    let next_page = if comments.len() > usize::from(limit) {
        Some(sort.get_next_comments_page(comments.pop().unwrap().1, limit, page))
    } else {
        None
    };

    super::apply_comments_replies(&mut comments, include_your_for, 2, limit, db, &ctx).await?;

    Ok(comments.into_iter().map(|(_, comment)| comment).collect())
    Ok((
        comments.into_iter().map(|(_, comment)| comment).collect(),
        next_page,
    ))
}

async fn route_unstable_posts_list(


@@ 216,6 255,52 @@ async fn route_unstable_posts_list(
    crate::json_response(&posts)
}

async fn route_unstable_posts_replies_list(
    params: (PostLocalID,),
    ctx: Arc<crate::RouteContext>,
    req: hyper::Request<hyper::Body>,
) -> Result<hyper::Response<hyper::Body>, crate::Error> {
    let (post_id,) = params;

    #[derive(Deserialize)]
    struct RepliesListQuery<'a> {
        #[serde(default)]
        include_your: bool,
        #[serde(default = "super::default_replies_limit")]
        limit: u8,
        page: Option<Cow<'a, str>>,
    }

    let query: RepliesListQuery = serde_urlencoded::from_str(req.uri().query().unwrap_or(""))?;

    let db = ctx.db_pool.get().await?;

    let include_your_for = if query.include_your {
        let user = crate::require_login(&req, &db).await?;
        Some(user)
    } else {
        None
    };

    let (replies, next_page) = get_post_comments(
        post_id,
        include_your_for,
        super::SortType::Hot,
        query.limit,
        query.page.as_deref(),
        &db,
        &ctx,
    )
    .await?;

    let body = RespList {
        items: (&replies).into(),
        next_page: next_page.as_deref().map(Cow::Borrowed),
    };

    crate::json_response(&body)
}

async fn route_unstable_posts_create(
    _: (),
    ctx: Arc<crate::RouteContext>,


@@ 337,16 422,10 @@ async fn route_unstable_posts_get(
) -> Result<hyper::Response<hyper::Body>, crate::Error> {
    use futures::future::TryFutureExt;

    fn default_sort() -> super::SortType {
        super::SortType::Hot
    }

    #[derive(Deserialize)]
    struct PostsGetQuery {
        #[serde(default)]
        include_your: bool,
        #[serde(default = "default_sort")]
        replies_sort: super::SortType,
    }

    let query: PostsGetQuery = serde_urlencoded::from_str(req.uri().query().unwrap_or(""))?;


@@ 367,18 446,16 @@ async fn route_unstable_posts_get(
        post: &'a RespPostListPost<'a>,
        approved: bool,
        local: bool,
        replies: Vec<RespPostCommentInfo<'a>>,
    }

    let (post_id,) = params;

    let (row, comments, your_vote) = futures::future::try_join3(
    let (row, your_vote) = futures::future::try_join(
        db.query_opt(
            "SELECT post.author, post.href, post.content_text, post.title, post.created, post.content_html, community.id, community.name, community.local, community.ap_id, person.username, person.local, person.ap_id, (SELECT COUNT(*) FROM post_like WHERE post_like.post = $1), post.approved, person.avatar, post.local FROM community, post LEFT OUTER JOIN person ON (person.id = post.author) WHERE post.community = community.id AND post.id = $1",
            &[&post_id],
        )
        .map_err(crate::Error::from),
        get_post_comments(post_id, include_your_for, query.replies_sort, &db, &ctx),
        async {
            if let Some(user) = include_your_for {
                let row = db.query_opt("SELECT 1 FROM post_like WHERE post=$1 AND person=$2", &[&post_id, &user]).await?;


@@ 462,7 539,6 @@ async fn route_unstable_posts_get(
            let output = RespPostInfo {
                post: &post,
                local: row.get(16),
                replies: comments,
                approved: row.get(14),
            };



@@ 914,6 990,7 @@ pub fn route_posts() -> crate::RouteNode<()> {
                .with_child(
                    "replies",
                    crate::RouteNode::new()
                        .with_handler_async("GET", route_unstable_posts_replies_list)
                        .with_handler_async("POST", route_unstable_posts_replies_create),
                )
                .with_child(