@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.
Files changed (195) hide show
  1. package/README.md +355 -53
  2. package/admin/pages/comments/all.njk +3 -1
  3. package/admin/pages/federation/followers.njk +8 -2
  4. package/dist/admin/comments.d.ts +2 -0
  5. package/dist/admin/comments.d.ts.map +1 -1
  6. package/dist/admin/comments.js +19 -49
  7. package/dist/admin/comments.js.map +1 -1
  8. package/dist/admin/documents.d.ts +5 -0
  9. package/dist/admin/documents.d.ts.map +1 -1
  10. package/dist/admin/documents.js +45 -17
  11. package/dist/admin/documents.js.map +1 -1
  12. package/dist/admin/federation.d.ts +21 -1
  13. package/dist/admin/federation.d.ts.map +1 -1
  14. package/dist/admin/federation.js +17 -7
  15. package/dist/admin/federation.js.map +1 -1
  16. package/dist/admin/store.d.ts +95 -14
  17. package/dist/admin/store.d.ts.map +1 -1
  18. package/dist/admin/store.js +206 -17
  19. package/dist/admin/store.js.map +1 -1
  20. package/dist/cli.d.ts.map +1 -1
  21. package/dist/cli.js +8 -1
  22. package/dist/cli.js.map +1 -1
  23. package/dist/comments/form.d.ts +3 -1
  24. package/dist/comments/form.d.ts.map +1 -1
  25. package/dist/comments/form.js +2 -1
  26. package/dist/comments/form.js.map +1 -1
  27. package/dist/comments/index.d.ts +3 -3
  28. package/dist/comments/index.d.ts.map +1 -1
  29. package/dist/comments/index.js +1 -1
  30. package/dist/comments/index.js.map +1 -1
  31. package/dist/comments/records.d.ts +56 -24
  32. package/dist/comments/records.d.ts.map +1 -1
  33. package/dist/comments/records.js +255 -31
  34. package/dist/comments/records.js.map +1 -1
  35. package/dist/comments/reply-notices.d.ts +42 -0
  36. package/dist/comments/reply-notices.d.ts.map +1 -0
  37. package/dist/comments/reply-notices.js +76 -0
  38. package/dist/comments/reply-notices.js.map +1 -0
  39. package/dist/comments/reply-post.d.ts +49 -0
  40. package/dist/comments/reply-post.d.ts.map +1 -0
  41. package/dist/comments/reply-post.js +66 -0
  42. package/dist/comments/reply-post.js.map +1 -0
  43. package/dist/comments/routes.d.ts +6 -1
  44. package/dist/comments/routes.d.ts.map +1 -1
  45. package/dist/comments/routes.js +49 -21
  46. package/dist/comments/routes.js.map +1 -1
  47. package/dist/comments/submission.d.ts +8 -25
  48. package/dist/comments/submission.d.ts.map +1 -1
  49. package/dist/comments/submission.js +19 -38
  50. package/dist/comments/submission.js.map +1 -1
  51. package/dist/comments/viewer.d.ts.map +1 -1
  52. package/dist/comments/viewer.js +1 -0
  53. package/dist/comments/viewer.js.map +1 -1
  54. package/dist/content/citation.d.ts +1 -0
  55. package/dist/content/citation.d.ts.map +1 -1
  56. package/dist/content/citation.js +2 -0
  57. package/dist/content/citation.js.map +1 -1
  58. package/dist/content/original.d.ts +6 -1
  59. package/dist/content/original.d.ts.map +1 -1
  60. package/dist/content/original.js +7 -4
  61. package/dist/content/original.js.map +1 -1
  62. package/dist/content/store.d.ts +43 -11
  63. package/dist/content/store.d.ts.map +1 -1
  64. package/dist/content/store.js +170 -10
  65. package/dist/content/store.js.map +1 -1
  66. package/dist/content/sync.d.ts +8 -0
  67. package/dist/content/sync.d.ts.map +1 -1
  68. package/dist/content/sync.js +164 -71
  69. package/dist/content/sync.js.map +1 -1
  70. package/dist/federation/article.d.ts.map +1 -1
  71. package/dist/federation/article.js +2 -1
  72. package/dist/federation/article.js.map +1 -1
  73. package/dist/federation/backfill.d.ts +114 -0
  74. package/dist/federation/backfill.d.ts.map +1 -0
  75. package/dist/federation/backfill.js +314 -0
  76. package/dist/federation/backfill.js.map +1 -0
  77. package/dist/federation/inbox.js +1 -1
  78. package/dist/federation/inbox.js.map +1 -1
  79. package/dist/federation/records.d.ts +13 -0
  80. package/dist/federation/records.d.ts.map +1 -1
  81. package/dist/federation/records.js +49 -10
  82. package/dist/federation/records.js.map +1 -1
  83. package/dist/federation/replies.d.ts +6 -0
  84. package/dist/federation/replies.d.ts.map +1 -1
  85. package/dist/federation/replies.js +18 -1
  86. package/dist/federation/replies.js.map +1 -1
  87. package/dist/index.d.ts +12 -4
  88. package/dist/index.d.ts.map +1 -1
  89. package/dist/index.js +81 -11
  90. package/dist/index.js.map +1 -1
  91. package/dist/init.d.ts +1 -1
  92. package/dist/init.d.ts.map +1 -1
  93. package/dist/init.js +1 -7
  94. package/dist/init.js.map +1 -1
  95. package/dist/plugin.d.ts +28 -2
  96. package/dist/plugin.d.ts.map +1 -1
  97. package/dist/plugin.js +10 -2
  98. package/dist/plugin.js.map +1 -1
  99. package/dist/plugins/site.d.ts.map +1 -1
  100. package/dist/plugins/site.js +7 -1
  101. package/dist/plugins/site.js.map +1 -1
  102. package/dist/web/context.d.ts +18 -3
  103. package/dist/web/context.d.ts.map +1 -1
  104. package/dist/web/context.js +17 -4
  105. package/dist/web/context.js.map +1 -1
  106. package/dist/web/conversation.d.ts +191 -28
  107. package/dist/web/conversation.d.ts.map +1 -1
  108. package/dist/web/conversation.js +670 -172
  109. package/dist/web/conversation.js.map +1 -1
  110. package/dist/web/documents.d.ts +0 -22
  111. package/dist/web/documents.d.ts.map +1 -1
  112. package/dist/web/documents.js +1 -34
  113. package/dist/web/documents.js.map +1 -1
  114. package/dist/web/feed-item.d.ts +12 -8
  115. package/dist/web/feed-item.d.ts.map +1 -1
  116. package/dist/web/feed-item.js +10 -10
  117. package/dist/web/feed-item.js.map +1 -1
  118. package/dist/web/feed-rss.d.ts.map +1 -1
  119. package/dist/web/feed-rss.js +34 -10
  120. package/dist/web/feed-rss.js.map +1 -1
  121. package/dist/web/feed-source.d.ts +33 -1
  122. package/dist/web/feed-source.d.ts.map +1 -1
  123. package/dist/web/feed-source.js +33 -0
  124. package/dist/web/feed-source.js.map +1 -1
  125. package/dist/web/feeds.d.ts +1 -1
  126. package/dist/web/feeds.d.ts.map +1 -1
  127. package/dist/web/feeds.js +7 -2
  128. package/dist/web/feeds.js.map +1 -1
  129. package/dist/web/guids.d.ts +47 -0
  130. package/dist/web/guids.d.ts.map +1 -0
  131. package/dist/web/guids.js +67 -0
  132. package/dist/web/guids.js.map +1 -0
  133. package/dist/web/index.d.ts +5 -6
  134. package/dist/web/index.d.ts.map +1 -1
  135. package/dist/web/index.js +3 -3
  136. package/dist/web/index.js.map +1 -1
  137. package/dist/web/redirects.d.ts +12 -0
  138. package/dist/web/redirects.d.ts.map +1 -1
  139. package/dist/web/redirects.js +16 -4
  140. package/dist/web/redirects.js.map +1 -1
  141. package/dist/web/render.d.ts +19 -9
  142. package/dist/web/render.d.ts.map +1 -1
  143. package/dist/web/render.js +44 -42
  144. package/dist/web/render.js.map +1 -1
  145. package/dist/web/routes.d.ts +2 -2
  146. package/dist/web/routes.d.ts.map +1 -1
  147. package/dist/web/routes.js +157 -84
  148. package/dist/web/routes.js.map +1 -1
  149. package/dist/webmention/index.d.ts +1 -1
  150. package/dist/webmention/index.d.ts.map +1 -1
  151. package/dist/webmention/index.js +1 -1
  152. package/dist/webmention/index.js.map +1 -1
  153. package/dist/webmention/links.d.ts +55 -0
  154. package/dist/webmention/links.d.ts.map +1 -1
  155. package/dist/webmention/links.js +162 -0
  156. package/dist/webmention/links.js.map +1 -1
  157. package/dist/webmention/microformats.d.ts +15 -0
  158. package/dist/webmention/microformats.d.ts.map +1 -1
  159. package/dist/webmention/microformats.js +63 -1
  160. package/dist/webmention/microformats.js.map +1 -1
  161. package/dist/webmention/pingback.js +2 -1
  162. package/dist/webmention/pingback.js.map +1 -1
  163. package/dist/webmention/receive.d.ts +5 -0
  164. package/dist/webmention/receive.d.ts.map +1 -1
  165. package/dist/webmention/receive.js +99 -3
  166. package/dist/webmention/receive.js.map +1 -1
  167. package/dist/webmention/reply-context.d.ts +15 -0
  168. package/dist/webmention/reply-context.d.ts.map +1 -1
  169. package/dist/webmention/reply-context.js +29 -0
  170. package/dist/webmention/reply-context.js.map +1 -1
  171. package/dist/webmention/reply-contexts.d.ts +6 -0
  172. package/dist/webmention/reply-contexts.d.ts.map +1 -1
  173. package/dist/webmention/reply-contexts.js +16 -10
  174. package/dist/webmention/reply-contexts.js.map +1 -1
  175. package/dist/webmention/routes.d.ts.map +1 -1
  176. package/dist/webmention/routes.js +2 -1
  177. package/dist/webmention/routes.js.map +1 -1
  178. package/dist/webmention/service.d.ts +30 -1
  179. package/dist/webmention/service.d.ts.map +1 -1
  180. package/dist/webmention/service.js +172 -32
  181. package/dist/webmention/service.js.map +1 -1
  182. package/package.json +1 -2
  183. package/themes/default/README.md +269 -95
  184. package/themes/default/layouts/base.njk +8 -0
  185. package/themes/default/layouts/comment.njk +90 -0
  186. package/themes/default/layouts/front-page.njk +9 -47
  187. package/themes/default/layouts/page.njk +2 -0
  188. package/themes/default/layouts/post.njk +14 -2
  189. package/themes/default/partials/backlinks.njk +19 -0
  190. package/themes/default/partials/cited-page.njk +3 -1
  191. package/themes/default/partials/comment-form.njk +10 -0
  192. package/themes/default/partials/comment.njk +79 -0
  193. package/themes/default/partials/conversation.njk +14 -59
  194. package/themes/default/src/style.css +42 -0
  195. package/themes/default/static/style.css +67 -0
@@ -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 { activityStreamsId, isListed, permalinkOfObjectId } from './documents.js';
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) => commentCounts(context, 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
- // What a top-level answer names. The object id when the post has one, and
50
- // the permalink otherwise: a post that has never been delivered can still
51
- // have native comments, and they have to hang off something.
52
- const root = objectId ?? document.permalink;
53
- const activities = objectId === undefined ? [] : activitiesAround(context.admin, objectId);
54
- const native = approvedComments(context.admin, document);
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 reacted = new Set();
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
- id: activity.activityId ?? `${activity.actorId}#rsvp`,
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 at
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
- id: activity.activityId ?? `${activity.actorId}#${kind}`,
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
- written.push(federatedInteraction(reply, naming(activity.actorId)));
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 comment of native) {
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
- const replies = threadOf(written, root, withdrawn);
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
- replies,
156
- likes,
157
- boosts,
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
- const post = posts.get(reply.inReplyTo);
210
- if (post === undefined || !isListed(post, now))
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 (post === undefined || !isListed(post, now))
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 (post === undefined || !isListed(post, now))
612
+ if (!shown(post))
233
613
  continue;
234
- said.push({ ...interactionOf(stored, post.permalink), post });
614
+ const entry = placedOn(post).get(stored.id);
615
+ if (entry !== undefined)
616
+ said.push({ ...entry, post });
235
617
  }
236
- return newestFirst(said, limit);
237
- }
238
- /** How many answers each of these posts has, by permalink. */
239
- function commentCounts(context, documents) {
240
- const counts = new Map();
241
- const approvals = readAllQuoteAuthorizations(context.contentDir);
242
- const naming = authorNaming(context.admin);
243
- for (const document of documents) {
244
- const objectId = activityStreamsId(document, context.baseUrl);
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 counts;
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
- id: quote.id,
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
- id: reply.id,
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
- * The approved native comments on a post, as interactions, oldest first.
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
- name: comment.author.name,
341
- // A commenter has no fediverse identity: they gave a name, maybe a
342
- // website, and nothing that names them anywhere else.
343
- handle: null,
344
- url: comment.author.url,
345
- // Only a webmention has one: it came out of the source page's `h-card`,
346
- // and a form asks nobody for a picture.
347
- avatar: comment.author.avatar === null ? null : avatarHref(comment.author.avatar),
348
- actorId: null,
349
- },
350
- // Where it can be read. A comment written here lives here, at its own
351
- // anchor; a webmention lives on the page it was sent from, and its `url`
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 ?? `${permalink}#${commentAnchor(comment.id)}`,
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 `id` a comment is anchored at on the page.
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(id) {
372
- return `comment-${id}`;
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
- * Everything said in a conversation, at every depth and in no order: the
376
- * replies and the mentions.
377
- *
378
- * The likes and the boosts are left out because they say nothing. A feed item
379
- * is somebody's words, and "@ada liked this" has none to carry — a reader
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
- export function spokenIn(conversation) {
384
- const said = [...conversation.mentions];
385
- const walk = (replies) => {
386
- for (const reply of replies) {
387
- said.push(reply);
388
- walk(reply.replies);
389
- }
390
- };
391
- walk(conversation.replies);
392
- return said;
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
- return newestFirst([...said], options.limit).map((entry) => ({
405
- id: entry.id,
406
- url: absoluteUrl(entry.url ?? '/', options.baseUrl),
407
- author: entry.author.name,
408
- published: entry.published,
409
- html: entry.content,
410
- ...(entry.post === undefined
411
- ? {}
412
- : { post: { title: postLabel(entry.post, options.cited), permalink: entry.post.permalink } }),
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 the post at the top, each carrying
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
- * Three things decide where a reply goes, and all three are what somebody
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(written, objectId, withdrawn) {
484
- const byId = new Map();
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 ?? objectId);
488
- if (!withdrawn.has(reply.id))
489
- byId.set(reply.id, reply);
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
- for (const reply of byId.values()) {
493
- const parent = surviving(reply, targets, byId, objectId);
494
- if (parent === undefined)
495
- continue;
496
- (parent === null ? top : parent.replies).push(reply);
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
- * The reply a reply hangs under: `null` for the post itself, the nearest
502
- * surviving ancestor for one whose own target was deleted, and `undefined` for
503
- * a note that is answering something else entirely.
504
- *
505
- * The walk is bounded by the number of notes, so a log carrying a cycle — two
506
- * notes each claiming to answer the other, which nothing stops a remote server
507
- * from sending — ends rather than hangs.
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, objectId) {
1054
+ function activitiesAround(admin, objectIds) {
559
1055
  const collected = new Map();
560
- const asked = new Set([objectId]);
561
- let frontier = [objectId];
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;