commit 85ed0eb
violette
·
2025-12-18 06:58:24 +0000 UTC
parent e66446a
Added emoji reactions (contributed by violette).
+156,
-5
1@@ -4,6 +4,7 @@
2 #include "xs.h"
3 #include "xs_json.h"
4 #include "xs_curl.h"
5+#include "xs_url.h"
6 #include "xs_mime.h"
7 #include "xs_openssl.h"
8 #include "xs_regex.h"
9@@ -1530,11 +1531,24 @@ xs_dict *msg_update(snac *snac, const xs_dict *object)
10
11
12 xs_dict *msg_admiration(snac *snac, const char *object, const char *type)
13-/* creates a Like or Announce message */
14+/* creates a Like, Announce or EmojiReact message */
15 {
16 xs *a_msg = NULL;
17 xs_dict *msg = NULL;
18 xs *wrk = NULL;
19+ char t = 0;
20+
21+ switch (*type) {
22+ case 'L':
23+ t = 'l';
24+ break;
25+ case 'A':
26+ t = 'a';
27+ break;
28+ case 'E':
29+ t = 'e';
30+ break;
31+ }
32
33 /* call the object */
34 timeline_request(snac, &object, &wrk, 0);
35@@ -1542,7 +1556,7 @@ xs_dict *msg_admiration(snac *snac, const char *object, const char *type)
36 if (valid_status(object_get(object, &a_msg))) {
37 xs *rcpts = xs_list_new();
38 xs *o_md5 = xs_md5_hex(object, strlen(object));
39- xs *id = xs_fmt("%s/%s/%s", snac->actor, *type == 'L' ? "l" : "a", o_md5);
40+ xs *id = xs_fmt("%s/%c/%s", snac->actor, t, o_md5);
41
42 msg = msg_base(snac, type, id, snac->actor, "@now", object);
43
44@@ -1586,6 +1600,113 @@ xs_dict *msg_repulsion(snac *user, const char *id, const char *type)
45 return msg;
46 }
47
48+xs_dict *msg_emoji_init(snac *snac, const char *mid, const char *eid)
49+/* creates an emoji reaction from a local user */
50+{
51+ xs_dict *n_msg = msg_admiration(snac, mid, "EmojiReact");
52+
53+ eid = xs_strip_chars_i(xs_dup(eid), ":");
54+ xs *content = NULL;
55+ xs *tag = xs_list_new();
56+ xs *dict = xs_dict_new();
57+ xs *icon = xs_dict_new();
58+ xs *accounts = xs_list_new();
59+
60+ /* may be a default emoji */
61+ xs *eidd = xs_dup(eid);
62+ const char *eidda = eid;
63+
64+ if (xs_is_emoji(xs_utf8_dec(&eidda)))
65+ content = xs_dup(eid);
66+
67+ else if (*eid == '%') {
68+ content = xs_url_dec_emoji(xs_dup(eid));
69+ if (content == NULL) {
70+ return NULL;
71+ }
72+ }
73+ else if (xs_dict_get(emojis(), xs_fmt(":%s:", eid)) == NULL)
74+ return NULL;
75+ else {
76+ content = xs_fmt(":%s:", eid);
77+ icon = xs_dict_set(icon, "type", "Image");
78+ icon = xs_dict_set(icon, "url", xs_fmt("%s/s/%s.png", snac->actor, eid));
79+ dict = xs_dict_set(dict, "icon", icon);
80+
81+ dict = xs_dict_set(dict, "id", xs_fmt("%s/s/%s.png", snac->actor, eid));
82+ dict = xs_dict_set(dict, "name", content);
83+ dict = xs_dict_set(dict, "type", "Emoji");
84+ tag = xs_list_append(tag, dict);
85+ }
86+
87+ accounts = xs_list_append(accounts, snac->actor);
88+
89+ n_msg = xs_dict_set(n_msg, "content", content);
90+ n_msg = xs_dict_set(n_msg, "accounts", accounts);
91+ n_msg = xs_dict_set(n_msg, "attributedTo", xs_list_get(xs_dup(xs_dict_get(n_msg, "to")), 1));
92+ n_msg = xs_dict_set(n_msg, "accountId", snac->uid);
93+ n_msg = xs_dict_set(n_msg, "tag", tag);
94+
95+ int ret = timeline_admire(snac, xs_dict_get(n_msg, "object"), snac->actor, 1, n_msg);
96+ if (ret == 200 || ret == 201) {
97+ enqueue_message(snac, n_msg);
98+ return n_msg;
99+ }
100+
101+ return NULL;
102+}
103+
104+xs_dict *msg_emoji_unreact(snac *user, const char *mid, const char *eid)
105+/* creates an Undo + emoji reaction message */
106+{
107+ xs *a_msg = NULL;
108+ xs_dict *msg = NULL;
109+
110+ if (valid_status(object_get(mid, &a_msg))) {
111+ /* create a clone of the original admiration message */
112+ xs *object = msg_admiration(user, mid, "EmojiReact");
113+
114+ /* delete the published date */
115+ object = xs_dict_del(object, "published");
116+
117+ /* create an undo message for this object */
118+ msg = msg_undo(user, object);
119+
120+ /* copy the 'to' field */
121+ msg = xs_dict_set(msg, "to", xs_dict_get(object, "to"));
122+ }
123+
124+ xs *emotes = object_get_emoji_reacts(mid);
125+ const char *v;
126+ int c = 0;
127+
128+ /* may be a default emoji */
129+ if (strlen(eid) == 12 && *eid == '%') {
130+ eid = xs_url_dec(eid);
131+ if (eid == NULL) {
132+ return NULL;
133+ }
134+ }
135+
136+ /* lets get all emotes for this msg, and compare it to our content */
137+ while (xs_list_next(emotes, &v, &c)) {
138+ xs_dict *e = NULL;
139+ if (valid_status(object_get_by_md5(v, &e))) {
140+ const char *content = xs_dict_get(e, "content");
141+ const char *id = xs_dict_get(e, "id");
142+ const char *actor = xs_dict_get(e, "actor");
143+ /* maybe formated as :{emoteName}: too */
144+ if (xs_str_in(eid, content) != -1)
145+ if (strcmp(user->actor, actor) == 0) {
146+ object_rm_emoji_react(mid, id);
147+ return msg;
148+ }
149+ }
150+ }
151+
152+ return NULL;
153+}
154+
155
156 xs_dict *msg_actor_place(snac *user, const char *label)
157 /* creates a Place object, if the user has a location defined */
158@@ -2605,6 +2726,16 @@ int process_input_message(snac *snac, const xs_dict *msg, const xs_dict *req)
159 else
160 if (strcmp(type, "Undo") == 0) { /** **/
161 const char *id = xs_dict_get(object, "object");
162+ const char *content = xs_dict_get(object, "content");
163+ /* misskey sends emojis as like + tag */
164+ xs *cd = xs_dup(content);
165+ const char *sna = cd;
166+ const xs_dict *tag = xs_dict_get(object, "tag");
167+ unsigned int utf = xs_utf8_dec((const char **)&sna);
168+
169+ int isEmoji = 0;
170+ if (xs_is_emoji(utf) || (tag && xs_list_len(tag) > 0))
171+ isEmoji = 1;
172
173 if (xs_type(object) != XSTYPE_DICT) {
174 snac_debug(snac, 1, xs_fmt("undo: overriding utype %s | %s | %s",
175@@ -2633,8 +2764,19 @@ int process_input_message(snac *snac, const xs_dict *msg, const xs_dict *req)
176 else
177 snac_log(snac, xs_fmt("error deleting follower %s", actor));
178 }
179+ /* *key emojis are like w/ Emoji tag */
180+ else
181+ if ((isEmoji || strcmp(utype, "EmojiReact") == 0) &&
182+ (content && strcmp(content, "♥") != 0)) {
183+ const xs_val *mid = xs_dict_get(object, "id");
184+ int status = object_rm_emoji_react((char *)id, mid);
185+ /* ensure *key notifications type */
186+ utype = "EmojiReact";
187+
188+ snac_log(snac, xs_fmt("Undo 'EmojiReact' for %s %d", id, status));
189+ }
190 else
191- if (strcmp(utype, "Like") == 0 || strcmp(utype, "EmojiReact") == 0) { /** **/
192+ if (strcmp(utype, "Like") == 0) { /** **/
193 int status = object_unadmire(id, actor, 1);
194
195 snac_log(snac, xs_fmt("Undo '%s' for %s %d", utype, id, status));
196@@ -2771,13 +2913,22 @@ int process_input_message(snac *snac, const xs_dict *msg, const xs_dict *req)
197 }
198 else
199 if (strcmp(type, "Like") == 0 || strcmp(type, "EmojiReact") == 0) { /** **/
200+ /* misskey sends emojis as Like + tag.
201+ * It is easier to handle them both at the same time. */
202+ const char *sna = xs_dict_get(msg, "content");
203+ const xs_dict *tag = xs_dict_get(msg, "tag");
204+ unsigned int utf = xs_utf8_dec((const char **)&sna);
205+
206+ if (xs_is_emoji(utf) || (tag && xs_list_len(tag) > 0))
207+ type = "EmojiReact";
208+
209 if (xs_type(object) == XSTYPE_DICT)
210 object = xs_dict_get(object, "id");
211
212 if (xs_is_null(object))
213 snac_log(snac, xs_fmt("malformed message: no 'id' field"));
214 else
215- if (timeline_admire(snac, object, actor, 1) == HTTP_STATUS_CREATED)
216+ if (timeline_admire(snac, object, actor, 1, xs_dup(msg)) == HTTP_STATUS_CREATED)
217 snac_log(snac, xs_fmt("new '%s' %s %s", type, actor, object));
218 else
219 snac_log(snac, xs_fmt("repeated '%s' from %s to %s", type, actor, object));
220@@ -2818,7 +2969,7 @@ int process_input_message(snac *snac, const xs_dict *msg, const xs_dict *req)
221 xs *this_relay = xs_fmt("%s/relay", srv_baseurl);
222
223 if (strcmp(actor, this_relay) != 0) {
224- if (valid_status(timeline_admire(snac, object, actor, 0)))
225+ if (valid_status(timeline_admire(snac, object, actor, 0, a_msg)))
226 snac_log(snac, xs_fmt("new 'Announce' %s %s", actor, object));
227 else
228 snac_log(snac, xs_fmt("repeated 'Announce' from %s to %s",
M
data.c
+116,
-8
1@@ -1043,6 +1043,14 @@ xs_list *object_children(const char *id)
2 }
3
4
5+xs_list *object_get_emoji_reacts(const char *id)
6+/* returns the list of an object's emoji reactions */
7+{
8+ xs *fn = _object_index_fn(id, "_e.idx");
9+ return index_list(fn, XS_ALL);
10+}
11+
12+
13 xs_list *object_likes(const char *id)
14 {
15 xs *fn = _object_index_fn(id, "_l.idx");
16@@ -1086,12 +1094,26 @@ int object_admire(const char *id, const char *actor, int like)
17
18
19 int object_unadmire(const char *id, const char *actor, int like)
20-/* actor no longer likes or announces this object */
21+/* actor retrives their likes, announces or emojis this object */
22 {
23+ switch (like) {
24+ case 0:
25+ like = 'a';
26+ break;
27+ case 1:
28+ like = 'l';
29+ break;
30+ case 2:
31+ like = 'e';
32+ break;
33+ }
34 int status;
35 xs *fn = _object_fn(id);
36
37- fn = xs_replace_i(fn, ".json", like ? "_l.idx" : "_a.idx");
38+ char sfx[7] = "_x.idx";
39+ sfx[1] = like;
40+
41+ fn = xs_replace_i(fn, ".json", sfx);
42
43 status = index_del(fn, actor);
44
45@@ -1099,7 +1121,46 @@ int object_unadmire(const char *id, const char *actor, int like)
46 index_gc(fn);
47
48 srv_debug(0,
49- xs_fmt("object_unadmire (%s) %s %s %d", like ? "Like" : "Announce", actor, fn, status));
50+ xs_fmt("object_unadmire (%s) %s %s %d", like >= 'e' ?
51+ (like == 'l' ? "Like" : "EmojiReact") : "Announce" , actor, fn, status));
52+
53+ return status;
54+}
55+
56+int object_emoji_react(const char *mid, const char *eid)
57+/* actor reacts w/ an emoji */
58+{
59+ int status = HTTP_STATUS_OK;
60+ xs *fn = _object_fn(mid);
61+
62+ fn = xs_replace_i(fn, ".json", "_e.idx");
63+
64+ if (!index_in(fn, eid)) {
65+ status = index_add(fn, eid);
66+
67+ srv_debug(1, xs_fmt("object_emoji_react (%s) added %s to %s", "EmojiReact", eid, fn));
68+ }
69+
70+ return status;
71+}
72+
73+
74+int object_rm_emoji_react(const char *mid, const char *eid)
75+/* actor retrives their emoji reaction */
76+{
77+ int status;
78+ xs *fn = _object_fn(mid);
79+
80+ fn = xs_replace_i(fn, ".json", "_e.idx");
81+
82+ status = index_del(fn, eid);
83+ object_del(eid);
84+
85+ if (valid_status(status))
86+ index_gc(fn);
87+
88+ srv_debug(0,
89+ xs_fmt("object_unadmire (EmojiReact) %s %s %d", eid, fn, status));
90
91 return status;
92 }
93@@ -1506,19 +1567,47 @@ int timeline_add(snac *snac, const char *id, const xs_dict *o_msg)
94 }
95
96
97-int timeline_admire(snac *snac, const char *id, const char *admirer, int like)
98-/* updates a timeline entry with a new admiration */
99+int timeline_emoji_react(const char *act, const char *id, xs_dict *msg)
100+/* adds an emoji reaction to a message */
101+{
102+ msg = xs_dict_append(msg, "attributedTo", act);
103+ msg = xs_dict_set(msg, "type", "EmojiReact");
104+ const char *emote_id = xs_dict_get(msg, "id");
105+
106+ int ret = object_add(emote_id, msg);
107+ if (ret == HTTP_STATUS_OK || ret == HTTP_STATUS_CREATED)
108+ ret = object_emoji_react(id, emote_id);
109+
110+ return ret;
111+}
112+
113+
114+int timeline_admire(snac *snac, const char *id,
115+ const char *admirer, int like, xs_dict *msg)
116+/* updates a timeline entry with a new admiration or emoji reaction */
117 {
118+ int ret;
119+ const char *content = xs_dict_get_path(msg, "content");
120+ const char *type = xs_dict_get_path(msg, "type");
121+
122 /* if we are admiring this, add to both timelines */
123 if (!like && strcmp(admirer, snac->actor) == 0) {
124 object_user_cache_add(snac, id, "public");
125 object_user_cache_add(snac, id, "private");
126 }
127
128- int ret = object_admire(id, admirer, like);
129+ /* use utf <3 as a like, as it is ugly */
130+ if (type && xs_match(type, "Like|EmojiReact|Emoji") &&
131+ content && strcmp(content, "❤") != 0) {
132+ ret = timeline_emoji_react(xs_dup(snac->actor), id, xs_dup(msg));
133+ snac_debug(snac, 1, xs_fmt("timeline_emoji_react %s", id));
134+ }
135
136- snac_debug(snac, 1, xs_fmt("timeline_admire (%s) %s %s",
137- like ? "Like" : "Announce", id, admirer));
138+ else {
139+ ret = object_admire(id, admirer, like);
140+ snac_debug(snac, 1, xs_fmt("timeline_admire (%s) %s %s",
141+ like ? "Like" : "Announce", id, admirer));
142+ }
143
144 return ret;
145 }
146@@ -1867,6 +1956,25 @@ xs_list *muted_list(snac *user)
147 return l;
148 }
149
150+/** emojis react **/
151+
152+const xs_str *emoji_reacted(snac *user, const char *id)
153+/* returns the emoji an user reacted to a message */
154+{
155+ xs *emojis = object_get_emoji_reacts(id);
156+ int c = 0;
157+ const char *v;
158+ xs_dict *msg;
159+
160+ while (xs_list_next(emojis, &v, &c)) {
161+ if (object_get_by_md5(v, &msg)) {
162+ const xs_val *act = xs_dict_get(msg, "actor");
163+ if (act && strcmp(act, user->actor) == 0)
164+ return xs_dict_get(msg, "content");
165+ }
166+ }
167+ return NULL;
168+}
169
170 /** bookmarking **/
171
M
format.c
+3,
-0
1@@ -459,6 +459,9 @@ xs_str *sanitize(const char *content)
2 char *p;
3 const char *v;
4
5+ if (!content)
6+ return NULL;
7+
8 sl = xs_regex_split(content, "</?[^>]+>");
9
10 p = sl;
M
html.c
+305,
-12
1@@ -54,9 +54,10 @@ int login(snac *user, const xs_dict *headers)
2 return logged_in;
3 }
4
5-
6-xs_str *replace_shortnames(xs_str *s, const xs_list *tag, int ems, const char *proxy)
7-/* replaces all the :shortnames: with the emojis in tag */
8+xs_str *_replace_shortnames(xs_str *s, const xs_list *tag, int ems,
9+ const char *proxy, const xs_list *cl, const char *act)
10+/* replace but also adds a class list and an actor in its alt text.
11+ * Used for emoji reactions */
12 {
13 if (!xs_is_null(tag)) {
14 xs *tag_list = NULL;
15@@ -69,11 +70,15 @@ xs_str *replace_shortnames(xs_str *s, const xs_list *tag, int ems, const char *p
16 tag_list = xs_dup(tag);
17 }
18
19- xs *style = xs_fmt("height: %dem; width: %dem; vertical-align: middle;", ems, ems);
20+ xs *style = xs_fmt("max-height: %dem; max-width: %dem;", ems, ems);
21 xs *class = xs_fmt("snac-emoji snac-emoji-%d-em", ems);
22+ if (cl)
23+ class = xs_str_cat(class, " ", xs_join(cl, " "));
24
25- const xs_dict *v;
26 int c = 0;
27+ const xs_val *v;
28+
29+ c = 0;
30
31 xs_set rep_emoji;
32 xs_set_init(&rep_emoji);
33@@ -100,6 +105,8 @@ xs_str *replace_shortnames(xs_str *s, const xs_list *tag, int ems, const char *p
34 if (!xs_is_string(mt))
35 mt = xs_mime_by_ext(u);
36
37+ act = act ? xs_fmt("%s\n%s", n, act) : xs_fmt("%s", n);
38+
39 if (strcmp(mt, "image/svg+xml") == 0 && !xs_is_true(xs_dict_get(srv_config, "enable_svg")))
40 s = xs_replace_i(s, n, "");
41 else {
42@@ -108,8 +115,8 @@ xs_str *replace_shortnames(xs_str *s, const xs_list *tag, int ems, const char *p
43 xs_html *img = xs_html_sctag("img",
44 xs_html_attr("loading", "lazy"),
45 xs_html_attr("src", url),
46- xs_html_attr("alt", n),
47- xs_html_attr("title", n),
48+ xs_html_attr("alt", act),
49+ xs_html_attr("title", act),
50 xs_html_attr("class", class),
51 xs_html_attr("style", style));
52
53@@ -130,6 +137,13 @@ xs_str *replace_shortnames(xs_str *s, const xs_list *tag, int ems, const char *p
54 }
55
56
57+xs_str *replace_shortnames(xs_str *s, const xs_list *tag, int ems, const char *proxy)
58+/* replaces all the :shortnames: with the emojis in tag */
59+{
60+ return _replace_shortnames(s, tag, ems, proxy, NULL, NULL);
61+}
62+
63+
64 xs_str *actor_name(xs_dict *actor, const char *proxy)
65 /* gets the actor name */
66 {
67@@ -430,6 +444,52 @@ void html_note_render_visibility(snac* user, xs_html *form, const int scope)
68 xs_html_add(form, paragraph);
69 }
70
71+/* html_note but moddled for emoji's needs. here and not bellow, since the
72+ * other one is already so complex. */
73+xs_html *html_emoji(snac *user, const char *summary,
74+ const char *div_id, const char *form_id,
75+ const char* placeholder, const char *post_id,
76+ const char* eid)
77+{
78+ xs *action = xs_fmt("%s/admin/action", user->actor);
79+
80+ xs_html *form;
81+ const int react = eid == NULL ? 0 : 1;
82+
83+ xs_html *note = xs_html_tag("div",
84+ xs_html_tag("details",
85+ xs_html_tag("summary",
86+ xs_html_text(summary)),
87+ xs_html_tag("p", NULL),
88+ xs_html_tag("div",
89+ xs_html_attr("class", "snac-note"),
90+ xs_html_attr("id", div_id),
91+ form = xs_html_tag("form",
92+ xs_html_attr("autocomplete", "off"),
93+ xs_html_attr("method", "post"),
94+ xs_html_attr("action", action),
95+ xs_html_attr("enctype", "multipart/form-data"),
96+ xs_html_attr("id", form_id),
97+ xs_html_sctag("input",
98+ xs_html_attr("type", "hidden"),
99+ xs_html_attr("name", "id"),
100+ xs_html_attr("value", post_id)),
101+ xs_html_sctag("input",
102+ xs_html_attr("type", react ? "hidden" : "text"),
103+ xs_html_attr("name", "eid"),
104+ xs_html_attr(react ? "value" : "placeholder", react ? eid : placeholder)),
105+ xs_html_text(" "),
106+ xs_html_sctag("input",
107+ xs_html_attr("type", "submit"),
108+ xs_html_attr("name", "action"),
109+ xs_html_attr("eid", "action"),
110+ xs_html_attr("value", react ? L("EmojiUnreact") : L("EmojiReact"))),
111+ xs_html_text(" "),
112+ xs_html_tag("p", NULL)))));
113+
114+ return note;
115+}
116+
117 xs_html *html_note(snac *user, const char *summary,
118 const char *div_id, const char *form_id,
119 const char *ta_plh, const char *ta_content,
120@@ -1356,6 +1416,28 @@ xs_html *html_top_controls(snac *user)
121 xs_html_attr("value", L("Like"))),
122 xs_html_text(" "),
123 xs_html_text(L("(by URL)"))),
124+ xs_html_tag("form",
125+ xs_html_attr("autocomplete", "off"),
126+ xs_html_attr("method", "post"),
127+ xs_html_attr("action", ops_action),
128+ xs_html_sctag("input",
129+ xs_html_attr("type", "text"),
130+ xs_html_attr("name", "eid"),
131+ xs_html_attr("required", "required"),
132+ xs_html_attr("placeholder", ":neocat:")),
133+ xs_html_text(" "),
134+ xs_html_sctag("input",
135+ xs_html_attr("type", "text"),
136+ xs_html_attr("name", "id"),
137+ xs_html_attr("required", "required"),
138+ xs_html_attr("placeholder", "https:/" "/fedi.example.com/bob/...")),
139+ xs_html_text(" "),
140+ xs_html_sctag("input",
141+ xs_html_attr("type", "submit"),
142+ xs_html_attr("name", "action"),
143+ xs_html_attr("value", L("EmojiReact"))),
144+ xs_html_text(" "),
145+ xs_html_text(L("(by URL)"))),
146 xs_html_tag("p", NULL)));
147
148 /** user settings **/
149@@ -2019,6 +2101,21 @@ xs_html *html_entry_controls(snac *user, const char *actor,
150 xs_html_tag("p", NULL));
151 }
152
153+ { /** emoji react **/
154+ /* the post textarea */
155+ xs *div_id = xs_fmt("%s_reply", md5);
156+ xs *form_id = xs_fmt("%s_reply_form", md5);
157+
158+ xs_html_add(controls, xs_html_tag("div",
159+ xs_html_tag("p", NULL),
160+ html_emoji(
161+ user, L("Emoji react"),
162+ div_id, form_id,
163+ ":neocat:", id,
164+ emoji_reacted(user, id))),
165+ xs_html_tag("p", NULL));
166+ }
167+
168 { /** reply **/
169 /* the post textarea */
170 xs *ct = build_mentions(user, msg);
171@@ -2345,6 +2442,168 @@ xs_html *html_entry(snac *user, xs_dict *msg, int read_only,
172 xs_html_add(snac_content_wrap,
173 snac_content);
174
175+ /* add all emoji reacts */
176+ int is_emoji = 0;
177+ {
178+ int c = 0;
179+ const xs_dict *k;
180+ xs *ls = xs_list_new();
181+ xs *sfrl = xs_dict_new();
182+ xs *rl = object_get_emoji_reacts(id);
183+
184+ xs_dict *m = NULL;
185+ while (xs_list_next(rl, &v, &c)) {
186+ if (valid_status(object_get_by_md5(v, &m))) {
187+ const char *content = xs_dict_get(m, "content");
188+ const char *actor = xs_dict_get(m, "actor");
189+ const xs_list *contentl = xs_dict_get(sfrl, content);
190+ xs *actors = xs_list_new();
191+ actors = xs_list_append(actors, actor);
192+ char me = actor && user && strcmp(actor, user->actor) == 0;
193+ int count = 1;
194+
195+ if (contentl) {
196+ count = atoi(xs_list_get(contentl, 0)) + 1;
197+ const xs_list *actorsc = xs_list_get(contentl, 1);
198+ if (strncmp(xs_list_get(contentl, 2), "1", 1) == 0)
199+ me = 1;
200+
201+ if (xs_list_in(actorsc, actor) != -1) {
202+ xs_free(actors);
203+ actors = xs_dup(actorsc);
204+ }
205+ else
206+ actors = xs_list_cat(actors, actorsc);
207+ }
208+
209+ xs *fl = xs_list_new();
210+ fl = xs_list_append(fl, xs_fmt("%d", count), actors, xs_fmt("%d", me));
211+ sfrl = xs_dict_append(sfrl, content, fl);
212+ }
213+ }
214+
215+ c = 0;
216+
217+ while (xs_list_next(rl, &k, &c)) {
218+ if (valid_status(object_get_by_md5(k, &m))) {
219+ const xs_dict *tag = xs_dict_get(m, "tag");
220+ const xs_dict *ide = xs_dict_get(m, "id");
221+
222+ const char *content = xs_dict_get(m, "content");
223+ const char *shortname;
224+ shortname = xs_dict_get(m, "content");
225+
226+ const xs_list *items = xs_dict_get(sfrl, content);
227+ const char *nb = xs_list_get(items, 0);
228+ const xs_list *actors = xs_list_get(items, 1);
229+ const char me = *xs_list_get(items, 2) == '1';
230+
231+ if (!xs_is_null(nb)) {
232+ is_emoji = 1;
233+
234+ const char *act = atoi(nb) > 1 ?
235+ xs_fmt("%d different actors \n\t%s", atoi(nb), xs_join(actors, ",\n\t")) :
236+ xs_dict_get(m, "actor");
237+
238+ xs *class = xs_list_new();
239+ class = xs_list_append(class, "snac-reaction");
240+
241+ xs_html *ret = NULL;
242+ if (tag && shortname) {
243+ xs *cl = xs_list_new();
244+ cl = xs_list_append(cl, "snac-reaction-image");
245+ xs *emoji = _replace_shortnames(xs_dup(shortname), tag, 2, proxy, cl, act);
246+
247+ if (me)
248+ class = xs_list_append(class, "snac-reacted");
249+
250+ ret = xs_html_tag("button",
251+ xs_html_attr("type", "submit"),
252+ xs_html_attr("name", "action"),
253+ xs_html_attr("value", me ? L("EmojiReact") : L("EmojiUnreact")),
254+ xs_html_raw(emoji),
255+ xs_html_tag("span",
256+ xs_html_raw(nb),
257+ xs_html_attr("style", "padding-left: 5px;")),
258+ xs_html_attr("title", act),
259+ xs_html_attr("class", xs_join(class, " ")));
260+
261+ if (!(ide && xs_startswith(ide, srv_baseurl)))
262+ xs_html_add(ret, xs_html_attr("disabled", "true"));
263+ }
264+ else if (shortname) {
265+ xs *sn = xs_dup(shortname);
266+ const char *sna = sn;
267+ unsigned int utf = xs_utf8_dec((const char **)&sna);
268+
269+ if (xs_is_emoji(utf)) {
270+ const char *style = "font-size: large;";
271+ if (me)
272+ class = xs_list_append(class, "snac-reacted");
273+ ret = xs_html_tag("button",
274+ xs_html_attr("type", "submit"),
275+ xs_html_attr("name", "action"),
276+ xs_html_attr("value", me ? L("EmojiUnreact") : L("EmojiReact")),
277+ xs_html_raw(xs_fmt("&#%d", utf)),
278+ xs_html_tag("span",
279+ xs_html_raw(nb),
280+ xs_html_attr("style", "font-size: initial; padding-left: 5px;")),
281+ xs_html_attr("title", act),
282+ xs_html_attr("class", xs_join(class, " ")),
283+ xs_html_attr("style", style));
284+ }
285+ }
286+ if (ret) {
287+ xs *s1;
288+ if (user) {
289+ xs *action = xs_fmt("%s/admin/action", user->actor);
290+ xs *form_id = xs_fmt("%s_reply_form", md5);
291+
292+ xs_html *form =
293+ xs_html_tag("form",
294+ xs_html_attr("autocomplete", "off"),
295+ xs_html_attr("method", "post"),
296+ xs_html_attr("action", action),
297+ xs_html_attr("enctype", "multipart/form-data"),
298+ xs_html_attr("style", "display: inline-flex;"
299+ "vertical-align: middle;"),
300+ xs_html_attr("id", form_id),
301+ xs_html_sctag("input",
302+ xs_html_attr("type", "hidden"),
303+ xs_html_attr("name", "id"),
304+ xs_html_attr("value", id)),
305+ xs_html_sctag("input",
306+ xs_html_attr("type", "hidden"),
307+ xs_html_attr("name", "eid"),
308+ xs_html_attr("value", shortname)),
309+ ret);
310+ s1 = xs_html_render(form);
311+ }
312+ else
313+ s1 = xs_html_render(ret);
314+
315+ ls = xs_list_append(ls, s1);
316+ sfrl = xs_dict_del(sfrl, content);
317+ }
318+ }
319+ }
320+ }
321+
322+ c = 0;
323+
324+ xs_html *emoji_div;
325+ if (xs_list_len(ls) > 0) {
326+ emoji_div = xs_html_tag("div", xs_html_text(L("Emoji reactions: ")),
327+ xs_html_attr("class", "snac-reaction-div"));
328+
329+ while (ls != NULL && xs_list_next(ls, &k, &c))
330+ xs_html_add(emoji_div, xs_html_raw(k));
331+
332+ xs_html_add(snac_content_wrap, emoji_div);
333+ }
334+
335+ }
336+
337 {
338 /** build the content string **/
339 const char *content = xs_dict_get(msg, "content");
340@@ -2371,7 +2630,8 @@ xs_html *html_entry(snac *user, xs_dict *msg, int read_only,
341
342 c = xs_replace_i(c, "<br><br>", "<p>");
343
344- c = xs_str_cat(c, "<p>");
345+ if (is_emoji == 0)
346+ c = xs_str_cat(c, "<p>");
347
348 /* replace the :shortnames: */
349 c = replace_shortnames(c, xs_dict_get(msg, "tag"), 2, proxy);
350@@ -3686,9 +3946,18 @@ xs_str *html_notifications(snac *user, int skip, int show)
351 if (strcmp(type, "EmojiReact") == 0 || strcmp(type, "Like") == 0) {
352 const char *content = xs_dict_get_path(noti, "msg.content");
353
354+ xs *cd = xs_dup(content);
355+ const char *sna = cd;
356+ const xs_dict *tag = xs_dict_get_path(noti, "msg.tag");
357+ unsigned int utf = xs_utf8_dec((const char **)&sna);
358+
359+ int isEmoji = 0;
360+ if (xs_is_emoji(utf) || (tag && xs_list_len(tag) > 0))
361+ isEmoji = 1;
362+
363 if (xs_type(content) == XSTYPE_STRING) {
364 xs *emoji = replace_shortnames(xs_dup(content), xs_dict_get_path(noti, "msg.tag"), 1, proxy);
365- wrk = xs_fmt("%s (%s️)", type, emoji);
366+ wrk = xs_fmt("%s (%s️)", isEmoji ? "EmojiReact" : "Like", emoji);
367 label = wrk;
368 }
369 }
370@@ -4583,8 +4852,8 @@ int html_get_handler(const xs_dict *req, const char *q_path,
371 xs *msg = msg_admiration(&snac, id, *action == 'L' ? "Like" : "Announce");
372
373 if (msg != NULL) {
374+ timeline_admire(&snac, xs_dict_get(msg, "object"), snac.actor, *action == 'L' ? 1 : 0, msg);
375 enqueue_message(&snac, msg);
376- timeline_admire(&snac, xs_dict_get(msg, "object"), snac.actor, *action == 'L' ? 1 : 0);
377
378 status = HTTP_STATUS_SEE_OTHER;
379 }
380@@ -4892,12 +5161,36 @@ int html_post_handler(const xs_dict *req, const char *q_path,
381
382 status = HTTP_STATUS_SEE_OTHER;
383
384+ if (strcmp(action, L("EmojiUnreact")) == 0) { /** **/
385+ const char *eid = xs_dict_get(p_vars, "eid");
386+
387+ if (eid != NULL) {
388+ xs *n_msg = msg_emoji_unreact(&snac, id, eid);
389+
390+ if (n_msg != NULL)
391+ enqueue_message(&snac, n_msg);
392+ }
393+ }
394+ else
395+ if (strcmp(action, L("EmojiReact")) == 0) { /** **/
396+ const char *eid = xs_dict_get(p_vars, "eid");
397+
398+ eid = xs_strip_chars_i(xs_dup(eid), ":");
399+
400+ const xs_dict *ret = msg_emoji_init(&snac, id, eid);
401+ /* fails if either invalid or already reacted */
402+ if (!ret)
403+ ret = msg_emoji_unreact(&snac, id, eid);
404+ if (!ret)
405+ status = HTTP_STATUS_NOT_FOUND;
406+ }
407+ else
408 if (strcmp(action, L("Like")) == 0) { /** **/
409 xs *msg = msg_admiration(&snac, id, "Like");
410
411 if (msg != NULL) {
412+ timeline_admire(&snac, xs_dict_get(msg, "object"), snac.actor, 1, msg);
413 enqueue_message(&snac, msg);
414- timeline_admire(&snac, xs_dict_get(msg, "object"), snac.actor, 1);
415 }
416 }
417 else
418@@ -4905,8 +5198,8 @@ int html_post_handler(const xs_dict *req, const char *q_path,
419 xs *msg = msg_admiration(&snac, id, "Announce");
420
421 if (msg != NULL) {
422+ timeline_admire(&snac, xs_dict_get(msg, "object"), snac.actor, 0, msg);
423 enqueue_message(&snac, msg);
424- timeline_admire(&snac, xs_dict_get(msg, "object"), snac.actor, 0);
425 }
426 }
427 else
M
main.c
+1,
-1
1@@ -498,7 +498,7 @@ int main(int argc, char *argv[])
2
3 if (msg != NULL) {
4 enqueue_message(&snac, msg);
5- timeline_admire(&snac, xs_dict_get(msg, "object"), snac.actor, 0);
6+ timeline_admire(&snac, xs_dict_get(msg, "object"), snac.actor, 0, "");
7
8 if (dbglevel) {
9 xs_json_dump(msg, 4, stdout);
+156,
-4
1@@ -1158,6 +1158,97 @@ xs_dict *mastoapi_status(snac *snac, const xs_dict *msg)
2 st = xs_dict_append(st, "tags", htl);
3 st = xs_dict_append(st, "emojis", eml);
4 }
5+ {
6+ xs *rl = object_get_emoji_reacts(id);
7+ xs *frl = xs_list_new(); /* final */
8+ xs *sfrl = xs_dict_new(); /* seen */
9+ int c = 0;
10+ const char *v;
11+
12+ xs_dict *msg = NULL;
13+ while (xs_list_next(rl, &v, &c)) {
14+ if (valid_status(object_get_by_md5(v, &msg))) {
15+ const char *content = xs_dict_get(msg, "content");
16+ const char *actor = xs_dict_get(msg, "actor");
17+ const xs_list *contentl = xs_dict_get(sfrl, content);
18+ /* NOTE: idk when there are no actor, but i encountered that bug.
19+ * Probably because of one of my previous attempts.
20+ * Keeping this just in case, can remove later */
21+ const char *me = actor && strcmp(actor, snac->actor) == 0 ?
22+ xs_stock(XSTYPE_TRUE) : xs_stock(XSTYPE_FALSE);
23+ int count = 1;
24+
25+ if (contentl) {
26+ count = atoi(xs_list_get(contentl, 0)) + 1;
27+ if (strncmp(xs_list_get(contentl, 1), xs_stock(XSTYPE_TRUE), 1) == 0)
28+ me = xs_stock(XSTYPE_TRUE);
29+ }
30+
31+ xs *fl = xs_list_new();
32+ fl = xs_list_append(fl, xs_fmt("%d", count), me);
33+ sfrl = xs_dict_append(sfrl, content, fl);
34+ }
35+ }
36+
37+ c = 0;
38+
39+ while (xs_list_next(rl, &v, &c)) {
40+ if (valid_status(object_get_by_md5(v, &msg))) {
41+ xs *d1 = xs_dict_new();
42+
43+ const xs_dict *icon = xs_dict_get(xs_list_get(xs_dict_get(msg, "tag"), 0), "icon");
44+ const char *o_url = xs_dict_get(icon, "url");
45+ const char *name = xs_dict_get(msg, "content");
46+ const char *actor = xs_dict_get(msg, "actor");
47+
48+ xs *nm = xs_dup(name);
49+ xs *url = NULL;
50+
51+ if (!xs_is_null(o_url)) {
52+ if (actor && snac && !strcmp(actor, snac->actor))
53+ url = make_url(o_url, NULL, 1);
54+ else
55+ url = xs_dup(o_url);
56+ }
57+
58+ xs *accounts = xs_list_new();
59+ if (actor) {
60+ xs *d2 = xs_dict_new();
61+ object_get(actor, &d2);
62+ xs *e_acct = mastoapi_account(snac, d2);
63+ accounts = xs_list_append(accounts, e_acct);
64+ }
65+
66+ const xs_list *item = xs_dict_get(sfrl, nm);
67+ const xs_str *nb = xs_list_get(item, 0);
68+ const xs_val *me = xs_list_get(item, 1);
69+ if (item == NULL)
70+ continue;
71+
72+ if (nm && strcmp(nm, "")) {
73+ if (url && strcmp(url, "")) {
74+ d1 = xs_dict_append(d1, "name", nm);
75+ d1 = xs_dict_append(d1, "shortcode", nm);
76+ d1 = xs_dict_append(d1, "accounts", accounts);
77+ d1 = xs_dict_append(d1, "me", me);
78+ d1 = xs_dict_append(d1, "url", url);
79+ d1 = xs_dict_append(d1, "static_url", url);
80+ d1 = xs_dict_append(d1, "visible_in_picker", xs_stock(XSTYPE_TRUE));
81+ d1 = xs_dict_append(d1, "count", nb);
82+ } else {
83+ d1 = xs_dict_append(d1, "name", nm);
84+ d1 = xs_dict_append(d1, "count", nb);
85+ d1 = xs_dict_append(d1, "me", me);
86+ d1 = xs_dict_append(d1, "visible_in_picker", xs_stock(XSTYPE_TRUE));
87+ }
88+ sfrl = xs_dict_del(sfrl, nm);
89+ frl = xs_list_append(frl, d1);
90+ }
91+ }
92+ }
93+
94+ st = xs_dict_append(st, "reactions", frl);
95+ }
96
97 xs_free(idx);
98 xs_free(ixc);
99@@ -2202,15 +2293,24 @@ int mastoapi_get_handler(const xs_dict *req, const char *q_path,
100 if (noti == NULL)
101 continue;
102
103+ const xs_dict *tag = xs_list_get(xs_dict_get_path(noti, "msg.tag"), 0);
104+
105 const char *type = xs_dict_get(noti, "type");
106 const char *utype = xs_dict_get(noti, "utype");
107 const char *objid = xs_dict_get(noti, "objid");
108 const char *id = xs_dict_get(noti, "id");
109 const char *actid = xs_dict_get(noti, "actor");
110+
111+ int isEmoji = 0;
112+
113 xs *fid = xs_replace(id, ".", "");
114 xs *actor = NULL;
115 xs *entry = NULL;
116
117+ if (tag) {
118+ isEmoji = strcmp(xs_dict_get(tag, "type"), "Emoji") ? 0 : 1;
119+ }
120+
121 if (!valid_status(actor_get(actid, &actor)))
122 continue;
123
124@@ -2234,9 +2334,12 @@ int mastoapi_get_handler(const xs_dict *req, const char *q_path,
125 }
126
127 /* convert the type */
128- if (strcmp(type, "Like") == 0 || strcmp(type, "EmojiReact") == 0)
129+ if (strcmp(type, "Like") == 0 && !isEmoji)
130 type = "favourite";
131 else
132+ if (isEmoji || strcmp(type, "EmojiReact") == 0)
133+ type = "reaction";
134+ else
135 if (strcmp(type, "Announce") == 0)
136 type = "reblog";
137 else
138@@ -2277,8 +2380,29 @@ int mastoapi_get_handler(const xs_dict *req, const char *q_path,
139 if (strcmp(type, "follow") != 0 && !xs_is_null(objid)) {
140 xs *st = mastoapi_status(&snac1, entry);
141
142- if (st)
143+ if (st) {
144 mn = xs_dict_append(mn, "status", st);
145+
146+ if (strcmp(type, "reaction") == 0 && !xs_is_null(objid)) {
147+ const char *eid = NULL;
148+ const char *url = NULL;
149+ int utf = 0;
150+
151+ const xs_dict *tag = xs_list_get(xs_dict_get_path(noti, "msg.tag"), 0);
152+ const char *content = xs_dict_get_path(noti, "msg.content");
153+
154+ url = xs_dict_get(xs_dict_get(tag, "icon"), "url");
155+ eid = xs_dict_get(tag, "name");
156+
157+ if (eid && url) {
158+ mn = xs_dict_append(mn, "emoji", eid);
159+ mn = xs_dict_append(mn, "emoji_url", url);
160+ }
161+
162+ if (xs_is_emoji((utf = xs_utf8_dec(&content))))
163+ mn = xs_dict_append(mn, "name", xs_fmt("&#%d;", utf));
164+ }
165+ }
166 }
167
168 out = xs_list_append(out, mn);
169@@ -2594,6 +2718,11 @@ int mastoapi_get_handler(const xs_dict *req, const char *q_path,
170 "\"max_expiration\":2629746,"
171 "\"max_options\":8,\"min_expiration\":300}");
172 cfg = xs_dict_append(cfg, "polls", d14);
173+
174+
175+ xs *d15 = xs_json_loads("{\"max_reactions\":50}");
176+ cfg = xs_dict_append(cfg, "reactions", d15);
177+
178 }
179
180 ins = xs_dict_append(ins, "configuration", cfg);
181@@ -3219,7 +3348,7 @@ int mastoapi_post_handler(const xs_dict *req, const char *q_path,
182
183 if (n_msg != NULL) {
184 enqueue_message(&snac, n_msg);
185- timeline_admire(&snac, xs_dict_get(n_msg, "object"), snac.actor, 1);
186+ timeline_admire(&snac, xs_dict_get(n_msg, "object"), snac.actor, 1, msg);
187
188 out = mastoapi_status(&snac, msg);
189 }
190@@ -3234,13 +3363,36 @@ int mastoapi_post_handler(const xs_dict *req, const char *q_path,
191 out = mastoapi_status(&snac, msg);
192 }
193 }
194+ else
195+ if (strcmp(op, "react") == 0) { /** **/
196+ const char *eid = xs_list_get(l, 5);
197+ xs *n_msg = msg_emoji_init(&snac, id, eid);
198+ if (n_msg)
199+ out = mastoapi_status(&snac, n_msg);
200+ }
201+ else
202+ if (strcmp(op, "unreact") == 0) { /** **/
203+ const char *eid = xs_list_get(l, 5);
204+ xs *content = xs_fmt("%s", eid);
205+
206+ if (eid) {
207+ xs *n_msg = msg_emoji_unreact(&snac, id, content);
208+
209+ if (n_msg != NULL) {
210+ enqueue_message(&snac, n_msg);
211+
212+ out = mastoapi_status(&snac, msg);
213+ }
214+ }
215+ }
216+
217 else
218 if (strcmp(op, "reblog") == 0) { /** **/
219 xs *n_msg = msg_admiration(&snac, id, "Announce");
220
221 if (n_msg != NULL) {
222 enqueue_message(&snac, n_msg);
223- timeline_admire(&snac, xs_dict_get(n_msg, "object"), snac.actor, 0);
224+ timeline_admire(&snac, xs_dict_get(n_msg, "object"), snac.actor, 0, msg);
225
226 out = mastoapi_status(&snac, msg);
227 }
M
snac.h
+9,
-1
1@@ -146,12 +146,15 @@ void object_touch(const char *id);
2 int object_admire(const char *id, const char *actor, int like);
3 int object_unadmire(const char *id, const char *actor, int like);
4
5+int object_emoji_react(const char *mid, const char *eid);
6+int object_rm_emoji_react(const char *mid, const char *eid);
7 int object_likes_len(const char *id);
8 int object_announces_len(const char *id);
9
10 xs_list *object_children(const char *id);
11 xs_list *object_likes(const char *id);
12 xs_list *object_announces(const char *id);
13+xs_list *object_get_emoji_reacts(const char *id);
14 int object_parent(const char *md5, char parent[MD5_HEX_SIZE]);
15
16 int object_user_cache_add(snac *snac, const char *id, const char *cachedir);
17@@ -180,7 +183,8 @@ xs_str *user_index_fn(snac *user, const char *idx_name);
18 xs_list *timeline_simple_list(snac *user, const char *idx_name, int skip, int show, int *more);
19 xs_list *timeline_list(snac *snac, const char *idx_name, int skip, int show, int *more);
20 int timeline_add(snac *snac, const char *id, const xs_dict *o_msg);
21-int timeline_admire(snac *snac, const char *id, const char *admirer, int like);
22+int timeline_admire(snac *snac, const char *id, const char *admirer, int like, xs_dict *msg);
23+int timeline_emoji_react(const char *atto, const char *id, xs_dict *o_msg);
24
25 xs_list *timeline_top_level(snac *snac, const xs_list *list);
26 void timeline_add_mark(snac *user);
27@@ -201,6 +205,8 @@ void unmute(snac *snac, const char *actor);
28 int is_muted(snac *snac, const char *actor);
29 xs_list *muted_list(snac *user);
30
31+const xs_str *emoji_reacted(snac *user, const char *id);
32+
33 int is_bookmarked(snac *user, const char *id);
34 int bookmark(snac *user, const char *id);
35 int unbookmark(snac *user, const char *id);
36@@ -358,6 +364,8 @@ xs_list *get_attachments(const xs_dict *msg);
37
38 xs_dict *msg_admiration(snac *snac, const char *object, const char *type);
39 xs_dict *msg_repulsion(snac *user, const char *id, const char *type);
40+xs_dict *msg_emoji_init(snac *user, const char *mid, const char *eid);
41+xs_dict *msg_emoji_unreact(snac *user, const char *id, const char *type);
42 xs_dict *msg_create(snac *snac, const xs_dict *object);
43 xs_dict *msg_follow(snac *snac, const char *actor);
44
+7,
-0
1@@ -22,6 +22,7 @@
2 int xs_unicode_nfc(unsigned int base, unsigned int diac, unsigned int *cpoint);
3 int xs_unicode_is_alpha(unsigned int cpoint);
4 int xs_unicode_is_right_to_left(unsigned int cpoint);
5+ int xs_is_emoji(unsigned int cpoint);
6
7 #ifdef _XS_H
8 xs_str *xs_utf8_insert(xs_str *str, unsigned int cpoint, int *offset);
9@@ -134,6 +135,12 @@ static unsigned int xs_unicode_width_table[] = {
10 0x20000, 0x2fffd, 2 /* more CJK */
11 };
12
13+/* magic number from https://en.wikipedia.org/wiki/Emoji#Unicode_blocks */
14+int xs_is_emoji(unsigned int cpoint) {
15+/* returns wether the input is an utf8 emoji */
16+ return cpoint > 0x00A9 && cpoint < 0x1ffff;
17+}
18+
19 int xs_unicode_width(unsigned int cpoint)
20 /* returns the width in columns of a Unicode codepoint (somewhat simplified) */
21 {
M
xs_url.h
+33,
-0
1@@ -6,6 +6,7 @@
2
3 xs_str *xs_url_dec(const char *str);
4 xs_str *xs_url_enc(const char *str);
5+xs_str *xs_url_dec_emoji(const char *str);
6 xs_dict *xs_url_vars(const char *str);
7 xs_dict *xs_multipart_form_data(const char *payload, int p_size, const char *header);
8
9@@ -79,6 +80,38 @@ xs_str *xs_url_dec(const char *str)
10 }
11
12
13+xs_str *xs_url_dec_emoji(const char *str)
14+/* decodes an URL, returns NULL if not every char is an encoded hex */
15+{
16+ xs_str *s = xs_str_new(NULL);
17+
18+ while (*str) {
19+ if (!xs_is_string(str))
20+ break;
21+
22+ if (*str == '%') {
23+ unsigned int i;
24+
25+ if (sscanf(str + 1, "%02x", &i) == 1) {
26+ unsigned char uc = i;
27+
28+ if (!xs_is_string((char *)&uc))
29+ break;
30+
31+ s = xs_append_m(s, (char *)&uc, 1);
32+ str += 2;
33+ }
34+ }
35+ else
36+ return NULL;
37+
38+ str++;
39+ }
40+
41+ return s;
42+}
43+
44+
45 xs_str *xs_url_enc(const char *str)
46 /* URL-encodes a string (RFC 3986) */
47 {