@rmdes/indiekit-endpoint-activitypub 2.9.2 → 2.10.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/assets/reader.css CHANGED
@@ -2996,6 +2996,53 @@
2996
2996
  z-index: 2;
2997
2997
  }
2998
2998
 
2999
+ /* ==========================================================================
3000
+ Poll / Question
3001
+ ========================================================================== */
3002
+
3003
+ .ap-poll {
3004
+ margin-top: var(--space-s);
3005
+ }
3006
+
3007
+ .ap-poll__option {
3008
+ position: relative;
3009
+ padding: var(--space-xs) var(--space-s);
3010
+ margin-bottom: var(--space-xs);
3011
+ border-radius: var(--border-radius-small);
3012
+ background: var(--color-offset);
3013
+ overflow: hidden;
3014
+ }
3015
+
3016
+ .ap-poll__bar {
3017
+ position: absolute;
3018
+ top: 0;
3019
+ left: 0;
3020
+ bottom: 0;
3021
+ background: var(--color-primary);
3022
+ opacity: 0.15;
3023
+ border-radius: var(--border-radius-small);
3024
+ }
3025
+
3026
+ .ap-poll__label {
3027
+ position: relative;
3028
+ font-size: var(--font-size-s);
3029
+ color: var(--color-on-background);
3030
+ }
3031
+
3032
+ .ap-poll__votes {
3033
+ position: relative;
3034
+ float: right;
3035
+ font-size: var(--font-size-s);
3036
+ font-weight: 600;
3037
+ color: var(--color-on-offset);
3038
+ }
3039
+
3040
+ .ap-poll__footer {
3041
+ font-size: var(--font-size-xs);
3042
+ color: var(--color-on-offset);
3043
+ margin-top: var(--space-xs);
3044
+ }
3045
+
2999
3046
  /* ==========================================================================
3000
3047
  Dark Mode Overrides
3001
3048
  Softens saturated colors that are uncomfortable on dark backgrounds.
package/index.js CHANGED
@@ -97,6 +97,7 @@ import { startBatchRefollow } from "./lib/batch-refollow.js";
97
97
  import { logActivity } from "./lib/activity-log.js";
98
98
  import { scheduleCleanup } from "./lib/timeline-cleanup.js";
99
99
  import { runSeparateMentionsMigration } from "./lib/migrations/separate-mentions.js";
100
+ import { deleteFederationController } from "./lib/controllers/federation-delete.js";
100
101
 
101
102
  const defaults = {
102
103
  mountPath: "/activitypub",
@@ -118,6 +119,7 @@ const defaults = {
118
119
  notificationRetentionDays: 30,
119
120
  debugDashboard: false,
120
121
  debugPassword: "",
122
+ defaultVisibility: "public", // "public" | "unlisted" | "followers"
121
123
  };
122
124
 
123
125
  export default class ActivityPubEndpoint {
@@ -309,6 +311,7 @@ export default class ActivityPubEndpoint {
309
311
  router.post("/admin/refollow/pause", refollowPauseController(mp, this));
310
312
  router.post("/admin/refollow/resume", refollowResumeController(mp, this));
311
313
  router.get("/admin/refollow/status", refollowStatusController(mp));
314
+ router.post("/admin/federation/delete", deleteFederationController(mp, this));
312
315
 
313
316
  return router;
314
317
  }
@@ -376,6 +379,7 @@ export default class ActivityPubEndpoint {
376
379
  post.properties,
377
380
  actorUrl,
378
381
  self._publicationUrl,
382
+ { visibility: self.options.defaultVisibility },
379
383
  );
380
384
 
381
385
  const object = activity.object || activity;
@@ -470,6 +474,7 @@ export default class ActivityPubEndpoint {
470
474
  {
471
475
  replyToActorUrl: replyToActor?.url,
472
476
  replyToActorHandle: replyToActor?.handle,
477
+ visibility: self.options.defaultVisibility,
473
478
  },
474
479
  );
475
480
 
@@ -871,6 +876,97 @@ export default class ActivityPubEndpoint {
871
876
  }
872
877
  }
873
878
 
879
+ /**
880
+ * Send Delete activity to all followers for a removed post.
881
+ * Mirrors broadcastActorUpdate() pattern: batch delivery with shared inbox dedup.
882
+ * @param {string} postUrl - Full URL of the deleted post
883
+ */
884
+ async broadcastDelete(postUrl) {
885
+ if (!this._federation) return;
886
+
887
+ try {
888
+ const { Delete } = await import("@fedify/fedify/vocab");
889
+ const handle = this.options.actor.handle;
890
+ const ctx = this._federation.createContext(
891
+ new URL(this._publicationUrl),
892
+ { handle, publicationUrl: this._publicationUrl },
893
+ );
894
+
895
+ const del = new Delete({
896
+ actor: ctx.getActorUri(handle),
897
+ object: new URL(postUrl),
898
+ });
899
+
900
+ const followers = await this._collections.ap_followers
901
+ .find({})
902
+ .project({ actorUrl: 1, inbox: 1, sharedInbox: 1 })
903
+ .toArray();
904
+
905
+ const inboxMap = new Map();
906
+ for (const f of followers) {
907
+ const key = f.sharedInbox || f.inbox;
908
+ if (key && !inboxMap.has(key)) {
909
+ inboxMap.set(key, f);
910
+ }
911
+ }
912
+
913
+ const uniqueRecipients = [...inboxMap.values()];
914
+ const BATCH_SIZE = 25;
915
+ const BATCH_DELAY_MS = 5000;
916
+ let delivered = 0;
917
+ let failed = 0;
918
+
919
+ console.info(
920
+ `[ActivityPub] Broadcasting Delete for ${postUrl} to ${uniqueRecipients.length} ` +
921
+ `unique inboxes (${followers.length} followers)`,
922
+ );
923
+
924
+ for (let i = 0; i < uniqueRecipients.length; i += BATCH_SIZE) {
925
+ const batch = uniqueRecipients.slice(i, i + BATCH_SIZE);
926
+ const recipients = batch.map((f) => ({
927
+ id: new URL(f.actorUrl),
928
+ inboxId: new URL(f.inbox || f.sharedInbox),
929
+ endpoints: f.sharedInbox
930
+ ? { sharedInbox: new URL(f.sharedInbox) }
931
+ : undefined,
932
+ }));
933
+
934
+ try {
935
+ await ctx.sendActivity(
936
+ { identifier: handle },
937
+ recipients,
938
+ del,
939
+ { preferSharedInbox: true },
940
+ );
941
+ delivered += batch.length;
942
+ } catch (error) {
943
+ failed += batch.length;
944
+ console.warn(
945
+ `[ActivityPub] Delete batch ${Math.floor(i / BATCH_SIZE) + 1} failed: ${error.message}`,
946
+ );
947
+ }
948
+
949
+ if (i + BATCH_SIZE < uniqueRecipients.length) {
950
+ await new Promise((resolve) => setTimeout(resolve, BATCH_DELAY_MS));
951
+ }
952
+ }
953
+
954
+ console.info(
955
+ `[ActivityPub] Delete broadcast complete for ${postUrl}: ${delivered} delivered, ${failed} failed`,
956
+ );
957
+
958
+ await logActivity(this._collections.ap_activities, {
959
+ direction: "outbound",
960
+ type: "Delete",
961
+ actorUrl: this._publicationUrl,
962
+ objectUrl: postUrl,
963
+ summary: `Sent Delete for ${postUrl} to ${delivered} inboxes`,
964
+ }).catch(() => {});
965
+ } catch (error) {
966
+ console.warn("[ActivityPub] broadcastDelete failed:", error.message);
967
+ }
968
+ }
969
+
874
970
  /**
875
971
  * Build the full actor URL from config.
876
972
  * @returns {string}
@@ -908,6 +1004,8 @@ export default class ActivityPubEndpoint {
908
1004
  Indiekit.addCollection("ap_messages");
909
1005
  // Explore tab collections
910
1006
  Indiekit.addCollection("ap_explore_tabs");
1007
+ // Reports collection
1008
+ Indiekit.addCollection("ap_reports");
911
1009
 
912
1010
  // Store collection references (posts resolved lazily)
913
1011
  const indiekitCollections = Indiekit.collections;
@@ -931,6 +1029,8 @@ export default class ActivityPubEndpoint {
931
1029
  ap_messages: indiekitCollections.get("ap_messages"),
932
1030
  // Explore tab collections
933
1031
  ap_explore_tabs: indiekitCollections.get("ap_explore_tabs"),
1032
+ // Reports collection
1033
+ ap_reports: indiekitCollections.get("ap_reports"),
934
1034
  get posts() {
935
1035
  return indiekitCollections.get("posts");
936
1036
  },
@@ -1106,6 +1206,22 @@ export default class ActivityPubEndpoint {
1106
1206
  { order: 1 },
1107
1207
  { background: true },
1108
1208
  );
1209
+
1210
+ // ap_reports indexes
1211
+ if (notifRetention > 0) {
1212
+ this._collections.ap_reports.createIndex(
1213
+ { createdAt: 1 },
1214
+ { expireAfterSeconds: notifRetention * 86_400 },
1215
+ );
1216
+ }
1217
+ this._collections.ap_reports.createIndex(
1218
+ { reporterUrl: 1 },
1219
+ { background: true },
1220
+ );
1221
+ this._collections.ap_reports.createIndex(
1222
+ { reportedUrls: 1 },
1223
+ { background: true },
1224
+ );
1109
1225
  } catch {
1110
1226
  // Index creation failed — collections not yet available.
1111
1227
  // Indexes already exist from previous startups; non-fatal.
@@ -0,0 +1,55 @@
1
+ /**
2
+ * POST /admin/federation/delete — Send Delete activity to all followers.
3
+ * Removes a post from the fediverse after local deletion.
4
+ * @param {string} mountPath - Plugin mount path
5
+ * @param {object} plugin - ActivityPub plugin instance
6
+ */
7
+ import { validateToken } from "../csrf.js";
8
+
9
+ export function deleteFederationController(mountPath, plugin) {
10
+ return async (request, response, next) => {
11
+ try {
12
+ if (!validateToken(request)) {
13
+ return response.status(403).json({
14
+ success: false,
15
+ error: "Invalid CSRF token",
16
+ });
17
+ }
18
+
19
+ const { url } = request.body;
20
+ if (!url) {
21
+ return response.status(400).json({
22
+ success: false,
23
+ error: "Missing post URL",
24
+ });
25
+ }
26
+
27
+ try {
28
+ new URL(url);
29
+ } catch {
30
+ return response.status(400).json({
31
+ success: false,
32
+ error: "Invalid post URL",
33
+ });
34
+ }
35
+
36
+ if (!plugin._federation) {
37
+ return response.status(503).json({
38
+ success: false,
39
+ error: "Federation not initialized",
40
+ });
41
+ }
42
+
43
+ await plugin.broadcastDelete(url);
44
+
45
+ if (request.headers.accept?.includes("application/json")) {
46
+ return response.json({ success: true, url });
47
+ }
48
+
49
+ const referrer = request.get("Referrer") || `${mountPath}/admin/activities`;
50
+ return response.redirect(referrer);
51
+ } catch (error) {
52
+ next(error);
53
+ }
54
+ };
55
+ }
@@ -117,7 +117,7 @@ export function readerController(mountPath) {
117
117
  }
118
118
 
119
119
  export function notificationsController(mountPath) {
120
- const validTabs = ["all", "reply", "like", "boost", "follow", "dm"];
120
+ const validTabs = ["all", "reply", "like", "boost", "follow", "dm", "report"];
121
121
 
122
122
  return async (request, response, next) => {
123
123
  try {
@@ -13,6 +13,7 @@ import {
13
13
  Block,
14
14
  Create,
15
15
  Delete,
16
+ Flag,
16
17
  Follow,
17
18
  Like,
18
19
  Move,
@@ -747,6 +748,67 @@ export function registerInboxListeners(inboxChain, options) {
747
748
  })
748
749
  .on(Remove, async () => {
749
750
  // Mastodon uses Remove for unpinning posts from featured collections — safe to ignore
751
+ })
752
+ // ── Flag (Report) ──────────────────────────────────────────────
753
+ .on(Flag, async (ctx, flag) => {
754
+ try {
755
+ const authLoader = await getAuthLoader(ctx);
756
+ const actorObj = await flag.getActor({ documentLoader: authLoader }).catch(() => null);
757
+
758
+ const reporterUrl = actorObj?.id?.href || flag.actorId?.href || "";
759
+ const reporterName = actorObj?.name?.toString() || actorObj?.preferredUsername?.toString() || reporterUrl;
760
+
761
+ // Extract reported objects — Flag can report actors or posts
762
+ const reportedIds = flag.objectIds?.map((u) => u.href) || [];
763
+ const reason = flag.content?.toString() || flag.summary?.toString() || "";
764
+
765
+ if (reportedIds.length === 0 && !reason) {
766
+ console.info("[ActivityPub] Ignoring empty Flag from", reporterUrl);
767
+ return;
768
+ }
769
+
770
+ // Store report
771
+ if (collections.ap_reports) {
772
+ await collections.ap_reports.insertOne({
773
+ reporterUrl,
774
+ reporterName,
775
+ reportedUrls: reportedIds,
776
+ reason,
777
+ createdAt: new Date().toISOString(),
778
+ read: false,
779
+ });
780
+ }
781
+
782
+ // Create notification
783
+ if (collections.ap_notifications) {
784
+ await addNotification(collections, {
785
+ uid: `flag:${reporterUrl}:${Date.now()}`,
786
+ type: "report",
787
+ actorUrl: reporterUrl,
788
+ actorName: reporterName,
789
+ actorPhoto: actorObj?.iconUrl?.href || actorObj?.icon?.url?.href || "",
790
+ actorHandle: actorObj?.preferredUsername
791
+ ? `@${actorObj.preferredUsername}@${new URL(reporterUrl).hostname}`
792
+ : reporterUrl,
793
+ objectUrl: reportedIds[0] || "",
794
+ summary: reason ? reason.slice(0, 200) : "Report received",
795
+ published: new Date().toISOString(),
796
+ createdAt: new Date().toISOString(),
797
+ });
798
+ }
799
+
800
+ await logActivity(collections, storeRawActivities, {
801
+ direction: "inbound",
802
+ type: "Flag",
803
+ actorUrl: reporterUrl,
804
+ objectUrl: reportedIds[0] || "",
805
+ summary: `Report from ${reporterName}: ${reason.slice(0, 100)}`,
806
+ });
807
+
808
+ console.info(`[ActivityPub] Flag received from ${reporterName} — ${reportedIds.length} objects reported`);
809
+ } catch (error) {
810
+ console.warn("[ActivityPub] Flag handler error:", error.message);
811
+ }
750
812
  });
751
813
  }
752
814
 
package/lib/jf2-to-as2.js CHANGED
@@ -48,7 +48,7 @@ function linkifyUrls(html) {
48
48
  * @param {string} publicationUrl - Publication base URL with trailing slash
49
49
  * @returns {object} ActivityStreams activity (Create, Like, or Announce)
50
50
  */
51
- export function jf2ToActivityStreams(properties, actorUrl, publicationUrl) {
51
+ export function jf2ToActivityStreams(properties, actorUrl, publicationUrl, options = {}) {
52
52
  const postType = properties["post-type"];
53
53
 
54
54
  if (postType === "like") {
@@ -60,6 +60,7 @@ export function jf2ToActivityStreams(properties, actorUrl, publicationUrl) {
60
60
  };
61
61
  }
62
62
 
63
+ // Reposts are always public — Mastodon and other implementations expect this
63
64
  if (postType === "repost") {
64
65
  return {
65
66
  "@context": "https://www.w3.org/ns/activitystreams",
@@ -72,14 +73,25 @@ export function jf2ToActivityStreams(properties, actorUrl, publicationUrl) {
72
73
  const isArticle = postType === "article" && properties.name;
73
74
  const postUrl = resolvePostUrl(properties.url, publicationUrl);
74
75
 
76
+ const visibility = properties.visibility || options.visibility || "public";
77
+ const followersUrl = `${actorUrl.replace(/\/$/, "")}/followers`;
78
+
75
79
  const object = {
76
80
  type: isArticle ? "Article" : "Note",
77
81
  id: postUrl,
78
82
  attributedTo: actorUrl,
79
83
  published: properties.published,
80
84
  url: postUrl,
81
- to: ["https://www.w3.org/ns/activitystreams#Public"],
82
- cc: [`${actorUrl.replace(/\/$/, "")}/followers`],
85
+ to: visibility === "unlisted"
86
+ ? [followersUrl]
87
+ : visibility === "followers"
88
+ ? [followersUrl]
89
+ : ["https://www.w3.org/ns/activitystreams#Public"],
90
+ cc: visibility === "unlisted"
91
+ ? ["https://www.w3.org/ns/activitystreams#Public"]
92
+ : visibility === "followers"
93
+ ? []
94
+ : [followersUrl],
83
95
  };
84
96
 
85
97
  if (postType === "bookmark") {
@@ -111,6 +123,10 @@ export function jf2ToActivityStreams(properties, actorUrl, publicationUrl) {
111
123
  }
112
124
  }
113
125
 
126
+ if (properties.sensitive || properties["post-status"] === "sensitive") {
127
+ object.sensitive = true;
128
+ }
129
+
114
130
  if (properties["in-reply-to"]) {
115
131
  object.inReplyTo = properties["in-reply-to"];
116
132
  }
@@ -161,6 +177,7 @@ export function jf2ToAS2Activity(properties, actorUrl, publicationUrl, options =
161
177
  });
162
178
  }
163
179
 
180
+ // Reposts are always public — Mastodon and other implementations expect this
164
181
  if (postType === "repost") {
165
182
  const repostOf = properties["repost-of"];
166
183
  if (!repostOf) return null;
@@ -180,17 +197,39 @@ export function jf2ToAS2Activity(properties, actorUrl, publicationUrl, options =
180
197
  attributedTo: actorUri,
181
198
  };
182
199
 
183
- // Addressing: for replies, include original author in CC so their server
184
- // threads the reply and notifies them
200
+ // Determine visibility: per-post override > option default > "public"
201
+ const visibility = properties.visibility || options.visibility || "public";
202
+
203
+ // Addressing based on visibility:
204
+ // - "public": to: PUBLIC, cc: followers (+ reply author)
205
+ // - "unlisted": to: followers, cc: PUBLIC (+ reply author)
206
+ // - "followers": to: followers (+ reply author), no PUBLIC
207
+ const PUBLIC = new URL("https://www.w3.org/ns/activitystreams#Public");
208
+ const followersUri = new URL(followersUrl);
209
+
185
210
  if (replyToActorUrl && properties["in-reply-to"]) {
186
- noteOptions.to = new URL("https://www.w3.org/ns/activitystreams#Public");
187
- noteOptions.ccs = [
188
- new URL(followersUrl),
189
- new URL(replyToActorUrl),
190
- ];
211
+ const replyAuthor = new URL(replyToActorUrl);
212
+ if (visibility === "unlisted") {
213
+ noteOptions.to = followersUri;
214
+ noteOptions.ccs = [PUBLIC, replyAuthor];
215
+ } else if (visibility === "followers") {
216
+ noteOptions.tos = [followersUri, replyAuthor];
217
+ } else {
218
+ // public (default)
219
+ noteOptions.to = PUBLIC;
220
+ noteOptions.ccs = [followersUri, replyAuthor];
221
+ }
191
222
  } else {
192
- noteOptions.to = new URL("https://www.w3.org/ns/activitystreams#Public");
193
- noteOptions.cc = new URL(followersUrl);
223
+ if (visibility === "unlisted") {
224
+ noteOptions.to = followersUri;
225
+ noteOptions.cc = PUBLIC;
226
+ } else if (visibility === "followers") {
227
+ noteOptions.to = followersUri;
228
+ } else {
229
+ // public (default)
230
+ noteOptions.to = PUBLIC;
231
+ noteOptions.cc = followersUri;
232
+ }
194
233
  }
195
234
 
196
235
  if (postUrl) {
@@ -230,6 +269,19 @@ export function jf2ToAS2Activity(properties, actorUrl, publicationUrl, options =
230
269
  }
231
270
  }
232
271
 
272
+ // Content warning / sensitive flag
273
+ if (properties.sensitive) {
274
+ noteOptions.sensitive = true;
275
+ }
276
+ if (properties["post-status"] === "sensitive") {
277
+ noteOptions.sensitive = true;
278
+ }
279
+ // Summary doubles as CW text in Mastodon (notes only — articles already use summary for description)
280
+ if (properties.summary && !isArticle) {
281
+ noteOptions.summary = properties.summary;
282
+ noteOptions.sensitive = true;
283
+ }
284
+
233
285
  if (properties["in-reply-to"]) {
234
286
  noteOptions.replyTarget = new URL(properties["in-reply-to"]);
235
287
  }
@@ -126,7 +126,7 @@ export async function getNotificationCountsByType(collections, unreadOnly = fals
126
126
 
127
127
  const results = await ap_notifications.aggregate(pipeline).toArray();
128
128
 
129
- const counts = { all: 0, reply: 0, like: 0, boost: 0, follow: 0, dm: 0 };
129
+ const counts = { all: 0, reply: 0, like: 0, boost: 0, follow: 0, dm: 0, report: 0 };
130
130
  for (const { _id, count } of results) {
131
131
  counts.all += count;
132
132
  if (_id === "reply" || _id === "mention") {
@@ -3,7 +3,7 @@
3
3
  * @module timeline-store
4
4
  */
5
5
 
6
- import { Article, Application, Emoji, Hashtag, Mention, Service } from "@fedify/fedify/vocab";
6
+ import { Article, Application, Emoji, Hashtag, Mention, Question, Service } from "@fedify/fedify/vocab";
7
7
  import sanitizeHtml from "sanitize-html";
8
8
 
9
9
  /**
@@ -132,6 +132,9 @@ export async function extractObjectData(object, options = {}) {
132
132
  if (object instanceof Article) {
133
133
  type = "article";
134
134
  }
135
+ if (object instanceof Question) {
136
+ type = "question";
137
+ }
135
138
  if (options.boostedBy) {
136
139
  type = "boost";
137
140
  }
@@ -152,6 +155,39 @@ export async function extractObjectData(object, options = {}) {
152
155
  const summary = object.summary?.toString() || "";
153
156
  const sensitive = object.sensitive || false;
154
157
 
158
+ // Poll options (Question type)
159
+ let pollOptions = [];
160
+ let votersCount = 0;
161
+ let pollClosed = false;
162
+ let pollEndTime = "";
163
+
164
+ if (object instanceof Question) {
165
+ try {
166
+ const exclusive = [];
167
+ for await (const opt of object.getExclusiveOptions?.() || []) {
168
+ exclusive.push({
169
+ name: opt.name?.toString() || "",
170
+ votes: typeof opt.replies?.totalItems === "number" ? opt.replies.totalItems : 0,
171
+ });
172
+ }
173
+ const inclusive = [];
174
+ for await (const opt of object.getInclusiveOptions?.() || []) {
175
+ inclusive.push({
176
+ name: opt.name?.toString() || "",
177
+ votes: typeof opt.replies?.totalItems === "number" ? opt.replies.totalItems : 0,
178
+ });
179
+ }
180
+ pollOptions = exclusive.length > 0 ? exclusive : inclusive;
181
+ } catch {
182
+ // Poll options couldn't be extracted — show as regular post
183
+ }
184
+
185
+ votersCount = typeof object.voters === "number" ? object.voters : 0;
186
+ pollEndTime = object.endTime ? String(object.endTime) : "";
187
+ const closedValue = object.closed;
188
+ pollClosed = closedValue === true || (closedValue != null && closedValue !== false);
189
+ }
190
+
155
191
  // Published date — store as ISO string per Indiekit convention
156
192
  const published = object.published
157
193
  ? String(object.published)
@@ -321,6 +357,10 @@ export async function extractObjectData(object, options = {}) {
321
357
  inReplyTo,
322
358
  quoteUrl,
323
359
  counts,
360
+ pollOptions,
361
+ votersCount,
362
+ pollClosed,
363
+ pollEndTime,
324
364
  createdAt: new Date().toISOString()
325
365
  };
326
366
 
package/locales/en.json CHANGED
@@ -166,7 +166,8 @@
166
166
  "likes": "Likes",
167
167
  "boosts": "Boosts",
168
168
  "follows": "Follows",
169
- "dms": "DMs"
169
+ "dms": "DMs",
170
+ "reports": "Reports"
170
171
  },
171
172
  "emptyTab": "No %s notifications yet."
172
173
  },
@@ -304,6 +305,20 @@
304
305
  "likes": "Likes",
305
306
  "boosts": "Boosts"
306
307
  }
308
+ },
309
+ "poll": {
310
+ "voters": "voters",
311
+ "votes": "votes",
312
+ "closed": "Poll closed",
313
+ "endsAt": "Ends"
314
+ },
315
+ "federation": {
316
+ "deleteSuccess": "Delete activity sent to followers",
317
+ "deleteButton": "Delete from fediverse"
318
+ },
319
+ "reports": {
320
+ "sentReport": "filed a report",
321
+ "title": "Reports"
307
322
  }
308
323
  }
309
324
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-activitypub",
3
- "version": "2.9.2",
3
+ "version": "2.10.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",
@@ -27,6 +27,10 @@
27
27
  {{ __("activitypub.notifications.tabs.dms") }}
28
28
  {% if tabCounts.dm %}<span class="ap-tab__count">{{ tabCounts.dm }}</span>{% endif %}
29
29
  </a>
30
+ <a href="{{ notifBase }}?tab=report" class="ap-tab{% if tab == 'report' %} ap-tab--active{% endif %}">
31
+ {{ __("activitypub.notifications.tabs.reports") }}
32
+ {% if tabCounts.report %}<span class="ap-tab__count">{{ tabCounts.report }}</span>{% endif %}
33
+ </a>
30
34
  <a href="{{ notifBase }}?tab=all" class="ap-tab{% if tab == 'all' %} ap-tab--active{% endif %}">
31
35
  {{ __("activitypub.notifications.tabs.all") }}
32
36
  {% if tabCounts.all %}<span class="ap-tab__count">{{ tabCounts.all }}</span>{% endif %}
@@ -100,6 +100,11 @@
100
100
 
101
101
  {# Media hidden behind CW #}
102
102
  {% include "partials/ap-item-media.njk" %}
103
+
104
+ {# Poll options #}
105
+ {% if item.type == "question" or (item.pollOptions and item.pollOptions.length > 0) %}
106
+ {% include "partials/ap-poll-options.njk" %}
107
+ {% endif %}
103
108
  </div>
104
109
  </div>
105
110
  {% else %}
@@ -118,6 +123,11 @@
118
123
 
119
124
  {# Media visible directly #}
120
125
  {% include "partials/ap-item-media.njk" %}
126
+
127
+ {# Poll options #}
128
+ {% if item.type == "question" or (item.pollOptions and item.pollOptions.length > 0) %}
129
+ {% include "partials/ap-poll-options.njk" %}
130
+ {% endif %}
121
131
  {% endif %}
122
132
 
123
133
  {# Mentions and hashtags #}
@@ -15,7 +15,7 @@
15
15
  {% endif %}
16
16
  <span class="ap-notification__avatar ap-notification__avatar--default" aria-hidden="true">{{ item.actorName[0] | upper if item.actorName else "?" }}</span>
17
17
  <span class="ap-notification__type-badge">
18
- {% if item.type == "like" %}❤{% elif item.type == "boost" %}🔁{% elif item.type == "follow" %}👤{% elif item.type == "reply" %}💬{% elif item.type == "mention" %}@{% elif item.type == "dm" %}✉{% endif %}
18
+ {% if item.type == "like" %}❤{% elif item.type == "boost" %}🔁{% elif item.type == "follow" %}👤{% elif item.type == "reply" %}💬{% elif item.type == "mention" %}@{% elif item.type == "dm" %}✉{% elif item.type == "report" %}⚑{% endif %}
19
19
  </span>
20
20
  </div>
21
21
 
@@ -38,6 +38,8 @@
38
38
  {{ __("activitypub.notifications.mentionedYou") }}
39
39
  {% elif item.type == "dm" %}
40
40
  {{ __("activitypub.messages.sentYouDM") }}
41
+ {% elif item.type == "report" %}
42
+ {{ __("activitypub.reports.sentReport") }}
41
43
  {% endif %}
42
44
  </span>
43
45
 
@@ -0,0 +1,30 @@
1
+ {# Poll options partial — renders vote results for Question-type posts #}
2
+ {% if item.pollOptions and item.pollOptions.length > 0 %}
3
+ {% set totalVotes = 0 %}
4
+ {% for opt in item.pollOptions %}
5
+ {% set totalVotes = totalVotes + opt.votes %}
6
+ {% endfor %}
7
+
8
+ <div class="ap-poll">
9
+ {% for opt in item.pollOptions %}
10
+ {% set pct = (totalVotes > 0) and ((opt.votes / totalVotes * 100) | round) or 0 %}
11
+ <div class="ap-poll__option">
12
+ <div class="ap-poll__bar" style="width: {{ pct }}%"></div>
13
+ <span class="ap-poll__label">{{ opt.name }}</span>
14
+ <span class="ap-poll__votes">{{ pct }}%</span>
15
+ </div>
16
+ {% endfor %}
17
+ <div class="ap-poll__footer">
18
+ {% if item.votersCount > 0 %}
19
+ {{ item.votersCount }} {{ __("activitypub.poll.voters") }}
20
+ {% elif totalVotes > 0 %}
21
+ {{ totalVotes }} {{ __("activitypub.poll.votes") }}
22
+ {% endif %}
23
+ {% if item.pollClosed %}
24
+ · {{ __("activitypub.poll.closed") }}
25
+ {% elif item.pollEndTime %}
26
+ · {{ __("activitypub.poll.endsAt") }} <time datetime="{{ item.pollEndTime }}">{{ item.pollEndTime | date("PPp") }}</time>
27
+ {% endif %}
28
+ </div>
29
+ </div>
30
+ {% endif %}