@geekity/cms 0.27.0 → 0.29.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 (319) hide show
  1. package/README.md +469 -84
  2. package/admin/layouts/shell.njk +6 -0
  3. package/admin/pages/comments/all.njk +3 -1
  4. package/admin/pages/documents/editor.njk +4 -0
  5. package/admin/pages/federation/followers.njk +8 -2
  6. package/admin/pages/plugins/installed.njk +1 -1
  7. package/admin/pages/tools/dev-mode.njk +47 -0
  8. package/admin/static/admin.css +8 -0
  9. package/admin/static/editor-actions.js +13 -9
  10. package/admin/static/editor.js +31 -17
  11. package/dist/admin/comments.d.ts +2 -0
  12. package/dist/admin/comments.d.ts.map +1 -1
  13. package/dist/admin/comments.js +19 -49
  14. package/dist/admin/comments.js.map +1 -1
  15. package/dist/admin/dev-mode.d.ts +9 -0
  16. package/dist/admin/dev-mode.d.ts.map +1 -0
  17. package/dist/admin/dev-mode.js +42 -0
  18. package/dist/admin/dev-mode.js.map +1 -0
  19. package/dist/admin/documents.d.ts +5 -0
  20. package/dist/admin/documents.d.ts.map +1 -1
  21. package/dist/admin/documents.js +45 -17
  22. package/dist/admin/documents.js.map +1 -1
  23. package/dist/admin/editor-actions.js +3 -1
  24. package/dist/admin/editor-actions.js.map +1 -1
  25. package/dist/admin/federation.d.ts +21 -1
  26. package/dist/admin/federation.d.ts.map +1 -1
  27. package/dist/admin/federation.js +19 -7
  28. package/dist/admin/federation.js.map +1 -1
  29. package/dist/admin/menu.d.ts.map +1 -1
  30. package/dist/admin/menu.js +2 -0
  31. package/dist/admin/menu.js.map +1 -1
  32. package/dist/admin/own-paragraph.d.ts +7 -0
  33. package/dist/admin/own-paragraph.d.ts.map +1 -0
  34. package/dist/admin/own-paragraph.js +9 -0
  35. package/dist/admin/own-paragraph.js.map +1 -0
  36. package/dist/admin/plugins.d.ts.map +1 -1
  37. package/dist/admin/plugins.js +19 -8
  38. package/dist/admin/plugins.js.map +1 -1
  39. package/dist/admin/preview.d.ts.map +1 -1
  40. package/dist/admin/preview.js +4 -1
  41. package/dist/admin/preview.js.map +1 -1
  42. package/dist/admin/routes.d.ts.map +1 -1
  43. package/dist/admin/routes.js +4 -0
  44. package/dist/admin/routes.js.map +1 -1
  45. package/dist/admin/settings-email.d.ts.map +1 -1
  46. package/dist/admin/settings-email.js +4 -0
  47. package/dist/admin/settings-email.js.map +1 -1
  48. package/dist/admin/store.d.ts +68 -12
  49. package/dist/admin/store.d.ts.map +1 -1
  50. package/dist/admin/store.js +167 -18
  51. package/dist/admin/store.js.map +1 -1
  52. package/dist/admin/templates.d.ts +1 -0
  53. package/dist/admin/templates.d.ts.map +1 -1
  54. package/dist/admin/templates.js +1 -0
  55. package/dist/admin/templates.js.map +1 -1
  56. package/dist/admin/uploads.d.ts +7 -1
  57. package/dist/admin/uploads.d.ts.map +1 -1
  58. package/dist/admin/uploads.js +33 -30
  59. package/dist/admin/uploads.js.map +1 -1
  60. package/dist/cli.d.ts +1 -1
  61. package/dist/cli.d.ts.map +1 -1
  62. package/dist/cli.js +78 -12
  63. package/dist/cli.js.map +1 -1
  64. package/dist/comments/form.d.ts +3 -1
  65. package/dist/comments/form.d.ts.map +1 -1
  66. package/dist/comments/form.js +2 -1
  67. package/dist/comments/form.js.map +1 -1
  68. package/dist/comments/index.d.ts +2 -2
  69. package/dist/comments/index.d.ts.map +1 -1
  70. package/dist/comments/index.js +1 -1
  71. package/dist/comments/index.js.map +1 -1
  72. package/dist/comments/records.d.ts +25 -20
  73. package/dist/comments/records.d.ts.map +1 -1
  74. package/dist/comments/records.js +113 -33
  75. package/dist/comments/records.js.map +1 -1
  76. package/dist/comments/reply-notices.d.ts +42 -0
  77. package/dist/comments/reply-notices.d.ts.map +1 -0
  78. package/dist/comments/reply-notices.js +76 -0
  79. package/dist/comments/reply-notices.js.map +1 -0
  80. package/dist/comments/reply-post.d.ts +49 -0
  81. package/dist/comments/reply-post.d.ts.map +1 -0
  82. package/dist/comments/reply-post.js +66 -0
  83. package/dist/comments/reply-post.js.map +1 -0
  84. package/dist/comments/routes.d.ts +6 -1
  85. package/dist/comments/routes.d.ts.map +1 -1
  86. package/dist/comments/routes.js +49 -21
  87. package/dist/comments/routes.js.map +1 -1
  88. package/dist/comments/submission.d.ts +8 -25
  89. package/dist/comments/submission.d.ts.map +1 -1
  90. package/dist/comments/submission.js +19 -38
  91. package/dist/comments/submission.js.map +1 -1
  92. package/dist/comments/viewer.d.ts.map +1 -1
  93. package/dist/comments/viewer.js +1 -0
  94. package/dist/comments/viewer.js.map +1 -1
  95. package/dist/config.d.ts +21 -0
  96. package/dist/config.d.ts.map +1 -1
  97. package/dist/config.js +2 -0
  98. package/dist/config.js.map +1 -1
  99. package/dist/content/citation.d.ts +1 -0
  100. package/dist/content/citation.d.ts.map +1 -1
  101. package/dist/content/citation.js +2 -0
  102. package/dist/content/citation.js.map +1 -1
  103. package/dist/content/markdown.d.ts.map +1 -1
  104. package/dist/content/markdown.js +33 -1
  105. package/dist/content/markdown.js.map +1 -1
  106. package/dist/content/migrated.d.ts +6 -0
  107. package/dist/content/migrated.d.ts.map +1 -0
  108. package/dist/content/migrated.js +12 -0
  109. package/dist/content/migrated.js.map +1 -0
  110. package/dist/content/original.d.ts +8 -0
  111. package/dist/content/original.d.ts.map +1 -0
  112. package/dist/content/original.js +15 -0
  113. package/dist/content/original.js.map +1 -0
  114. package/dist/content/parser.d.ts +5 -0
  115. package/dist/content/parser.d.ts.map +1 -1
  116. package/dist/content/parser.js +1 -1
  117. package/dist/content/parser.js.map +1 -1
  118. package/dist/content/schedule.d.ts +7 -0
  119. package/dist/content/schedule.d.ts.map +1 -1
  120. package/dist/content/schedule.js +11 -2
  121. package/dist/content/schedule.js.map +1 -1
  122. package/dist/content/store.d.ts +43 -11
  123. package/dist/content/store.d.ts.map +1 -1
  124. package/dist/content/store.js +170 -10
  125. package/dist/content/store.js.map +1 -1
  126. package/dist/content/sync.d.ts +8 -0
  127. package/dist/content/sync.d.ts.map +1 -1
  128. package/dist/content/sync.js +172 -57
  129. package/dist/content/sync.js.map +1 -1
  130. package/dist/content/video.d.ts +12 -0
  131. package/dist/content/video.d.ts.map +1 -0
  132. package/dist/content/video.js +91 -0
  133. package/dist/content/video.js.map +1 -0
  134. package/dist/dev-mode.d.ts +28 -0
  135. package/dist/dev-mode.d.ts.map +1 -0
  136. package/dist/dev-mode.js +64 -0
  137. package/dist/dev-mode.js.map +1 -0
  138. package/dist/drain.d.ts +9 -3
  139. package/dist/drain.d.ts.map +1 -1
  140. package/dist/drain.js +24 -3
  141. package/dist/drain.js.map +1 -1
  142. package/dist/federation/article.d.ts.map +1 -1
  143. package/dist/federation/article.js +2 -1
  144. package/dist/federation/article.js.map +1 -1
  145. package/dist/federation/backfill.d.ts +114 -0
  146. package/dist/federation/backfill.d.ts.map +1 -0
  147. package/dist/federation/backfill.js +312 -0
  148. package/dist/federation/backfill.js.map +1 -0
  149. package/dist/federation/delivery.d.ts +2 -0
  150. package/dist/federation/delivery.d.ts.map +1 -1
  151. package/dist/federation/delivery.js +46 -16
  152. package/dist/federation/delivery.js.map +1 -1
  153. package/dist/federation/inbox.d.ts.map +1 -1
  154. package/dist/federation/inbox.js +14 -4
  155. package/dist/federation/inbox.js.map +1 -1
  156. package/dist/federation/mount.d.ts.map +1 -1
  157. package/dist/federation/mount.js +8 -4
  158. package/dist/federation/mount.js.map +1 -1
  159. package/dist/federation/records.d.ts +13 -0
  160. package/dist/federation/records.d.ts.map +1 -1
  161. package/dist/federation/records.js +49 -10
  162. package/dist/federation/records.js.map +1 -1
  163. package/dist/federation/relays.d.ts.map +1 -1
  164. package/dist/federation/relays.js +16 -1
  165. package/dist/federation/relays.js.map +1 -1
  166. package/dist/federation/replies.d.ts +6 -0
  167. package/dist/federation/replies.d.ts.map +1 -1
  168. package/dist/federation/replies.js +18 -1
  169. package/dist/federation/replies.js.map +1 -1
  170. package/dist/index.d.ts +13 -3
  171. package/dist/index.d.ts.map +1 -1
  172. package/dist/index.js +95 -14
  173. package/dist/index.js.map +1 -1
  174. package/dist/indexnow.d.ts +1 -1
  175. package/dist/indexnow.d.ts.map +1 -1
  176. package/dist/indexnow.js +7 -0
  177. package/dist/indexnow.js.map +1 -1
  178. package/dist/init.d.ts +1 -1
  179. package/dist/init.d.ts.map +1 -1
  180. package/dist/init.js +1 -7
  181. package/dist/init.js.map +1 -1
  182. package/dist/mail/service.d.ts +3 -1
  183. package/dist/mail/service.d.ts.map +1 -1
  184. package/dist/mail/service.js +17 -0
  185. package/dist/mail/service.js.map +1 -1
  186. package/dist/notify.d.ts +1 -1
  187. package/dist/notify.d.ts.map +1 -1
  188. package/dist/notify.js +7 -0
  189. package/dist/notify.js.map +1 -1
  190. package/dist/plugin.d.ts +67 -4
  191. package/dist/plugin.d.ts.map +1 -1
  192. package/dist/plugin.js.map +1 -1
  193. package/dist/plugins/folder.d.ts +2 -0
  194. package/dist/plugins/folder.d.ts.map +1 -1
  195. package/dist/plugins/folder.js +26 -5
  196. package/dist/plugins/folder.js.map +1 -1
  197. package/dist/plugins/manage.d.ts +8 -6
  198. package/dist/plugins/manage.d.ts.map +1 -1
  199. package/dist/plugins/manage.js +1 -4
  200. package/dist/plugins/manage.js.map +1 -1
  201. package/dist/plugins/site.d.ts.map +1 -1
  202. package/dist/plugins/site.js +56 -0
  203. package/dist/plugins/site.js.map +1 -1
  204. package/dist/supervisor/control.d.ts +15 -0
  205. package/dist/supervisor/control.d.ts.map +1 -0
  206. package/dist/supervisor/control.js +89 -0
  207. package/dist/supervisor/control.js.map +1 -0
  208. package/dist/supervisor/primary.d.ts +18 -4
  209. package/dist/supervisor/primary.d.ts.map +1 -1
  210. package/dist/supervisor/primary.js +128 -16
  211. package/dist/supervisor/primary.js.map +1 -1
  212. package/dist/supervisor/protocol.d.ts +12 -0
  213. package/dist/supervisor/protocol.d.ts.map +1 -1
  214. package/dist/supervisor/protocol.js +21 -2
  215. package/dist/supervisor/protocol.js.map +1 -1
  216. package/dist/supervisor/supervision.d.ts +1 -0
  217. package/dist/supervisor/supervision.d.ts.map +1 -1
  218. package/dist/supervisor/supervision.js +14 -1
  219. package/dist/supervisor/supervision.js.map +1 -1
  220. package/dist/supervisor/worker.d.ts.map +1 -1
  221. package/dist/supervisor/worker.js +31 -6
  222. package/dist/supervisor/worker.js.map +1 -1
  223. package/dist/web/context.d.ts +22 -0
  224. package/dist/web/context.d.ts.map +1 -1
  225. package/dist/web/context.js +20 -4
  226. package/dist/web/context.js.map +1 -1
  227. package/dist/web/conversation.d.ts +155 -16
  228. package/dist/web/conversation.d.ts.map +1 -1
  229. package/dist/web/conversation.js +602 -135
  230. package/dist/web/conversation.js.map +1 -1
  231. package/dist/web/documents.d.ts +0 -22
  232. package/dist/web/documents.d.ts.map +1 -1
  233. package/dist/web/documents.js +1 -34
  234. package/dist/web/documents.js.map +1 -1
  235. package/dist/web/feed-item.d.ts +19 -14
  236. package/dist/web/feed-item.d.ts.map +1 -1
  237. package/dist/web/feed-item.js +10 -9
  238. package/dist/web/feed-item.js.map +1 -1
  239. package/dist/web/feed-rss.d.ts +2 -2
  240. package/dist/web/feed-rss.d.ts.map +1 -1
  241. package/dist/web/feed-rss.js +36 -12
  242. package/dist/web/feed-rss.js.map +1 -1
  243. package/dist/web/feed-source.d.ts +30 -1
  244. package/dist/web/feed-source.d.ts.map +1 -1
  245. package/dist/web/feed-source.js +25 -0
  246. package/dist/web/feed-source.js.map +1 -1
  247. package/dist/web/feeds.d.ts +1 -1
  248. package/dist/web/feeds.d.ts.map +1 -1
  249. package/dist/web/feeds.js +7 -2
  250. package/dist/web/feeds.js.map +1 -1
  251. package/dist/web/guids.d.ts +59 -0
  252. package/dist/web/guids.d.ts.map +1 -0
  253. package/dist/web/guids.js +79 -0
  254. package/dist/web/guids.js.map +1 -0
  255. package/dist/web/index.d.ts +5 -6
  256. package/dist/web/index.d.ts.map +1 -1
  257. package/dist/web/index.js +3 -3
  258. package/dist/web/index.js.map +1 -1
  259. package/dist/web/redirects.d.ts +29 -10
  260. package/dist/web/redirects.d.ts.map +1 -1
  261. package/dist/web/redirects.js +99 -53
  262. package/dist/web/redirects.js.map +1 -1
  263. package/dist/web/render.d.ts +19 -9
  264. package/dist/web/render.d.ts.map +1 -1
  265. package/dist/web/render.js +44 -42
  266. package/dist/web/render.js.map +1 -1
  267. package/dist/web/routes.d.ts +2 -2
  268. package/dist/web/routes.d.ts.map +1 -1
  269. package/dist/web/routes.js +94 -31
  270. package/dist/web/routes.js.map +1 -1
  271. package/dist/webmention/index.d.ts +1 -1
  272. package/dist/webmention/index.d.ts.map +1 -1
  273. package/dist/webmention/index.js +1 -1
  274. package/dist/webmention/index.js.map +1 -1
  275. package/dist/webmention/links.d.ts +55 -0
  276. package/dist/webmention/links.d.ts.map +1 -1
  277. package/dist/webmention/links.js +162 -0
  278. package/dist/webmention/links.js.map +1 -1
  279. package/dist/webmention/microformats.d.ts +15 -0
  280. package/dist/webmention/microformats.d.ts.map +1 -1
  281. package/dist/webmention/microformats.js +63 -1
  282. package/dist/webmention/microformats.js.map +1 -1
  283. package/dist/webmention/pingback.js +2 -1
  284. package/dist/webmention/pingback.js.map +1 -1
  285. package/dist/webmention/receive.d.ts +5 -0
  286. package/dist/webmention/receive.d.ts.map +1 -1
  287. package/dist/webmention/receive.js +99 -3
  288. package/dist/webmention/receive.js.map +1 -1
  289. package/dist/webmention/reply-context.d.ts +15 -0
  290. package/dist/webmention/reply-context.d.ts.map +1 -1
  291. package/dist/webmention/reply-context.js +29 -0
  292. package/dist/webmention/reply-context.js.map +1 -1
  293. package/dist/webmention/reply-contexts.d.ts +6 -0
  294. package/dist/webmention/reply-contexts.d.ts.map +1 -1
  295. package/dist/webmention/reply-contexts.js +16 -10
  296. package/dist/webmention/reply-contexts.js.map +1 -1
  297. package/dist/webmention/routes.d.ts.map +1 -1
  298. package/dist/webmention/routes.js +2 -1
  299. package/dist/webmention/routes.js.map +1 -1
  300. package/dist/webmention/service.d.ts +31 -2
  301. package/dist/webmention/service.d.ts.map +1 -1
  302. package/dist/webmention/service.js +183 -33
  303. package/dist/webmention/service.js.map +1 -1
  304. package/package.json +1 -2
  305. package/themes/default/README.md +390 -75
  306. package/themes/default/layouts/base.njk +19 -2
  307. package/themes/default/layouts/comment.njk +90 -0
  308. package/themes/default/layouts/front-page.njk +9 -47
  309. package/themes/default/layouts/page.njk +2 -0
  310. package/themes/default/layouts/post.njk +22 -4
  311. package/themes/default/partials/backlinks.njk +19 -0
  312. package/themes/default/partials/body-end.njk +1 -0
  313. package/themes/default/partials/cited-page.njk +3 -1
  314. package/themes/default/partials/comment-form.njk +10 -0
  315. package/themes/default/partials/comment.njk +79 -0
  316. package/themes/default/partials/conversation.njk +14 -59
  317. package/themes/default/partials/head-end.njk +1 -0
  318. package/themes/default/src/style.css +46 -0
  319. package/themes/default/static/style.css +75 -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, siteAuthorContext } from './authors.js';
9
+ import { activityStreamsId, isListed, isServed, permalinkOfObjectId } from './documents.js';
10
+ import { isRepliesKey, repliesFeedPath, repliesKey } from './feed-source.js';
11
+ import { COMMENT_PAGE_PREFIX, commentPageHref, feedGuid, replyGuid } 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,25 +47,47 @@ 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;
78
+ const root = rootOf(context, document);
53
79
  const activities = objectId === undefined ? [] : activitiesAround(context.admin, objectId);
54
- const native = approvedComments(context.admin, document);
55
80
  const naming = authorNaming(context.admin);
56
81
  const quotes = objectId === undefined
57
82
  ? []
58
83
  : quotesOf(context.admin, readAllQuoteAuthorizations(context.contentDir), objectId, naming);
59
- if (activities.length === 0 && native.length === 0 && quotes.length === 0)
60
- return NOTHING;
61
84
  const withdrawn = withdrawnBy(activities);
62
85
  const written = [];
63
86
  const likes = [];
64
87
  const boosts = [];
65
88
  const mentions = [...quotes];
66
89
  const answers = [];
90
+ const held = new Map();
67
91
  const reacted = new Set();
68
92
  const isEvent = postTypeOf(document) === 'event';
69
93
  for (const activity of activities) {
@@ -123,7 +147,12 @@ function postConversation(context, document) {
123
147
  continue;
124
148
  written.push(federatedInteraction(reply, naming(activity.actorId)));
125
149
  }
126
- for (const comment of native) {
150
+ for (const stored of context.admin.listCommentsFor(document.slug)) {
151
+ const comment = interactionOf(stored, document.permalink);
152
+ if (stored.status !== 'approved') {
153
+ held.set(comment.id, comment);
154
+ continue;
155
+ }
127
156
  // A repost joins the boosts, because a reader looking at the page is being
128
157
  // told the same thing by both; a mention is its own group, because it is
129
158
  // neither an answer nor a reaction.
@@ -138,35 +167,353 @@ function postConversation(context, document) {
138
167
  : boosts;
139
168
  group.push({ ...comment, replies: [] });
140
169
  }
170
+ // The reply posts answering the post or anything said under it, each
171
+ // bringing what was said under it: an answer to a reply post lands against
172
+ // the reply post, and this is what stitches it into the thread it is in.
173
+ const names = namesIn(context, document, root, [...written, ...held.values()]);
174
+ for (const answer of context.store.listRepliesTo([...names.keys()])) {
175
+ const target = names.get(answer.inReplyTo ?? '');
176
+ if (target === undefined || seen.has(answer.path))
177
+ continue;
178
+ const under = gather(context, answer, new Set([...seen, answer.path]));
179
+ const top = (entry) => entry.inReplyTo === null ? { ...entry, inReplyTo: under.root } : entry;
180
+ written.push(replyPostInteraction(context, answer, under.root, target));
181
+ written.push(...under.written.map(top));
182
+ for (const [id, entry] of under.held)
183
+ held.set(id, top(entry));
184
+ for (const id of under.withdrawn)
185
+ withdrawn.add(id);
186
+ }
141
187
  // Sorted before threading rather than after, because a thread is built by
142
188
  // walking this list: two sources' entries would otherwise interleave in the
143
189
  // order the code happened to read them rather than in the order they were
144
190
  // written.
145
191
  written.sort(byPublished);
146
- const replies = threadOf(written, root, withdrawn);
192
+ return { root, written, withdrawn, held, likes, boosts, mentions, answers };
193
+ }
194
+ /**
195
+ * One native comment's page: the comment, its replies threaded under it by
196
+ * the rule the post's thread uses, and the chain from it up to the post.
197
+ *
198
+ * Its replies are the same list threaded from the comment rather than from
199
+ * the post, so a reply the post's thread shows is shown here, and one it
200
+ * leaves out is left out here.
201
+ */
202
+ function commentThread(context, id) {
203
+ const stored = context.admin.getComment(id);
204
+ if (stored?.source !== 'comment' || stored.status !== 'approved')
205
+ return undefined;
206
+ const post = context.store.getBySlug(stored.slug);
207
+ if (post === undefined)
208
+ return undefined;
209
+ const said = gather(context, post);
210
+ const comment = said.written.find((reply) => reply.id === id);
211
+ if (comment === undefined)
212
+ return undefined;
213
+ const replies = threadOf(said, id);
147
214
  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
215
  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
- },
216
+ post,
217
+ comment: { ...comment, replies },
218
+ ancestors: ancestorsOf(said, comment),
219
+ };
220
+ }
221
+ function replyNamed(context, document, url) {
222
+ return namedIn(context, document, gather(context, document), url)?.id;
223
+ }
224
+ /** The entry of a gathered conversation a URL names, held ones included. */
225
+ function namedIn(context, document, said, url) {
226
+ let named;
227
+ try {
228
+ named = new URL(url, context.baseUrl);
229
+ }
230
+ catch {
231
+ return undefined;
232
+ }
233
+ const commentId = commentNamedBy(named, document, context.baseUrl);
234
+ return [...said.written, ...said.held.values()].find((reply) => reply.id === commentId ||
235
+ reply.id === named.href ||
236
+ (reply.url !== null && absoluteUrl(reply.url, context.baseUrl) === named.href));
237
+ }
238
+ function heldAt(context, url) {
239
+ const post = documentNamed(context, url);
240
+ if (post === undefined || !isServed(post, context.store.now()))
241
+ return undefined;
242
+ if (url === absoluteUrl(post.permalink, context.baseUrl) || url === rootOf(context, post)) {
243
+ return { kind: 'document', post, author: documentAuthor(context, post) };
244
+ }
245
+ const said = gather(context, post);
246
+ const reply = namedIn(context, post, said, url);
247
+ if (reply === undefined || said.withdrawn.has(reply.id) || said.held.has(reply.id)) {
248
+ return undefined;
249
+ }
250
+ return { kind: 'reply', post, reply };
251
+ }
252
+ function repliesTo(context, segment) {
253
+ if (!isRepliesKey(segment)) {
254
+ const stored = context.admin.getComment(segment);
255
+ if (stored?.source !== 'comment')
256
+ return undefined;
257
+ const post = context.store.getBySlug(stored.slug);
258
+ return post === undefined ? undefined : repliesIn(context, post, segment);
259
+ }
260
+ // Each source's index is asked in turn, so a key naming nothing costs three
261
+ // lookups and no more (TASK-327).
262
+ const [document] = context.store.listByRepliesKey(segment);
263
+ if (document !== undefined) {
264
+ if (repliesKey(feedGuid(document, context.baseUrl)) === segment) {
265
+ return repliesIn(context, document, null);
266
+ }
267
+ const root = threadRootOf(context, document);
268
+ return root === undefined ? undefined : repliesIn(context, root, rootOf(context, document));
269
+ }
270
+ const [comment] = context.admin.listCommentsByRepliesKey(segment);
271
+ if (comment !== undefined) {
272
+ const post = context.store.getBySlug(comment.slug);
273
+ return post === undefined ? undefined : repliesIn(context, post, comment.id);
274
+ }
275
+ for (const activity of context.admin.listRepliesByKey(segment)) {
276
+ const reply = replyFrom(activity);
277
+ if (reply === undefined)
278
+ continue;
279
+ const post = documentNamed(context, reply.id);
280
+ return post === undefined ? undefined : repliesIn(context, post, reply.id);
281
+ }
282
+ return undefined;
283
+ }
284
+ /**
285
+ * The direct replies of one reply in a document's thread, or of the document
286
+ * itself, while the document is served and the reply is one a reader sees.
287
+ */
288
+ function repliesIn(context, post, id) {
289
+ if (!isServed(post, context.store.now()))
290
+ return undefined;
291
+ const thread = postConversation(context, post).replies;
292
+ const reply = id === null ? null : visibleReply(thread, id);
293
+ if (reply === undefined)
294
+ return undefined;
295
+ return {
296
+ post,
297
+ reply,
298
+ replies: directReplies(reply?.replies ?? thread).map((entry) => ({ ...entry, parent: reply })),
168
299
  };
169
300
  }
301
+ function upstreamsOf(context, document) {
302
+ const top = threadRootOf(context, document) ?? document;
303
+ if (!isServed(top, context.store.now()))
304
+ return [];
305
+ const said = gather(context, top);
306
+ const site = new URL(context.baseUrl).origin;
307
+ const elsewhere = (url) => {
308
+ if (url === null || url === undefined)
309
+ return false;
310
+ try {
311
+ return new URL(url).origin !== site;
312
+ }
313
+ catch {
314
+ return false;
315
+ }
316
+ };
317
+ const found = [];
318
+ const posts = [
319
+ top,
320
+ ...said.written.flatMap((entry) => {
321
+ const post = entry.source === 'post' ? context.store.getByPermalink(entry.url ?? '') : undefined;
322
+ return post === undefined ? [] : [post];
323
+ }),
324
+ ];
325
+ for (const post of posts) {
326
+ if (!elsewhere(post.inReplyTo))
327
+ continue;
328
+ found.push({
329
+ post,
330
+ source: absoluteUrl(post.permalink, context.baseUrl),
331
+ target: post.inReplyTo,
332
+ replies: postConversation(context, post).replies,
333
+ });
334
+ }
335
+ const entries = new Map(said.written.map((entry) => [entry.id, entry]));
336
+ for (const entry of said.written) {
337
+ if (entry.source !== 'comment' || entry.inReplyTo === null)
338
+ continue;
339
+ const parent = entries.get(entry.inReplyTo);
340
+ if (parent?.source !== 'webmention' || !elsewhere(parent.url))
341
+ continue;
342
+ const page = commentThread(context, entry.id);
343
+ if (page === undefined)
344
+ continue;
345
+ found.push({
346
+ post: page.post,
347
+ source: absoluteUrl(commentPageHref(entry.id), context.baseUrl),
348
+ target: parent.url,
349
+ replies: page.comment.replies,
350
+ });
351
+ }
352
+ return found;
353
+ }
354
+ /**
355
+ * The document whose conversation a URL is in: the post a comment's page or
356
+ * anchor is on, the post a webmention was sent to, the post a fediverse reply
357
+ * answers however deep, or the document at the URL itself.
358
+ */
359
+ function documentNamed(context, url, depth = 0) {
360
+ if (depth > MAXIMUM_NOTE_DEPTH)
361
+ return undefined;
362
+ let named;
363
+ try {
364
+ named = new URL(url);
365
+ }
366
+ catch {
367
+ return undefined;
368
+ }
369
+ if (named.origin === new URL(context.baseUrl).origin) {
370
+ const id = commentIdAt(named.pathname);
371
+ if (id !== undefined) {
372
+ const comment = context.admin.getComment(id);
373
+ return comment === undefined ? undefined : context.store.getBySlug(comment.slug);
374
+ }
375
+ const permalink = permalinkOfObjectId(`${named.origin}${named.pathname}`, context.baseUrl);
376
+ const found = permalink === undefined ? undefined : context.store.getByPermalink(permalink);
377
+ return found ?? context.store.getByStoredObjectId(named.href);
378
+ }
379
+ const [comment] = context.admin.listCommentsAt(named.href);
380
+ if (comment !== undefined)
381
+ return context.store.getBySlug(comment.slug);
382
+ for (const activity of context.admin.listActivitiesAbout([named.href])) {
383
+ const reply = replyFrom(activity);
384
+ if (reply?.id !== named.href)
385
+ continue;
386
+ return documentNamed(context, reply.inReplyTo, depth + 1);
387
+ }
388
+ return context.store.getByStoredObjectId(named.href);
389
+ }
390
+ /**
391
+ * The document at the top of the conversation a reply post is in: what its
392
+ * `in-reply-to` names, and what that one is in when it is a reply post too.
393
+ * `undefined` for a reply post answering nothing on this site.
394
+ */
395
+ function threadRootOf(context, document, seen = new Set()) {
396
+ seen.add(document.path);
397
+ if (document.inReplyTo === undefined)
398
+ return undefined;
399
+ const named = documentNamed(context, document.inReplyTo);
400
+ if (named === undefined || seen.has(named.path))
401
+ return undefined;
402
+ return threadRootOf(context, named, seen) ?? named;
403
+ }
404
+ /** How many notes deep a reply is followed back to the post it is about. */
405
+ const MAXIMUM_NOTE_DEPTH = 32;
406
+ /**
407
+ * What a top-level answer names. The object id when the post has one, and the
408
+ * permalink otherwise: a post that has never been delivered can still have
409
+ * native comments, and they have to hang off something.
410
+ */
411
+ function rootOf(context, document) {
412
+ return activityStreamsId(document, context.baseUrl) ?? document.permalink;
413
+ }
414
+ /**
415
+ * Every URL a reply post's `in-reply-to` could name something here by, and the
416
+ * id each one names: the post by its permalink, every URL it used to live at
417
+ * and its object id, and every entry by its id, its url and, for a comment
418
+ * written here, its page and its anchor.
419
+ */
420
+ function namesIn(context, document, root, replies) {
421
+ const permalink = absoluteUrl(document.permalink, context.baseUrl);
422
+ const former = (document.redirectFrom ?? []).map((url) => absoluteUrl(url, context.baseUrl));
423
+ const names = new Map([permalink, ...former, root].map((url) => [url, root]));
424
+ for (const reply of replies) {
425
+ for (const name of namesOf(reply, permalink, context.baseUrl)) {
426
+ if (!names.has(name))
427
+ names.set(name, reply.id);
428
+ }
429
+ }
430
+ return names;
431
+ }
432
+ function namesOf(reply, permalink, baseUrl) {
433
+ const names = [reply.id];
434
+ if (reply.url !== null)
435
+ names.push(absoluteUrl(reply.url, baseUrl));
436
+ if (reply.source === 'comment')
437
+ names.push(...commentNames(reply.id, permalink, baseUrl));
438
+ return names;
439
+ }
440
+ /**
441
+ * The URLs a reply post can answer a native comment by: its own page, and its
442
+ * anchor on the post at this absolute permalink.
443
+ */
444
+ export function commentNames(id, permalink, baseUrl) {
445
+ return [absoluteUrl(commentPageHref(id), baseUrl), `${permalink}#${commentAnchor(id)}`];
446
+ }
447
+ /** A reply post as the thread's own shape, answering `inReplyTo`. */
448
+ function replyPostInteraction(context, post, id, inReplyTo) {
449
+ return {
450
+ id,
451
+ source: 'post',
452
+ kind: 'reply',
453
+ author: documentAuthor(context, post),
454
+ url: post.permalink,
455
+ content: post.html,
456
+ published: new Date(post.date ?? post.updated ?? 0),
457
+ inReplyTo,
458
+ status: 'published',
459
+ replies: [],
460
+ };
461
+ }
462
+ /** Who a post or page on this site is by: its author, else the site's. */
463
+ function documentAuthor(context, post) {
464
+ const users = context.users();
465
+ const site = readSiteSettings(context.contentDir);
466
+ const author = authorContext(users, post.author) ?? siteAuthorContext(users, site.author);
467
+ return {
468
+ name: author?.name ?? site.title,
469
+ handle: null,
470
+ url: author?.url ?? null,
471
+ avatar: author?.avatar ?? null,
472
+ actorId: null,
473
+ feed: author?.username === undefined ? null : authorFeedHref(author.username, 'rss'),
474
+ };
475
+ }
476
+ /** The comment a URL on this site names by its page or by its anchor on the post. */
477
+ function commentNamedBy(url, document, baseUrl) {
478
+ const post = new URL(absoluteUrl(document.permalink, baseUrl));
479
+ if (url.origin !== post.origin)
480
+ return undefined;
481
+ const onItsPage = commentIdAt(url.pathname);
482
+ if (onItsPage !== undefined)
483
+ return onItsPage;
484
+ const anchor = `#${commentAnchor('')}`;
485
+ if (url.pathname !== post.pathname || !url.hash.startsWith(anchor))
486
+ return undefined;
487
+ return decoded(url.hash.slice(anchor.length));
488
+ }
489
+ /**
490
+ * What a reply answers, from the top of the thread down to its parent.
491
+ *
492
+ * An ancestor a reader may not see is a `null`, and the walk goes on through
493
+ * what that one answered when the site still knows it: a pending or spam
494
+ * comment is in the index, and a withdrawn note is still in the log. A deleted
495
+ * comment is known by nothing, so the chain stops at it and goes straight to
496
+ * the post. Each id is visited once, so a cycle ends.
497
+ */
498
+ function ancestorsOf(said, reply) {
499
+ const all = new Map(said.written.map((entry) => [entry.id, entry]));
500
+ const chain = [];
501
+ const seen = new Set([reply.id]);
502
+ for (let target = reply.inReplyTo; target !== null && target !== said.root;) {
503
+ if (seen.has(target))
504
+ break;
505
+ seen.add(target);
506
+ const known = all.get(target);
507
+ if (known !== undefined && !said.withdrawn.has(target)) {
508
+ chain.unshift(known);
509
+ target = known.inReplyTo;
510
+ continue;
511
+ }
512
+ chain.unshift(null);
513
+ target = (known ?? said.held.get(target))?.inReplyTo ?? null;
514
+ }
515
+ return chain;
516
+ }
170
517
  function rsvpGroups(answers) {
171
518
  const people = latestAnswerPerPerson(answers);
172
519
  return RSVP_GROUP_ORDER.map((value) => ({
@@ -196,6 +543,9 @@ function siteConversation(context, limit) {
196
543
  const naming = authorNaming(context.admin);
197
544
  const posts = new PostsByObjectId(context);
198
545
  const now = context.store.now();
546
+ const policy = commentPolicyOf(readSiteJson(context.contentDir));
547
+ // A page that takes no comments shows none, so the site's feed has none of its.
548
+ const shown = (post) => post !== undefined && isListed(post, now) && answerable(post, policy, now);
199
549
  const said = [];
200
550
  for (let offset = 0; said.length < limit;) {
201
551
  const page = context.admin.listReplies({ limit: limit * OVERSCAN, offset });
@@ -206,8 +556,10 @@ function siteConversation(context, limit) {
206
556
  const reply = replyFrom(activity);
207
557
  if (reply === undefined)
208
558
  continue;
209
- const post = posts.get(reply.inReplyTo);
210
- if (post === undefined || !isListed(post, now))
559
+ // A note answering another note is as much this post's as one answering
560
+ // the post, so the post is found the way a reply post finds its thread.
561
+ const post = documentNamed(context, reply.inReplyTo);
562
+ if (!shown(post))
211
563
  continue;
212
564
  said.push({ ...federatedInteraction(reply, naming(activity.actorId)), post });
213
565
  if (said.length === limit)
@@ -218,7 +570,7 @@ function siteConversation(context, limit) {
218
570
  // approval per quote, and a site has few enough to read whole.
219
571
  for (const approval of readAllQuoteAuthorizations(context.contentDir)) {
220
572
  const post = posts.get(approval.post);
221
- if (post === undefined || !isListed(post, now))
573
+ if (!shown(post))
222
574
  continue;
223
575
  for (const quote of quotesOf(context.admin, [approval], approval.post, naming)) {
224
576
  said.push({ ...quote, post });
@@ -229,26 +581,46 @@ function siteConversation(context, limit) {
229
581
  limit: limit * OVERSCAN,
230
582
  })) {
231
583
  const post = context.store.getBySlug(stored.slug);
232
- if (post === undefined || !isListed(post, now))
584
+ if (!shown(post))
233
585
  continue;
234
586
  said.push({ ...interactionOf(stored, post.permalink), post });
235
587
  }
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'));
588
+ // Each post's thread, read once: where each reply sits and what it carries
589
+ // is the thread's to say, so the site's feed and the post's agree.
590
+ const threads = new Map();
591
+ const placedOn = (post) => {
592
+ let found = threads.get(post.path);
593
+ if (found === undefined) {
594
+ found = new Map(placed(postConversation(context, post).replies).map((reply) => [reply.id, reply]));
595
+ threads.set(post.path, found);
596
+ }
597
+ return found;
598
+ };
599
+ // A reply post is in the feed of the thread it is shown in, unlisted or
600
+ // not: the thread is listed, and the reply post is one of its answers.
601
+ for (const answer of context.store.listReplyPosts({ limit: limit * OVERSCAN })) {
602
+ const post = threadRootOf(context, answer);
603
+ if (!shown(post))
604
+ continue;
605
+ const reply = placedOn(post).get(rootOf(context, answer));
606
+ if (reply !== undefined)
607
+ said.push({ ...reply, post });
250
608
  }
251
- return counts;
609
+ return newestFirst(said, limit).map((entry) => {
610
+ const thread = placedOn(entry.post);
611
+ return {
612
+ ...(thread.get(entry.id) ?? { ...entry, parent: null }),
613
+ post: entry.post,
614
+ replyingTo: entry.inReplyTo === null ? null : (thread.get(entry.inReplyTo)?.author.name ?? null),
615
+ };
616
+ });
617
+ }
618
+ /** How many visible replies answer each of these documents directly, by permalink. */
619
+ function directCounts(context, documents) {
620
+ return new Map(documents.map((document) => [
621
+ document.permalink,
622
+ directReplies(postConversation(context, document).replies).length,
623
+ ]));
252
624
  }
253
625
  /**
254
626
  * How many candidates a site-wide page reads for each one it keeps.
@@ -316,20 +688,6 @@ function federatedInteraction(reply, author) {
316
688
  replies: [],
317
689
  };
318
690
  }
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));
332
- }
333
691
  /** One stored comment as the thread's own shape. */
334
692
  function interactionOf(comment, permalink) {
335
693
  return {
@@ -346,12 +704,16 @@ function interactionOf(comment, permalink) {
346
704
  // and a form asks nobody for a picture.
347
705
  avatar: comment.author.avatar === null ? null : avatarHref(comment.author.avatar),
348
706
  actorId: null,
707
+ feed: comment.source === 'webmention' ? comment.author.url : null,
349
708
  },
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
709
+ // Where it can be read. A comment written here has a page of its own
710
+ // (TASK-318); a webmention lives on the page it was sent from, and its
711
+ // `url` says so. A theme that prints `url` for a fediverse reply prints a
353
712
  // working link for either, and so does a feed.
354
- url: comment.url ?? `${permalink}#${commentAnchor(comment.id)}`,
713
+ url: comment.url ??
714
+ (comment.source === 'comment'
715
+ ? commentPageHref(comment.id)
716
+ : `${permalink}#${commentAnchor(comment.id)}`),
355
717
  content: comment.content.html,
356
718
  published: new Date(comment.submitted),
357
719
  inReplyTo: comment.inReplyTo,
@@ -371,6 +733,23 @@ function interactionOf(comment, permalink) {
371
733
  export function commentAnchor(id) {
372
734
  return `comment-${id}`;
373
735
  }
736
+ /** The id of the comment whose page a path is, or `undefined` (TASK-319). */
737
+ export function commentIdAt(pathname) {
738
+ if (!pathname.startsWith(COMMENT_PAGE_PREFIX) || !pathname.endsWith('/'))
739
+ return undefined;
740
+ const encoded = pathname.slice(COMMENT_PAGE_PREFIX.length, -1);
741
+ if (encoded === '' || encoded.includes('/'))
742
+ return undefined;
743
+ return decoded(encoded);
744
+ }
745
+ function decoded(value) {
746
+ try {
747
+ return decodeURIComponent(value);
748
+ }
749
+ catch {
750
+ return undefined;
751
+ }
752
+ }
374
753
  /**
375
754
  * Everything said in a conversation, at every depth and in no order: the
376
755
  * replies and the mentions.
@@ -381,15 +760,35 @@ export function commentAnchor(id) {
381
760
  * tell what it was for.
382
761
  */
383
762
  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;
763
+ return [
764
+ ...conversation.mentions.map((mention) => ({ ...mention, parent: null })),
765
+ ...placed(conversation.replies),
766
+ ];
767
+ }
768
+ /** Every visible reply of a thread at any depth, each with the reply it sits under. */
769
+ function placed(replies, parent = null) {
770
+ return directReplies(replies).flatMap((reply) => [
771
+ { ...reply, parent },
772
+ ...placed(reply.replies, reply),
773
+ ]);
774
+ }
775
+ /**
776
+ * The visible replies directly under an entry of a thread (TASK-324): a
777
+ * hidden comment's visible replies count as its parent's, as {@link Placed}
778
+ * says, so every visible reply is directly under exactly one thing.
779
+ */
780
+ function directReplies(replies) {
781
+ return replies.flatMap((reply) => (isWithheld(reply) ? directReplies(reply.replies) : [reply]));
782
+ }
783
+ /**
784
+ * The `/replies/` feed of one reply: a native comment's by its id, anything
785
+ * else's by the key of its guid, as is a native comment whose id would read
786
+ * as a key.
787
+ */
788
+ function repliesHrefOf(reply, baseUrl) {
789
+ return repliesFeedPath(reply.source === 'comment' && !isRepliesKey(reply.id)
790
+ ? reply.id
791
+ : repliesKey(replyGuid(reply, baseUrl)));
393
792
  }
394
793
  /**
395
794
  * Interactions as a comments feed carries them: newest first, at most `limit`,
@@ -401,16 +800,43 @@ export function spokenIn(conversation) {
401
800
  * moment before it becomes bytes this site publishes.
402
801
  */
403
802
  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
- }));
803
+ const { baseUrl } = options;
804
+ return newestFirst([...said], options.limit).map((entry) => {
805
+ const thread = entry.post ?? options.post;
806
+ const answered = entry.parent === null
807
+ ? thread === undefined
808
+ ? undefined
809
+ : feedGuid(thread, baseUrl)
810
+ : replyGuid(entry.parent, baseUrl);
811
+ const replies = directReplies(entry.replies);
812
+ return {
813
+ id: replyGuid(entry, baseUrl),
814
+ url: absoluteUrl(entry.url ?? '/', baseUrl),
815
+ author: entry.author.name,
816
+ published: entry.published,
817
+ html: entry.content,
818
+ ...(answered === undefined ? {} : { inReplyTo: answered }),
819
+ ...(replies.length === 0
820
+ ? {}
821
+ : {
822
+ replies: {
823
+ count: replies.length,
824
+ feed: absoluteUrl(repliesHrefOf(entry, baseUrl), baseUrl),
825
+ },
826
+ }),
827
+ ...(entry.author.feed === null
828
+ ? {}
829
+ : { authorFeed: absoluteUrl(entry.author.feed, baseUrl) }),
830
+ ...(entry.post === undefined
831
+ ? {}
832
+ : {
833
+ post: { title: postLabel(entry.post, options.cited), permalink: entry.post.permalink },
834
+ }),
835
+ ...(entry.replyingTo === undefined || entry.replyingTo === null
836
+ ? {}
837
+ : { replyingTo: entry.replyingTo }),
838
+ };
839
+ });
414
840
  }
415
841
  /** The newest `limit` of a mixed list, which is the order every feed shows. */
416
842
  function newestFirst(said, limit) {
@@ -464,80 +890,120 @@ class PostsByObjectId {
464
890
  }
465
891
  }
466
892
  /**
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.
893
+ * The replies as a thread: those answering `under` at the top, each carrying
894
+ * the replies that answer it, oldest first at every level. `under` is the
895
+ * post, or one reply for that reply's own page.
469
896
  *
470
- * Three things decide where a reply goes, and all three are what somebody
897
+ * Four things decide where a reply goes, and all four are what somebody
471
898
  * reading the page would expect:
472
899
  *
473
900
  * - A reply whose target is gone — deleted by its author — is shown under
474
901
  * whatever *that* was answering, rather than disappearing with it. The
475
902
  * conversation stays readable and only the withdrawn note is missing.
903
+ * - A reply answering a native comment a reader may not see (TASK-325) is
904
+ * shown under a placeholder for it, the way the reply's own page shows it.
905
+ * A comment waiting for a moderator or filed as spam still says what it
906
+ * answers, and the placeholder goes there; a deleted one is known by
907
+ * nothing, so its placeholder goes under the post. A hidden comment nobody
908
+ * visible answered gets no placeholder at all.
476
909
  * - A reply answering something that is no part of this conversation is left
477
910
  * out. The log holds it because it named something this walk asked about — a
478
911
  * note answering the like of a post is the shape of that — but the post is
479
912
  * not what it is talking about.
480
913
  * - A note the site was told about twice is one reply, because the map is
481
914
  * keyed by the note's own id.
915
+ *
916
+ * The walk is bounded by the number of notes, and a placeholder is made once,
917
+ * so a log carrying a cycle — two notes each claiming to answer the other,
918
+ * which nothing stops a remote server from sending — ends rather than hangs.
482
919
  */
483
- function threadOf(written, objectId, withdrawn) {
484
- const byId = new Map();
920
+ function threadOf(said, under = said.root) {
921
+ const shown = new Map();
485
922
  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);
923
+ for (const reply of said.written) {
924
+ targets.set(reply.id, reply.inReplyTo ?? said.root);
925
+ if (!said.withdrawn.has(reply.id))
926
+ shown.set(reply.id, reply);
490
927
  }
491
928
  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);
929
+ const withheld = new Map();
930
+ // The list an entry answering `target` joins, or `undefined` for one that
931
+ // belongs somewhere else. Only a native entry can answer a deleted comment:
932
+ // a fediverse note naming an unknown id is answering something else.
933
+ const siblings = (target, self, native) => {
934
+ for (let step = 0; step <= targets.size; step += 1) {
935
+ if (target === undefined)
936
+ return undefined;
937
+ if (target === under)
938
+ return top;
939
+ if (target === said.root)
940
+ return undefined;
941
+ const parent = shown.get(target);
942
+ if (parent !== undefined)
943
+ return parent.id === self ? undefined : parent.replies;
944
+ if (said.held.has(target) || (native && !targets.has(target))) {
945
+ return placeholder(target)?.replies;
946
+ }
947
+ target = targets.get(target);
948
+ }
949
+ return undefined;
950
+ };
951
+ const placeholder = (id) => {
952
+ if (withheld.has(id))
953
+ return withheld.get(id);
954
+ withheld.set(id, undefined);
955
+ const list = siblings(said.held.get(id)?.inReplyTo ?? said.root, id, true);
956
+ if (list === undefined)
957
+ return undefined;
958
+ const entry = { id, withheld: true, replies: [] };
959
+ list.push(entry);
960
+ withheld.set(id, entry);
961
+ return entry;
962
+ };
963
+ for (const reply of shown.values()) {
964
+ siblings(targets.get(reply.id), reply.id, reply.source !== 'activitypub')?.push(reply);
497
965
  }
498
966
  return top;
499
967
  }
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);
968
+ /** The entry of a thread with this id that a reader can see, at any depth. */
969
+ export function visibleReply(replies, id) {
970
+ for (const reply of replies) {
971
+ if (!isWithheld(reply) && reply.id === id)
972
+ return reply;
973
+ const found = visibleReply(reply.replies, id);
974
+ if (found !== undefined)
975
+ return found;
523
976
  }
524
977
  return undefined;
525
978
  }
979
+ export function isWithheld(reply) {
980
+ return 'withheld' in reply;
981
+ }
526
982
  /** Oldest first, which is the order a conversation reads in. */
527
983
  function byPublished(left, right) {
528
984
  return left.published.getTime() - right.published.getTime();
529
985
  }
986
+ /**
987
+ * When a thread entry starts: a reply's own date, and a placeholder's first
988
+ * visible reply's, since the date of a hidden comment is not the reader's.
989
+ */
990
+ function startOf(reply) {
991
+ if (!isWithheld(reply))
992
+ return reply.published.getTime();
993
+ const [first] = reply.replies;
994
+ return first === undefined ? 0 : startOf(first);
995
+ }
530
996
  /** Put every level of a thread in time order, all the way down. */
531
997
  function sortThread(replies) {
532
- replies.sort(byPublished);
533
998
  for (const reply of replies)
534
999
  sortThread(reply.replies);
1000
+ replies.sort((left, right) => startOf(left) - startOf(right));
535
1001
  }
536
- /** How many replies a thread holds, counting all the way down. */
1002
+ /** How many visible replies a thread holds, counting all the way down. */
537
1003
  function countReplies(replies) {
538
1004
  let total = 0;
539
1005
  for (const reply of replies)
540
- total += 1 + countReplies(reply.replies);
1006
+ total += (isWithheld(reply) ? 0 : 1) + countReplies(reply.replies);
541
1007
  return total;
542
1008
  }
543
1009
  /** The two reactions, as the activity types they arrive as. */
@@ -639,6 +1105,7 @@ function authorNaming(admin) {
639
1105
  url: profile?.url ?? actorId,
640
1106
  avatar: icon === null ? null : avatarHref(icon),
641
1107
  actorId,
1108
+ feed: null,
642
1109
  };
643
1110
  known.set(actorId, author);
644
1111
  return author;