@rmdes/indiekit-endpoint-activitypub 2.9.2 โ 2.10.1
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 +99 -0
- package/index.js +116 -0
- package/lib/controllers/compose.js +11 -1
- package/lib/controllers/federation-delete.js +55 -0
- package/lib/controllers/reader.js +1 -1
- package/lib/inbox-listeners.js +62 -0
- package/lib/jf2-to-as2.js +64 -12
- package/lib/storage/notifications.js +1 -1
- package/lib/timeline-store.js +41 -1
- package/locales/en.json +23 -2
- package/package.json +1 -1
- package/views/activitypub-compose.njk +29 -0
- package/views/activitypub-notifications.njk +4 -0
- package/views/partials/ap-item-card.njk +10 -0
- package/views/partials/ap-notification-card.njk +3 -1
- package/views/partials/ap-poll-options.njk +30 -0
package/assets/reader.css
CHANGED
|
@@ -1094,6 +1094,58 @@
|
|
|
1094
1094
|
outline-offset: -2px;
|
|
1095
1095
|
}
|
|
1096
1096
|
|
|
1097
|
+
.ap-compose__cw {
|
|
1098
|
+
display: flex;
|
|
1099
|
+
flex-direction: column;
|
|
1100
|
+
gap: var(--space-xs);
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
.ap-compose__cw-toggle {
|
|
1104
|
+
cursor: pointer;
|
|
1105
|
+
display: flex;
|
|
1106
|
+
align-items: center;
|
|
1107
|
+
gap: var(--space-xs);
|
|
1108
|
+
font-size: var(--font-size-s);
|
|
1109
|
+
color: var(--color-on-offset);
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
.ap-compose__cw-input {
|
|
1113
|
+
border: var(--border-width-thin) solid var(--color-outline);
|
|
1114
|
+
border-radius: var(--border-radius-small);
|
|
1115
|
+
background: var(--color-offset);
|
|
1116
|
+
color: var(--color-on-background);
|
|
1117
|
+
font: inherit;
|
|
1118
|
+
font-size: var(--font-size-s);
|
|
1119
|
+
padding: var(--space-s);
|
|
1120
|
+
width: 100%;
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
.ap-compose__cw-input:focus {
|
|
1124
|
+
border-color: var(--color-primary);
|
|
1125
|
+
outline: none;
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
.ap-compose__visibility {
|
|
1129
|
+
border: var(--border-width-thin) solid var(--color-outline);
|
|
1130
|
+
border-radius: var(--border-radius-small);
|
|
1131
|
+
display: flex;
|
|
1132
|
+
flex-wrap: wrap;
|
|
1133
|
+
gap: var(--space-s) var(--space-m);
|
|
1134
|
+
padding: var(--space-m);
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
.ap-compose__visibility legend {
|
|
1138
|
+
font-weight: 600;
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
.ap-compose__visibility-option {
|
|
1142
|
+
cursor: pointer;
|
|
1143
|
+
display: flex;
|
|
1144
|
+
align-items: center;
|
|
1145
|
+
gap: var(--space-xs);
|
|
1146
|
+
font-size: var(--font-size-s);
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1097
1149
|
.ap-compose__syndication {
|
|
1098
1150
|
border: var(--border-width-thin) solid var(--color-outline);
|
|
1099
1151
|
border-radius: var(--border-radius-small);
|
|
@@ -2996,6 +3048,53 @@
|
|
|
2996
3048
|
z-index: 2;
|
|
2997
3049
|
}
|
|
2998
3050
|
|
|
3051
|
+
/* ==========================================================================
|
|
3052
|
+
Poll / Question
|
|
3053
|
+
========================================================================== */
|
|
3054
|
+
|
|
3055
|
+
.ap-poll {
|
|
3056
|
+
margin-top: var(--space-s);
|
|
3057
|
+
}
|
|
3058
|
+
|
|
3059
|
+
.ap-poll__option {
|
|
3060
|
+
position: relative;
|
|
3061
|
+
padding: var(--space-xs) var(--space-s);
|
|
3062
|
+
margin-bottom: var(--space-xs);
|
|
3063
|
+
border-radius: var(--border-radius-small);
|
|
3064
|
+
background: var(--color-offset);
|
|
3065
|
+
overflow: hidden;
|
|
3066
|
+
}
|
|
3067
|
+
|
|
3068
|
+
.ap-poll__bar {
|
|
3069
|
+
position: absolute;
|
|
3070
|
+
top: 0;
|
|
3071
|
+
left: 0;
|
|
3072
|
+
bottom: 0;
|
|
3073
|
+
background: var(--color-primary);
|
|
3074
|
+
opacity: 0.15;
|
|
3075
|
+
border-radius: var(--border-radius-small);
|
|
3076
|
+
}
|
|
3077
|
+
|
|
3078
|
+
.ap-poll__label {
|
|
3079
|
+
position: relative;
|
|
3080
|
+
font-size: var(--font-size-s);
|
|
3081
|
+
color: var(--color-on-background);
|
|
3082
|
+
}
|
|
3083
|
+
|
|
3084
|
+
.ap-poll__votes {
|
|
3085
|
+
position: relative;
|
|
3086
|
+
float: right;
|
|
3087
|
+
font-size: var(--font-size-s);
|
|
3088
|
+
font-weight: 600;
|
|
3089
|
+
color: var(--color-on-offset);
|
|
3090
|
+
}
|
|
3091
|
+
|
|
3092
|
+
.ap-poll__footer {
|
|
3093
|
+
font-size: var(--font-size-xs);
|
|
3094
|
+
color: var(--color-on-offset);
|
|
3095
|
+
margin-top: var(--space-xs);
|
|
3096
|
+
}
|
|
3097
|
+
|
|
2999
3098
|
/* ==========================================================================
|
|
3000
3099
|
Dark Mode Overrides
|
|
3001
3100
|
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.
|
|
@@ -168,7 +168,8 @@ export function submitComposeController(mountPath, plugin) {
|
|
|
168
168
|
}
|
|
169
169
|
|
|
170
170
|
const { application } = request.app.locals;
|
|
171
|
-
const { content } = request.body;
|
|
171
|
+
const { content, visibility, summary } = request.body;
|
|
172
|
+
const cwEnabled = request.body["cw-enabled"];
|
|
172
173
|
const inReplyTo = request.body["in-reply-to"];
|
|
173
174
|
const syndicateTo = request.body["mp-syndicate-to"];
|
|
174
175
|
|
|
@@ -209,6 +210,15 @@ export function submitComposeController(mountPath, plugin) {
|
|
|
209
210
|
micropubData.append("in-reply-to", inReplyTo);
|
|
210
211
|
}
|
|
211
212
|
|
|
213
|
+
if (visibility && visibility !== "public") {
|
|
214
|
+
micropubData.append("visibility", visibility);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (cwEnabled && summary && summary.trim()) {
|
|
218
|
+
micropubData.append("summary", summary.trim());
|
|
219
|
+
micropubData.append("sensitive", "true");
|
|
220
|
+
}
|
|
221
|
+
|
|
212
222
|
if (syndicateTo) {
|
|
213
223
|
const targets = Array.isArray(syndicateTo)
|
|
214
224
|
? syndicateTo
|
|
@@ -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 {
|
package/lib/inbox-listeners.js
CHANGED
|
@@ -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:
|
|
82
|
-
|
|
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
|
-
//
|
|
184
|
-
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
193
|
-
|
|
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") {
|
package/lib/timeline-store.js
CHANGED
|
@@ -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
|
@@ -145,7 +145,13 @@
|
|
|
145
145
|
"syndicateLabel": "Syndicate to",
|
|
146
146
|
"submitMicropub": "Post reply",
|
|
147
147
|
"cancel": "Cancel",
|
|
148
|
-
"errorEmpty": "Reply content cannot be empty"
|
|
148
|
+
"errorEmpty": "Reply content cannot be empty",
|
|
149
|
+
"visibilityLabel": "Visibility",
|
|
150
|
+
"visibilityPublic": "Public",
|
|
151
|
+
"visibilityUnlisted": "Unlisted",
|
|
152
|
+
"visibilityFollowers": "Followers only",
|
|
153
|
+
"cwLabel": "Content warning",
|
|
154
|
+
"cwPlaceholder": "Write your warning hereโฆ"
|
|
149
155
|
},
|
|
150
156
|
"notifications": {
|
|
151
157
|
"title": "Notifications",
|
|
@@ -166,7 +172,8 @@
|
|
|
166
172
|
"likes": "Likes",
|
|
167
173
|
"boosts": "Boosts",
|
|
168
174
|
"follows": "Follows",
|
|
169
|
-
"dms": "DMs"
|
|
175
|
+
"dms": "DMs",
|
|
176
|
+
"reports": "Reports"
|
|
170
177
|
},
|
|
171
178
|
"emptyTab": "No %s notifications yet."
|
|
172
179
|
},
|
|
@@ -304,6 +311,20 @@
|
|
|
304
311
|
"likes": "Likes",
|
|
305
312
|
"boosts": "Boosts"
|
|
306
313
|
}
|
|
314
|
+
},
|
|
315
|
+
"poll": {
|
|
316
|
+
"voters": "voters",
|
|
317
|
+
"votes": "votes",
|
|
318
|
+
"closed": "Poll closed",
|
|
319
|
+
"endsAt": "Ends"
|
|
320
|
+
},
|
|
321
|
+
"federation": {
|
|
322
|
+
"deleteSuccess": "Delete activity sent to followers",
|
|
323
|
+
"deleteButton": "Delete from fediverse"
|
|
324
|
+
},
|
|
325
|
+
"reports": {
|
|
326
|
+
"sentReport": "filed a report",
|
|
327
|
+
"title": "Reports"
|
|
307
328
|
}
|
|
308
329
|
}
|
|
309
330
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rmdes/indiekit-endpoint-activitypub",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.10.1",
|
|
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,18 @@
|
|
|
27
27
|
<input type="hidden" name="in-reply-to" value="{{ replyTo }}">
|
|
28
28
|
{% endif %}
|
|
29
29
|
|
|
30
|
+
{# Content warning toggle + summary #}
|
|
31
|
+
<div class="ap-compose__cw">
|
|
32
|
+
<label class="ap-compose__cw-toggle">
|
|
33
|
+
<input type="checkbox" name="cw-enabled" id="cw-toggle"
|
|
34
|
+
onchange="document.getElementById('cw-text').style.display = this.checked ? 'block' : 'none'">
|
|
35
|
+
{{ __("activitypub.compose.cwLabel") }}
|
|
36
|
+
</label>
|
|
37
|
+
<input type="text" name="summary" id="cw-text" class="ap-compose__cw-input"
|
|
38
|
+
placeholder="{{ __('activitypub.compose.cwPlaceholder') }}"
|
|
39
|
+
style="display: none">
|
|
40
|
+
</div>
|
|
41
|
+
|
|
30
42
|
{# Content textarea #}
|
|
31
43
|
<div class="ap-compose__editor">
|
|
32
44
|
<textarea name="content" class="ap-compose__textarea"
|
|
@@ -35,6 +47,23 @@
|
|
|
35
47
|
required></textarea>
|
|
36
48
|
</div>
|
|
37
49
|
|
|
50
|
+
{# Visibility #}
|
|
51
|
+
<fieldset class="ap-compose__visibility">
|
|
52
|
+
<legend>{{ __("activitypub.compose.visibilityLabel") }}</legend>
|
|
53
|
+
<label class="ap-compose__visibility-option">
|
|
54
|
+
<input type="radio" name="visibility" value="public" checked>
|
|
55
|
+
{{ __("activitypub.compose.visibilityPublic") }}
|
|
56
|
+
</label>
|
|
57
|
+
<label class="ap-compose__visibility-option">
|
|
58
|
+
<input type="radio" name="visibility" value="unlisted">
|
|
59
|
+
{{ __("activitypub.compose.visibilityUnlisted") }}
|
|
60
|
+
</label>
|
|
61
|
+
<label class="ap-compose__visibility-option">
|
|
62
|
+
<input type="radio" name="visibility" value="followers">
|
|
63
|
+
{{ __("activitypub.compose.visibilityFollowers") }}
|
|
64
|
+
</label>
|
|
65
|
+
</fieldset>
|
|
66
|
+
|
|
38
67
|
{# Syndication targets #}
|
|
39
68
|
{% if syndicationTargets.length > 0 %}
|
|
40
69
|
<fieldset class="ap-compose__syndication">
|
|
@@ -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 %}
|