From aab50e06b20bbf211cbe1ede1b815c6751022883 Mon Sep 17 00:00:00 2001 From: blankie Date: Thu, 23 Nov 2023 19:49:27 +1100 Subject: [PATCH] Show user's posts on their page --- client.cpp | 31 +++++++++++++++++++++++++++++++ client.h | 6 +++--- models.cpp | 1 + models.h | 1 + routes/css.cpp | 17 +++++++++++++++++ routes/status.cpp | 5 +++++ routes/user.cpp | 38 ++++++++++++++++++++++++++++++++------ servehelper.cpp | 20 +++++++++++++++----- timeutils.cpp | 6 ++++-- 9 files changed, 109 insertions(+), 16 deletions(-) diff --git a/client.cpp b/client.cpp index f423055..e8504a2 100644 --- a/client.cpp +++ b/client.cpp @@ -102,6 +102,37 @@ std::optional MastodonClient::get_post(const std::string& host, const std: } } +std::vector MastodonClient::get_posts(const std::string& host, const std::string& account_id, PostSortingMethod sorting_method, std::optional max_id) { + using namespace std::string_literals; + + const char* sorting_parameters[3] = {"exclude_replies=true", "", "only_media=true"}; + std::string query = sorting_parameters[sorting_method]; + if (max_id) { + if (!query.empty()) { + query += '&'; + } + query += "max_id="; + query += std::move(*max_id); + } + + std::string url = "https://"s + host + "/api/v1/accounts/" + account_id + "/statuses"; + if (!query.empty()) { + url += '?'; + url += query; + } + std::string resp = this->_send_request(url); + std::vector posts = nlohmann::json::parse(std::move(resp)); + + for (Post& post : posts) { + post.account.same_server = host == post.account.server; + if (post.reblog) { + post.reblog->account.same_server = host == post.reblog->account.server; + } + } + + return posts; +} + CURL* MastodonClient::_get_easy() { CURL* curl = pthread_getspecific(this->_easy_key); if (!curl) { diff --git a/client.h b/client.h index f328679..0ea2b28 100644 --- a/client.h +++ b/client.h @@ -4,10 +4,9 @@ #include #include #include - #include -struct Account; // forward declaration from models.h -struct Post; // forward declaration from models.h + +#include "models.h" class CurlException : public std::exception { public: @@ -52,6 +51,7 @@ public: std::optional get_account_by_username(const std::string& host, const std::string& username); std::optional get_post(const std::string& host, const std::string& id); + std::vector get_posts(const std::string& host, const std::string& account_id, PostSortingMethod sorting_method, std::optional max_id); private: CURL* _get_easy(); diff --git a/models.cpp b/models.cpp index 3707edf..2251923 100644 --- a/models.cpp +++ b/models.cpp @@ -36,6 +36,7 @@ void from_json(const json& j, Account& account) { account.created_at = parse_rfc3339(j.at("created_at").get_ref()); account.note_html = j.at("note").get(); j.at("avatar").get_to(account.avatar); + j.at("avatar_static").get_to(account.avatar_static); j.at("header").get_to(account.header); j.at("followers_count").get_to(account.followers_count); j.at("following_count").get_to(account.following_count); diff --git a/models.h b/models.h index 68861f2..f720d09 100644 --- a/models.h +++ b/models.h @@ -32,6 +32,7 @@ struct Account { time_t created_at; blankie::html::HTMLString note_html; std::string avatar; + std::string avatar_static; std::string header; uint64_t followers_count; uint64_t following_count; diff --git a/routes/css.cpp b/routes/css.cpp index ffa4886..3638df6 100644 --- a/routes/css.cpp +++ b/routes/css.cpp @@ -50,6 +50,9 @@ a:hover { } /* POST */ +.post { + margin-top: 1em; +} .post-header, .post-header a { display: flex; } @@ -147,6 +150,20 @@ a:hover { .user_page-user_posts_nav a { text-decoration: none; } +.user_page-user_posts_nav .selected { + font-weight: bold; + color: inherit; +} +.user_page-user_posts_nav .selected:hover { + text-decoration: underline; +} + +.user_page-more_posts { + margin-top: 1em; + text-align: center; + /* don't ask why, but making it a block element just works */ + display: block; +} )EOF"; // one for \0, one for trailing newline #define CSS_LEN sizeof(css) / sizeof(css[0]) - 2 diff --git a/routes/status.cpp b/routes/status.cpp index 19087b1..1b70402 100644 --- a/routes/status.cpp +++ b/routes/status.cpp @@ -22,6 +22,11 @@ void status_route(const httplib::Request& req, httplib::Response& res) { return; } + if (post->reblog) { + serve_redirect(req, res, get_origin(req) + '/' + server + "/@" + post->reblog->account.acct(false) + '/' + post->reblog->id, true); + return; + } + Element body("body", { serialize_post(req, server, *post), }); diff --git a/routes/user.cpp b/routes/user.cpp index 8999f7e..25f8362 100644 --- a/routes/user.cpp +++ b/routes/user.cpp @@ -14,13 +14,28 @@ static inline Element sorting_method_link(const httplib::Request& req, const std void user_route(const httplib::Request& req, httplib::Response& res) { + using namespace std::string_literals; + std::string server = req.matches.str(1); std::string username = req.matches.str(2); PostSortingMethod sorting_method = get_sorting_method(req.matches.str(3)); + std::optional max_id; + if (req.has_param("max_id")) { + max_id = req.get_param_value("max_id"); + if (max_id->empty() || max_id->find_first_not_of("0123456789") != std::string::npos) { + res.status = 400; + serve_error(req, res, "400: Bad Request", "Invalid max_id query paramter"); + return; + } + } std::optional account; + std::vector posts; try { account = mastodon_client.get_account_by_username(server, username); + if (account) { + posts = mastodon_client.get_posts(server, account->id, sorting_method, std::move(max_id)); + } } catch (const std::exception& e) { res.status = 500; serve_error(req, res, "500: Internal server error", "Failed to fetch user information", e.what()); @@ -36,6 +51,17 @@ void user_route(const httplib::Request& req, httplib::Response& res) { Element body("body", { user_header(req, server, *account, sorting_method), }); + body.nodes.reserve(body.nodes.size() + 2 * posts.size() + 1); + for (const Post& post : posts) { + body.nodes.push_back(serialize_post(req, server, post)); + body.nodes.push_back(Element("hr")); + } + if (!posts.empty()) { + body.nodes.push_back(Element("a", {{"class", "user_page-more_posts"}, {"href", "?max_id="s + posts[posts.size() - 1].id + "#user_posts_nav"}}, {"See more"})); + } else { + body.nodes.push_back(Element("p", {{"class", "user_page-more_posts"}}, {"There are no more posts"})); + } + serve(req, res, account->display_name + " (" + account->acct() + ')', std::move(body)); } @@ -83,7 +109,7 @@ static inline Element user_header(const httplib::Request& req, const std::string std::move(user_links), }), - Element("nav", {{"class", "user_page-user_posts_nav"}}, { + Element("nav", {{"class", "user_page-user_posts_nav"}, {"id", "user_posts_nav"}}, { sorting_method_link(req, server, account, sorting_method, PostSortingMethod::Posts), sorting_method_link(req, server, account, sorting_method, PostSortingMethod::PostsAndReplies), sorting_method_link(req, server, account, sorting_method, PostSortingMethod::Media), @@ -109,11 +135,11 @@ static inline Element user_link_field(const httplib::Request& req, const Account static inline Element sorting_method_link(const httplib::Request& req, const std::string& server, const Account& account, PostSortingMethod current_method, PostSortingMethod new_method) { const char* method_name = sorting_method_names[new_method]; + Element a("a", {{"href", get_origin(req) + '/' + server + "/@" + account.acct(false) + sorting_method_suffixes[new_method] + "#user_posts_nav"}}, { + method_name, + }); if (current_method == new_method) { - return Element("b", {method_name}); - } else { - return Element("a", {{"href", get_origin(req) + '/' + server + "/@" + account.acct(false) + sorting_method_suffixes[new_method]}}, { - method_name, - }); + a.attributes.push_back({"class", "selected"}); } + return a; } diff --git a/servehelper.cpp b/servehelper.cpp index fd9f8a8..e92d956 100644 --- a/servehelper.cpp +++ b/servehelper.cpp @@ -14,7 +14,7 @@ static inline void preprocess_html(const httplib::Request& req, const std::string& domain_name, const std::vector& emojis, lxb_dom_element_t* element); static inline void preprocess_link(const httplib::Request& req, const std::string& domain_name, lxb_dom_element_t* element); -static inline bool should_fix_link(lxb_dom_element_t* element); +static inline bool should_fix_link(lxb_dom_element_t* element, const std::string& cls); static inline lxb_dom_node_t* emojify(lxb_dom_node_t* child, const std::vector& emojis); static inline std::vector emojify(lxb_dom_document_t* document, std::string str, const std::vector& emojis); @@ -167,15 +167,19 @@ bool should_send_304(const httplib::Request& req, uint64_t hash) { Element serialize_post(const httplib::Request& req, const std::string& server, const Post& post) { using namespace std::string_literals; + if (post.reblog) { + return serialize_post(req, server, *post.reblog); + } + std::string time_title = post.edited_at < 0 ? full_time(post.created_at) : "Created: "s + full_time(post.created_at) + "\nEdited: " + full_time(post.edited_at); const char* time_badge = post.edited_at < 0 ? "" : " (edited)"; - Element div("div", { + Element div("div", {{"class", "post"}}, { Element("div", {{"class", "post-header"}}, { Element("a", {{"href", get_origin(req) + '/' + server + "/@" + post.account.acct(false)}}, { - Element("img", {{"class", "post-avatar"}, {"alt", "User profile picture"}, {"src", post.account.avatar}}, {}), + Element("img", {{"class", "post-avatar"}, {"alt", "User profile picture"}, {"src", post.account.avatar_static}}, {}), Element("span", { Element("b", {preprocess_html(req, post.account.emojis, post.account.display_name)}), Element("br"), "@", post.account.acct(), @@ -248,7 +252,7 @@ static inline void preprocess_link(const httplib::Request& req, const std::strin lxb_dom_element_set_attribute(element, reinterpret_cast("href"), 4, reinterpret_cast(href.data()), href.size()); } - if (should_fix_link(element)) { + if (should_fix_link(element, cls)) { // Set the content of each to its href lxb_status_t status = lxb_dom_node_text_content_set(lxb_dom_interface_node(element), reinterpret_cast(href.data()), href.size()); if (status != LXB_STATUS_OK) { @@ -257,7 +261,13 @@ static inline void preprocess_link(const httplib::Request& req, const std::strin } } -static inline bool should_fix_link(lxb_dom_element_t* element) { +static std::regex unhandled_link_re("\\bunhandled-link\\b"); +static inline bool should_fix_link(lxb_dom_element_t* element, const std::string& cls) { + // https://vt.social/@LucydiaLuminous/111448085044245037 + if (std::regex_search(cls, unhandled_link_re)) { + return true; + } + auto expected_element = [](lxb_dom_node_t* node, const char* expected_cls) { if (node->type != LXB_DOM_NODE_TYPE_ELEMENT) { return false; diff --git a/timeutils.cpp b/timeutils.cpp index 6aa7e17..c6daf0b 100644 --- a/timeutils.cpp +++ b/timeutils.cpp @@ -46,10 +46,12 @@ std::string relative_time(time_t from, time_t to) { return std::to_string(diff) + 's'; } else if (diff < 60 * 60) { return std::to_string(diff / 60) + 'm'; - } else if (diff < 60 * 60 * 60) { + } else if (diff < 24 * 60 * 60) { return std::to_string(diff / (60 * 60)) + 'h'; - } else if (diff < 24 * 60 * 60 * 60) { + } else if (diff < 7 * 24 * 60 * 60) { return std::to_string(diff / (24 * 60 * 60)) + 'd'; + } else if (diff < 4 * 7 * 24 * 60 * 60) { + return std::to_string(diff / (7 * 24 * 60 * 60)) + 'w'; } else { return short_time(from); }