@rmdes/indiekit-endpoint-activitypub 0.1.9 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/jf2-to-as2.js CHANGED
@@ -1,18 +1,39 @@
1
1
  /**
2
2
  * Convert Indiekit JF2 post properties to ActivityStreams 2.0 objects.
3
3
  *
4
- * JF2 is the simplified Microformats2 JSON format used by Indiekit internally.
5
- * ActivityStreams 2.0 (AS2) is the JSON-LD format used by ActivityPub for federation.
4
+ * Two export flavors:
5
+ * - jf2ToActivityStreams() returns plain JSON-LD objects (for content negotiation)
6
+ * - jf2ToAS2Activity() — returns Fedify vocab instances (for outbox + syndicator)
7
+ */
8
+
9
+ import { Temporal } from "@js-temporal/polyfill";
10
+ import {
11
+ Announce,
12
+ Article,
13
+ Audio,
14
+ Create,
15
+ Hashtag,
16
+ Image,
17
+ Like,
18
+ Note,
19
+ Video,
20
+ } from "@fedify/fedify";
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Plain JSON-LD (content negotiation on individual post URLs)
24
+ // ---------------------------------------------------------------------------
25
+
26
+ /**
27
+ * Convert JF2 properties to a plain ActivityStreams JSON-LD object.
6
28
  *
7
- * @param {object} properties - JF2 post properties from Indiekit's posts collection
8
- * @param {string} actorUrl - This actor's URL (e.g. "https://rmendes.net/")
29
+ * @param {object} properties - JF2 post properties
30
+ * @param {string} actorUrl - Actor URL (e.g. "https://example.com/activitypub/users/rick")
9
31
  * @param {string} publicationUrl - Publication base URL with trailing slash
10
32
  * @returns {object} ActivityStreams activity (Create, Like, or Announce)
11
33
  */
12
34
  export function jf2ToActivityStreams(properties, actorUrl, publicationUrl) {
13
35
  const postType = properties["post-type"];
14
36
 
15
- // Like — not wrapped in Create, stands alone
16
37
  if (postType === "like") {
17
38
  return {
18
39
  "@context": "https://www.w3.org/ns/activitystreams",
@@ -22,7 +43,6 @@ export function jf2ToActivityStreams(properties, actorUrl, publicationUrl) {
22
43
  };
23
44
  }
24
45
 
25
- // Repost/boost — Announce activity
26
46
  if (postType === "repost") {
27
47
  return {
28
48
  "@context": "https://www.w3.org/ns/activitystreams",
@@ -32,7 +52,6 @@ export function jf2ToActivityStreams(properties, actorUrl, publicationUrl) {
32
52
  };
33
53
  }
34
54
 
35
- // Everything else is wrapped in a Create activity
36
55
  const isArticle = postType === "article" && properties.name;
37
56
  const postUrl = resolvePostUrl(properties.url, publicationUrl);
38
57
 
@@ -43,10 +62,9 @@ export function jf2ToActivityStreams(properties, actorUrl, publicationUrl) {
43
62
  published: properties.published,
44
63
  url: postUrl,
45
64
  to: ["https://www.w3.org/ns/activitystreams#Public"],
46
- cc: [`${actorUrl.replace(/\/$/, "")}/activitypub/followers`],
65
+ cc: [`${actorUrl.replace(/\/$/, "")}/followers`],
47
66
  };
48
67
 
49
- // Content — bookmarks get special treatment
50
68
  if (postType === "bookmark") {
51
69
  const bookmarkUrl = properties["bookmark-of"];
52
70
  const commentary = properties.content?.html || properties.content || "";
@@ -71,19 +89,163 @@ export function jf2ToActivityStreams(properties, actorUrl, publicationUrl) {
71
89
  }
72
90
  }
73
91
 
74
- // Reply
75
92
  if (properties["in-reply-to"]) {
76
93
  object.inReplyTo = properties["in-reply-to"];
77
94
  }
78
95
 
79
- // Media attachments
96
+ const attachments = buildPlainAttachments(properties, publicationUrl);
97
+ if (attachments.length > 0) {
98
+ object.attachment = attachments;
99
+ }
100
+
101
+ const tags = buildPlainTags(properties, publicationUrl, object.tag);
102
+ if (tags.length > 0) {
103
+ object.tag = tags;
104
+ }
105
+
106
+ return {
107
+ "@context": "https://www.w3.org/ns/activitystreams",
108
+ type: "Create",
109
+ actor: actorUrl,
110
+ object,
111
+ };
112
+ }
113
+
114
+ // ---------------------------------------------------------------------------
115
+ // Fedify vocab objects (outbox dispatcher + syndicator delivery)
116
+ // ---------------------------------------------------------------------------
117
+
118
+ /**
119
+ * Convert JF2 properties to a Fedify Activity object.
120
+ *
121
+ * @param {object} properties - JF2 post properties
122
+ * @param {string} actorUrl - Actor URL (e.g. "https://example.com/activitypub/users/rick")
123
+ * @param {string} publicationUrl - Publication base URL with trailing slash
124
+ * @returns {import("@fedify/fedify").Activity | null}
125
+ */
126
+ export function jf2ToAS2Activity(properties, actorUrl, publicationUrl) {
127
+ const postType = properties["post-type"];
128
+ const actorUri = new URL(actorUrl);
129
+
130
+ if (postType === "like") {
131
+ const likeOf = properties["like-of"];
132
+ if (!likeOf) return null;
133
+ return new Like({
134
+ actor: actorUri,
135
+ object: new URL(likeOf),
136
+ });
137
+ }
138
+
139
+ if (postType === "repost") {
140
+ const repostOf = properties["repost-of"];
141
+ if (!repostOf) return null;
142
+ return new Announce({
143
+ actor: actorUri,
144
+ object: new URL(repostOf),
145
+ to: new URL("https://www.w3.org/ns/activitystreams#Public"),
146
+ });
147
+ }
148
+
149
+ const isArticle = postType === "article" && properties.name;
150
+ const postUrl = resolvePostUrl(properties.url, publicationUrl);
151
+ const followersUrl = `${actorUrl.replace(/\/$/, "")}/followers`;
152
+
153
+ const noteOptions = {
154
+ attributedTo: actorUri,
155
+ to: new URL("https://www.w3.org/ns/activitystreams#Public"),
156
+ cc: new URL(followersUrl),
157
+ };
158
+
159
+ if (postUrl) {
160
+ noteOptions.id = new URL(postUrl);
161
+ noteOptions.url = new URL(postUrl);
162
+ }
163
+
164
+ if (properties.published) {
165
+ try {
166
+ noteOptions.published = Temporal.Instant.from(properties.published);
167
+ } catch {
168
+ // Invalid date format — skip
169
+ }
170
+ }
171
+
172
+ // Content
173
+ if (postType === "bookmark") {
174
+ const bookmarkUrl = properties["bookmark-of"];
175
+ const commentary = properties.content?.html || properties.content || "";
176
+ noteOptions.content = commentary
177
+ ? `${commentary}<br><br>\u{1F516} <a href="${bookmarkUrl}">${bookmarkUrl}</a>`
178
+ : `\u{1F516} <a href="${bookmarkUrl}">${bookmarkUrl}</a>`;
179
+ } else {
180
+ noteOptions.content = properties.content?.html || properties.content || "";
181
+ }
182
+
183
+ if (isArticle) {
184
+ noteOptions.name = properties.name;
185
+ if (properties.summary) {
186
+ noteOptions.summary = properties.summary;
187
+ }
188
+ }
189
+
190
+ if (properties["in-reply-to"]) {
191
+ noteOptions.inReplyTo = new URL(properties["in-reply-to"]);
192
+ }
193
+
194
+ // Attachments
195
+ const fedifyAttachments = buildFedifyAttachments(properties, publicationUrl);
196
+ if (fedifyAttachments.length > 0) {
197
+ noteOptions.attachments = fedifyAttachments;
198
+ }
199
+
200
+ // Hashtags
201
+ const fedifyTags = buildFedifyTags(properties, publicationUrl, postType);
202
+ if (fedifyTags.length > 0) {
203
+ noteOptions.tags = fedifyTags;
204
+ }
205
+
206
+ const object = isArticle
207
+ ? new Article(noteOptions)
208
+ : new Note(noteOptions);
209
+
210
+ return new Create({
211
+ actor: actorUri,
212
+ object,
213
+ });
214
+ }
215
+
216
+ // ---------------------------------------------------------------------------
217
+ // URL resolution helpers
218
+ // ---------------------------------------------------------------------------
219
+
220
+ /**
221
+ * Resolve a post URL, ensuring it's absolute.
222
+ * @param {string} url - Post URL (may be relative or absolute)
223
+ * @param {string} publicationUrl - Base publication URL
224
+ * @returns {string} Absolute URL
225
+ */
226
+ export function resolvePostUrl(url, publicationUrl) {
227
+ if (!url) return "";
228
+ if (url.startsWith("http")) return url;
229
+ const base = publicationUrl.replace(/\/$/, "");
230
+ return `${base}/${url.replace(/^\//, "")}`;
231
+ }
232
+
233
+ function resolveMediaUrl(url, publicationUrl) {
234
+ if (!url) return "";
235
+ if (url.startsWith("http")) return url;
236
+ const base = publicationUrl.replace(/\/$/, "");
237
+ return `${base}/${url.replace(/^\//, "")}`;
238
+ }
239
+
240
+ // ---------------------------------------------------------------------------
241
+ // Attachment builders
242
+ // ---------------------------------------------------------------------------
243
+
244
+ function buildPlainAttachments(properties, publicationUrl) {
80
245
  const attachments = [];
81
246
 
82
247
  if (properties.photo) {
83
- const photos = Array.isArray(properties.photo)
84
- ? properties.photo
85
- : [properties.photo];
86
- for (const photo of photos) {
248
+ for (const photo of asArray(properties.photo)) {
87
249
  const url = typeof photo === "string" ? photo : photo.url;
88
250
  const alt = typeof photo === "string" ? "" : photo.alt || "";
89
251
  attachments.push({
@@ -96,10 +258,7 @@ export function jf2ToActivityStreams(properties, actorUrl, publicationUrl) {
96
258
  }
97
259
 
98
260
  if (properties.video) {
99
- const videos = Array.isArray(properties.video)
100
- ? properties.video
101
- : [properties.video];
102
- for (const video of videos) {
261
+ for (const video of asArray(properties.video)) {
103
262
  const url = typeof video === "string" ? video : video.url;
104
263
  attachments.push({
105
264
  type: "Video",
@@ -110,10 +269,7 @@ export function jf2ToActivityStreams(properties, actorUrl, publicationUrl) {
110
269
  }
111
270
 
112
271
  if (properties.audio) {
113
- const audios = Array.isArray(properties.audio)
114
- ? properties.audio
115
- : [properties.audio];
116
- for (const audio of audios) {
272
+ for (const audio of asArray(properties.audio)) {
117
273
  const url = typeof audio === "string" ? audio : audio.url;
118
274
  attachments.push({
119
275
  type: "Audio",
@@ -123,59 +279,102 @@ export function jf2ToActivityStreams(properties, actorUrl, publicationUrl) {
123
279
  }
124
280
  }
125
281
 
126
- if (attachments.length > 0) {
127
- object.attachment = attachments;
282
+ return attachments;
283
+ }
284
+
285
+ function buildFedifyAttachments(properties, publicationUrl) {
286
+ const attachments = [];
287
+
288
+ if (properties.photo) {
289
+ for (const photo of asArray(properties.photo)) {
290
+ const url = typeof photo === "string" ? photo : photo.url;
291
+ const alt = typeof photo === "string" ? "" : photo.alt || "";
292
+ attachments.push(
293
+ new Image({
294
+ url: new URL(resolveMediaUrl(url, publicationUrl)),
295
+ mediaType: guessMediaType(url),
296
+ name: alt,
297
+ }),
298
+ );
299
+ }
300
+ }
301
+
302
+ if (properties.video) {
303
+ for (const video of asArray(properties.video)) {
304
+ const url = typeof video === "string" ? video : video.url;
305
+ attachments.push(
306
+ new Video({
307
+ url: new URL(resolveMediaUrl(url, publicationUrl)),
308
+ }),
309
+ );
310
+ }
128
311
  }
129
312
 
130
- // Categories → hashtags
313
+ if (properties.audio) {
314
+ for (const audio of asArray(properties.audio)) {
315
+ const url = typeof audio === "string" ? audio : audio.url;
316
+ attachments.push(
317
+ new Audio({
318
+ url: new URL(resolveMediaUrl(url, publicationUrl)),
319
+ }),
320
+ );
321
+ }
322
+ }
323
+
324
+ return attachments;
325
+ }
326
+
327
+ // ---------------------------------------------------------------------------
328
+ // Tag builders
329
+ // ---------------------------------------------------------------------------
330
+
331
+ function buildPlainTags(properties, publicationUrl, existing) {
332
+ const tags = [...(existing || [])];
131
333
  if (properties.category) {
132
- const categories = Array.isArray(properties.category)
133
- ? properties.category
134
- : [properties.category];
135
- object.tag = [
136
- ...(object.tag || []),
137
- ...categories.map((cat) => ({
334
+ for (const cat of asArray(properties.category)) {
335
+ tags.push({
138
336
  type: "Hashtag",
139
337
  name: `#${cat.replace(/\s+/g, "")}`,
140
338
  href: `${publicationUrl}categories/${encodeURIComponent(cat)}`,
141
- })),
142
- ];
339
+ });
340
+ }
143
341
  }
144
-
145
- return {
146
- "@context": "https://www.w3.org/ns/activitystreams",
147
- type: "Create",
148
- actor: actorUrl,
149
- object,
150
- };
342
+ return tags;
151
343
  }
152
344
 
153
- /**
154
- * Resolve a post URL, ensuring it's absolute.
155
- * @param {string} url - Post URL (may be relative or absolute)
156
- * @param {string} publicationUrl - Base publication URL
157
- * @returns {string} Absolute URL
158
- */
159
- export function resolvePostUrl(url, publicationUrl) {
160
- if (!url) return "";
161
- if (url.startsWith("http")) return url;
162
- const base = publicationUrl.replace(/\/$/, "");
163
- return `${base}/${url.replace(/^\//, "")}`;
345
+ function buildFedifyTags(properties, publicationUrl, postType) {
346
+ const tags = [];
347
+ if (postType === "bookmark") {
348
+ tags.push(
349
+ new Hashtag({
350
+ name: "#bookmark",
351
+ href: new URL(`${publicationUrl}categories/bookmark`),
352
+ }),
353
+ );
354
+ }
355
+ if (properties.category) {
356
+ for (const cat of asArray(properties.category)) {
357
+ tags.push(
358
+ new Hashtag({
359
+ name: `#${cat.replace(/\s+/g, "")}`,
360
+ href: new URL(
361
+ `${publicationUrl}categories/${encodeURIComponent(cat)}`,
362
+ ),
363
+ }),
364
+ );
365
+ }
366
+ }
367
+ return tags;
164
368
  }
165
369
 
166
- /**
167
- * Resolve a media URL, ensuring it's absolute.
168
- */
169
- function resolveMediaUrl(url, publicationUrl) {
170
- if (!url) return "";
171
- if (url.startsWith("http")) return url;
172
- const base = publicationUrl.replace(/\/$/, "");
173
- return `${base}/${url.replace(/^\//, "")}`;
370
+ // ---------------------------------------------------------------------------
371
+ // Utilities
372
+ // ---------------------------------------------------------------------------
373
+
374
+ function asArray(value) {
375
+ return Array.isArray(value) ? value : [value];
174
376
  }
175
377
 
176
- /**
177
- * Guess MIME type from file extension.
178
- */
179
378
  function guessMediaType(url) {
180
379
  const ext = url.split(".").pop()?.toLowerCase();
181
380
  const types = {
@@ -0,0 +1,55 @@
1
+ /**
2
+ * MongoDB-backed KvStore adapter for Fedify.
3
+ *
4
+ * Implements Fedify's KvStore interface using a MongoDB collection.
5
+ * Keys are string arrays (e.g. ["keypair", "rsa", "rick"]) — we serialize
6
+ * them as a joined path string for MongoDB's _id field.
7
+ */
8
+
9
+ /**
10
+ * @implements {import("@fedify/fedify").KvStore}
11
+ */
12
+ export class MongoKvStore {
13
+ /** @param {import("mongodb").Collection} collection */
14
+ constructor(collection) {
15
+ this.collection = collection;
16
+ }
17
+
18
+ /**
19
+ * Serialize a Fedify key (string[]) to a MongoDB document _id.
20
+ * @param {string[]} key
21
+ * @returns {string}
22
+ */
23
+ _serializeKey(key) {
24
+ return key.join("/");
25
+ }
26
+
27
+ /**
28
+ * @param {string[]} key
29
+ * @returns {Promise<unknown>}
30
+ */
31
+ async get(key) {
32
+ const doc = await this.collection.findOne({ _id: this._serializeKey(key) });
33
+ return doc ? doc.value : undefined;
34
+ }
35
+
36
+ /**
37
+ * @param {string[]} key
38
+ * @param {unknown} value
39
+ */
40
+ async set(key, value) {
41
+ const id = this._serializeKey(key);
42
+ await this.collection.updateOne(
43
+ { _id: id },
44
+ { $set: { _id: id, value, updatedAt: new Date().toISOString() } },
45
+ { upsert: true },
46
+ );
47
+ }
48
+
49
+ /**
50
+ * @param {string[]} key
51
+ */
52
+ async delete(key) {
53
+ await this.collection.deleteOne({ _id: this._serializeKey(key) });
54
+ }
55
+ }
package/locales/en.json CHANGED
@@ -19,6 +19,24 @@
19
19
  "direction": "Direction",
20
20
  "directionInbound": "Received",
21
21
  "directionOutbound": "Sent",
22
+ "profile": {
23
+ "title": "Profile",
24
+ "intro": "Edit how your actor appears to other fediverse users. Changes take effect immediately.",
25
+ "nameLabel": "Display name",
26
+ "nameHint": "Your name as shown on your fediverse profile",
27
+ "summaryLabel": "Bio",
28
+ "summaryHint": "A short description of yourself. HTML is allowed.",
29
+ "urlLabel": "Website URL",
30
+ "urlHint": "Your website address, shown as a link on your profile",
31
+ "iconLabel": "Avatar URL",
32
+ "iconHint": "URL to your profile picture (square, at least 400x400px recommended)",
33
+ "imageLabel": "Header image URL",
34
+ "imageHint": "URL to a banner image shown at the top of your profile",
35
+ "manualApprovalLabel": "Manually approve followers",
36
+ "manualApprovalHint": "When enabled, follow requests require your approval before they take effect",
37
+ "save": "Save profile",
38
+ "saved": "Profile saved. Changes are now visible to the fediverse."
39
+ },
22
40
  "migrate": {
23
41
  "title": "Mastodon migration",
24
42
  "intro": "This guide walks you through moving your Mastodon identity to your IndieWeb site. Complete each step in order — your existing followers will be notified and can re-follow you automatically.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-activitypub",
3
- "version": "0.1.9",
3
+ "version": "0.2.0",
4
4
  "description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.",
5
5
  "keywords": [
6
6
  "indiekit",
@@ -39,6 +39,7 @@
39
39
  "dependencies": {
40
40
  "@fedify/fedify": "^1.10.0",
41
41
  "@fedify/express": "^1.9.0",
42
+ "@js-temporal/polyfill": "^0.5.0",
42
43
  "express": "^5.0.0"
43
44
  },
44
45
  "peerDependencies": {
@@ -22,6 +22,10 @@
22
22
  title: __("activitypub.activities"),
23
23
  url: mountPath + "/admin/activities"
24
24
  },
25
+ {
26
+ title: __("activitypub.profile.title"),
27
+ url: mountPath + "/admin/profile"
28
+ },
25
29
  {
26
30
  title: __("activitypub.migrate.title"),
27
31
  url: mountPath + "/admin/migrate"
@@ -76,7 +76,7 @@
76
76
  @change="readFile($event)">
77
77
  <template x-if="fileName">
78
78
  <p class="hint" style="margin-top: 0.5em">
79
- <strong x-text="fileName"></strong> — <span x-text="lineCount + ' lines'"></span>
79
+ <strong x-text="fileName"></strong> — <span x-text="handles.length + ' accounts found'"></span>
80
80
  </p>
81
81
  </template>
82
82
  <template x-if="fileError">
@@ -85,7 +85,7 @@
85
85
  </div>
86
86
 
87
87
  <button class="button" type="button"
88
- :disabled="importing || !csvContent"
88
+ :disabled="importing || handles.length === 0"
89
89
  @click="startImport()">
90
90
  <span x-show="!importing">{{ __("activitypub.migrate.importButton") }}</span>
91
91
  <span x-show="importing" x-text="statusText"></span>
@@ -119,7 +119,7 @@
119
119
  <script>
120
120
  function csvImport(mountPath) {
121
121
  return {
122
- csvContent: '',
122
+ handles: [],
123
123
  fileName: '',
124
124
  lineCount: 0,
125
125
  fileError: '',
@@ -129,9 +129,14 @@
129
129
  resultText: '',
130
130
  resultErrors: [],
131
131
 
132
+ /**
133
+ * Parse CSV client-side — extract handles (first column) only.
134
+ * This keeps the JSON payload small (handles only, no raw CSV),
135
+ * avoiding Express's default 100KB body parser limit.
136
+ */
132
137
  readFile(event) {
133
138
  var self = this;
134
- self.csvContent = '';
139
+ self.handles = [];
135
140
  self.fileName = '';
136
141
  self.lineCount = 0;
137
142
  self.fileError = '';
@@ -151,9 +156,19 @@
151
156
  var reader = new FileReader();
152
157
  reader.onload = function(e) {
153
158
  var text = e.target.result;
154
- self.csvContent = text;
159
+ var lines = text.split('\n').filter(function(l) { return l.trim(); });
155
160
  self.fileName = file.name;
156
- self.lineCount = text.split('\n').filter(function(l) { return l.trim(); }).length;
161
+ self.lineCount = lines.length;
162
+
163
+ // Extract handles: skip header, take first CSV column, keep only valid handles
164
+ var parsed = [];
165
+ for (var i = 1; i < lines.length; i++) {
166
+ var handle = lines[i].split(',')[0].trim();
167
+ if (handle && handle.indexOf('@') !== -1) {
168
+ parsed.push(handle);
169
+ }
170
+ }
171
+ self.handles = parsed;
157
172
  };
158
173
  reader.onerror = function() {
159
174
  self.fileError = 'Could not read file';
@@ -183,12 +198,19 @@
183
198
  return;
184
199
  }
185
200
 
201
+ if (self.handles.length === 0) {
202
+ self.importing = false;
203
+ self.resultType = 'error';
204
+ self.resultText = 'No valid handles found in the CSV file.';
205
+ return;
206
+ }
207
+
186
208
  try {
187
209
  var res = await fetch(mountPath + '/admin/migrate/import', {
188
210
  method: 'POST',
189
211
  headers: { 'Content-Type': 'application/json' },
190
212
  body: JSON.stringify({
191
- csvContent: self.csvContent,
213
+ handles: self.handles,
192
214
  importTypes: importTypes
193
215
  })
194
216
  });
@@ -0,0 +1,74 @@
1
+ {% extends "document.njk" %}
2
+
3
+ {% from "heading/macro.njk" import heading with context %}
4
+ {% from "input/macro.njk" import input with context %}
5
+ {% from "textarea/macro.njk" import textarea with context %}
6
+ {% from "checkboxes/macro.njk" import checkboxes with context %}
7
+ {% from "button/macro.njk" import button with context %}
8
+ {% from "notification-banner/macro.njk" import notificationBanner with context %}
9
+ {% from "prose/macro.njk" import prose with context %}
10
+
11
+ {% block content %}
12
+ {{ heading({ text: title, level: 1, parent: { text: __("activitypub.title"), href: mountPath } }) }}
13
+
14
+ {% if result %}
15
+ {{ notificationBanner({ type: result.type, text: result.text }) }}
16
+ {% endif %}
17
+
18
+ {{ prose({ text: __("activitypub.profile.intro") }) }}
19
+
20
+ <form method="post" novalidate>
21
+ {{ input({
22
+ name: "name",
23
+ label: __("activitypub.profile.nameLabel"),
24
+ hint: __("activitypub.profile.nameHint"),
25
+ value: profile.name
26
+ }) }}
27
+
28
+ {{ textarea({
29
+ name: "summary",
30
+ label: __("activitypub.profile.summaryLabel"),
31
+ hint: __("activitypub.profile.summaryHint"),
32
+ value: profile.summary,
33
+ rows: 4
34
+ }) }}
35
+
36
+ {{ input({
37
+ name: "url",
38
+ label: __("activitypub.profile.urlLabel"),
39
+ hint: __("activitypub.profile.urlHint"),
40
+ value: profile.url,
41
+ type: "url"
42
+ }) }}
43
+
44
+ {{ input({
45
+ name: "icon",
46
+ label: __("activitypub.profile.iconLabel"),
47
+ hint: __("activitypub.profile.iconHint"),
48
+ value: profile.icon,
49
+ type: "url"
50
+ }) }}
51
+
52
+ {{ input({
53
+ name: "image",
54
+ label: __("activitypub.profile.imageLabel"),
55
+ hint: __("activitypub.profile.imageHint"),
56
+ value: profile.image,
57
+ type: "url"
58
+ }) }}
59
+
60
+ {{ checkboxes({
61
+ name: "manuallyApprovesFollowers",
62
+ items: [
63
+ {
64
+ label: __("activitypub.profile.manualApprovalLabel"),
65
+ value: "true",
66
+ hint: __("activitypub.profile.manualApprovalHint")
67
+ }
68
+ ],
69
+ values: ["true"] if profile.manuallyApprovesFollowers else []
70
+ }) }}
71
+
72
+ {{ button({ text: __("activitypub.profile.save") }) }}
73
+ </form>
74
+ {% endblock %}