@geekity/cms 0.28.0 → 0.30.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/README.md +355 -53
- package/admin/pages/comments/all.njk +3 -1
- package/admin/pages/federation/followers.njk +8 -2
- package/dist/admin/comments.d.ts +2 -0
- package/dist/admin/comments.d.ts.map +1 -1
- package/dist/admin/comments.js +19 -49
- package/dist/admin/comments.js.map +1 -1
- package/dist/admin/documents.d.ts +5 -0
- package/dist/admin/documents.d.ts.map +1 -1
- package/dist/admin/documents.js +45 -17
- package/dist/admin/documents.js.map +1 -1
- package/dist/admin/federation.d.ts +21 -1
- package/dist/admin/federation.d.ts.map +1 -1
- package/dist/admin/federation.js +17 -7
- package/dist/admin/federation.js.map +1 -1
- package/dist/admin/store.d.ts +95 -14
- package/dist/admin/store.d.ts.map +1 -1
- package/dist/admin/store.js +206 -17
- package/dist/admin/store.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +8 -1
- package/dist/cli.js.map +1 -1
- package/dist/comments/form.d.ts +3 -1
- package/dist/comments/form.d.ts.map +1 -1
- package/dist/comments/form.js +2 -1
- package/dist/comments/form.js.map +1 -1
- package/dist/comments/index.d.ts +3 -3
- package/dist/comments/index.d.ts.map +1 -1
- package/dist/comments/index.js +1 -1
- package/dist/comments/index.js.map +1 -1
- package/dist/comments/records.d.ts +56 -24
- package/dist/comments/records.d.ts.map +1 -1
- package/dist/comments/records.js +255 -31
- package/dist/comments/records.js.map +1 -1
- package/dist/comments/reply-notices.d.ts +42 -0
- package/dist/comments/reply-notices.d.ts.map +1 -0
- package/dist/comments/reply-notices.js +76 -0
- package/dist/comments/reply-notices.js.map +1 -0
- package/dist/comments/reply-post.d.ts +49 -0
- package/dist/comments/reply-post.d.ts.map +1 -0
- package/dist/comments/reply-post.js +66 -0
- package/dist/comments/reply-post.js.map +1 -0
- package/dist/comments/routes.d.ts +6 -1
- package/dist/comments/routes.d.ts.map +1 -1
- package/dist/comments/routes.js +49 -21
- package/dist/comments/routes.js.map +1 -1
- package/dist/comments/submission.d.ts +8 -25
- package/dist/comments/submission.d.ts.map +1 -1
- package/dist/comments/submission.js +19 -38
- package/dist/comments/submission.js.map +1 -1
- package/dist/comments/viewer.d.ts.map +1 -1
- package/dist/comments/viewer.js +1 -0
- package/dist/comments/viewer.js.map +1 -1
- package/dist/content/citation.d.ts +1 -0
- package/dist/content/citation.d.ts.map +1 -1
- package/dist/content/citation.js +2 -0
- package/dist/content/citation.js.map +1 -1
- package/dist/content/original.d.ts +6 -1
- package/dist/content/original.d.ts.map +1 -1
- package/dist/content/original.js +7 -4
- package/dist/content/original.js.map +1 -1
- package/dist/content/store.d.ts +43 -11
- package/dist/content/store.d.ts.map +1 -1
- package/dist/content/store.js +170 -10
- package/dist/content/store.js.map +1 -1
- package/dist/content/sync.d.ts +8 -0
- package/dist/content/sync.d.ts.map +1 -1
- package/dist/content/sync.js +164 -71
- package/dist/content/sync.js.map +1 -1
- package/dist/federation/article.d.ts.map +1 -1
- package/dist/federation/article.js +2 -1
- package/dist/federation/article.js.map +1 -1
- package/dist/federation/backfill.d.ts +114 -0
- package/dist/federation/backfill.d.ts.map +1 -0
- package/dist/federation/backfill.js +314 -0
- package/dist/federation/backfill.js.map +1 -0
- package/dist/federation/inbox.js +1 -1
- package/dist/federation/inbox.js.map +1 -1
- package/dist/federation/records.d.ts +13 -0
- package/dist/federation/records.d.ts.map +1 -1
- package/dist/federation/records.js +49 -10
- package/dist/federation/records.js.map +1 -1
- package/dist/federation/replies.d.ts +6 -0
- package/dist/federation/replies.d.ts.map +1 -1
- package/dist/federation/replies.js +18 -1
- package/dist/federation/replies.js.map +1 -1
- package/dist/index.d.ts +12 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +81 -11
- package/dist/index.js.map +1 -1
- package/dist/init.d.ts +1 -1
- package/dist/init.d.ts.map +1 -1
- package/dist/init.js +1 -7
- package/dist/init.js.map +1 -1
- package/dist/plugin.d.ts +28 -2
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +10 -2
- package/dist/plugin.js.map +1 -1
- package/dist/plugins/site.d.ts.map +1 -1
- package/dist/plugins/site.js +7 -1
- package/dist/plugins/site.js.map +1 -1
- package/dist/web/context.d.ts +18 -3
- package/dist/web/context.d.ts.map +1 -1
- package/dist/web/context.js +17 -4
- package/dist/web/context.js.map +1 -1
- package/dist/web/conversation.d.ts +191 -28
- package/dist/web/conversation.d.ts.map +1 -1
- package/dist/web/conversation.js +670 -172
- package/dist/web/conversation.js.map +1 -1
- package/dist/web/documents.d.ts +0 -22
- package/dist/web/documents.d.ts.map +1 -1
- package/dist/web/documents.js +1 -34
- package/dist/web/documents.js.map +1 -1
- package/dist/web/feed-item.d.ts +12 -8
- package/dist/web/feed-item.d.ts.map +1 -1
- package/dist/web/feed-item.js +10 -10
- package/dist/web/feed-item.js.map +1 -1
- package/dist/web/feed-rss.d.ts.map +1 -1
- package/dist/web/feed-rss.js +34 -10
- package/dist/web/feed-rss.js.map +1 -1
- package/dist/web/feed-source.d.ts +33 -1
- package/dist/web/feed-source.d.ts.map +1 -1
- package/dist/web/feed-source.js +33 -0
- package/dist/web/feed-source.js.map +1 -1
- package/dist/web/feeds.d.ts +1 -1
- package/dist/web/feeds.d.ts.map +1 -1
- package/dist/web/feeds.js +7 -2
- package/dist/web/feeds.js.map +1 -1
- package/dist/web/guids.d.ts +47 -0
- package/dist/web/guids.d.ts.map +1 -0
- package/dist/web/guids.js +67 -0
- package/dist/web/guids.js.map +1 -0
- package/dist/web/index.d.ts +5 -6
- package/dist/web/index.d.ts.map +1 -1
- package/dist/web/index.js +3 -3
- package/dist/web/index.js.map +1 -1
- package/dist/web/redirects.d.ts +12 -0
- package/dist/web/redirects.d.ts.map +1 -1
- package/dist/web/redirects.js +16 -4
- package/dist/web/redirects.js.map +1 -1
- package/dist/web/render.d.ts +19 -9
- package/dist/web/render.d.ts.map +1 -1
- package/dist/web/render.js +44 -42
- package/dist/web/render.js.map +1 -1
- package/dist/web/routes.d.ts +2 -2
- package/dist/web/routes.d.ts.map +1 -1
- package/dist/web/routes.js +157 -84
- package/dist/web/routes.js.map +1 -1
- package/dist/webmention/index.d.ts +1 -1
- package/dist/webmention/index.d.ts.map +1 -1
- package/dist/webmention/index.js +1 -1
- package/dist/webmention/index.js.map +1 -1
- package/dist/webmention/links.d.ts +55 -0
- package/dist/webmention/links.d.ts.map +1 -1
- package/dist/webmention/links.js +162 -0
- package/dist/webmention/links.js.map +1 -1
- package/dist/webmention/microformats.d.ts +15 -0
- package/dist/webmention/microformats.d.ts.map +1 -1
- package/dist/webmention/microformats.js +63 -1
- package/dist/webmention/microformats.js.map +1 -1
- package/dist/webmention/pingback.js +2 -1
- package/dist/webmention/pingback.js.map +1 -1
- package/dist/webmention/receive.d.ts +5 -0
- package/dist/webmention/receive.d.ts.map +1 -1
- package/dist/webmention/receive.js +99 -3
- package/dist/webmention/receive.js.map +1 -1
- package/dist/webmention/reply-context.d.ts +15 -0
- package/dist/webmention/reply-context.d.ts.map +1 -1
- package/dist/webmention/reply-context.js +29 -0
- package/dist/webmention/reply-context.js.map +1 -1
- package/dist/webmention/reply-contexts.d.ts +6 -0
- package/dist/webmention/reply-contexts.d.ts.map +1 -1
- package/dist/webmention/reply-contexts.js +16 -10
- package/dist/webmention/reply-contexts.js.map +1 -1
- package/dist/webmention/routes.d.ts.map +1 -1
- package/dist/webmention/routes.js +2 -1
- package/dist/webmention/routes.js.map +1 -1
- package/dist/webmention/service.d.ts +30 -1
- package/dist/webmention/service.d.ts.map +1 -1
- package/dist/webmention/service.js +172 -32
- package/dist/webmention/service.js.map +1 -1
- package/package.json +1 -2
- package/themes/default/README.md +269 -95
- package/themes/default/layouts/base.njk +8 -0
- package/themes/default/layouts/comment.njk +90 -0
- package/themes/default/layouts/front-page.njk +9 -47
- package/themes/default/layouts/page.njk +2 -0
- package/themes/default/layouts/post.njk +14 -2
- package/themes/default/partials/backlinks.njk +19 -0
- package/themes/default/partials/cited-page.njk +3 -1
- package/themes/default/partials/comment-form.njk +10 -0
- package/themes/default/partials/comment.njk +79 -0
- package/themes/default/partials/conversation.njk +14 -59
- package/themes/default/src/style.css +42 -0
- package/themes/default/static/style.css +67 -0
package/dist/web/conversation.js
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
|
+
import { readSiteJson, readSiteSettings } from '../admin/settings.js';
|
|
1
2
|
import { avatarHref } from '../avatars/avatars.js';
|
|
3
|
+
import { answerable, commentPolicyOf } from '../comments/policy.js';
|
|
2
4
|
import { postLabel, postTypeOf } from '../content/post-type.js';
|
|
3
5
|
import { RSVP_LABELS } from '../content/rsvp.js';
|
|
4
6
|
import { readAllQuoteAuthorizations } from '../federation/quotes.js';
|
|
5
7
|
import { actorHandle, guessedName, quoteFrom, replyFrom, REPLY_ACTIVITY_TYPE, } from '../federation/replies.js';
|
|
6
|
-
import {
|
|
8
|
+
import { authorContext, authorFeedHref, profileContext, siteAuthorContext } from './authors.js';
|
|
9
|
+
import { activityStreamsId, isListed, isServed, permalinkOfObjectId } from './documents.js';
|
|
10
|
+
import { isKey, repliesFeedPath, repliesKey } from './feed-source.js';
|
|
11
|
+
import { COMMENT_PAGE_PREFIX, commentPageHref, feedGuid } from './guids.js';
|
|
7
12
|
import { absoluteUrl } from './negotiate.js';
|
|
8
13
|
import { sanitizeCommentHtml } from './sanitize.js';
|
|
9
14
|
const RSVP_GROUP_ORDER = ['yes', 'maybe', 'interested', 'no'];
|
|
@@ -23,19 +28,16 @@ const RSVP_ACTIVITIES = {
|
|
|
23
28
|
export function createConversation(context) {
|
|
24
29
|
return {
|
|
25
30
|
thread: (document) => postConversation(context, document),
|
|
26
|
-
counts: (documents) =>
|
|
31
|
+
counts: (documents) => directCounts(context, documents),
|
|
27
32
|
latest: (limit) => siteConversation(context, limit),
|
|
33
|
+
comment: (id) => commentThread(context, id),
|
|
34
|
+
replyNamed: (document, url) => replyNamed(context, document, url),
|
|
35
|
+
heldAt: (url) => heldAt(context, url),
|
|
36
|
+
repliesTo: (segment) => repliesTo(context, segment),
|
|
37
|
+
documentOf: (url) => documentNamed(context, url),
|
|
38
|
+
upstreams: (document) => upstreamsOf(context, document),
|
|
28
39
|
};
|
|
29
40
|
}
|
|
30
|
-
/** A conversation with nothing in it, which is what most posts have. */
|
|
31
|
-
const NOTHING = {
|
|
32
|
-
replies: [],
|
|
33
|
-
likes: [],
|
|
34
|
-
boosts: [],
|
|
35
|
-
mentions: [],
|
|
36
|
-
rsvps: [],
|
|
37
|
-
counts: { replies: 0, likes: 0, boosts: 0, mentions: 0, rsvps: 0, total: 0 },
|
|
38
|
-
};
|
|
39
41
|
/**
|
|
40
42
|
* The conversation under one post: everything said about it, from every source
|
|
41
43
|
* the site has, threaded into one.
|
|
@@ -45,26 +47,57 @@ const NOTHING = {
|
|
|
45
47
|
* can still have native comments, which hang off its permalink instead.
|
|
46
48
|
*/
|
|
47
49
|
function postConversation(context, document) {
|
|
50
|
+
const said = gather(context, document);
|
|
51
|
+
const replies = threadOf(said);
|
|
52
|
+
sortThread(replies);
|
|
53
|
+
const { likes, boosts, mentions } = said;
|
|
54
|
+
likes.sort(byPublished);
|
|
55
|
+
boosts.sort(byPublished);
|
|
56
|
+
mentions.sort(byPublished);
|
|
57
|
+
const counted = countReplies(replies);
|
|
58
|
+
const rsvps = rsvpGroups(said.answers);
|
|
59
|
+
const answered = rsvps.reduce((sum, group) => sum + group.people.length, 0);
|
|
60
|
+
return {
|
|
61
|
+
replies,
|
|
62
|
+
likes,
|
|
63
|
+
boosts,
|
|
64
|
+
mentions,
|
|
65
|
+
rsvps,
|
|
66
|
+
counts: {
|
|
67
|
+
replies: counted,
|
|
68
|
+
likes: likes.length,
|
|
69
|
+
boosts: boosts.length,
|
|
70
|
+
mentions: mentions.length,
|
|
71
|
+
rsvps: answered,
|
|
72
|
+
total: counted + likes.length + boosts.length + mentions.length + answered,
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
function gather(context, document, seen = new Set([document.path])) {
|
|
48
77
|
const objectId = activityStreamsId(document, context.baseUrl);
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
const
|
|
78
|
+
const root = rootOf(context, document);
|
|
79
|
+
const stored = context.admin.listCommentsFor(document.slug);
|
|
80
|
+
const users = context.users();
|
|
81
|
+
// What was imported from the fediverse is walked from too: an answer to an
|
|
82
|
+
// imported note names the note, which no logged activity may carry.
|
|
83
|
+
const origins = stored.flatMap((comment) => comment.source === 'activitypub' && comment.origin !== undefined ? [comment.origin] : []);
|
|
84
|
+
const activities = objectId === undefined ? [] : activitiesAround(context.admin, [objectId, ...origins]);
|
|
55
85
|
const naming = authorNaming(context.admin);
|
|
56
86
|
const quotes = objectId === undefined
|
|
57
87
|
? []
|
|
58
88
|
: quotesOf(context.admin, readAllQuoteAuthorizations(context.contentDir), objectId, naming);
|
|
59
|
-
if (activities.length === 0 && native.length === 0 && quotes.length === 0)
|
|
60
|
-
return NOTHING;
|
|
61
89
|
const withdrawn = withdrawnBy(activities);
|
|
62
90
|
const written = [];
|
|
63
91
|
const likes = [];
|
|
64
92
|
const boosts = [];
|
|
65
93
|
const mentions = [...quotes];
|
|
66
94
|
const answers = [];
|
|
67
|
-
const
|
|
95
|
+
const held = new Map();
|
|
96
|
+
// One like per actor, so a like imported from the site this one replaced
|
|
97
|
+
// and the same person's like delivered here are one.
|
|
98
|
+
const reacted = new Set(stored.flatMap(({ kind, actor }) => actor === undefined || (kind !== 'like' && kind !== 'boost' && kind !== 'repost')
|
|
99
|
+
? []
|
|
100
|
+
: [`${kind === 'like' ? 'like' : 'boost'}\0${actor}`]));
|
|
68
101
|
const isEvent = postTypeOf(document) === 'event';
|
|
69
102
|
for (const activity of activities) {
|
|
70
103
|
const answer = isEvent ? RSVP_ACTIVITIES[activity.activityType] : undefined;
|
|
@@ -75,7 +108,7 @@ function postConversation(context, document) {
|
|
|
75
108
|
continue;
|
|
76
109
|
const author = naming(activity.actorId);
|
|
77
110
|
answers.push({
|
|
78
|
-
|
|
111
|
+
...unstored(activity.activityId ?? `${activity.actorId}#rsvp`),
|
|
79
112
|
source: 'activitypub',
|
|
80
113
|
kind: 'reply',
|
|
81
114
|
author,
|
|
@@ -95,17 +128,21 @@ function postConversation(context, document) {
|
|
|
95
128
|
// belongs to that reply's own conversation, wherever it is shown.
|
|
96
129
|
if (activity.objectId !== objectId)
|
|
97
130
|
continue;
|
|
98
|
-
// One like per actor, however many times it was delivered, and none
|
|
99
|
-
// all once its actor has taken it back.
|
|
131
|
+
// One like per actor, however many times it was delivered, and none
|
|
132
|
+
// at all once its actor has taken it back.
|
|
100
133
|
const key = `${kind}\0${activity.actorId}`;
|
|
101
134
|
if (reacted.has(key))
|
|
102
135
|
continue;
|
|
103
136
|
if (activity.activityId !== null && withdrawn.has(activity.activityId))
|
|
104
137
|
continue;
|
|
138
|
+
if (activity.activityId !== null &&
|
|
139
|
+
heldComment(context.admin, activity.activityId) !== undefined) {
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
105
142
|
reacted.add(key);
|
|
106
143
|
const author = naming(activity.actorId);
|
|
107
144
|
(kind === 'like' ? likes : boosts).push({
|
|
108
|
-
|
|
145
|
+
...unstored(activity.activityId ?? `${activity.actorId}#${kind}`),
|
|
109
146
|
source: 'activitypub',
|
|
110
147
|
kind,
|
|
111
148
|
author,
|
|
@@ -119,11 +156,17 @@ function postConversation(context, document) {
|
|
|
119
156
|
continue;
|
|
120
157
|
}
|
|
121
158
|
const reply = replyFrom(activity);
|
|
122
|
-
if (reply === undefined)
|
|
159
|
+
if (reply === undefined || heldComment(context.admin, reply.id) !== undefined)
|
|
123
160
|
continue;
|
|
124
|
-
|
|
161
|
+
const parent = heldComment(context.admin, reply.inReplyTo);
|
|
162
|
+
written.push(federatedInteraction(parent === undefined ? reply : { ...reply, inReplyTo: parent.id }, naming(activity.actorId)));
|
|
125
163
|
}
|
|
126
|
-
for (const
|
|
164
|
+
for (const record of stored) {
|
|
165
|
+
const comment = interactionOf(record, document.permalink, context.baseUrl, users);
|
|
166
|
+
if (record.status !== 'approved') {
|
|
167
|
+
held.set(comment.id, comment);
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
127
170
|
// A repost joins the boosts, because a reader looking at the page is being
|
|
128
171
|
// told the same thing by both; a mention is its own group, because it is
|
|
129
172
|
// neither an answer nor a reaction.
|
|
@@ -138,35 +181,353 @@ function postConversation(context, document) {
|
|
|
138
181
|
: boosts;
|
|
139
182
|
group.push({ ...comment, replies: [] });
|
|
140
183
|
}
|
|
184
|
+
// The reply posts answering the post or anything said under it, each
|
|
185
|
+
// bringing what was said under it: an answer to a reply post lands against
|
|
186
|
+
// the reply post, and this is what stitches it into the thread it is in.
|
|
187
|
+
const names = namesIn(context, document, root, [...written, ...held.values()]);
|
|
188
|
+
for (const answer of context.store.listRepliesTo([...names.keys()])) {
|
|
189
|
+
const target = names.get(answer.inReplyTo ?? '');
|
|
190
|
+
if (target === undefined || seen.has(answer.path))
|
|
191
|
+
continue;
|
|
192
|
+
const under = gather(context, answer, new Set([...seen, answer.path]));
|
|
193
|
+
const top = (entry) => entry.inReplyTo === null ? { ...entry, inReplyTo: under.root } : entry;
|
|
194
|
+
written.push(replyPostInteraction(context, answer, under.root, target));
|
|
195
|
+
written.push(...under.written.map(top));
|
|
196
|
+
for (const [id, entry] of under.held)
|
|
197
|
+
held.set(id, top(entry));
|
|
198
|
+
for (const id of under.withdrawn)
|
|
199
|
+
withdrawn.add(id);
|
|
200
|
+
}
|
|
141
201
|
// Sorted before threading rather than after, because a thread is built by
|
|
142
202
|
// walking this list: two sources' entries would otherwise interleave in the
|
|
143
203
|
// order the code happened to read them rather than in the order they were
|
|
144
204
|
// written.
|
|
145
205
|
written.sort(byPublished);
|
|
146
|
-
|
|
206
|
+
return { root, written, withdrawn, held, likes, boosts, mentions, answers };
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* One native comment's page: the comment, its replies threaded under it by
|
|
210
|
+
* the rule the post's thread uses, and the chain from it up to the post.
|
|
211
|
+
*
|
|
212
|
+
* Its replies are the same list threaded from the comment rather than from
|
|
213
|
+
* the post, so a reply the post's thread shows is shown here, and one it
|
|
214
|
+
* leaves out is left out here.
|
|
215
|
+
*/
|
|
216
|
+
function commentThread(context, id) {
|
|
217
|
+
const stored = context.admin.getComment(id);
|
|
218
|
+
if (stored?.source !== 'comment' || stored.status !== 'approved')
|
|
219
|
+
return undefined;
|
|
220
|
+
const post = context.store.getBySlug(stored.slug);
|
|
221
|
+
if (post === undefined)
|
|
222
|
+
return undefined;
|
|
223
|
+
const said = gather(context, post);
|
|
224
|
+
const comment = said.written.find((reply) => reply.id === id);
|
|
225
|
+
if (comment === undefined)
|
|
226
|
+
return undefined;
|
|
227
|
+
const replies = threadOf(said, id);
|
|
147
228
|
sortThread(replies);
|
|
148
|
-
likes.sort(byPublished);
|
|
149
|
-
boosts.sort(byPublished);
|
|
150
|
-
mentions.sort(byPublished);
|
|
151
|
-
const counted = countReplies(replies);
|
|
152
|
-
const rsvps = rsvpGroups(answers);
|
|
153
|
-
const answered = rsvps.reduce((sum, group) => sum + group.people.length, 0);
|
|
154
229
|
return {
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
mentions,
|
|
159
|
-
rsvps,
|
|
160
|
-
counts: {
|
|
161
|
-
replies: counted,
|
|
162
|
-
likes: likes.length,
|
|
163
|
-
boosts: boosts.length,
|
|
164
|
-
mentions: mentions.length,
|
|
165
|
-
rsvps: answered,
|
|
166
|
-
total: counted + likes.length + boosts.length + mentions.length + answered,
|
|
167
|
-
},
|
|
230
|
+
post,
|
|
231
|
+
comment: { ...comment, replies },
|
|
232
|
+
ancestors: ancestorsOf(said, comment),
|
|
168
233
|
};
|
|
169
234
|
}
|
|
235
|
+
function replyNamed(context, document, url) {
|
|
236
|
+
return namedIn(context, document, gather(context, document), url);
|
|
237
|
+
}
|
|
238
|
+
/** The entry of a gathered conversation a URL names, held ones included. */
|
|
239
|
+
function namedIn(context, document, said, url) {
|
|
240
|
+
let named;
|
|
241
|
+
try {
|
|
242
|
+
named = new URL(url, context.baseUrl);
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
return undefined;
|
|
246
|
+
}
|
|
247
|
+
const key = keyNamedBy(named, document, context.baseUrl);
|
|
248
|
+
return [...said.written, ...said.held.values()].find((reply) => reply.key === key ||
|
|
249
|
+
reply.id === named.href ||
|
|
250
|
+
(reply.url !== null && absoluteUrl(reply.url, context.baseUrl) === named.href));
|
|
251
|
+
}
|
|
252
|
+
function heldAt(context, url) {
|
|
253
|
+
const post = documentNamed(context, url);
|
|
254
|
+
if (post === undefined || !isServed(post, context.store.now()))
|
|
255
|
+
return undefined;
|
|
256
|
+
if (url === absoluteUrl(post.permalink, context.baseUrl) || url === rootOf(context, post)) {
|
|
257
|
+
return { kind: 'document', post, author: documentAuthor(context, post) };
|
|
258
|
+
}
|
|
259
|
+
const said = gather(context, post);
|
|
260
|
+
const reply = namedIn(context, post, said, url);
|
|
261
|
+
if (reply === undefined || said.withdrawn.has(reply.id) || said.held.has(reply.id)) {
|
|
262
|
+
return undefined;
|
|
263
|
+
}
|
|
264
|
+
return { kind: 'reply', post, reply };
|
|
265
|
+
}
|
|
266
|
+
function repliesTo(context, key) {
|
|
267
|
+
if (!isKey(key))
|
|
268
|
+
return undefined;
|
|
269
|
+
// Each source's index is asked in turn, so a key naming nothing costs three
|
|
270
|
+
// lookups and no more (TASK-327).
|
|
271
|
+
const [document] = context.store.listByRepliesKey(key);
|
|
272
|
+
if (document !== undefined) {
|
|
273
|
+
if (repliesKey(feedGuid(document, context.baseUrl)) === key) {
|
|
274
|
+
return repliesIn(context, document, null);
|
|
275
|
+
}
|
|
276
|
+
const root = threadRootOf(context, document);
|
|
277
|
+
return root === undefined ? undefined : repliesIn(context, root, key);
|
|
278
|
+
}
|
|
279
|
+
const comment = context.admin.getComment(key);
|
|
280
|
+
if (comment !== undefined) {
|
|
281
|
+
const post = context.store.getBySlug(comment.slug);
|
|
282
|
+
return post === undefined ? undefined : repliesIn(context, post, key);
|
|
283
|
+
}
|
|
284
|
+
for (const activity of context.admin.listRepliesByKey(key)) {
|
|
285
|
+
const reply = replyFrom(activity);
|
|
286
|
+
if (reply === undefined)
|
|
287
|
+
continue;
|
|
288
|
+
const post = documentNamed(context, reply.id);
|
|
289
|
+
return post === undefined ? undefined : repliesIn(context, post, key);
|
|
290
|
+
}
|
|
291
|
+
return undefined;
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* The direct replies of one reply in a document's thread, or of the document
|
|
295
|
+
* itself, while the document is served and the reply is one a reader sees.
|
|
296
|
+
*/
|
|
297
|
+
function repliesIn(context, post, key) {
|
|
298
|
+
if (!isServed(post, context.store.now()))
|
|
299
|
+
return undefined;
|
|
300
|
+
const thread = postConversation(context, post).replies;
|
|
301
|
+
const reply = key === null ? null : visibleReply(thread, key);
|
|
302
|
+
if (reply === undefined)
|
|
303
|
+
return undefined;
|
|
304
|
+
return {
|
|
305
|
+
post,
|
|
306
|
+
reply,
|
|
307
|
+
replies: repliesFed(reply?.replies ?? thread).map((entry) => ({ ...entry, parent: reply })),
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
function upstreamsOf(context, document) {
|
|
311
|
+
const top = threadRootOf(context, document) ?? document;
|
|
312
|
+
if (!isServed(top, context.store.now()))
|
|
313
|
+
return [];
|
|
314
|
+
const said = gather(context, top);
|
|
315
|
+
const site = new URL(context.baseUrl).origin;
|
|
316
|
+
const elsewhere = (url) => {
|
|
317
|
+
if (url === null || url === undefined)
|
|
318
|
+
return false;
|
|
319
|
+
try {
|
|
320
|
+
return new URL(url).origin !== site;
|
|
321
|
+
}
|
|
322
|
+
catch {
|
|
323
|
+
return false;
|
|
324
|
+
}
|
|
325
|
+
};
|
|
326
|
+
const found = [];
|
|
327
|
+
const posts = [
|
|
328
|
+
top,
|
|
329
|
+
...said.written.flatMap((entry) => {
|
|
330
|
+
const post = entry.source === 'post' ? context.store.getByPermalink(entry.url ?? '') : undefined;
|
|
331
|
+
return post === undefined ? [] : [post];
|
|
332
|
+
}),
|
|
333
|
+
];
|
|
334
|
+
for (const post of posts) {
|
|
335
|
+
if (!elsewhere(post.inReplyTo))
|
|
336
|
+
continue;
|
|
337
|
+
found.push({
|
|
338
|
+
post,
|
|
339
|
+
source: absoluteUrl(post.permalink, context.baseUrl),
|
|
340
|
+
target: post.inReplyTo,
|
|
341
|
+
replies: postConversation(context, post).replies,
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
const entries = new Map(said.written.map((entry) => [entry.id, entry]));
|
|
345
|
+
for (const entry of said.written) {
|
|
346
|
+
if (entry.source !== 'comment' || entry.inReplyTo === null)
|
|
347
|
+
continue;
|
|
348
|
+
const parent = entries.get(entry.inReplyTo);
|
|
349
|
+
if (parent?.source !== 'webmention' || !elsewhere(parent.url))
|
|
350
|
+
continue;
|
|
351
|
+
const page = commentThread(context, entry.id);
|
|
352
|
+
if (page === undefined)
|
|
353
|
+
continue;
|
|
354
|
+
found.push({
|
|
355
|
+
post: page.post,
|
|
356
|
+
source: absoluteUrl(commentPageHref(entry.id), context.baseUrl),
|
|
357
|
+
target: parent.url,
|
|
358
|
+
replies: page.comment.replies,
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
return found;
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* The document whose conversation a URL is in: the post a comment's page or
|
|
365
|
+
* anchor is on, the post a webmention was sent to, the post a fediverse reply
|
|
366
|
+
* answers however deep, or the document at the URL itself.
|
|
367
|
+
*/
|
|
368
|
+
function documentNamed(context, url, depth = 0) {
|
|
369
|
+
if (depth > MAXIMUM_NOTE_DEPTH)
|
|
370
|
+
return undefined;
|
|
371
|
+
let named;
|
|
372
|
+
try {
|
|
373
|
+
named = new URL(url);
|
|
374
|
+
}
|
|
375
|
+
catch {
|
|
376
|
+
return undefined;
|
|
377
|
+
}
|
|
378
|
+
if (named.origin === new URL(context.baseUrl).origin) {
|
|
379
|
+
const key = commentKeyAt(named.pathname);
|
|
380
|
+
if (key !== undefined) {
|
|
381
|
+
const comment = context.admin.getComment(key);
|
|
382
|
+
return comment === undefined ? undefined : context.store.getBySlug(comment.slug);
|
|
383
|
+
}
|
|
384
|
+
const permalink = permalinkOfObjectId(`${named.origin}${named.pathname}`, context.baseUrl);
|
|
385
|
+
const found = permalink === undefined ? undefined : context.store.getByPermalink(permalink);
|
|
386
|
+
return found ?? context.store.getByStoredObjectId(named.href);
|
|
387
|
+
}
|
|
388
|
+
const comment = context.admin.listCommentsAt(named.href)[0] ?? heldComment(context.admin, named.href);
|
|
389
|
+
if (comment !== undefined)
|
|
390
|
+
return context.store.getBySlug(comment.slug);
|
|
391
|
+
for (const activity of context.admin.listActivitiesAbout([named.href])) {
|
|
392
|
+
const reply = replyFrom(activity);
|
|
393
|
+
if (reply?.id !== named.href)
|
|
394
|
+
continue;
|
|
395
|
+
return documentNamed(context, reply.inReplyTo, depth + 1);
|
|
396
|
+
}
|
|
397
|
+
return context.store.getByStoredObjectId(named.href);
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* The document at the top of the conversation a reply post is in: what its
|
|
401
|
+
* `in-reply-to` names, and what that one is in when it is a reply post too.
|
|
402
|
+
* `undefined` for a reply post answering nothing on this site.
|
|
403
|
+
*/
|
|
404
|
+
function threadRootOf(context, document, seen = new Set()) {
|
|
405
|
+
seen.add(document.path);
|
|
406
|
+
if (document.inReplyTo === undefined)
|
|
407
|
+
return undefined;
|
|
408
|
+
const named = documentNamed(context, document.inReplyTo);
|
|
409
|
+
if (named === undefined || seen.has(named.path))
|
|
410
|
+
return undefined;
|
|
411
|
+
return threadRootOf(context, named, seen) ?? named;
|
|
412
|
+
}
|
|
413
|
+
/** How many notes deep a reply is followed back to the post it is about. */
|
|
414
|
+
const MAXIMUM_NOTE_DEPTH = 32;
|
|
415
|
+
/**
|
|
416
|
+
* What a top-level answer names. The object id when the post has one, and the
|
|
417
|
+
* permalink otherwise: a post that has never been delivered can still have
|
|
418
|
+
* native comments, and they have to hang off something.
|
|
419
|
+
*/
|
|
420
|
+
function rootOf(context, document) {
|
|
421
|
+
return activityStreamsId(document, context.baseUrl) ?? document.permalink;
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* Every URL a reply post's `in-reply-to` could name something here by, and the
|
|
425
|
+
* id each one names: the post by its permalink, every URL it used to live at
|
|
426
|
+
* and its object id, and every entry by its id, its url and, for a comment
|
|
427
|
+
* written here, its page and its anchor.
|
|
428
|
+
*/
|
|
429
|
+
function namesIn(context, document, root, replies) {
|
|
430
|
+
const permalink = absoluteUrl(document.permalink, context.baseUrl);
|
|
431
|
+
const former = (document.redirectFrom ?? []).map((url) => absoluteUrl(url, context.baseUrl));
|
|
432
|
+
const names = new Map([permalink, ...former, root].map((url) => [url, root]));
|
|
433
|
+
for (const reply of replies) {
|
|
434
|
+
for (const name of namesOf(reply, permalink, context.baseUrl)) {
|
|
435
|
+
if (!names.has(name))
|
|
436
|
+
names.set(name, reply.id);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
return names;
|
|
440
|
+
}
|
|
441
|
+
function namesOf(reply, permalink, baseUrl) {
|
|
442
|
+
const names = [reply.id];
|
|
443
|
+
if (reply.url !== null)
|
|
444
|
+
names.push(absoluteUrl(reply.url, baseUrl));
|
|
445
|
+
if (reply.source === 'comment')
|
|
446
|
+
names.push(...commentNames(reply.id, permalink, baseUrl));
|
|
447
|
+
return names;
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* The URLs a reply post can answer a native comment by: its own page, and its
|
|
451
|
+
* anchor on the post at this absolute permalink.
|
|
452
|
+
*/
|
|
453
|
+
export function commentNames(id, permalink, baseUrl) {
|
|
454
|
+
return [absoluteUrl(commentPageHref(id), baseUrl), `${permalink}#${commentAnchor(id)}`];
|
|
455
|
+
}
|
|
456
|
+
/** A reply post as the thread's own shape, answering `inReplyTo`. */
|
|
457
|
+
function replyPostInteraction(context, post, id, inReplyTo) {
|
|
458
|
+
return {
|
|
459
|
+
...unstored(id),
|
|
460
|
+
source: 'post',
|
|
461
|
+
kind: 'reply',
|
|
462
|
+
author: documentAuthor(context, post),
|
|
463
|
+
url: post.permalink,
|
|
464
|
+
content: post.html,
|
|
465
|
+
published: new Date(post.date ?? post.updated ?? 0),
|
|
466
|
+
inReplyTo,
|
|
467
|
+
status: 'published',
|
|
468
|
+
replies: [],
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
/** Who a post or page on this site is by: its author, else the site's. */
|
|
472
|
+
function documentAuthor(context, post) {
|
|
473
|
+
const users = context.users();
|
|
474
|
+
const site = readSiteSettings(context.contentDir);
|
|
475
|
+
const author = authorContext(users, post.author) ?? siteAuthorContext(users, site.author);
|
|
476
|
+
return bylineAuthor(author, site.title);
|
|
477
|
+
}
|
|
478
|
+
/** A byline as the thread names its author, `fallback` when it names nobody. */
|
|
479
|
+
function bylineAuthor(author, fallback) {
|
|
480
|
+
return {
|
|
481
|
+
name: author?.name ?? fallback,
|
|
482
|
+
handle: null,
|
|
483
|
+
url: author?.url ?? null,
|
|
484
|
+
avatar: author?.avatar ?? null,
|
|
485
|
+
actorId: null,
|
|
486
|
+
feed: author?.username === undefined ? null : authorFeedHref(author.username, 'rss'),
|
|
487
|
+
username: author?.username ?? null,
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
/** The key a URL on this site names by a comment's page or by an anchor on the post. */
|
|
491
|
+
function keyNamedBy(url, document, baseUrl) {
|
|
492
|
+
const post = new URL(absoluteUrl(document.permalink, baseUrl));
|
|
493
|
+
if (url.origin !== post.origin)
|
|
494
|
+
return undefined;
|
|
495
|
+
const onItsPage = commentKeyAt(url.pathname);
|
|
496
|
+
if (onItsPage !== undefined)
|
|
497
|
+
return onItsPage;
|
|
498
|
+
const anchor = `#${commentAnchor('')}`;
|
|
499
|
+
if (url.pathname !== post.pathname || !url.hash.startsWith(anchor))
|
|
500
|
+
return undefined;
|
|
501
|
+
return url.hash.slice(anchor.length);
|
|
502
|
+
}
|
|
503
|
+
/**
|
|
504
|
+
* What a reply answers, from the top of the thread down to its parent.
|
|
505
|
+
*
|
|
506
|
+
* An ancestor a reader may not see is a `null`, and the walk goes on through
|
|
507
|
+
* what that one answered when the site still knows it: a pending or spam
|
|
508
|
+
* comment is in the index, and a withdrawn note is still in the log. A deleted
|
|
509
|
+
* comment is known by nothing, so the chain stops at it and goes straight to
|
|
510
|
+
* the post. Each id is visited once, so a cycle ends.
|
|
511
|
+
*/
|
|
512
|
+
function ancestorsOf(said, reply) {
|
|
513
|
+
const all = new Map(said.written.map((entry) => [entry.id, entry]));
|
|
514
|
+
const chain = [];
|
|
515
|
+
const seen = new Set([reply.id]);
|
|
516
|
+
for (let target = reply.inReplyTo; target !== null && target !== said.root;) {
|
|
517
|
+
if (seen.has(target))
|
|
518
|
+
break;
|
|
519
|
+
seen.add(target);
|
|
520
|
+
const known = all.get(target);
|
|
521
|
+
if (known !== undefined && !said.withdrawn.has(target)) {
|
|
522
|
+
chain.unshift(known);
|
|
523
|
+
target = known.inReplyTo;
|
|
524
|
+
continue;
|
|
525
|
+
}
|
|
526
|
+
chain.unshift(null);
|
|
527
|
+
target = (known ?? said.held.get(target))?.inReplyTo ?? null;
|
|
528
|
+
}
|
|
529
|
+
return chain;
|
|
530
|
+
}
|
|
170
531
|
function rsvpGroups(answers) {
|
|
171
532
|
const people = latestAnswerPerPerson(answers);
|
|
172
533
|
return RSVP_GROUP_ORDER.map((value) => ({
|
|
@@ -196,6 +557,9 @@ function siteConversation(context, limit) {
|
|
|
196
557
|
const naming = authorNaming(context.admin);
|
|
197
558
|
const posts = new PostsByObjectId(context);
|
|
198
559
|
const now = context.store.now();
|
|
560
|
+
const policy = commentPolicyOf(readSiteJson(context.contentDir));
|
|
561
|
+
// A page that takes no comments shows none, so the site's feed has none of its.
|
|
562
|
+
const shown = (post) => post !== undefined && isListed(post, now) && answerable(post, policy, now);
|
|
199
563
|
const said = [];
|
|
200
564
|
for (let offset = 0; said.length < limit;) {
|
|
201
565
|
const page = context.admin.listReplies({ limit: limit * OVERSCAN, offset });
|
|
@@ -204,10 +568,12 @@ function siteConversation(context, limit) {
|
|
|
204
568
|
offset += page.length;
|
|
205
569
|
for (const activity of page) {
|
|
206
570
|
const reply = replyFrom(activity);
|
|
207
|
-
if (reply === undefined)
|
|
571
|
+
if (reply === undefined || heldComment(context.admin, reply.id) !== undefined)
|
|
208
572
|
continue;
|
|
209
|
-
|
|
210
|
-
|
|
573
|
+
// A note answering another note is as much this post's as one answering
|
|
574
|
+
// the post, so the post is found the way a reply post finds its thread.
|
|
575
|
+
const post = documentNamed(context, reply.inReplyTo);
|
|
576
|
+
if (!shown(post))
|
|
211
577
|
continue;
|
|
212
578
|
said.push({ ...federatedInteraction(reply, naming(activity.actorId)), post });
|
|
213
579
|
if (said.length === limit)
|
|
@@ -218,37 +584,62 @@ function siteConversation(context, limit) {
|
|
|
218
584
|
// approval per quote, and a site has few enough to read whole.
|
|
219
585
|
for (const approval of readAllQuoteAuthorizations(context.contentDir)) {
|
|
220
586
|
const post = posts.get(approval.post);
|
|
221
|
-
if (
|
|
587
|
+
if (!shown(post))
|
|
222
588
|
continue;
|
|
223
589
|
for (const quote of quotesOf(context.admin, [approval], approval.post, naming)) {
|
|
224
590
|
said.push({ ...quote, post });
|
|
225
591
|
}
|
|
226
592
|
}
|
|
593
|
+
// Each post's thread, read once: where each reply sits and what it carries
|
|
594
|
+
// is the thread's to say, so the site's feed and the post's agree.
|
|
595
|
+
const threads = new Map();
|
|
596
|
+
const placedOn = (post) => {
|
|
597
|
+
let found = threads.get(post.path);
|
|
598
|
+
if (found === undefined) {
|
|
599
|
+
found = new Map(saidIn(postConversation(context, post)).map((reply) => [reply.id, reply]));
|
|
600
|
+
threads.set(post.path, found);
|
|
601
|
+
}
|
|
602
|
+
return found;
|
|
603
|
+
};
|
|
604
|
+
// A stored comment is taken from its post's thread rather than read on its
|
|
605
|
+
// own, so a reaction or an event's RSVP, which the thread holds apart, is
|
|
606
|
+
// left out here as it is from the post's feed.
|
|
227
607
|
for (const stored of context.admin.listComments({
|
|
228
608
|
status: 'approved',
|
|
229
609
|
limit: limit * OVERSCAN,
|
|
230
610
|
})) {
|
|
231
611
|
const post = context.store.getBySlug(stored.slug);
|
|
232
|
-
if (
|
|
612
|
+
if (!shown(post))
|
|
233
613
|
continue;
|
|
234
|
-
|
|
614
|
+
const entry = placedOn(post).get(stored.id);
|
|
615
|
+
if (entry !== undefined)
|
|
616
|
+
said.push({ ...entry, post });
|
|
235
617
|
}
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
const federated = objectId === undefined
|
|
246
|
-
? 0
|
|
247
|
-
: context.admin.countRepliesTo(objectId) +
|
|
248
|
-
quotesOf(context.admin, approvals, objectId, naming).length;
|
|
249
|
-
counts.set(document.permalink, federated + context.admin.countCommentsFor(document.slug, 'approved'));
|
|
618
|
+
// A reply post is in the feed of the thread it is shown in, unlisted or
|
|
619
|
+
// not: the thread is listed, and the reply post is one of its answers.
|
|
620
|
+
for (const answer of context.store.listReplyPosts({ limit: limit * OVERSCAN })) {
|
|
621
|
+
const post = threadRootOf(context, answer);
|
|
622
|
+
if (!shown(post))
|
|
623
|
+
continue;
|
|
624
|
+
const reply = placedOn(post).get(rootOf(context, answer));
|
|
625
|
+
if (reply !== undefined)
|
|
626
|
+
said.push({ ...reply, post });
|
|
250
627
|
}
|
|
251
|
-
return
|
|
628
|
+
return newestFirst(said.filter(conversational), limit).map((entry) => {
|
|
629
|
+
const thread = placedOn(entry.post);
|
|
630
|
+
return {
|
|
631
|
+
...(thread.get(entry.id) ?? { ...entry, parent: null }),
|
|
632
|
+
post: entry.post,
|
|
633
|
+
replyingTo: entry.inReplyTo === null ? null : (thread.get(entry.inReplyTo)?.author.name ?? null),
|
|
634
|
+
};
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
/** How many visible replies answer each of these documents directly, by permalink. */
|
|
638
|
+
function directCounts(context, documents) {
|
|
639
|
+
return new Map(documents.map((document) => [
|
|
640
|
+
document.permalink,
|
|
641
|
+
repliesFed(postConversation(context, document).replies).length,
|
|
642
|
+
]));
|
|
252
643
|
}
|
|
253
644
|
/**
|
|
254
645
|
* How many candidates a site-wide page reads for each one it keeps.
|
|
@@ -281,7 +672,7 @@ function quotesOf(admin, approvals, objectId, naming) {
|
|
|
281
672
|
if (quote?.id !== approval.quote || quote.quoted !== objectId)
|
|
282
673
|
continue;
|
|
283
674
|
quotes.push({
|
|
284
|
-
|
|
675
|
+
...unstored(quote.id),
|
|
285
676
|
source: 'activitypub',
|
|
286
677
|
kind: 'mention',
|
|
287
678
|
author: naming(quote.actorId),
|
|
@@ -302,7 +693,7 @@ function quotesOf(admin, approvals, objectId, naming) {
|
|
|
302
693
|
/** One logged fediverse reply as the thread's own shape. */
|
|
303
694
|
function federatedInteraction(reply, author) {
|
|
304
695
|
return {
|
|
305
|
-
|
|
696
|
+
...unstored(reply.id),
|
|
306
697
|
source: 'activitypub',
|
|
307
698
|
kind: 'reply',
|
|
308
699
|
author,
|
|
@@ -316,42 +707,41 @@ function federatedInteraction(reply, author) {
|
|
|
316
707
|
replies: [],
|
|
317
708
|
};
|
|
318
709
|
}
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
*
|
|
322
|
-
* Only approved ones. A comment waiting for a moderator and one filed as spam
|
|
323
|
-
* are both in the file and both in the index, and neither is anything a reader
|
|
324
|
-
* should be shown — which is the whole difference between this and what the
|
|
325
|
-
* moderation screen asks for.
|
|
326
|
-
*/
|
|
327
|
-
function approvedComments(admin, document) {
|
|
328
|
-
return admin
|
|
329
|
-
.listCommentsFor(document.slug)
|
|
330
|
-
.filter((comment) => comment.status === 'approved')
|
|
331
|
-
.map((comment) => interactionOf(comment, document.permalink));
|
|
710
|
+
function unstored(id) {
|
|
711
|
+
return { id, key: repliesKey(id), guid: id, migrated: false };
|
|
332
712
|
}
|
|
333
713
|
/** One stored comment as the thread's own shape. */
|
|
334
|
-
function interactionOf(comment, permalink) {
|
|
714
|
+
function interactionOf(comment, permalink, baseUrl, users) {
|
|
715
|
+
// A user the site no longer has is named as the comment was signed.
|
|
716
|
+
const user = users.find((candidate) => candidate.username === comment.user);
|
|
335
717
|
return {
|
|
336
718
|
id: comment.id,
|
|
719
|
+
key: comment.id,
|
|
720
|
+
guid: comment.source === 'comment'
|
|
721
|
+
? absoluteUrl(commentPageHref(comment.id), baseUrl)
|
|
722
|
+
: (comment.origin ?? comment.url ?? comment.id),
|
|
723
|
+
migrated: comment.migrated === true,
|
|
337
724
|
source: comment.source,
|
|
338
725
|
kind: comment.kind,
|
|
339
|
-
author:
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
// Where it can be read. A comment written here
|
|
351
|
-
//
|
|
352
|
-
// says so. A theme that prints `url` for a fediverse reply prints a
|
|
726
|
+
author: user === undefined
|
|
727
|
+
? {
|
|
728
|
+
name: comment.author.name,
|
|
729
|
+
handle: comment.actor === undefined ? null : (actorHandle(comment.actor) ?? null),
|
|
730
|
+
url: comment.author.url,
|
|
731
|
+
avatar: comment.author.avatar === null ? null : avatarHref(comment.author.avatar),
|
|
732
|
+
actorId: comment.actor ?? null,
|
|
733
|
+
feed: comment.source === 'webmention' ? comment.author.url : null,
|
|
734
|
+
username: null,
|
|
735
|
+
}
|
|
736
|
+
: bylineAuthor(profileContext(user), comment.author.name),
|
|
737
|
+
// Where it can be read. A comment written here has a page of its own
|
|
738
|
+
// (TASK-318); a webmention lives on the page it was sent from, and its
|
|
739
|
+
// `url` says so. A theme that prints `url` for a fediverse reply prints a
|
|
353
740
|
// working link for either, and so does a feed.
|
|
354
|
-
url: comment.url ??
|
|
741
|
+
url: comment.url ??
|
|
742
|
+
(comment.source === 'comment'
|
|
743
|
+
? commentPageHref(comment.id)
|
|
744
|
+
: `${permalink}#${commentAnchor(comment.id)}`),
|
|
355
745
|
content: comment.content.html,
|
|
356
746
|
published: new Date(comment.submitted),
|
|
357
747
|
inReplyTo: comment.inReplyTo,
|
|
@@ -363,33 +753,71 @@ function interactionOf(comment, permalink) {
|
|
|
363
753
|
};
|
|
364
754
|
}
|
|
365
755
|
/**
|
|
366
|
-
* The
|
|
756
|
+
* The stored comment holding a fediverse note or activity id as its origin:
|
|
757
|
+
* one imported from the site this one replaced, keyed by that id as it would
|
|
758
|
+
* have been had it arrived here (TASK-336). A delivery or a fetch of the same
|
|
759
|
+
* id is that comment again, and the stored one wins, since it is the one the
|
|
760
|
+
* moderator decided on and the thread anchors.
|
|
761
|
+
*/
|
|
762
|
+
export function heldComment(admin, id) {
|
|
763
|
+
const comment = admin.getComment(repliesKey(id));
|
|
764
|
+
return comment?.origin === id ? comment : undefined;
|
|
765
|
+
}
|
|
766
|
+
/**
|
|
767
|
+
* The `id` a thread entry is anchored at on the page, by its key.
|
|
367
768
|
*
|
|
368
769
|
* Prefixed rather than bare so a comment can never collide with a heading
|
|
369
770
|
* anchor the post's own Markdown produced.
|
|
370
771
|
*/
|
|
371
|
-
export function commentAnchor(
|
|
372
|
-
return `comment-${
|
|
772
|
+
export function commentAnchor(key) {
|
|
773
|
+
return `comment-${key}`;
|
|
774
|
+
}
|
|
775
|
+
/** The key of the comment whose page a path is, or `undefined` (TASK-319). */
|
|
776
|
+
export function commentKeyAt(pathname) {
|
|
777
|
+
if (!pathname.startsWith(COMMENT_PAGE_PREFIX) || !pathname.endsWith('/'))
|
|
778
|
+
return undefined;
|
|
779
|
+
const key = pathname.slice(COMMENT_PAGE_PREFIX.length, -1);
|
|
780
|
+
return isKey(key) ? key : undefined;
|
|
781
|
+
}
|
|
782
|
+
/** What a post's comments feed carries, at every depth and in no order. */
|
|
783
|
+
export function spokenIn(conversation) {
|
|
784
|
+
return saidIn(conversation).filter(conversational);
|
|
785
|
+
}
|
|
786
|
+
/** The mentions and every visible reply of a conversation, each with its parent. */
|
|
787
|
+
function saidIn(conversation) {
|
|
788
|
+
return [
|
|
789
|
+
...conversation.mentions.map((mention) => ({ ...mention, parent: null })),
|
|
790
|
+
...placed(conversation.replies),
|
|
791
|
+
];
|
|
373
792
|
}
|
|
374
793
|
/**
|
|
375
|
-
*
|
|
376
|
-
*
|
|
377
|
-
*
|
|
378
|
-
*
|
|
379
|
-
*
|
|
380
|
-
* polling a comments feed would get an entry with an empty body and no way to
|
|
381
|
-
* tell what it was for.
|
|
794
|
+
* Whether a feed carries it and a reply count counts it (TASK-335): somebody's
|
|
795
|
+
* words, a reply or a mention, said here. A like, a boost or a repost says
|
|
796
|
+
* nothing — "@ada liked this" would be an item with an empty body — and a
|
|
797
|
+
* migrated entry was published before the site came here, so a reader must
|
|
798
|
+
* not see it as new.
|
|
382
799
|
*/
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
800
|
+
function conversational(entry) {
|
|
801
|
+
return !entry.migrated && (entry.kind === 'reply' || entry.kind === 'mention');
|
|
802
|
+
}
|
|
803
|
+
/** Every visible reply of a thread at any depth, each with the reply it sits under. */
|
|
804
|
+
function placed(replies, parent = null) {
|
|
805
|
+
return directReplies(replies).flatMap((reply) => [
|
|
806
|
+
{ ...reply, parent },
|
|
807
|
+
...placed(reply.replies, reply),
|
|
808
|
+
]);
|
|
809
|
+
}
|
|
810
|
+
/**
|
|
811
|
+
* The visible replies directly under an entry of a thread (TASK-324): a
|
|
812
|
+
* hidden comment's visible replies count as its parent's, as {@link Placed}
|
|
813
|
+
* says, so every visible reply is directly under exactly one thing.
|
|
814
|
+
*/
|
|
815
|
+
function directReplies(replies) {
|
|
816
|
+
return replies.flatMap((reply) => (isWithheld(reply) ? directReplies(reply.replies) : [reply]));
|
|
817
|
+
}
|
|
818
|
+
/** The direct replies a feed carries and a count advertises. */
|
|
819
|
+
function repliesFed(replies) {
|
|
820
|
+
return directReplies(replies).filter(conversational);
|
|
393
821
|
}
|
|
394
822
|
/**
|
|
395
823
|
* Interactions as a comments feed carries them: newest first, at most `limit`,
|
|
@@ -401,16 +829,43 @@ export function spokenIn(conversation) {
|
|
|
401
829
|
* moment before it becomes bytes this site publishes.
|
|
402
830
|
*/
|
|
403
831
|
export function feedComments(said, options) {
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
832
|
+
const { baseUrl } = options;
|
|
833
|
+
return newestFirst([...said], options.limit).map((entry) => {
|
|
834
|
+
const thread = entry.post ?? options.post;
|
|
835
|
+
const answered = entry.parent === null
|
|
836
|
+
? thread === undefined
|
|
837
|
+
? undefined
|
|
838
|
+
: feedGuid(thread, baseUrl)
|
|
839
|
+
: entry.parent.guid;
|
|
840
|
+
const replies = repliesFed(entry.replies);
|
|
841
|
+
return {
|
|
842
|
+
id: entry.guid,
|
|
843
|
+
url: absoluteUrl(entry.url ?? '/', baseUrl),
|
|
844
|
+
author: entry.author.name,
|
|
845
|
+
published: entry.published,
|
|
846
|
+
html: entry.content,
|
|
847
|
+
...(answered === undefined ? {} : { inReplyTo: answered }),
|
|
848
|
+
...(replies.length === 0
|
|
849
|
+
? {}
|
|
850
|
+
: {
|
|
851
|
+
replies: {
|
|
852
|
+
count: replies.length,
|
|
853
|
+
feed: absoluteUrl(repliesFeedPath(entry.key), baseUrl),
|
|
854
|
+
},
|
|
855
|
+
}),
|
|
856
|
+
...(entry.author.feed === null
|
|
857
|
+
? {}
|
|
858
|
+
: { authorFeed: absoluteUrl(entry.author.feed, baseUrl) }),
|
|
859
|
+
...(entry.post === undefined
|
|
860
|
+
? {}
|
|
861
|
+
: {
|
|
862
|
+
post: { title: postLabel(entry.post, options.cited), permalink: entry.post.permalink },
|
|
863
|
+
}),
|
|
864
|
+
...(entry.replyingTo === undefined || entry.replyingTo === null
|
|
865
|
+
? {}
|
|
866
|
+
: { replyingTo: entry.replyingTo }),
|
|
867
|
+
};
|
|
868
|
+
});
|
|
414
869
|
}
|
|
415
870
|
/** The newest `limit` of a mixed list, which is the order every feed shows. */
|
|
416
871
|
function newestFirst(said, limit) {
|
|
@@ -464,80 +919,121 @@ class PostsByObjectId {
|
|
|
464
919
|
}
|
|
465
920
|
}
|
|
466
921
|
/**
|
|
467
|
-
* The replies as a thread: those answering
|
|
468
|
-
* the replies that answer it, oldest first at every level.
|
|
922
|
+
* The replies as a thread: those answering `under` at the top, each carrying
|
|
923
|
+
* the replies that answer it, oldest first at every level. `under` is the
|
|
924
|
+
* post, or one reply for that reply's own page.
|
|
469
925
|
*
|
|
470
|
-
*
|
|
926
|
+
* Four things decide where a reply goes, and all four are what somebody
|
|
471
927
|
* reading the page would expect:
|
|
472
928
|
*
|
|
473
929
|
* - A reply whose target is gone — deleted by its author — is shown under
|
|
474
930
|
* whatever *that* was answering, rather than disappearing with it. The
|
|
475
931
|
* conversation stays readable and only the withdrawn note is missing.
|
|
932
|
+
* - A reply answering a native comment a reader may not see (TASK-325) is
|
|
933
|
+
* shown under a placeholder for it, the way the reply's own page shows it.
|
|
934
|
+
* A comment waiting for a moderator or filed as spam still says what it
|
|
935
|
+
* answers, and the placeholder goes there; a deleted one is known by
|
|
936
|
+
* nothing, so its placeholder goes under the post. A hidden comment nobody
|
|
937
|
+
* visible answered gets no placeholder at all.
|
|
476
938
|
* - A reply answering something that is no part of this conversation is left
|
|
477
939
|
* out. The log holds it because it named something this walk asked about — a
|
|
478
940
|
* note answering the like of a post is the shape of that — but the post is
|
|
479
941
|
* not what it is talking about.
|
|
480
942
|
* - A note the site was told about twice is one reply, because the map is
|
|
481
943
|
* keyed by the note's own id.
|
|
944
|
+
*
|
|
945
|
+
* The walk is bounded by the number of notes, and a placeholder is made once,
|
|
946
|
+
* so a log carrying a cycle — two notes each claiming to answer the other,
|
|
947
|
+
* which nothing stops a remote server from sending — ends rather than hangs.
|
|
482
948
|
*/
|
|
483
|
-
function threadOf(
|
|
484
|
-
const
|
|
949
|
+
function threadOf(said, under = said.root) {
|
|
950
|
+
const shown = new Map();
|
|
485
951
|
const targets = new Map();
|
|
486
|
-
for (const reply of written) {
|
|
487
|
-
targets.set(reply.id, reply.inReplyTo ??
|
|
488
|
-
if (!withdrawn.has(reply.id))
|
|
489
|
-
|
|
952
|
+
for (const reply of said.written) {
|
|
953
|
+
targets.set(reply.id, reply.inReplyTo ?? said.root);
|
|
954
|
+
if (!said.withdrawn.has(reply.id))
|
|
955
|
+
shown.set(reply.id, reply);
|
|
490
956
|
}
|
|
491
957
|
const top = [];
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
958
|
+
const withheld = new Map();
|
|
959
|
+
// The list an entry answering `target` joins, or `undefined` for one that
|
|
960
|
+
// belongs somewhere else. Only a native entry can answer a deleted comment:
|
|
961
|
+
// a fediverse note naming an unknown id is answering something else.
|
|
962
|
+
const siblings = (target, self, native) => {
|
|
963
|
+
for (let step = 0; step <= targets.size; step += 1) {
|
|
964
|
+
if (target === undefined)
|
|
965
|
+
return undefined;
|
|
966
|
+
if (target === under)
|
|
967
|
+
return top;
|
|
968
|
+
if (target === said.root)
|
|
969
|
+
return undefined;
|
|
970
|
+
const parent = shown.get(target);
|
|
971
|
+
if (parent !== undefined)
|
|
972
|
+
return parent.id === self ? undefined : parent.replies;
|
|
973
|
+
if (said.held.has(target) || (native && !targets.has(target))) {
|
|
974
|
+
return placeholder(target)?.replies;
|
|
975
|
+
}
|
|
976
|
+
target = targets.get(target);
|
|
977
|
+
}
|
|
978
|
+
return undefined;
|
|
979
|
+
};
|
|
980
|
+
const placeholder = (id) => {
|
|
981
|
+
if (withheld.has(id))
|
|
982
|
+
return withheld.get(id);
|
|
983
|
+
withheld.set(id, undefined);
|
|
984
|
+
const list = siblings(said.held.get(id)?.inReplyTo ?? said.root, id, true);
|
|
985
|
+
if (list === undefined)
|
|
986
|
+
return undefined;
|
|
987
|
+
const key = isKey(id) ? id : repliesKey(id);
|
|
988
|
+
const entry = { id, key, withheld: true, replies: [] };
|
|
989
|
+
list.push(entry);
|
|
990
|
+
withheld.set(id, entry);
|
|
991
|
+
return entry;
|
|
992
|
+
};
|
|
993
|
+
for (const reply of shown.values()) {
|
|
994
|
+
siblings(targets.get(reply.id), reply.id, reply.source !== 'activitypub')?.push(reply);
|
|
497
995
|
}
|
|
498
996
|
return top;
|
|
499
997
|
}
|
|
500
|
-
/**
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
*/
|
|
509
|
-
function surviving(reply, targets, byId, objectId) {
|
|
510
|
-
let target = targets.get(reply.id);
|
|
511
|
-
for (let step = 0; step <= targets.size; step += 1) {
|
|
512
|
-
if (target === undefined)
|
|
513
|
-
return undefined;
|
|
514
|
-
if (target === objectId)
|
|
515
|
-
return null;
|
|
516
|
-
const parent = byId.get(target);
|
|
517
|
-
if (parent !== undefined)
|
|
518
|
-
return parent === reply ? undefined : parent;
|
|
519
|
-
// The target is not a reply the site is showing: either a note its author
|
|
520
|
-
// deleted, whose own target this reply moves to, or nothing to do with the
|
|
521
|
-
// post at all.
|
|
522
|
-
target = targets.get(target);
|
|
998
|
+
/** The entry of a thread with this key that a reader can see, at any depth. */
|
|
999
|
+
export function visibleReply(replies, key) {
|
|
1000
|
+
for (const reply of replies) {
|
|
1001
|
+
if (!isWithheld(reply) && reply.key === key)
|
|
1002
|
+
return reply;
|
|
1003
|
+
const found = visibleReply(reply.replies, key);
|
|
1004
|
+
if (found !== undefined)
|
|
1005
|
+
return found;
|
|
523
1006
|
}
|
|
524
1007
|
return undefined;
|
|
525
1008
|
}
|
|
1009
|
+
export function isWithheld(reply) {
|
|
1010
|
+
return 'withheld' in reply;
|
|
1011
|
+
}
|
|
526
1012
|
/** Oldest first, which is the order a conversation reads in. */
|
|
527
1013
|
function byPublished(left, right) {
|
|
528
1014
|
return left.published.getTime() - right.published.getTime();
|
|
529
1015
|
}
|
|
1016
|
+
/**
|
|
1017
|
+
* When a thread entry starts: a reply's own date, and a placeholder's first
|
|
1018
|
+
* visible reply's, since the date of a hidden comment is not the reader's.
|
|
1019
|
+
*/
|
|
1020
|
+
function startOf(reply) {
|
|
1021
|
+
if (!isWithheld(reply))
|
|
1022
|
+
return reply.published.getTime();
|
|
1023
|
+
const [first] = reply.replies;
|
|
1024
|
+
return first === undefined ? 0 : startOf(first);
|
|
1025
|
+
}
|
|
530
1026
|
/** Put every level of a thread in time order, all the way down. */
|
|
531
1027
|
function sortThread(replies) {
|
|
532
|
-
replies.sort(byPublished);
|
|
533
1028
|
for (const reply of replies)
|
|
534
1029
|
sortThread(reply.replies);
|
|
1030
|
+
replies.sort((left, right) => startOf(left) - startOf(right));
|
|
535
1031
|
}
|
|
536
|
-
/** How many replies a thread holds, counting all the way down. */
|
|
1032
|
+
/** How many visible replies a thread holds, counting all the way down. */
|
|
537
1033
|
function countReplies(replies) {
|
|
538
1034
|
let total = 0;
|
|
539
1035
|
for (const reply of replies)
|
|
540
|
-
total += 1 + countReplies(reply.replies);
|
|
1036
|
+
total += (isWithheld(reply) ? 0 : 1) + countReplies(reply.replies);
|
|
541
1037
|
return total;
|
|
542
1038
|
}
|
|
543
1039
|
/** The two reactions, as the activity types they arrive as. */
|
|
@@ -555,10 +1051,10 @@ const REACTIONS = {
|
|
|
555
1051
|
* stops when a round finds nothing new, which it must, because a row is
|
|
556
1052
|
* collected once and there are finitely many.
|
|
557
1053
|
*/
|
|
558
|
-
function activitiesAround(admin,
|
|
1054
|
+
function activitiesAround(admin, objectIds) {
|
|
559
1055
|
const collected = new Map();
|
|
560
|
-
const asked = new Set(
|
|
561
|
-
let frontier = [
|
|
1056
|
+
const asked = new Set(objectIds);
|
|
1057
|
+
let frontier = [...asked];
|
|
562
1058
|
while (frontier.length > 0) {
|
|
563
1059
|
const next = [];
|
|
564
1060
|
for (const activity of admin.listActivitiesAbout(frontier)) {
|
|
@@ -639,6 +1135,8 @@ function authorNaming(admin) {
|
|
|
639
1135
|
url: profile?.url ?? actorId,
|
|
640
1136
|
avatar: icon === null ? null : avatarHref(icon),
|
|
641
1137
|
actorId,
|
|
1138
|
+
feed: null,
|
|
1139
|
+
username: null,
|
|
642
1140
|
};
|
|
643
1141
|
known.set(actorId, author);
|
|
644
1142
|
return author;
|