commit 3c427fa

_  ·  2025-04-06 07:22:44 +0000 UTC
parents 0e4c210, 6f884e0
Merge pull request 'master' (#1) from grunfink/snac2:master into master

Reviewed-on: https://codeberg.org/pmjv/snac2/pulls/1
9 files changed,  +337, -43
M data.c
M html.c
M snac.h
+12, -2
 1@@ -1,6 +1,16 @@
 2 # Release Notes
 3 
 4-## 2.74
 5+## UNRELEASED
 6+
 7+Added support for scheduled posts.
 8+
 9+Fixed incorrect poll vote format, which was causing problems in platforms like GotoSocial.
10+
11+Mastodon API: added support for `/api/v1/instance/peers`.
12+
13+Some Czech and Russian translation fixes.
14+
15+## 2.74 "The Days of Nicole, the Fediverse Chick"
16 
17 Added Spanish (default, Argentina and Uruguay) translation (contributed by gnemmi).
18 
19@@ -22,7 +32,7 @@ Added Greek translation (contributed by uhuru).
20 
21 Added Italian translation (contributed by anzu).
22 
23-Mastodon API: added support for /api/v1/custom_emojis (contributed by violette).
24+Mastodon API: added support for `/api/v1/custom_emojis` (contributed by violette).
25 
26 Improved Undo+Follow logic (contributed by rozenglass).
27 
+8, -8
 1@@ -6,16 +6,12 @@ Investigate the problem with boosts inside the same instance (see https://codebe
 2 
 3 Editing / Updating a post does not index newly added hashtags.
 4 
 5-Wrong level of message visibility when using the Mastodon API: https://codeberg.org/grunfink/snac2/issues/200#issuecomment-2351042
 6-
 7 Unfollowing guppe groups seems to work (http status of 200), but messages continue to arrive as if it didn't.
 8 
 9 Important: deleting a follower should do more that just delete the object, see https://codeberg.org/grunfink/snac2/issues/43#issuecomment-956721
10 
11 ## Wishlist
12 
13-Each notification should show a link to the full thread, to see it in context.
14-
15 The instance timeline should also show boosts from users.
16 
17 Mastoapi: implement /v1/conversations.
18@@ -30,14 +26,10 @@ Integrate "Added handling for International Domain Names" PR https://codeberg.or
19 
20 Do something about Akkoma and Misskey's quoted replies (they use the `quoteUrl` field instead of `inReplyTo`).
21 
22-Add a list of hashtags to drop.
23-
24 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).
25 
26 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.
27 
28-The actual storage system wastes too much disk space (lots of small files that really consume 4k of storage). Consider alternatives.
29-
30 ## Closed
31 
32 Start a TODO file (2022-08-25T10:07:44+0200).
33@@ -367,3 +359,11 @@ Add support for /authorize_interaction (whatever it is) (2025-01-16T14:45:28+010
34 Implement following of hashtags (this is not trivial) (2025-01-30T16:12:16+0100).
35 
36 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).
37+
38+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).
39+
40+Each notification should show a link to the full thread, to see it in context (2025-03-23T15:44:50+0100).
41+
42+Add a list of hashtags to drop (2025-03-23T15:45:30+0100).
43+
44+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).
+2, -0
1@@ -2759,6 +2759,8 @@ int process_user_queue(snac *snac)
2         cnt++;
3     }
4 
5+    scheduled_process(snac);
6+
7     return cnt;
8 }
9 
M data.c
+69, -4
  1@@ -1929,6 +1929,70 @@ xs_list *draft_list(snac *user)
  2 }
  3 
  4 
  5+/** scheduled posts **/
  6+
  7+int is_scheduled(snac *user, const char *id)
  8+/* returns true if this note is scheduled for future sending */
  9+{
 10+    return object_user_cache_in(user, id, "sched");
 11+}
 12+
 13+
 14+void schedule_del(snac *user, const char *id)
 15+/* deletes an scheduled post */
 16+{
 17+    object_user_cache_del(user, id, "sched");
 18+}
 19+
 20+
 21+void schedule_add(snac *user, const char *id, const xs_dict *msg)
 22+/* schedules this post for later */
 23+{
 24+    /* delete from the index, in case it was already there */
 25+    schedule_del(user, id);
 26+
 27+    /* overwrite object */
 28+    object_add_ow(id, msg);
 29+
 30+    /* [re]add to the index */
 31+    object_user_cache_add(user, id, "sched");
 32+}
 33+
 34+
 35+xs_list *scheduled_list(snac *user)
 36+/* return the list of scheduled posts */
 37+{
 38+    return object_user_cache_list(user, "sched", XS_ALL, 1);
 39+}
 40+
 41+
 42+void scheduled_process(snac *user)
 43+/* processes the scheduled list, sending those ready to be sent */
 44+{
 45+    xs *posts = scheduled_list(user);
 46+    const char *md5;
 47+    xs *right_now = xs_str_utctime(0, ISO_DATE_SPEC);
 48+
 49+    xs_list_foreach(posts, md5) {
 50+        xs *msg = NULL;
 51+
 52+        if (valid_status(object_get_by_md5(md5, &msg))) {
 53+            if (strcmp(xs_dict_get(msg, "published"), right_now) < 0) {
 54+                /* due date! */
 55+                const char *id = xs_dict_get(msg, "id");
 56+
 57+                timeline_add(user, id, msg);
 58+
 59+                xs *c_msg = msg_create(user, msg);
 60+                enqueue_message(user, c_msg);
 61+
 62+                schedule_del(user, id);
 63+            }
 64+        }
 65+    }
 66+}
 67+
 68+
 69 /** hiding **/
 70 
 71 xs_str *_hidden_fn(snac *snac, const char *id)
 72@@ -2619,10 +2683,9 @@ xs_list *inbox_list(void)
 73     xs_list *ibl = xs_list_new();
 74     xs *spec     = xs_fmt("%s/inbox/" "*", srv_basedir);
 75     xs *files    = xs_glob(spec, 0, 0);
 76-    xs_list *p   = files;
 77     const xs_val *v;
 78 
 79-    while (xs_list_iter(&p, &v)) {
 80+    xs_list_foreach(files, v) {
 81         FILE *f;
 82 
 83         if ((f = fopen(v, "r")) != NULL) {
 84@@ -2630,7 +2693,9 @@ xs_list *inbox_list(void)
 85 
 86             if (line && *line) {
 87                 line = xs_strip_i(line);
 88-                ibl  = xs_list_append(ibl, line);
 89+
 90+                if (!is_instance_blocked(line))
 91+                    ibl = xs_list_append(ibl, line);
 92             }
 93 
 94             fclose(f);
 95@@ -3696,7 +3761,7 @@ void purge_user(snac *snac)
 96     _purge_user_subdir(snac, "public",  pub_days);
 97 
 98     const char *idxs[] = { "followers.idx", "private.idx", "public.idx",
 99-                           "pinned.idx", "bookmark.idx", "draft.idx", NULL };
100+                           "pinned.idx", "bookmark.idx", "draft.idx", "sched.idx", NULL };
101 
102     for (n = 0; idxs[n]; n++) {
103         xs *idx = xs_fmt("%s/%s", snac->basedir, idxs[n]);
+51, -0
 1@@ -0,0 +1,51 @@
 2+#!/usr/bin/env bash
 3+##
 4+## SNAC-ADMIN
 5+##  a simple script that is supposed to improve
 6+##  a snac admin's life, especially when snac
 7+##  is being run as a systemd.unit with
 8+##  DynamicUser=yes enabled.
 9+##  Please make sure to adjust SNAC_DIR
10+##  down below according to your setup.
11+##
12+## USAGE
13+##  snac-admin state
14+##  snac-admin adduser rikkert
15+##  snac-admin block example.org
16+##  snac-admin verify_links lisa
17+##  ...
18+##
19+## Author: @chris@social.shtrophic.net
20+## 
21+## Released into the public domain 
22+##
23+
24+set -e
25+
26+SNAC_PID=$(pidof snac)
27+SNAC_DIR=/var/lib/snac
28+
29+SNAC_VERB=$1
30+shift
31+
32+if [ -z $SNAC_PID ]; then
33+	echo "no such process" >&2
34+	exit 1
35+fi
36+
37+if [ $(id -u) -ne 0 ]; then
38+	echo "not root" >&2
39+	exit 1
40+fi
41+
42+if [ ! -d $SNAC_DIR ]; then
43+	echo "$SNAC_DIR is not a directory" >&2
44+	exit 1
45+fi
46+
47+if [ -z $SNAC_VERB ]; then
48+	echo "no arguments" >&2
49+	exit 1
50+fi
51+
52+nsenter -ae -S follow -G follow -t $SNAC_PID -- snac $SNAC_VERB $SNAC_DIR $@
M html.c
+152, -21
  1@@ -14,6 +14,7 @@
  2 #include "xs_curl.h"
  3 #include "xs_unicode.h"
  4 #include "xs_url.h"
  5+#include "xs_random.h"
  6 
  7 #include "snac.h"
  8 
  9@@ -72,6 +73,9 @@ xs_str *replace_shortnames(xs_str *s, const xs_list *tag, int ems, const char *p
 10         const xs_dict *v;
 11         int c = 0;
 12 
 13+        xs_set rep_emoji;
 14+        xs_set_init(&rep_emoji);
 15+
 16         while (xs_list_next(tag_list, &v, &c)) {
 17             const char *t = xs_dict_get(v, "type");
 18 
 19@@ -79,6 +83,10 @@ xs_str *replace_shortnames(xs_str *s, const xs_list *tag, int ems, const char *p
 20                 const char *n = xs_dict_get(v, "name");
 21                 const xs_dict *i = xs_dict_get(v, "icon");
 22 
 23+                /* avoid repeated emojis (Misskey seems to return this) */
 24+                if (xs_set_add(&rep_emoji, n) == 0)
 25+                    continue;
 26+
 27                 if (xs_is_string(n) && xs_is_dict(i)) {
 28                     const char *u = xs_dict_get(i, "url");
 29                     const char *mt = xs_dict_get(i, "mediaType");
 30@@ -93,6 +101,8 @@ xs_str *replace_shortnames(xs_str *s, const xs_list *tag, int ems, const char *p
 31                                 xs_html_attr("loading", "lazy"),
 32                                 xs_html_attr("src", url),
 33                                 xs_html_attr("alt", n),
 34+                                xs_html_attr("title", n),
 35+                                xs_html_attr("class", "snac-emoji"),
 36                                 xs_html_attr("style", style));
 37 
 38                             xs *s1 = xs_html_render(img);
 39@@ -104,6 +114,8 @@ xs_str *replace_shortnames(xs_str *s, const xs_list *tag, int ems, const char *p
 40                 }
 41             }
 42         }
 43+
 44+        xs_set_free(&rep_emoji);
 45     }
 46 
 47     return s;
 48@@ -339,7 +351,7 @@ xs_html *html_note(snac *user, const char *summary,
 49                    const xs_val *mnt_only, const char *redir,
 50                    const char *in_reply_to, int poll,
 51                    const xs_list *att_files, const xs_list *att_alt_texts,
 52-                   int is_draft)
 53+                   int is_draft, const char *published)
 54 /* Yes, this is a FUCKTON of arguments and I'm a bit embarrased */
 55 {
 56     xs *action = xs_fmt("%s/admin/note", user->actor);
 57@@ -429,6 +441,36 @@ xs_html *html_note(snac *user, const char *summary,
 58                 xs_html_attr("name", "is_draft"),
 59                 xs_html_attr(is_draft ? "checked" : "", NULL))));
 60 
 61+    /* post date and time */
 62+    xs *post_date = NULL;
 63+    xs *post_time = NULL;
 64+
 65+    if (xs_is_string(published)) {
 66+        time_t t = xs_parse_iso_date(published, 0);
 67+
 68+        if (t > 0) {
 69+            post_date = xs_str_time(t, "%Y-%m-%d", 1);
 70+            post_time = xs_str_time(t, "%H:%M:%S", 1);
 71+        }
 72+    }
 73+
 74+    if (edit_id == NULL || is_draft || is_scheduled(user, edit_id)) {
 75+        xs_html_add(form,
 76+            xs_html_tag("p",
 77+                xs_html_text(L("Post date and time (empty, right now; in the future, schedule for later):")),
 78+                xs_html_sctag("br", NULL),
 79+                xs_html_sctag("input",
 80+                    xs_html_attr("type",  "date"),
 81+                    xs_html_attr("value", post_date ? post_date : ""),
 82+                    xs_html_attr("name",  "post_date")),
 83+                xs_html_text(" "),
 84+                xs_html_sctag("input",
 85+                    xs_html_attr("type",  "time"),
 86+                    xs_html_attr("value", post_time ? post_time : ""),
 87+                    xs_html_attr("step",  "1"),
 88+                    xs_html_attr("name",  "post_time"))));
 89+    }
 90+
 91     if (edit_id)
 92         xs_html_add(form,
 93             xs_html_sctag("input",
 94@@ -1116,7 +1158,7 @@ xs_html *html_top_controls(snac *user)
 95             NULL, NULL,
 96             xs_stock(XSTYPE_FALSE), "",
 97             xs_stock(XSTYPE_FALSE), NULL,
 98-            NULL, 1, NULL, NULL, 0),
 99+            NULL, 1, NULL, NULL, 0, NULL),
100 
101         /** operations **/
102         xs_html_tag("details",
103@@ -1774,7 +1816,8 @@ xs_html *html_entry_controls(snac *user, const char *actor,
104                 id, NULL,
105                 xs_dict_get(msg, "sensitive"), xs_dict_get(msg, "summary"),
106                 xs_stock(is_msg_public(msg) ? XSTYPE_FALSE : XSTYPE_TRUE), redir,
107-                NULL, 0, att_files, att_alt_texts, is_draft(user, id))),
108+                NULL, 0, att_files, att_alt_texts, is_draft(user, id),
109+                xs_dict_get(msg, "published"))),
110             xs_html_tag("p", NULL));
111     }
112 
113@@ -1793,7 +1836,7 @@ xs_html *html_entry_controls(snac *user, const char *actor,
114                 NULL, NULL,
115                 xs_dict_get(msg, "sensitive"), xs_dict_get(msg, "summary"),
116                 xs_stock(is_msg_public(msg) ? XSTYPE_FALSE : XSTYPE_TRUE), redir,
117-                id, 0, NULL, NULL, 0)),
118+                id, 0, NULL, NULL, 0, NULL)),
119             xs_html_tag("p", NULL));
120     }
121 
122@@ -2689,6 +2732,11 @@ xs_html *html_entry(snac *user, xs_dict *msg, int read_only,
123         }
124     }
125 
126+    /* add an invisible hr, to help differentiate between posts in text browsers */
127+    xs_html_add(entry_top,
128+        xs_html_sctag("hr",
129+            xs_html_attr("hidden", NULL)));
130+
131     return entry_top;
132 }
133 
134@@ -2830,6 +2878,18 @@ xs_str *html_timeline(snac *user, const xs_list *list, int read_only,
135                         xs_html_text(L("drafts")))));
136         }
137 
138+        {
139+            /* show the list of scheduled posts */
140+            xs *url = xs_fmt("%s/sched", user->actor);
141+            xs_html_add(lol,
142+                xs_html_tag("li",
143+                    xs_html_tag("a",
144+                        xs_html_attr("href", url),
145+                        xs_html_attr("class", "snac-list-link"),
146+                        xs_html_attr("title", L("Scheduled posts")),
147+                        xs_html_text(L("scheduled posts")))));
148+        }
149+
150         /* the list of followed hashtags */
151         const char *followed_hashtags = xs_dict_get(user->config, "followed_hashtags");
152 
153@@ -3130,7 +3190,7 @@ xs_html *html_people_list(snac *user, xs_list *list, const char *header, const c
154                     NULL, actor_id,
155                     xs_stock(XSTYPE_FALSE), "",
156                     xs_stock(XSTYPE_FALSE), NULL,
157-                    NULL, 0, NULL, NULL, 0),
158+                    NULL, 0, NULL, NULL, 0, NULL),
159                 xs_html_tag("p", NULL));
160 
161             xs_html_add(snac_post, snac_controls);
162@@ -3879,6 +3939,21 @@ int html_get_handler(const xs_dict *req, const char *q_path,
163         }
164     }
165     else
166+    if (strcmp(p_path, "sched") == 0) { /** list of scheduled posts **/
167+        if (!login(&snac, req)) {
168+            *body  = xs_dup(uid);
169+            status = HTTP_STATUS_UNAUTHORIZED;
170+        }
171+        else {
172+            xs *list = scheduled_list(&snac);
173+
174+            *body = html_timeline(&snac, list, 0, skip, show,
175+                0, L("Scheduled posts"), "", 0, error);
176+            *b_size = strlen(*body);
177+            status  = HTTP_STATUS_OK;
178+        }
179+    }
180+    else
181     if (xs_startswith(p_path, "list/")) { /** list timelines **/
182         if (!login(&snac, req)) {
183             *body  = xs_dup(uid);
184@@ -4175,12 +4250,14 @@ int html_post_handler(const xs_dict *req, const char *q_path,
185         snac_debug(&snac, 1, xs_fmt("web action '%s' received", p_path));
186 
187         /* post note */
188-        const xs_str *content      = xs_dict_get(p_vars, "content");
189-        const xs_str *in_reply_to  = xs_dict_get(p_vars, "in_reply_to");
190-        const xs_str *to           = xs_dict_get(p_vars, "to");
191-        const xs_str *sensitive    = xs_dict_get(p_vars, "sensitive");
192-        const xs_str *summary      = xs_dict_get(p_vars, "summary");
193-        const xs_str *edit_id      = xs_dict_get(p_vars, "edit_id");
194+        const char *content      = xs_dict_get(p_vars, "content");
195+        const char *in_reply_to  = xs_dict_get(p_vars, "in_reply_to");
196+        const char *to           = xs_dict_get(p_vars, "to");
197+        const char *sensitive    = xs_dict_get(p_vars, "sensitive");
198+        const char *summary      = xs_dict_get(p_vars, "summary");
199+        const char *edit_id      = xs_dict_get(p_vars, "edit_id");
200+        const char *post_date    = xs_dict_get_def(p_vars, "post_date", "");
201+        const char *post_time    = xs_dict_get_def(p_vars, "post_time", "");
202         int priv             = !xs_is_null(xs_dict_get(p_vars, "mentioned_only"));
203         int store_as_draft   = !xs_is_null(xs_dict_get(p_vars, "is_draft"));
204         xs *attach_list      = xs_list_new();
205@@ -4210,9 +4287,12 @@ int html_post_handler(const xs_dict *req, const char *q_path,
206                 const char *fn = xs_list_get(attach_file, 0);
207 
208                 if (xs_is_string(fn) && *fn != '\0') {
209-                    char *ext = strrchr(fn, '.');
210-                    xs *hash  = xs_md5_hex(fn, strlen(fn));
211-                    xs *id    = xs_fmt("%s%s", hash, ext);
212+                    char rnd[32];
213+                    xs_rnd_buf(rnd, sizeof(rnd));
214+
215+                    const char *ext = strrchr(fn, '.');
216+                    xs *hash  = xs_md5_hex(rnd, strlen(rnd));
217+                    xs *id    = xs_fmt("post-%s%s", hash, ext ? ext : "");
218                     xs *url   = xs_fmt("%s/s/%s", snac.actor, id);
219                     int fo    = xs_number_get(xs_list_get(attach_file, 1));
220                     int fs    = xs_number_get(xs_list_get(attach_file, 2));
221@@ -4268,6 +4348,29 @@ int html_post_handler(const xs_dict *req, const char *q_path,
222                 msg = xs_dict_set(msg, "summary",   xs_is_null(summary) ? "..." : summary);
223             }
224 
225+            if (xs_is_string(post_date) && *post_date) {
226+                xs *local_pubdate = xs_fmt("%sT%s", post_date,
227+                    xs_is_string(post_time) && *post_time ? post_time : "00:00:00");
228+
229+                time_t t = xs_parse_iso_date(local_pubdate, 1);
230+
231+                if (t != 0) {
232+                    xs *iso_date = xs_str_iso_date(t);
233+                    msg = xs_dict_set(msg, "published", iso_date);
234+
235+                    snac_debug(&snac, 1, xs_fmt("Published date: [%s]", iso_date));
236+                }
237+                else
238+                    snac_log(&snac, xs_fmt("Invalid post date: [%s]", local_pubdate));
239+            }
240+
241+            /* is the published date from the future? */
242+            int future_post = 0;
243+            xs *right_now = xs_str_utctime(0, ISO_DATE_SPEC);
244+
245+            if (strcmp(xs_dict_get(msg, "published"), right_now) > 0)
246+                future_post = 1;
247+
248             if (xs_is_null(edit_id)) {
249                 /* new message */
250                 const char *id = xs_dict_get(msg, "id");
251@@ -4275,6 +4378,10 @@ int html_post_handler(const xs_dict *req, const char *q_path,
252                 if (store_as_draft) {
253                     draft_add(&snac, id, msg);
254                 }
255+                else
256+                if (future_post) {
257+                    schedule_add(&snac, id, msg);
258+                }
259                 else {
260                     c_msg = msg_create(&snac, msg);
261                     timeline_add(&snac, id, msg);
262@@ -4286,7 +4393,7 @@ int html_post_handler(const xs_dict *req, const char *q_path,
263 
264                 if (valid_status(object_get(edit_id, &p_msg))) {
265                     /* copy relevant fields from previous version */
266-                    char *fields[] = { "id", "context", "url", "published",
267+                    char *fields[] = { "id", "context", "url",
268                                        "to", "inReplyTo", NULL };
269                     int n;
270 
271@@ -4302,18 +4409,34 @@ int html_post_handler(const xs_dict *req, const char *q_path,
272                     if (is_draft(&snac, edit_id)) {
273                         /* message was previously a draft; it's a create activity */
274 
275-                        /* set the published field to now */
276-                        xs *published = xs_str_utctime(0, ISO_DATE_SPEC);
277-                        msg = xs_dict_set(msg, "published", published);
278+                        /* if the date is from the past, overwrite it with right_now */
279+                        if (strcmp(xs_dict_get(msg, "published"), right_now) < 0) {
280+                            snac_debug(&snac, 1, xs_fmt("setting draft ancient date to %s", right_now));
281+                            msg = xs_dict_set(msg, "published", right_now);
282+                        }
283 
284                         /* overwrite object */
285                         object_add_ow(edit_id, msg);
286 
287-                        c_msg = msg_create(&snac, msg);
288-                        timeline_add(&snac, edit_id, msg);
289+                        if (future_post) {
290+                            schedule_add(&snac, edit_id, msg);
291+                        }
292+                        else {
293+                            c_msg = msg_create(&snac, msg);
294+                            timeline_add(&snac, edit_id, msg);
295+                        }
296+
297                         draft_del(&snac, edit_id);
298                     }
299+                    else
300+                    if (is_scheduled(&snac, edit_id)) {
301+                        /* editing an scheduled post; just update it */
302+                        schedule_add(&snac, edit_id, msg);
303+                    }
304                     else {
305+                        /* ignore the (possibly changed) published date */
306+                        msg = xs_dict_set(msg, "published", xs_dict_get(p_msg, "published"));
307+
308                         /* set the updated field */
309                         xs *updated = xs_str_utctime(0, ISO_DATE_SPEC);
310                         msg = xs_dict_set(msg, "updated", updated);
311@@ -4397,6 +4520,9 @@ int html_post_handler(const xs_dict *req, const char *q_path,
312         if (strcmp(action, L("Hide")) == 0) { /** **/
313             if (is_draft(&snac, id))
314                 draft_del(&snac, id);
315+            else
316+            if (is_scheduled(&snac, id))
317+                schedule_del(&snac, id);
318             else
319                 hide(&snac, id);
320         }
321@@ -4493,6 +4619,8 @@ int html_post_handler(const xs_dict *req, const char *q_path,
322 
323                 draft_del(&snac, id);
324 
325+                schedule_del(&snac, id);
326+
327                 snac_log(&snac, xs_fmt("deleted entry %s", id));
328             }
329         }
330@@ -4636,7 +4764,7 @@ int html_post_handler(const xs_dict *req, const char *q_path,
331                     if (xs_startswith(mimetype, "image/")) {
332                         const char *ext = strrchr(fn, '.');
333                         xs *hash        = xs_md5_hex(fn, strlen(fn));
334-                        xs *id          = xs_fmt("%s%s", hash, ext);
335+                        xs *id          = xs_fmt("%s-%s%s", uploads[n], hash, ext ? ext : "");
336                         xs *url         = xs_fmt("%s/s/%s", snac.actor, id);
337                         int fo          = xs_number_get(xs_list_get(uploaded_file, 1));
338                         int fs          = xs_number_get(xs_list_get(uploaded_file, 2));
339@@ -4705,6 +4833,9 @@ int html_post_handler(const xs_dict *req, const char *q_path,
340             /* set the option */
341             msg = xs_dict_append(msg, "name", v);
342 
343+            /* delete the content */
344+            msg = xs_dict_del(msg, "content");
345+
346             xs *c_msg = msg_create(&snac, msg);
347 
348             enqueue_message(&snac, c_msg);
+35, -6
 1@@ -2256,6 +2256,25 @@ int mastoapi_get_handler(const xs_dict *req, const char *q_path,
 2         status = HTTP_STATUS_OK;
 3     }
 4     else
 5+    if (strcmp(cmd, "/v1/instance/peers") == 0) { /** **/
 6+        /* get the collected inbox list as the instances "this domain is aware of" */
 7+        xs *list = inbox_list();
 8+        xs *peers = xs_list_new();
 9+        const char *inbox;
10+
11+        xs_list_foreach(list, inbox) {
12+            xs *l = xs_split(inbox, "/");
13+            const char *domain = xs_list_get(l, 2);
14+
15+            if (xs_is_string(domain))
16+                peers = xs_list_append(peers, domain);
17+        }
18+
19+        *body  = xs_json_dumps(peers, 4);
20+        *ctype = "application/json";
21+        status = HTTP_STATUS_OK;
22+    }
23+    else
24     if (xs_startswith(cmd, "/v1/statuses/")) { /** **/
25         /* information about a status */
26         if (logged_in) {
27@@ -2707,14 +2726,24 @@ int mastoapi_post_handler(const xs_dict *req, const char *q_path,
28                 msg = xs_dict_set(msg, "summary",   summary);
29             }
30 
31-            /* store */
32-            timeline_add(&snac, xs_dict_get(msg, "id"), msg);
33+            /* scheduled? */
34+            const char *scheduled_at = xs_dict_get(args, "scheduled_at");
35 
36-            /* 'Create' message */
37-            xs *c_msg = msg_create(&snac, msg);
38-            enqueue_message(&snac, c_msg);
39+            if (xs_is_string(scheduled_at) && *scheduled_at) {
40+                msg = xs_dict_set(msg, "published", scheduled_at);
41 
42-            timeline_touch(&snac);
43+                schedule_add(&snac, xs_dict_get(msg, "id"), msg);
44+            }
45+            else {
46+                /* store */
47+                timeline_add(&snac, xs_dict_get(msg, "id"), msg);
48+
49+                /* 'Create' message */
50+                xs *c_msg = msg_create(&snac, msg);
51+                enqueue_message(&snac, c_msg);
52+
53+                timeline_touch(&snac);
54+            }
55 
56             /* convert to a mastodon status as a response code */
57             xs *st = mastoapi_status(&snac, msg);
+1, -1
1@@ -446,7 +446,7 @@ msgstr "Событие"
2 
3 #: html.c:1977 html.c:2006
4 msgid "boosted"
5-msgstr "продвинуто"
6+msgstr "поделился"
7 
8 #: html.c:2022
9 msgid "in reply to"
M snac.h
+7, -1
 1@@ -1,7 +1,7 @@
 2 /* snac - A simple, minimalistic ActivityPub instance */
 3 /* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
 4 
 5-#define VERSION "2.74"
 6+#define VERSION "2.75-dev"
 7 
 8 #define USER_AGENT "snac/" VERSION
 9 
10@@ -205,6 +205,12 @@ void draft_del(snac *user, const char *id);
11 void draft_add(snac *user, const char *id, const xs_dict *msg);
12 xs_list *draft_list(snac *user);
13 
14+int is_scheduled(snac *user, const char *id);
15+void schedule_del(snac *user, const char *id);
16+void schedule_add(snac *user, const char *id, const xs_dict *msg);
17+xs_list *scheduled_list(snac *user);
18+void scheduled_process(snac *user);
19+
20 int limited(snac *user, const char *id, int cmd);
21 #define is_limited(user, id) limited((user), (id), 0)
22 #define limit(user, id) limited((user), (id), 1)