commit 3c427faaf8bc3c1cf942566252a098b81c603419
parent 0e4c210e7ad9c3b7d0e37b71b2d91c6b4798b799
Author: _ <pmjv@noreply.codeberg.org>
Date: Sun, 6 Apr 2025 07:22:44 +0000
Merge pull request 'master' (#1) from grunfink/snac2:master into master
Reviewed-on: https://codeberg.org/pmjv/snac2/pulls/1
Diffstat:
9 files changed, 337 insertions(+), 43 deletions(-)
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
@@ -1,6 +1,16 @@
# Release Notes
-## 2.74
+## UNRELEASED
+
+Added support for scheduled posts.
+
+Fixed incorrect poll vote format, which was causing problems in platforms like GotoSocial.
+
+Mastodon API: added support for `/api/v1/instance/peers`.
+
+Some Czech and Russian translation fixes.
+
+## 2.74 "The Days of Nicole, the Fediverse Chick"
Added Spanish (default, Argentina and Uruguay) translation (contributed by gnemmi).
@@ -22,7 +32,7 @@ Added Greek translation (contributed by uhuru).
Added Italian translation (contributed by anzu).
-Mastodon API: added support for /api/v1/custom_emojis (contributed by violette).
+Mastodon API: added support for `/api/v1/custom_emojis` (contributed by violette).
Improved Undo+Follow logic (contributed by rozenglass).
diff --git a/TODO.md b/TODO.md
@@ -6,16 +6,12 @@ Investigate the problem with boosts inside the same instance (see https://codebe
Editing / Updating a post does not index newly added hashtags.
-Wrong level of message visibility when using the Mastodon API: https://codeberg.org/grunfink/snac2/issues/200#issuecomment-2351042
-
Unfollowing guppe groups seems to work (http status of 200), but messages continue to arrive as if it didn't.
Important: deleting a follower should do more that just delete the object, see https://codeberg.org/grunfink/snac2/issues/43#issuecomment-956721
## Wishlist
-Each notification should show a link to the full thread, to see it in context.
-
The instance timeline should also show boosts from users.
Mastoapi: implement /v1/conversations.
@@ -30,14 +26,10 @@ Integrate "Added handling for International Domain Names" PR https://codeberg.or
Do something about Akkoma and Misskey's quoted replies (they use the `quoteUrl` field instead of `inReplyTo`).
-Add a list of hashtags to drop.
-
Take a look at crashes in the brittle Mastodon official app (crashes when hitting the reply button, crashes or 'ownVotes is null' errors when trying to show polls).
The 'history' pages are just monthly HTML snapshots of the local timeline. This is ok and cheap and easy, but is problematic if you e.g. intentionally delete a post because it will remain there in the history forever. If you activate local timeline purging, purged entries will remain in the history as 'ghosts', which may or may not be what the user wants.
-The actual storage system wastes too much disk space (lots of small files that really consume 4k of storage). Consider alternatives.
-
## Closed
Start a TODO file (2022-08-25T10:07:44+0200).
@@ -367,3 +359,11 @@ Add support for /authorize_interaction (whatever it is) (2025-01-16T14:45:28+010
Implement following of hashtags (this is not trivial) (2025-01-30T16:12:16+0100).
Add support for subscribing and posting to relays (see https://codeberg.org/grunfink/snac2/issues/216 for more information) (2025-01-30T16:12:34+0100).
+
+Wrong level of message visibility when using the Mastodon API: https://codeberg.org/grunfink/snac2/issues/200#issuecomment-2351042 (2025-03-23T15:44:35+0100).
+
+Each notification should show a link to the full thread, to see it in context (2025-03-23T15:44:50+0100).
+
+Add a list of hashtags to drop (2025-03-23T15:45:30+0100).
+
+The actual storage system wastes too much disk space (lots of small files that really consume 4k of storage). Consider alternatives (2025-03-23T15:46:02+0100).
diff --git a/activitypub.c b/activitypub.c
@@ -2759,6 +2759,8 @@ int process_user_queue(snac *snac)
cnt++;
}
+ scheduled_process(snac);
+
return cnt;
}
diff --git a/data.c b/data.c
@@ -1929,6 +1929,70 @@ xs_list *draft_list(snac *user)
}
+/** scheduled posts **/
+
+int is_scheduled(snac *user, const char *id)
+/* returns true if this note is scheduled for future sending */
+{
+ return object_user_cache_in(user, id, "sched");
+}
+
+
+void schedule_del(snac *user, const char *id)
+/* deletes an scheduled post */
+{
+ object_user_cache_del(user, id, "sched");
+}
+
+
+void schedule_add(snac *user, const char *id, const xs_dict *msg)
+/* schedules this post for later */
+{
+ /* delete from the index, in case it was already there */
+ schedule_del(user, id);
+
+ /* overwrite object */
+ object_add_ow(id, msg);
+
+ /* [re]add to the index */
+ object_user_cache_add(user, id, "sched");
+}
+
+
+xs_list *scheduled_list(snac *user)
+/* return the list of scheduled posts */
+{
+ return object_user_cache_list(user, "sched", XS_ALL, 1);
+}
+
+
+void scheduled_process(snac *user)
+/* processes the scheduled list, sending those ready to be sent */
+{
+ xs *posts = scheduled_list(user);
+ const char *md5;
+ xs *right_now = xs_str_utctime(0, ISO_DATE_SPEC);
+
+ xs_list_foreach(posts, md5) {
+ xs *msg = NULL;
+
+ if (valid_status(object_get_by_md5(md5, &msg))) {
+ if (strcmp(xs_dict_get(msg, "published"), right_now) < 0) {
+ /* due date! */
+ const char *id = xs_dict_get(msg, "id");
+
+ timeline_add(user, id, msg);
+
+ xs *c_msg = msg_create(user, msg);
+ enqueue_message(user, c_msg);
+
+ schedule_del(user, id);
+ }
+ }
+ }
+}
+
+
/** hiding **/
xs_str *_hidden_fn(snac *snac, const char *id)
@@ -2619,10 +2683,9 @@ xs_list *inbox_list(void)
xs_list *ibl = xs_list_new();
xs *spec = xs_fmt("%s/inbox/" "*", srv_basedir);
xs *files = xs_glob(spec, 0, 0);
- xs_list *p = files;
const xs_val *v;
- while (xs_list_iter(&p, &v)) {
+ xs_list_foreach(files, v) {
FILE *f;
if ((f = fopen(v, "r")) != NULL) {
@@ -2630,7 +2693,9 @@ xs_list *inbox_list(void)
if (line && *line) {
line = xs_strip_i(line);
- ibl = xs_list_append(ibl, line);
+
+ if (!is_instance_blocked(line))
+ ibl = xs_list_append(ibl, line);
}
fclose(f);
@@ -3696,7 +3761,7 @@ void purge_user(snac *snac)
_purge_user_subdir(snac, "public", pub_days);
const char *idxs[] = { "followers.idx", "private.idx", "public.idx",
- "pinned.idx", "bookmark.idx", "draft.idx", NULL };
+ "pinned.idx", "bookmark.idx", "draft.idx", "sched.idx", NULL };
for (n = 0; idxs[n]; n++) {
xs *idx = xs_fmt("%s/%s", snac->basedir, idxs[n]);
diff --git a/examples/snac-admin b/examples/snac-admin
@@ -0,0 +1,51 @@
+#!/usr/bin/env bash
+##
+## SNAC-ADMIN
+## a simple script that is supposed to improve
+## a snac admin's life, especially when snac
+## is being run as a systemd.unit with
+## DynamicUser=yes enabled.
+## Please make sure to adjust SNAC_DIR
+## down below according to your setup.
+##
+## USAGE
+## snac-admin state
+## snac-admin adduser rikkert
+## snac-admin block example.org
+## snac-admin verify_links lisa
+## ...
+##
+## Author: @chris@social.shtrophic.net
+##
+## Released into the public domain
+##
+
+set -e
+
+SNAC_PID=$(pidof snac)
+SNAC_DIR=/var/lib/snac
+
+SNAC_VERB=$1
+shift
+
+if [ -z $SNAC_PID ]; then
+ echo "no such process" >&2
+ exit 1
+fi
+
+if [ $(id -u) -ne 0 ]; then
+ echo "not root" >&2
+ exit 1
+fi
+
+if [ ! -d $SNAC_DIR ]; then
+ echo "$SNAC_DIR is not a directory" >&2
+ exit 1
+fi
+
+if [ -z $SNAC_VERB ]; then
+ echo "no arguments" >&2
+ exit 1
+fi
+
+nsenter -ae -S follow -G follow -t $SNAC_PID -- snac $SNAC_VERB $SNAC_DIR $@
diff --git a/html.c b/html.c
@@ -14,6 +14,7 @@
#include "xs_curl.h"
#include "xs_unicode.h"
#include "xs_url.h"
+#include "xs_random.h"
#include "snac.h"
@@ -72,6 +73,9 @@ xs_str *replace_shortnames(xs_str *s, const xs_list *tag, int ems, const char *p
const xs_dict *v;
int c = 0;
+ xs_set rep_emoji;
+ xs_set_init(&rep_emoji);
+
while (xs_list_next(tag_list, &v, &c)) {
const char *t = xs_dict_get(v, "type");
@@ -79,6 +83,10 @@ xs_str *replace_shortnames(xs_str *s, const xs_list *tag, int ems, const char *p
const char *n = xs_dict_get(v, "name");
const xs_dict *i = xs_dict_get(v, "icon");
+ /* avoid repeated emojis (Misskey seems to return this) */
+ if (xs_set_add(&rep_emoji, n) == 0)
+ continue;
+
if (xs_is_string(n) && xs_is_dict(i)) {
const char *u = xs_dict_get(i, "url");
const char *mt = xs_dict_get(i, "mediaType");
@@ -93,6 +101,8 @@ xs_str *replace_shortnames(xs_str *s, const xs_list *tag, int ems, const char *p
xs_html_attr("loading", "lazy"),
xs_html_attr("src", url),
xs_html_attr("alt", n),
+ xs_html_attr("title", n),
+ xs_html_attr("class", "snac-emoji"),
xs_html_attr("style", style));
xs *s1 = xs_html_render(img);
@@ -104,6 +114,8 @@ xs_str *replace_shortnames(xs_str *s, const xs_list *tag, int ems, const char *p
}
}
}
+
+ xs_set_free(&rep_emoji);
}
return s;
@@ -339,7 +351,7 @@ xs_html *html_note(snac *user, const char *summary,
const xs_val *mnt_only, const char *redir,
const char *in_reply_to, int poll,
const xs_list *att_files, const xs_list *att_alt_texts,
- int is_draft)
+ int is_draft, const char *published)
/* Yes, this is a FUCKTON of arguments and I'm a bit embarrased */
{
xs *action = xs_fmt("%s/admin/note", user->actor);
@@ -429,6 +441,36 @@ xs_html *html_note(snac *user, const char *summary,
xs_html_attr("name", "is_draft"),
xs_html_attr(is_draft ? "checked" : "", NULL))));
+ /* post date and time */
+ xs *post_date = NULL;
+ xs *post_time = NULL;
+
+ if (xs_is_string(published)) {
+ time_t t = xs_parse_iso_date(published, 0);
+
+ if (t > 0) {
+ post_date = xs_str_time(t, "%Y-%m-%d", 1);
+ post_time = xs_str_time(t, "%H:%M:%S", 1);
+ }
+ }
+
+ if (edit_id == NULL || is_draft || is_scheduled(user, edit_id)) {
+ xs_html_add(form,
+ xs_html_tag("p",
+ xs_html_text(L("Post date and time (empty, right now; in the future, schedule for later):")),
+ xs_html_sctag("br", NULL),
+ xs_html_sctag("input",
+ xs_html_attr("type", "date"),
+ xs_html_attr("value", post_date ? post_date : ""),
+ xs_html_attr("name", "post_date")),
+ xs_html_text(" "),
+ xs_html_sctag("input",
+ xs_html_attr("type", "time"),
+ xs_html_attr("value", post_time ? post_time : ""),
+ xs_html_attr("step", "1"),
+ xs_html_attr("name", "post_time"))));
+ }
+
if (edit_id)
xs_html_add(form,
xs_html_sctag("input",
@@ -1116,7 +1158,7 @@ xs_html *html_top_controls(snac *user)
NULL, NULL,
xs_stock(XSTYPE_FALSE), "",
xs_stock(XSTYPE_FALSE), NULL,
- NULL, 1, NULL, NULL, 0),
+ NULL, 1, NULL, NULL, 0, NULL),
/** operations **/
xs_html_tag("details",
@@ -1774,7 +1816,8 @@ xs_html *html_entry_controls(snac *user, const char *actor,
id, NULL,
xs_dict_get(msg, "sensitive"), xs_dict_get(msg, "summary"),
xs_stock(is_msg_public(msg) ? XSTYPE_FALSE : XSTYPE_TRUE), redir,
- NULL, 0, att_files, att_alt_texts, is_draft(user, id))),
+ NULL, 0, att_files, att_alt_texts, is_draft(user, id),
+ xs_dict_get(msg, "published"))),
xs_html_tag("p", NULL));
}
@@ -1793,7 +1836,7 @@ xs_html *html_entry_controls(snac *user, const char *actor,
NULL, NULL,
xs_dict_get(msg, "sensitive"), xs_dict_get(msg, "summary"),
xs_stock(is_msg_public(msg) ? XSTYPE_FALSE : XSTYPE_TRUE), redir,
- id, 0, NULL, NULL, 0)),
+ id, 0, NULL, NULL, 0, NULL)),
xs_html_tag("p", NULL));
}
@@ -2689,6 +2732,11 @@ xs_html *html_entry(snac *user, xs_dict *msg, int read_only,
}
}
+ /* add an invisible hr, to help differentiate between posts in text browsers */
+ xs_html_add(entry_top,
+ xs_html_sctag("hr",
+ xs_html_attr("hidden", NULL)));
+
return entry_top;
}
@@ -2830,6 +2878,18 @@ xs_str *html_timeline(snac *user, const xs_list *list, int read_only,
xs_html_text(L("drafts")))));
}
+ {
+ /* show the list of scheduled posts */
+ xs *url = xs_fmt("%s/sched", user->actor);
+ xs_html_add(lol,
+ xs_html_tag("li",
+ xs_html_tag("a",
+ xs_html_attr("href", url),
+ xs_html_attr("class", "snac-list-link"),
+ xs_html_attr("title", L("Scheduled posts")),
+ xs_html_text(L("scheduled posts")))));
+ }
+
/* the list of followed hashtags */
const char *followed_hashtags = xs_dict_get(user->config, "followed_hashtags");
@@ -3130,7 +3190,7 @@ xs_html *html_people_list(snac *user, xs_list *list, const char *header, const c
NULL, actor_id,
xs_stock(XSTYPE_FALSE), "",
xs_stock(XSTYPE_FALSE), NULL,
- NULL, 0, NULL, NULL, 0),
+ NULL, 0, NULL, NULL, 0, NULL),
xs_html_tag("p", NULL));
xs_html_add(snac_post, snac_controls);
@@ -3879,6 +3939,21 @@ int html_get_handler(const xs_dict *req, const char *q_path,
}
}
else
+ if (strcmp(p_path, "sched") == 0) { /** list of scheduled posts **/
+ if (!login(&snac, req)) {
+ *body = xs_dup(uid);
+ status = HTTP_STATUS_UNAUTHORIZED;
+ }
+ else {
+ xs *list = scheduled_list(&snac);
+
+ *body = html_timeline(&snac, list, 0, skip, show,
+ 0, L("Scheduled posts"), "", 0, error);
+ *b_size = strlen(*body);
+ status = HTTP_STATUS_OK;
+ }
+ }
+ else
if (xs_startswith(p_path, "list/")) { /** list timelines **/
if (!login(&snac, req)) {
*body = xs_dup(uid);
@@ -4175,12 +4250,14 @@ int html_post_handler(const xs_dict *req, const char *q_path,
snac_debug(&snac, 1, xs_fmt("web action '%s' received", p_path));
/* post note */
- const xs_str *content = xs_dict_get(p_vars, "content");
- const xs_str *in_reply_to = xs_dict_get(p_vars, "in_reply_to");
- const xs_str *to = xs_dict_get(p_vars, "to");
- const xs_str *sensitive = xs_dict_get(p_vars, "sensitive");
- const xs_str *summary = xs_dict_get(p_vars, "summary");
- const xs_str *edit_id = xs_dict_get(p_vars, "edit_id");
+ const char *content = xs_dict_get(p_vars, "content");
+ const char *in_reply_to = xs_dict_get(p_vars, "in_reply_to");
+ const char *to = xs_dict_get(p_vars, "to");
+ const char *sensitive = xs_dict_get(p_vars, "sensitive");
+ const char *summary = xs_dict_get(p_vars, "summary");
+ const char *edit_id = xs_dict_get(p_vars, "edit_id");
+ const char *post_date = xs_dict_get_def(p_vars, "post_date", "");
+ const char *post_time = xs_dict_get_def(p_vars, "post_time", "");
int priv = !xs_is_null(xs_dict_get(p_vars, "mentioned_only"));
int store_as_draft = !xs_is_null(xs_dict_get(p_vars, "is_draft"));
xs *attach_list = xs_list_new();
@@ -4210,9 +4287,12 @@ int html_post_handler(const xs_dict *req, const char *q_path,
const char *fn = xs_list_get(attach_file, 0);
if (xs_is_string(fn) && *fn != '\0') {
- char *ext = strrchr(fn, '.');
- xs *hash = xs_md5_hex(fn, strlen(fn));
- xs *id = xs_fmt("%s%s", hash, ext);
+ char rnd[32];
+ xs_rnd_buf(rnd, sizeof(rnd));
+
+ const char *ext = strrchr(fn, '.');
+ xs *hash = xs_md5_hex(rnd, strlen(rnd));
+ xs *id = xs_fmt("post-%s%s", hash, ext ? ext : "");
xs *url = xs_fmt("%s/s/%s", snac.actor, id);
int fo = xs_number_get(xs_list_get(attach_file, 1));
int fs = xs_number_get(xs_list_get(attach_file, 2));
@@ -4268,6 +4348,29 @@ int html_post_handler(const xs_dict *req, const char *q_path,
msg = xs_dict_set(msg, "summary", xs_is_null(summary) ? "..." : summary);
}
+ if (xs_is_string(post_date) && *post_date) {
+ xs *local_pubdate = xs_fmt("%sT%s", post_date,
+ xs_is_string(post_time) && *post_time ? post_time : "00:00:00");
+
+ time_t t = xs_parse_iso_date(local_pubdate, 1);
+
+ if (t != 0) {
+ xs *iso_date = xs_str_iso_date(t);
+ msg = xs_dict_set(msg, "published", iso_date);
+
+ snac_debug(&snac, 1, xs_fmt("Published date: [%s]", iso_date));
+ }
+ else
+ snac_log(&snac, xs_fmt("Invalid post date: [%s]", local_pubdate));
+ }
+
+ /* is the published date from the future? */
+ int future_post = 0;
+ xs *right_now = xs_str_utctime(0, ISO_DATE_SPEC);
+
+ if (strcmp(xs_dict_get(msg, "published"), right_now) > 0)
+ future_post = 1;
+
if (xs_is_null(edit_id)) {
/* new message */
const char *id = xs_dict_get(msg, "id");
@@ -4275,6 +4378,10 @@ int html_post_handler(const xs_dict *req, const char *q_path,
if (store_as_draft) {
draft_add(&snac, id, msg);
}
+ else
+ if (future_post) {
+ schedule_add(&snac, id, msg);
+ }
else {
c_msg = msg_create(&snac, msg);
timeline_add(&snac, id, msg);
@@ -4286,7 +4393,7 @@ int html_post_handler(const xs_dict *req, const char *q_path,
if (valid_status(object_get(edit_id, &p_msg))) {
/* copy relevant fields from previous version */
- char *fields[] = { "id", "context", "url", "published",
+ char *fields[] = { "id", "context", "url",
"to", "inReplyTo", NULL };
int n;
@@ -4302,18 +4409,34 @@ int html_post_handler(const xs_dict *req, const char *q_path,
if (is_draft(&snac, edit_id)) {
/* message was previously a draft; it's a create activity */
- /* set the published field to now */
- xs *published = xs_str_utctime(0, ISO_DATE_SPEC);
- msg = xs_dict_set(msg, "published", published);
+ /* if the date is from the past, overwrite it with right_now */
+ if (strcmp(xs_dict_get(msg, "published"), right_now) < 0) {
+ snac_debug(&snac, 1, xs_fmt("setting draft ancient date to %s", right_now));
+ msg = xs_dict_set(msg, "published", right_now);
+ }
/* overwrite object */
object_add_ow(edit_id, msg);
- c_msg = msg_create(&snac, msg);
- timeline_add(&snac, edit_id, msg);
+ if (future_post) {
+ schedule_add(&snac, edit_id, msg);
+ }
+ else {
+ c_msg = msg_create(&snac, msg);
+ timeline_add(&snac, edit_id, msg);
+ }
+
draft_del(&snac, edit_id);
}
+ else
+ if (is_scheduled(&snac, edit_id)) {
+ /* editing an scheduled post; just update it */
+ schedule_add(&snac, edit_id, msg);
+ }
else {
+ /* ignore the (possibly changed) published date */
+ msg = xs_dict_set(msg, "published", xs_dict_get(p_msg, "published"));
+
/* set the updated field */
xs *updated = xs_str_utctime(0, ISO_DATE_SPEC);
msg = xs_dict_set(msg, "updated", updated);
@@ -4398,6 +4521,9 @@ int html_post_handler(const xs_dict *req, const char *q_path,
if (is_draft(&snac, id))
draft_del(&snac, id);
else
+ if (is_scheduled(&snac, id))
+ schedule_del(&snac, id);
+ else
hide(&snac, id);
}
else
@@ -4493,6 +4619,8 @@ int html_post_handler(const xs_dict *req, const char *q_path,
draft_del(&snac, id);
+ schedule_del(&snac, id);
+
snac_log(&snac, xs_fmt("deleted entry %s", id));
}
}
@@ -4636,7 +4764,7 @@ int html_post_handler(const xs_dict *req, const char *q_path,
if (xs_startswith(mimetype, "image/")) {
const char *ext = strrchr(fn, '.');
xs *hash = xs_md5_hex(fn, strlen(fn));
- xs *id = xs_fmt("%s%s", hash, ext);
+ xs *id = xs_fmt("%s-%s%s", uploads[n], hash, ext ? ext : "");
xs *url = xs_fmt("%s/s/%s", snac.actor, id);
int fo = xs_number_get(xs_list_get(uploaded_file, 1));
int fs = xs_number_get(xs_list_get(uploaded_file, 2));
@@ -4705,6 +4833,9 @@ int html_post_handler(const xs_dict *req, const char *q_path,
/* set the option */
msg = xs_dict_append(msg, "name", v);
+ /* delete the content */
+ msg = xs_dict_del(msg, "content");
+
xs *c_msg = msg_create(&snac, msg);
enqueue_message(&snac, c_msg);
diff --git a/mastoapi.c b/mastoapi.c
@@ -2256,6 +2256,25 @@ int mastoapi_get_handler(const xs_dict *req, const char *q_path,
status = HTTP_STATUS_OK;
}
else
+ if (strcmp(cmd, "/v1/instance/peers") == 0) { /** **/
+ /* get the collected inbox list as the instances "this domain is aware of" */
+ xs *list = inbox_list();
+ xs *peers = xs_list_new();
+ const char *inbox;
+
+ xs_list_foreach(list, inbox) {
+ xs *l = xs_split(inbox, "/");
+ const char *domain = xs_list_get(l, 2);
+
+ if (xs_is_string(domain))
+ peers = xs_list_append(peers, domain);
+ }
+
+ *body = xs_json_dumps(peers, 4);
+ *ctype = "application/json";
+ status = HTTP_STATUS_OK;
+ }
+ else
if (xs_startswith(cmd, "/v1/statuses/")) { /** **/
/* information about a status */
if (logged_in) {
@@ -2707,14 +2726,24 @@ int mastoapi_post_handler(const xs_dict *req, const char *q_path,
msg = xs_dict_set(msg, "summary", summary);
}
- /* store */
- timeline_add(&snac, xs_dict_get(msg, "id"), msg);
+ /* scheduled? */
+ const char *scheduled_at = xs_dict_get(args, "scheduled_at");
- /* 'Create' message */
- xs *c_msg = msg_create(&snac, msg);
- enqueue_message(&snac, c_msg);
+ if (xs_is_string(scheduled_at) && *scheduled_at) {
+ msg = xs_dict_set(msg, "published", scheduled_at);
- timeline_touch(&snac);
+ schedule_add(&snac, xs_dict_get(msg, "id"), msg);
+ }
+ else {
+ /* store */
+ timeline_add(&snac, xs_dict_get(msg, "id"), msg);
+
+ /* 'Create' message */
+ xs *c_msg = msg_create(&snac, msg);
+ enqueue_message(&snac, c_msg);
+
+ timeline_touch(&snac);
+ }
/* convert to a mastodon status as a response code */
xs *st = mastoapi_status(&snac, msg);
diff --git a/po/ru.po b/po/ru.po
@@ -446,7 +446,7 @@ msgstr "Событие"
#: html.c:1977 html.c:2006
msgid "boosted"
-msgstr "продвинуто"
+msgstr "поделился"
#: html.c:2022
msgid "in reply to"
diff --git a/snac.h b/snac.h
@@ -1,7 +1,7 @@
/* snac - A simple, minimalistic ActivityPub instance */
/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
-#define VERSION "2.74"
+#define VERSION "2.75-dev"
#define USER_AGENT "snac/" VERSION
@@ -205,6 +205,12 @@ void draft_del(snac *user, const char *id);
void draft_add(snac *user, const char *id, const xs_dict *msg);
xs_list *draft_list(snac *user);
+int is_scheduled(snac *user, const char *id);
+void schedule_del(snac *user, const char *id);
+void schedule_add(snac *user, const char *id, const xs_dict *msg);
+xs_list *scheduled_list(snac *user);
+void scheduled_process(snac *user);
+
int limited(snac *user, const char *id, int cmd);
#define is_limited(user, id) limited((user), (id), 0)
#define limit(user, id) limited((user), (id), 1)