~vpzom/lotide

959d5ffe598419ec624880ad076da1037afb932c — Colin Reeder 10 days ago 9656139 + 90f1c5c
Merge branch 'paginate-all-the-things'
M openapi/openapi.json => openapi/openapi.json +121 -23
@@ 2,7 2,7 @@
	"openapi": "3.0.1",
	"info": {
		"title": "lotide API",
		"version": "0.7.0-pre"
		"version": "0.9.0-pre"
	},
	"components": {
		"schemas": {


@@ 580,6 580,25 @@
						"required": false,
						"schema": {"type": "boolean"},
						"description": "Filter to either communities you are following or waiting to follow. Requires login."
					},
					{
						"name": "local",
						"in": "query",
						"required": false,
						"schema": {"type": "boolean"},
						"description": "Filter to either local or remote communities."
					},
					{
						"name": "limit",
						"in": "query",
						"required": false,
						"schema": {"type": "integer"}
					},
					{
						"name": "page",
						"in": "query",
						"required": false,
						"schema": {"type": "string"}
					}
				],
				"responses": {


@@ 588,9 607,19 @@
						"content": {
							"application/json": {
								"schema": {
									"type": "array",
									"items": {
										"$ref": "#/components/schemas/CommunityInfo"
									"type": "object",
									"required": ["items", "next_page"],
									"properties": {
										"items": {
											"type": "array",
											"items": {
												"$ref": "#/components/schemas/CommunityInfo"
											}
										},
										"next_page": {
											"type": "string",
											"nullable": true
										}
									}
								}
							}


@@ 824,6 853,7 @@
		},
		"/api/unstable/communities/{communityID}/posts": {
			"get": {
				"deprecated": true,
				"summary": "List posts published to a community",
				"parameters": [
					{


@@ 1225,6 1255,39 @@
						"required": false,
						"schema": {"type": "string"},
						"description": "If true, will omit posts from communities marked as hide_posts_from_aggregates"
					},
					{
						"name": "community",
						"in": "query",
						"required": false,
						"schema": {"type": "integer"},
						"description": "If present, will filter to posts approved in the specified community"
					},
					{
						"name": "in_your_follows",
						"in": "query",
						"required": false,
						"schema": {"type": "boolean"},
						"description": "Filter by whether the post is approved in one of the communities you follow"
					},
					{
						"name": "sort_sticky",
						"in": "query",
						"required": false,
						"schema": {"type": "boolean"},
						"description": "If true, will sort sticky posts to the top"
					},
					{
						"name": "limit",
						"in": "query",
						"required": false,
						"schema": {"type": "integer"}
					},
					{
						"name": "page",
						"in": "query",
						"required": false,
						"schema": {"type": "string"}
					}
				],
				"responses": {


@@ 1233,8 1296,20 @@
						"content": {
							"application/json": {
								"schema": {
									"type": "array",
									"items": {"$ref": "#/components/schemas/PostListPost"}
									"type": "object",
									"required": ["items", "next_page"],
									"properties": {
										"items": {
											"type": "array",
											"items": {
												"$ref": "#/components/schemas/PostListPost"
											}
										},
										"next_page": {
											"type": "string",
											"nullable": true
										}
									}
								}
							}
						}


@@ 1622,6 1697,18 @@
						"in": "path",
						"required": true,
						"schema": {"$ref": "#/components/schemas/PathUserID"}
					},
					{
						"name": "limit",
						"in": "query",
						"required": false,
						"schema": {"type": "integer"}
					},
					{
						"name": "page",
						"in": "query",
						"required": false,
						"schema": {"type": "string"}
					}
				],
				"responses": {


@@ 1630,23 1717,33 @@
						"content": {
							"application/json": {
								"schema": {
									"type": "array",
									"items": {
										"oneOf": [
											{
												"type": "object",
												"required": ["type", "id", "content_text", "content_html", "created", "post"],
												"properties": {
													"type": {"type": "string", "enum": ["comment"]},
													"id": {"type": "integer"},
													"content_text": {"type": "string", "nullable": true},
													"content_html": {"type": "string", "nullable": true},
													"created": {"type": "string", "format": "date-time"},
													"post": {"$ref": "#/components/schemas/MinimalPostInfo"}
												}
											},
											{"$ref": "#/components/schemas/SomePostInfo"}
										]
									"type": "object",
									"required": ["items", "next_page"],
									"properties": {
										"items": {
											"type": "array",
											"items": {
												"oneOf": [
													{
														"type": "object",
														"required": ["type", "id", "content_text", "content_html", "created", "post"],
														"properties": {
															"type": {"type": "string", "enum": ["comment"]},
															"id": {"type": "integer"},
															"content_text": {"type": "string", "nullable": true},
															"content_html": {"type": "string", "nullable": true},
															"created": {"type": "string", "format": "date-time"},
															"post": {"$ref": "#/components/schemas/MinimalPostInfo"}
														}
													},
													{"$ref": "#/components/schemas/SomePostInfo"}
												]
											}
										},
										"next_page": {
											"type": "string",
											"nullable": true
										}
									}
								}
							}


@@ 1717,6 1814,7 @@
		},
		"/api/unstable/users/~me/following:posts": {
			"get": {
				"deprecated": true,
				"summary": "Fetch posts from all the communities you follow",
				"responses": {
					"200": {

M src/main.rs => src/main.rs +15 -5
@@ 154,17 154,22 @@ pub struct BaseContext {
}

impl BaseContext {
    pub fn process_href<'a>(&self, href: &'a str, post_id: PostLocalID) -> Cow<'a, str> {
    pub fn process_href<'a>(
        &self,
        href: impl Into<Cow<'a, str>>,
        post_id: PostLocalID,
    ) -> Cow<'a, str> {
        let href = href.into();
        if href.starts_with("local-media://") {
            format!("{}/stable/posts/{}/href", self.host_url_api, post_id).into()
        } else {
            href.into()
            href
        }
    }

    pub fn process_href_opt<'a>(
        &self,
        href: Option<&'a str>,
        href: Option<Cow<'a, str>>,
        post_id: PostLocalID,
    ) -> Option<Cow<'a, str>> {
        match href {


@@ 192,11 197,16 @@ impl BaseContext {
        }
    }

    pub fn process_avatar_href<'a>(&self, href: &'a str, user_id: UserLocalID) -> Cow<'a, str> {
    pub fn process_avatar_href<'a>(
        &self,
        href: impl Into<Cow<'a, str>>,
        user_id: UserLocalID,
    ) -> Cow<'a, str> {
        let href = href.into();
        if href.starts_with("local-media://") {
            format!("{}/stable/users/{}/avatar/href", self.host_url_api, user_id,).into()
        } else {
            href.into()
            href
        }
    }


M src/routes/api/communities.rs => src/routes/api/communities.rs +118 -53
@@ 1,10 1,11 @@
use crate::types::{
    CommunityLocalID, MaybeIncludeYour, PostLocalID, RespAvatarInfo, RespCommunityFeeds,
    RespCommunityFeedsType, RespCommunityInfo, RespMinimalAuthorInfo, RespMinimalCommunityInfo,
    RespModeratorInfo, RespPostListPost, RespYourFollowInfo, UserLocalID,
    RespCommunityFeedsType, RespCommunityInfo, RespList, RespMinimalAuthorInfo,
    RespMinimalCommunityInfo, RespModeratorInfo, RespPostListPost, RespYourFollowInfo, UserLocalID,
};
use serde_derive::Deserialize;
use std::borrow::Cow;
use std::convert::TryInto;
use std::ops::Deref;
use std::sync::Arc;



@@ 29,16 30,26 @@ async fn route_unstable_communities_list(
) -> Result<hyper::Response<hyper::Body>, crate::Error> {
    use std::fmt::Write;

    fn default_limit() -> i64 {
        30
    }

    #[derive(Deserialize)]
    struct CommunitiesListQuery<'a> {
        #[serde(default)]
        search: Option<Cow<'a, str>>,

        local: Option<bool>,

        #[serde(rename = "your_follow.accepted")]
        your_follow_accepted: Option<bool>,

        #[serde(default)]
        include_your: bool,

        #[serde(default = "default_limit")]
        limit: i64,

        page: Option<Cow<'a, str>>,
    }

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


@@ 83,61 94,114 @@ async fn route_unstable_communities_list(
            values.len()
        )
        .unwrap();
        did_where = true;
        values.push(req_your_follow_accepted);
        write!(sql, " AND accepted=${})", values.len()).unwrap();
    }
    if let Some(req_local) = &query.local {
        values.push(req_local);
        write!(
            sql,
            " {} community.local=${}",
            if did_where { "AND" } else { "WHERE" },
            values.len()
        )
        .unwrap();
        did_where = true;
    }

    let page = query
        .page
        .as_deref()
        .map(super::parse_number_58)
        .transpose()
        .map_err(|_| super::InvalidPage.into_user_error())?;

    if let Some(page) = &page {
        values.push(page);
        write!(
            sql,
            " {} id >= ${}",
            if did_where { "AND" } else { "WHERE" },
            values.len()
        )
        .unwrap();
    }

    write!(sql, " ORDER BY id").unwrap();

    let limit_plus_1 = query.limit + 1;

    values.push(&limit_plus_1);
    write!(sql, " LIMIT ${}", values.len()).unwrap();

    let sql: &str = &sql;
    let rows = db.query(sql, &values).await?;
    let mut rows = db.query(sql, &values).await?;

    let output: Vec<_> = rows
        .iter()
        .map(|row| {
            let id = CommunityLocalID(row.get(0));
            let name = row.get(1);
            let local = row.get(2);
            let ap_id = row.get(3);
    let next_page = if rows.len() > query.limit.try_into().unwrap() {
        let row = rows.pop().unwrap();

            let (description, description_text, description_html) =
                get_community_description_fields(row.get(4), row.get(5));
        let id: i64 = row.get(0);

            let host = crate::get_actor_host_or_unknown(local, ap_id, &ctx.local_hostname);
        Some(super::format_number_58(id))
    } else {
        None
    };

            RespCommunityInfo {
                base: RespMinimalCommunityInfo {
                    id,
                    name,
                    local,
                    host,
                    remote_url: ap_id,
                },
    let rows = rows;

    let output = RespList {
        items: rows
            .iter()
            .map(|row| {
                let id = CommunityLocalID(row.get(0));
                let name: &str = row.get(1);
                let local = row.get(2);
                let ap_id = row.get(3);

                let (description, description_text, description_html) =
                    get_community_description_fields(row.get(4), row.get(5));

                let host = crate::get_actor_host_or_unknown(local, ap_id, &ctx.local_hostname);

                RespCommunityInfo {
                    base: RespMinimalCommunityInfo {
                        id,
                        name: Cow::Borrowed(name),
                        local,
                        host,
                        remote_url: ap_id.map(Cow::Borrowed),
                    },

                description,
                description_html,
                description_text,
                    description,
                    description_html,
                    description_text,

                feeds: RespCommunityFeeds {
                    atom: RespCommunityFeedsType {
                        new: format!("{}/stable/communities/{}/feed", ctx.host_url_api, id),
                    feeds: RespCommunityFeeds {
                        atom: RespCommunityFeedsType {
                            new: format!("{}/stable/communities/{}/feed", ctx.host_url_api, id),
                        },
                    },
                },

                you_are_moderator: if query.include_your {
                    Some(row.get(7))
                } else {
                    None
                },
                your_follow: if query.include_your {
                    Some(match row.get(6) {
                        Some(accepted) => Some(RespYourFollowInfo { accepted }),
                        None => None,
                    })
                } else {
                    None
                },
            }
        })
        .collect();
                    you_are_moderator: if query.include_your {
                        Some(row.get(7))
                    } else {
                        None
                    },
                    your_follow: if query.include_your {
                        Some(match row.get(6) {
                            Some(accepted) => Some(RespYourFollowInfo { accepted }),
                            None => None,
                        })
                    } else {
                        None
                    },
                }
            })
            .collect::<Vec<_>>()
            .into(),
        next_page: next_page.map(Cow::Owned),
    };

    crate::json_response(&output)
}


@@ 261,7 325,7 @@ async fn route_unstable_communities_get(
    let info = RespCommunityInfo {
        base: RespMinimalCommunityInfo {
            id: community_id,
            name: row.get(0),
            name: Cow::Borrowed(row.get(0)),
            local: community_local,
            host: if community_local {
                (&ctx.local_hostname).into()


@@ 271,7 335,7 @@ async fn route_unstable_communities_get(
                    None => "[unknown]".into(),
                }
            },
            remote_url: community_ap_id,
            remote_url: community_ap_id.map(Cow::Borrowed),
        },
        description,
        description_html,


@@ 719,7 783,7 @@ async fn route_unstable_communities_posts_list(

        RespMinimalCommunityInfo {
            id: community_id,
            name: row.get(0),
            name: Cow::Borrowed(row.get(0)),
            local: community_local,
            host: if community_local {
                (&ctx.local_hostname).into()


@@ 729,7 793,7 @@ async fn route_unstable_communities_posts_list(
                    None => "[unknown]".into(),
                }
            },
            remote_url: community_ap_id,
            remote_url: community_ap_id.map(Cow::Borrowed),
        }
    };



@@ 791,13 855,14 @@ async fn route_unstable_communities_posts_list(

            let post = RespPostListPost {
                id,
                title,
                href: ctx.process_href_opt(href, id),
                content_text,
                title: Cow::Borrowed(title),
                href: ctx.process_href_opt(href.map(Cow::Borrowed), id),
                content_text: content_text.map(Cow::Borrowed),
                content_html_safe: content_html.map(|html| crate::clean_html(&html)),
                author: author.as_ref(),
                author: author.map(Cow::Owned),
                created: Cow::Owned(created.to_rfc3339()),
                community: Cow::Borrowed(&community),
                relevance: None,
                replies_count_total: Some(row.get(12)),
                score: row.get(11),
                sticky: row.get(13),

M src/routes/api/mod.rs => src/routes/api/mod.rs +95 -35
@@ 96,6 96,8 @@ impl SortType {
    pub fn handle_page(
        &self,
        page: Option<&str>,
        table: &str,
        sort_sticky: bool,
        mut value_out: ValueConsumer,
    ) -> Result<(Option<String>, Option<String>), InvalidPage> {
        match page {


@@ 107,9 109,18 @@ impl SortType {
                    Ok((None, Some(format!(" OFFSET ${}", idx))))
                }
                SortType::New => {
                    let page: (chrono::DateTime<chrono::offset::FixedOffset>, i64) = {
                    let page: (
                        Option<bool>,
                        chrono::DateTime<chrono::offset::FixedOffset>,
                        i64,
                    ) = {
                        let mut spl = page.split(',');

                        let sticky = if sort_sticky {
                            Some(spl.next().ok_or(InvalidPage)?)
                        } else {
                            None
                        };
                        let ts = spl.next().ok_or(InvalidPage)?;
                        let u = spl.next().ok_or(InvalidPage)?;
                        if spl.next().is_some() {


@@ 117,23 128,32 @@ impl SortType {
                        } else {
                            use chrono::TimeZone;

                            let sticky: Option<bool> = sticky
                                .map(|x| x.parse().map_err(|_| InvalidPage))
                                .transpose()?;
                            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)
                            (sticky, ts.into(), u)
                        }
                    };

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

                    let base = format!(
                        "({2}.created < ${0} OR ({2}.created = ${0} AND {2}.id <= ${1}))",
                        idx1, idx2, table,
                    );

                    Ok((
                        Some(format!(
                            " AND created < ${0} OR (created = ${0} AND id <= ${1})",
                            idx1, idx2
                        )),
                        Some(match page.0 {
                            None => format!(" AND {}", base),
                            Some(true) => format!(" AND ((NOT {}.sticky) OR {})", table, base),
                            Some(false) => format!(" AND ((NOT {}.sticky) AND {})", table, base),
                        }),
                        None,
                    ))
                }


@@ 158,7 178,37 @@ impl SortType {
            SortType::New => {
                let ts: chrono::DateTime<chrono::offset::FixedOffset> =
                    comment.created.parse().unwrap();
                format!("{},{}", ts, comment.base.id)
                format!("{},{}", ts.timestamp_nanos(), comment.base.id)
            }
        }
    }

    fn get_next_posts_page(
        &self,
        post: &RespPostListPost<'_>,
        sort_sticky: bool,
        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> =
                    post.created.parse().unwrap();

                let ts = ts.timestamp_nanos();

                if sort_sticky {
                    format!("{},{},{}", post.sticky, ts, post.id)
                } else {
                    format!("{},{}", ts, post.id)
                }
            }
        }
    }


@@ 896,39 946,42 @@ async fn handle_common_posts_list(
        + Send,
    ctx: &crate::RouteContext,
    include_your: bool,
) -> Result<Vec<serde_json::Value>, crate::Error> {
    relevance: bool,
) -> Result<Vec<RespPostListPost<'static>>, crate::Error> {
    use futures::stream::TryStreamExt;

    let posts: Vec<serde_json::Value> = stream
    let posts: Vec<_> = stream
        .map_err(crate::Error::from)
        .and_then(|row| {
        .map_ok(|row| {
            let id = PostLocalID(row.get(0));
            let author_id = row.get::<_, Option<_>>(1).map(UserLocalID);
            let href: Option<&str> = row.get(2);
            let content_text: Option<&str> = row.get(3);
            let content_html: Option<&str> = row.get(6);
            let title: &str = row.get(4);
            let href: Option<String> = row.get(2);
            let content_text: Option<String> = row.get(3);
            let content_html: Option<String> = row.get(6);
            let title: String = row.get(4);
            let created: chrono::DateTime<chrono::FixedOffset> = row.get(5);
            let community_id = CommunityLocalID(row.get(7));
            let community_name: &str = row.get(8);
            let community_name: String = row.get(8);
            let community_local: bool = row.get(9);
            let community_ap_id: Option<&str> = row.get(10);
            let community_ap_id: Option<String> = row.get(10);

            let author = author_id.map(|id| {
                let author_name: &str = row.get(11);
                let author_name: String = row.get(11);
                let author_local: bool = row.get(12);
                let author_ap_id: Option<&str> = row.get(13);
                let author_avatar: Option<&str> = row.get(14);
                let author_ap_id: Option<String> = row.get(13);
                let author_avatar: Option<String> = row.get(14);
                RespMinimalAuthorInfo {
                    id,
                    username: author_name.into(),
                    local: author_local,
                    host: crate::get_actor_host_or_unknown(
                        author_local,
                        author_ap_id,
                        author_ap_id.as_deref(),
                        &ctx.local_hostname,
                    ),
                    remote_url: author_ap_id.map(|x| x.to_owned().into()),
                    )
                    .into_owned()
                    .into(),
                    remote_url: author_ap_id.map(Cow::Owned),
                    avatar: author_avatar.map(|url| RespAvatarInfo {
                        url: ctx.process_avatar_href(url, id).into_owned().into(),
                    }),


@@ 937,27 990,34 @@ async fn handle_common_posts_list(

            let community = RespMinimalCommunityInfo {
                id: community_id,
                name: community_name,
                name: Cow::Owned(community_name),
                local: community_local,
                host: crate::get_actor_host_or_unknown(
                    community_local,
                    community_ap_id,
                    community_ap_id.as_deref(),
                    &ctx.local_hostname,
                ),
                remote_url: community_ap_id,
                )
                .into_owned()
                .into(),
                remote_url: community_ap_id.map(Cow::Owned),
            };

            let post = RespPostListPost {
                id,
                title,
                href: ctx.process_href_opt(href, id),
                content_text,
                title: Cow::Owned(title),
                href: ctx.process_href_opt(href.map(Cow::Owned), id),
                content_text: content_text.map(Cow::Owned),
                content_html_safe: content_html.map(|html| crate::clean_html(&html)),
                author: author.as_ref(),
                author: author.map(Cow::Owned),
                created: Cow::Owned(created.to_rfc3339()),
                community: Cow::Owned(community),
                score: row.get(15),
                sticky: row.get(17),
                relevance: if relevance {
                    row.get(if include_your { 19 } else { 18 })
                } else {
                    None
                },
                replies_count_total: Some(row.get(16)),
                your_vote: if include_your {
                    Some(if row.get(18) {


@@ 970,7 1030,7 @@ async fn handle_common_posts_list(
                },
            };

            futures::future::ready(serde_json::to_value(&post).map_err(Into::into))
            post
        })
        .try_collect()
        .await?;


@@ 980,8 1040,8 @@ async fn handle_common_posts_list(

// https://github.com/rust-lang/rust-clippy/issues/7271
#[allow(clippy::needless_lifetimes)]
pub async fn process_comment_content<'a>(
    lang: &crate::Translator,
pub async fn process_comment_content<'a, 'b>(
    lang: &'b crate::Translator,
    content_text: Option<Cow<'a, str>>,
    content_markdown: Option<String>,
) -> Result<(Option<Cow<'a, str>>, Option<String>, Option<String>), crate::Error> {

M src/routes/api/posts.rs => src/routes/api/posts.rs +178 -24
@@ 1,5 1,5 @@
use super::{
    JustURL, RespAvatarInfo, RespList, RespMinimalAuthorInfo, RespMinimalCommentInfo,
    InvalidPage, JustURL, RespAvatarInfo, RespList, RespMinimalAuthorInfo, RespMinimalCommentInfo,
    RespMinimalCommunityInfo, RespPostCommentInfo, RespPostListPost, ValueConsumer,
};
use crate::types::{


@@ 43,6 43,8 @@ async fn get_post_comments<'a>(
    let (page_part1, page_part2) = sort
        .handle_page(
            page,
            "reply",
            false,
            ValueConsumer {
                targets: vec![&mut con1, &mut con2],
                start_idx: values.len(),


@@ 167,24 169,86 @@ async fn route_unstable_posts_list(
        Extra(PostsListExtraSortType),
    }

    impl PostsListSortType {
        fn get_next_posts_page(
            &self,
            post: &RespPostListPost<'_>,
            sort_sticky: bool,
            limit: u8,
            current_page: Option<&str>,
        ) -> String {
            match self {
                Self::Normal(inner) => {
                    inner.get_next_posts_page(post, sort_sticky, limit, current_page)
                }
                Self::Extra(PostsListExtraSortType::Relevant) => super::format_number_58(
                    i64::from(limit)
                        + match current_page {
                            None => 0,
                            Some(current_page) => super::parse_number_58(current_page).unwrap(),
                        },
                ),
            }
        }

        pub fn handle_page(
            &self,
            page: Option<&str>,
            sort_sticky: bool,
            mut value_out: ValueConsumer,
        ) -> Result<(Option<String>, Option<String>), InvalidPage> {
            match self {
                Self::Extra(sort) => {
                    match page {
                        None => Ok((None, None)),
                        Some(page) => match sort {
                            PostsListExtraSortType::Relevant => {
                                // TODO maybe
                                let page: i64 =
                                    super::parse_number_58(page).map_err(|_| InvalidPage)?;
                                let idx = value_out.push(page);
                                Ok((None, Some(format!(" OFFSET ${}", idx))))
                            }
                        },
                    }
                }
                Self::Normal(sort) => sort.handle_page(page, "post", sort_sticky, value_out),
            }
        }
    }

    impl Default for PostsListSortType {
        fn default() -> Self {
            Self::Normal(super::SortType::Hot)
        }
    }

    fn default_limit() -> u8 {
        30
    }

    #[derive(Deserialize)]
    struct PostsListQuery<'a> {
        in_any_local_community: Option<bool>,
        in_your_follows: Option<bool>,
        search: Option<Cow<'a, str>>,
        #[serde(default)]
        use_aggregate_filters: bool,
        community: Option<CommunityLocalID>,

        #[serde(default = "default_limit")]
        limit: u8,

        page: Option<Cow<'a, str>>,

        #[serde(default)]
        include_your: bool,

        #[serde(default)]
        sort: PostsListSortType,

        #[serde(default)]
        sort_sticky: bool,
    }

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


@@ 199,11 263,16 @@ async fn route_unstable_posts_list(
        None
    };

    let mut search_value_idx = None;
    let limit_plus_1: i64 = (query.limit + 1).into();

    let limit: i64 = 30;
    let mut values: Vec<&(dyn tokio_postgres::types::ToSql + Sync)> = vec![&limit_plus_1];

    let mut values: Vec<&(dyn tokio_postgres::types::ToSql + Sync)> = vec![&limit];
    let search_value_idx = if let Some(search) = &query.search {
        values.push(search);
        Some(values.len())
    } else {
        None
    };

    let include_your_idx = if let Some(user) = &include_your_for {
        values.push(user);


@@ 217,14 286,23 @@ async fn route_unstable_posts_list(
        super::common_posts_list_query(include_your_idx)
    );

    let relevance_sql = if let Some(search_value_idx) = search_value_idx {
        Some(format!("ts_rank_cd(to_tsvector('english', title || ' ' || COALESCE(content_text, content_markdown, content_html, '')), plainto_tsquery('english', ${}))", search_value_idx))
    } else {
        None
    };

    if let Some(relevance_sql) = &relevance_sql {
        sql.push_str(", ");
        sql.push_str(relevance_sql);
    }

    sql.push_str( " FROM community, post LEFT OUTER JOIN person ON (person.id = post.author) WHERE post.community = community.id AND deleted=FALSE");
    if query.use_aggregate_filters {
        sql.push_str(" AND community.hide_posts_from_aggregates=FALSE");
    }
    if let Some(search) = &query.search {
        values.push(search);
        search_value_idx = Some(values.len());
        write!(sql, " AND to_tsvector('english', title || ' ' || COALESCE(content_text, content_markdown, content_html, '')) @@ plainto_tsquery('english', ${})", values.len()).unwrap();
    if let Some(search_value_idx) = &search_value_idx {
        write!(sql, " AND to_tsvector('english', title || ' ' || COALESCE(content_text, content_markdown, content_html, '')) @@ plainto_tsquery('english', ${})", search_value_idx).unwrap();
    }
    if let Some(value) = query.in_any_local_community {
        write!(


@@ 234,12 312,59 @@ async fn route_unstable_posts_list(
        )
        .unwrap();
    }
    if let Some(value) = query.in_your_follows {
        if let Some(include_your_idx) = include_your_idx {
            write!(
                sql,
                " AND {}(community.id IN (SELECT community FROM community_follow WHERE accepted AND follower=${}) AND post.approved)",
                if value { "" } else { "NOT " },
                include_your_idx,
            ).unwrap();
        } else {
            return Err(crate::Error::InternalStrStatic(
                "in_your_follows can only be used with include_your=true",
            ));
        }
    }
    if let Some(value) = &query.community {
        values.push(value);
        write!(sql, " AND community.id=${} AND post.approved", values.len(),).unwrap();
    }

    let mut con1 = None;
    let mut con2 = None;
    let (page_part1, page_part2) = query
        .sort
        .handle_page(
            query.page.as_deref(),
            query.sort_sticky,
            ValueConsumer {
                targets: vec![&mut con1, &mut con2],
                start_idx: values.len(),
                used: 0,
            },
        )
        .map_err(super::InvalidPage::into_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 {
        sql.push_str(&part);
    }

    sql.push_str(" ORDER BY ");
    match query.sort {
    if query.sort_sticky {
        sql.push_str("sticky DESC, ");
    }
    match &query.sort {
        PostsListSortType::Normal(ty) => sql.push_str(ty.post_sort_sql()),
        PostsListSortType::Extra(PostsListExtraSortType::Relevant) => {
            if let Some(search_value_idx) = search_value_idx {
                write!(sql, "ts_rank_cd(to_tsvector('english', title || ' ' || COALESCE(content_text, content_markdown, content_html, '')), plainto_tsquery('english', ${})) DESC", search_value_idx).unwrap();
            if let Some(relevance_sql) = relevance_sql {
                write!(sql, "{} DESC, post.id DESC", relevance_sql).unwrap();
            } else {
                return Err(crate::Error::UserError(crate::simple_response(
                    hyper::StatusCode::BAD_REQUEST,


@@ 250,13 375,41 @@ async fn route_unstable_posts_list(
    }
    sql.push_str(" LIMIT $1");

    if let Some(part) = page_part2 {
        sql.push_str(&part);
    }

    let sql: &str = &sql;

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

    let posts = super::handle_common_posts_list(stream, &ctx, include_your_for.is_some()).await?;
    let posts = super::handle_common_posts_list(
        stream,
        &ctx,
        include_your_for.is_some(),
        search_value_idx.is_some(),
    )
    .await?;
    let output = if posts.len() > query.limit as usize {
        let last_post = &posts[posts.len() - 1];

        RespList {
            next_page: Some(Cow::Owned(query.sort.get_next_posts_page(
                last_post,
                query.sort_sticky,
                query.limit,
                query.page.as_deref(),
            ))),
            items: Cow::Borrowed(&posts[0..(posts.len() - 1)]),
        }
    } else {
        RespList {
            items: posts.into(),
            next_page: None,
        }
    };

    crate::json_response(&posts)
    crate::json_response(&output)
}

async fn route_unstable_posts_replies_list(


@@ 472,15 625,15 @@ async fn route_unstable_posts_get(
            lang.tr("no_such_post", None).into_owned(),
        )),
        Some(row) => {
            let href = row.get(1);
            let content_text = row.get(2);
            let href: Option<&str> = row.get(1);
            let content_text: Option<&str> = row.get(2);
            let content_html: Option<&str> = row.get(5);
            let title = row.get(3);
            let title: &str = row.get(3);
            let created: chrono::DateTime<chrono::FixedOffset> = row.get(4);
            let community_id = CommunityLocalID(row.get(6));
            let community_name = row.get(7);
            let community_name: &str = row.get(7);
            let community_local = row.get(8);
            let community_ap_id = row.get(9);
            let community_ap_id: Option<&str> = row.get(9);

            let author = match row.get(10) {
                Some(author_username) => {


@@ 508,25 661,26 @@ async fn route_unstable_posts_get(

            let community = RespMinimalCommunityInfo {
                id: community_id,
                name: community_name,
                name: Cow::Borrowed(community_name),
                local: community_local,
                host: crate::get_actor_host_or_unknown(
                    community_local,
                    community_ap_id,
                    &ctx.local_hostname,
                ),
                remote_url: community_ap_id,
                remote_url: community_ap_id.map(Cow::Borrowed),
            };

            let post = RespPostListPost {
                id: post_id,
                title,
                href: ctx.process_href_opt(href, post_id),
                content_text,
                title: Cow::Borrowed(title),
                href: ctx.process_href_opt(href.map(Cow::Borrowed), post_id),
                content_text: content_text.map(Cow::Borrowed),
                content_html_safe: content_html.map(|html| crate::clean_html(&html)),
                author: author.as_ref(),
                author: author.map(Cow::Owned),
                created: Cow::Owned(created.to_rfc3339()),
                community: Cow::Owned(community),
                relevance: None,
                replies_count_total: None,
                score: row.get(13),
                sticky: row.get(17),

M src/routes/api/stable.rs => src/routes/api/stable.rs +1 -1
@@ 155,7 155,7 @@ async fn route_stable_communities_feed_get(
            let title: String = row.get(4);

            let href_raw: Option<&str> = row.get(2);
            let href = ctx.process_href_opt(href_raw, post_id);
            let href = ctx.process_href_opt(href_raw.map(Cow::Borrowed), post_id);

            let post_ap_id = if row.get(8) {
                crate::apub_util::get_local_post_apub_id(post_id, &ctx.host_url_apub).to_string()

M src/routes/api/users.rs => src/routes/api/users.rs +95 -13
@@ 1,6 1,7 @@
use super::InvalidPage;
use crate::types::{
    CommentLocalID, CommunityLocalID, JustContentText, MaybeIncludeYour, PostLocalID,
    RespAvatarInfo, RespLoginUserInfo, RespMinimalAuthorInfo, RespMinimalCommentInfo,
    RespAvatarInfo, RespList, RespLoginUserInfo, RespMinimalAuthorInfo, RespMinimalCommentInfo,
    RespMinimalCommunityInfo, RespMinimalPostInfo, RespNotification, RespNotificationInfo,
    RespPostListPost, RespThingInfo, RespUserInfo, UserLocalID,
};


@@ 347,7 348,7 @@ async fn route_unstable_users_following_posts_list(
        values,
    ).await?;

    let posts = super::handle_common_posts_list(stream, &ctx, true).await?;
    let posts = super::handle_common_posts_list(stream, &ctx, true, false).await?;

    crate::json_response(&posts)
}


@@ 582,13 583,87 @@ async fn route_unstable_users_things_list(

    let user_id = user_id.try_resolve(&req, &db).await?;

    let limit: i64 = 30;
    fn default_limit() -> u8 {
        30
    }

    let rows = db.query(
        "(SELECT TRUE, post.id, post.href, post.title, post.created, community.id, community.name, community.local, community.ap_id, (SELECT COUNT(*) FROM post_like WHERE post_like.post = post.id), (SELECT COUNT(*) FROM reply WHERE reply.post = post.id), post.sticky FROM post, community WHERE post.community = community.id AND post.author = $1 AND NOT post.deleted) UNION ALL (SELECT FALSE, reply.id, reply.content_text, reply.content_html, reply.created, post.id, post.title, NULL, NULL, NULL, NULL, NULL FROM reply, post WHERE post.id = reply.post AND reply.author = $1 AND NOT reply.deleted) ORDER BY created DESC LIMIT $2",
        &[&user_id, &limit],
    )
        .await?;
    #[derive(Deserialize)]
    struct UserThingsListQuery<'a> {
        #[serde(default = "default_limit")]
        limit: u8,

        page: Option<Cow<'a, str>>,
    }
    let query: UserThingsListQuery = serde_urlencoded::from_str(req.uri().query().unwrap_or(""))?;

    let limit_plus_1: i64 = (query.limit + 1).into();

    let page: Option<(chrono::DateTime<chrono::offset::FixedOffset>, bool, i64)> = query
        .page
        .map(|src| {
            let mut spl = src.split(',');

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

                let ts: i64 = ts.parse().map_err(|_| InvalidPage)?;
                let is_post: bool = is_post.parse().map_err(|_| InvalidPage)?;
                let id: i64 = id.parse().map_err(|_| InvalidPage)?;

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

                Ok((ts.into(), is_post, id))
            }
        })
        .transpose()
        .map_err(|err| err.into_user_error())?;

    let mut values: Vec<&(dyn postgres_types::ToSql + Sync)> = vec![&user_id, &limit_plus_1];

    let page_conditions = match &page {
        Some((ts, is_post, id)) => {
            values.push(ts);
            values.push(id);

            Cow::Owned(format!(
                " AND (created < $3 OR (created = $3 AND {}))",
                if *is_post {
                    "is_post AND id > $4"
                } else {
                    "is_post OR id > $4"
                }
            ))
        }
        None => Cow::Borrowed(""),
    };

    let sql: &str = &format!(
        "(SELECT TRUE AS is_post, post.id AS thing_id, post.href, post.title, post.created, community.id, community.name, community.local, community.ap_id, (SELECT COUNT(*) FROM post_like WHERE post_like.post = post.id), (SELECT COUNT(*) FROM reply WHERE reply.post = post.id), post.sticky FROM post, community WHERE post.community = community.id AND post.author = $1 AND NOT post.deleted) UNION ALL (SELECT FALSE AS is_post, reply.id AS thing_id, reply.content_text, reply.content_html, reply.created, post.id, post.title, NULL, NULL, NULL, NULL, NULL FROM reply, post WHERE post.id = reply.post AND reply.author = $1 AND NOT reply.deleted){} ORDER BY created DESC, is_post ASC, thing_id DESC LIMIT $2",
        page_conditions,
    );

    let mut rows = db.query(sql, &values).await?;

    let next_page = if rows.len() > query.limit as usize {
        let row = rows.pop().unwrap();

        let ts: chrono::DateTime<chrono::offset::FixedOffset> = row.get(4);
        let ts = ts.timestamp_nanos();

        let is_post: bool = row.get(0);
        let id: i64 = row.get(1);

        Some(format!("{},{},{}", ts, is_post, id))
    } else {
        None
    };

    let rows = rows;

    let things: Vec<RespThingInfo> = rows
        .iter()


@@ 604,20 679,24 @@ async fn route_unstable_users_things_list(

                RespThingInfo::Post(RespPostListPost {
                    id: post_id,
                    href: ctx.process_href_opt(row.get(2), post_id),
                    title: row.get(3),
                    href: ctx.process_href_opt(
                        row.get::<_, Option<&str>>(2).map(Cow::Borrowed),
                        post_id,
                    ),
                    title: Cow::Borrowed(row.get(3)),
                    created: Cow::Owned(created),
                    community: Cow::Owned(RespMinimalCommunityInfo {
                        id: CommunityLocalID(row.get(5)),
                        name: row.get(6),
                        name: Cow::Borrowed(row.get(6)),
                        local: community_local,
                        host: crate::get_actor_host_or_unknown(
                            community_local,
                            community_ap_id,
                            &ctx.local_hostname,
                        ),
                        remote_url: community_ap_id,
                        remote_url: community_ap_id.map(Cow::Borrowed),
                    }),
                    relevance: None,
                    replies_count_total: row.get(10),
                    sticky: row.get(11),
                    score: row.get(9),


@@ 645,7 724,10 @@ async fn route_unstable_users_things_list(
        })
        .collect();

    crate::json_response(&things)
    crate::json_response(&RespList {
        next_page: next_page.map(Cow::Owned),
        items: Cow::Owned(things),
    })
}

pub fn route_users() -> crate::RouteNode<()> {

M types/src/lib.rs => types/src/lib.rs +14 -12
@@ 175,31 175,33 @@ pub struct JustUser<'a> {
#[derive(Serialize, Clone)]
pub struct RespMinimalCommunityInfo<'a> {
    pub id: CommunityLocalID,
    pub name: &'a str,
    pub name: Cow<'a, str>,
    pub local: bool,
    pub host: Cow<'a, str>,
    pub remote_url: Option<&'a str>,
    pub remote_url: Option<Cow<'a, str>>,
}

#[derive(Serialize)]
#[derive(Serialize, Clone)]
pub struct RespMinimalPostInfo<'a> {
    pub id: PostLocalID,
    pub title: &'a str,
}

#[derive(Serialize)]
#[derive(Serialize, Clone)]
pub struct RespPostListPost<'a> {
    pub id: PostLocalID,
    pub title: &'a str,
    pub title: Cow<'a, str>,
    pub href: Option<Cow<'a, str>>,
    pub content_text: Option<&'a str>,
    pub content_text: Option<Cow<'a, str>>,
    #[serde(rename = "content_html")]
    pub content_html_safe: Option<String>,
    pub author: Option<&'a RespMinimalAuthorInfo<'a>>,
    pub author: Option<Cow<'a, RespMinimalAuthorInfo<'a>>>,
    pub created: Cow<'a, str>,
    pub community: Cow<'a, RespMinimalCommunityInfo<'a>>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub replies_count_total: Option<i64>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub relevance: Option<f32>,
    pub score: i64,
    pub sticky: bool,
    #[serde(skip_serializing_if = "Option::is_none")]


@@ 245,7 247,7 @@ impl<'a> RespPostCommentInfo<'a> {
    }
}

#[derive(Serialize)]
#[derive(Serialize, Clone)]
#[serde(tag = "type")]
pub enum RespThingInfo<'a> {
    #[serde(rename = "post")]


@@ 268,17 270,17 @@ pub struct RespPostInfo<'a> {
    pub local: bool,
}

#[derive(Serialize)]
#[derive(Serialize, Clone)]
pub struct RespCommunityFeedsType {
    pub new: String,
}

#[derive(Serialize)]
#[derive(Serialize, Clone)]
pub struct RespCommunityFeeds {
    pub atom: RespCommunityFeedsType,
}

#[derive(Serialize)]
#[derive(Serialize, Clone)]
pub struct RespCommunityInfo<'a> {
    #[serde(flatten)]
    pub base: RespMinimalCommunityInfo<'a>,


@@ 294,7 296,7 @@ pub struct RespCommunityInfo<'a> {
    pub your_follow: Option<Option<RespYourFollowInfo>>,
}

#[derive(Serialize)]
#[derive(Serialize, Clone)]
pub struct RespYourFollowInfo {
    pub accepted: bool,
}