@geekity/cms 0.29.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 (52) hide show
  1. package/README.md +84 -32
  2. package/dist/admin/store.d.ts +42 -15
  3. package/dist/admin/store.d.ts.map +1 -1
  4. package/dist/admin/store.js +71 -31
  5. package/dist/admin/store.js.map +1 -1
  6. package/dist/cli.d.ts.map +1 -1
  7. package/dist/cli.js +10 -3
  8. package/dist/cli.js.map +1 -1
  9. package/dist/comments/index.d.ts +2 -2
  10. package/dist/comments/index.d.ts.map +1 -1
  11. package/dist/comments/index.js +1 -1
  12. package/dist/comments/index.js.map +1 -1
  13. package/dist/comments/records.d.ts +32 -4
  14. package/dist/comments/records.d.ts.map +1 -1
  15. package/dist/comments/records.js +182 -9
  16. package/dist/comments/records.js.map +1 -1
  17. package/dist/comments/routes.js +3 -3
  18. package/dist/comments/routes.js.map +1 -1
  19. package/dist/federation/backfill.js +6 -4
  20. package/dist/federation/backfill.js.map +1 -1
  21. package/dist/index.d.ts +2 -2
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +4 -3
  24. package/dist/index.js.map +1 -1
  25. package/dist/plugin.d.ts +28 -2
  26. package/dist/plugin.d.ts.map +1 -1
  27. package/dist/plugin.js +10 -2
  28. package/dist/plugin.js.map +1 -1
  29. package/dist/plugins/site.d.ts.map +1 -1
  30. package/dist/plugins/site.js +7 -1
  31. package/dist/plugins/site.js.map +1 -1
  32. package/dist/web/conversation.d.ts +47 -23
  33. package/dist/web/conversation.d.ts.map +1 -1
  34. package/dist/web/conversation.js +151 -120
  35. package/dist/web/conversation.js.map +1 -1
  36. package/dist/web/feed-source.d.ts +10 -7
  37. package/dist/web/feed-source.d.ts.map +1 -1
  38. package/dist/web/feed-source.js +21 -13
  39. package/dist/web/feed-source.js.map +1 -1
  40. package/dist/web/guids.d.ts +4 -16
  41. package/dist/web/guids.d.ts.map +1 -1
  42. package/dist/web/guids.js +5 -17
  43. package/dist/web/guids.js.map +1 -1
  44. package/dist/web/routes.d.ts.map +1 -1
  45. package/dist/web/routes.js +63 -53
  46. package/dist/web/routes.js.map +1 -1
  47. package/dist/webmention/receive.js +5 -5
  48. package/dist/webmention/receive.js.map +1 -1
  49. package/package.json +1 -1
  50. package/themes/default/README.md +41 -31
  51. package/themes/default/layouts/comment.njk +3 -3
  52. package/themes/default/partials/comment.njk +5 -5
@@ -5,10 +5,10 @@ import { postLabel, postTypeOf } from '../content/post-type.js';
5
5
  import { RSVP_LABELS } from '../content/rsvp.js';
6
6
  import { readAllQuoteAuthorizations } from '../federation/quotes.js';
7
7
  import { actorHandle, guessedName, quoteFrom, replyFrom, REPLY_ACTIVITY_TYPE, } from '../federation/replies.js';
8
- import { authorContext, authorFeedHref, siteAuthorContext } from './authors.js';
8
+ import { authorContext, authorFeedHref, profileContext, siteAuthorContext } from './authors.js';
9
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';
10
+ import { isKey, repliesFeedPath, repliesKey } from './feed-source.js';
11
+ import { COMMENT_PAGE_PREFIX, commentPageHref, feedGuid } from './guids.js';
12
12
  import { absoluteUrl } from './negotiate.js';
13
13
  import { sanitizeCommentHtml } from './sanitize.js';
14
14
  const RSVP_GROUP_ORDER = ['yes', 'maybe', 'interested', 'no'];
@@ -76,7 +76,12 @@ function postConversation(context, document) {
76
76
  function gather(context, document, seen = new Set([document.path])) {
77
77
  const objectId = activityStreamsId(document, context.baseUrl);
78
78
  const root = rootOf(context, document);
79
- const activities = objectId === undefined ? [] : activitiesAround(context.admin, objectId);
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]);
80
85
  const naming = authorNaming(context.admin);
81
86
  const quotes = objectId === undefined
82
87
  ? []
@@ -88,7 +93,11 @@ function gather(context, document, seen = new Set([document.path])) {
88
93
  const mentions = [...quotes];
89
94
  const answers = [];
90
95
  const held = new Map();
91
- const reacted = new Set();
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}`]));
92
101
  const isEvent = postTypeOf(document) === 'event';
93
102
  for (const activity of activities) {
94
103
  const answer = isEvent ? RSVP_ACTIVITIES[activity.activityType] : undefined;
@@ -99,7 +108,7 @@ function gather(context, document, seen = new Set([document.path])) {
99
108
  continue;
100
109
  const author = naming(activity.actorId);
101
110
  answers.push({
102
- id: activity.activityId ?? `${activity.actorId}#rsvp`,
111
+ ...unstored(activity.activityId ?? `${activity.actorId}#rsvp`),
103
112
  source: 'activitypub',
104
113
  kind: 'reply',
105
114
  author,
@@ -119,17 +128,21 @@ function gather(context, document, seen = new Set([document.path])) {
119
128
  // belongs to that reply's own conversation, wherever it is shown.
120
129
  if (activity.objectId !== objectId)
121
130
  continue;
122
- // One like per actor, however many times it was delivered, and none at
123
- // 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.
124
133
  const key = `${kind}\0${activity.actorId}`;
125
134
  if (reacted.has(key))
126
135
  continue;
127
136
  if (activity.activityId !== null && withdrawn.has(activity.activityId))
128
137
  continue;
138
+ if (activity.activityId !== null &&
139
+ heldComment(context.admin, activity.activityId) !== undefined) {
140
+ continue;
141
+ }
129
142
  reacted.add(key);
130
143
  const author = naming(activity.actorId);
131
144
  (kind === 'like' ? likes : boosts).push({
132
- id: activity.activityId ?? `${activity.actorId}#${kind}`,
145
+ ...unstored(activity.activityId ?? `${activity.actorId}#${kind}`),
133
146
  source: 'activitypub',
134
147
  kind,
135
148
  author,
@@ -143,13 +156,14 @@ function gather(context, document, seen = new Set([document.path])) {
143
156
  continue;
144
157
  }
145
158
  const reply = replyFrom(activity);
146
- if (reply === undefined)
159
+ if (reply === undefined || heldComment(context.admin, reply.id) !== undefined)
147
160
  continue;
148
- 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)));
149
163
  }
150
- for (const stored of context.admin.listCommentsFor(document.slug)) {
151
- const comment = interactionOf(stored, document.permalink);
152
- if (stored.status !== 'approved') {
164
+ for (const record of stored) {
165
+ const comment = interactionOf(record, document.permalink, context.baseUrl, users);
166
+ if (record.status !== 'approved') {
153
167
  held.set(comment.id, comment);
154
168
  continue;
155
169
  }
@@ -219,7 +233,7 @@ function commentThread(context, id) {
219
233
  };
220
234
  }
221
235
  function replyNamed(context, document, url) {
222
- return namedIn(context, document, gather(context, document), url)?.id;
236
+ return namedIn(context, document, gather(context, document), url);
223
237
  }
224
238
  /** The entry of a gathered conversation a URL names, held ones included. */
225
239
  function namedIn(context, document, said, url) {
@@ -230,8 +244,8 @@ function namedIn(context, document, said, url) {
230
244
  catch {
231
245
  return undefined;
232
246
  }
233
- const commentId = commentNamedBy(named, document, context.baseUrl);
234
- return [...said.written, ...said.held.values()].find((reply) => reply.id === commentId ||
247
+ const key = keyNamedBy(named, document, context.baseUrl);
248
+ return [...said.written, ...said.held.values()].find((reply) => reply.key === key ||
235
249
  reply.id === named.href ||
236
250
  (reply.url !== null && absoluteUrl(reply.url, context.baseUrl) === named.href));
237
251
  }
@@ -249,35 +263,30 @@ function heldAt(context, url) {
249
263
  }
250
264
  return { kind: 'reply', post, reply };
251
265
  }
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
- }
266
+ function repliesTo(context, key) {
267
+ if (!isKey(key))
268
+ return undefined;
260
269
  // Each source's index is asked in turn, so a key naming nothing costs three
261
270
  // lookups and no more (TASK-327).
262
- const [document] = context.store.listByRepliesKey(segment);
271
+ const [document] = context.store.listByRepliesKey(key);
263
272
  if (document !== undefined) {
264
- if (repliesKey(feedGuid(document, context.baseUrl)) === segment) {
273
+ if (repliesKey(feedGuid(document, context.baseUrl)) === key) {
265
274
  return repliesIn(context, document, null);
266
275
  }
267
276
  const root = threadRootOf(context, document);
268
- return root === undefined ? undefined : repliesIn(context, root, rootOf(context, document));
277
+ return root === undefined ? undefined : repliesIn(context, root, key);
269
278
  }
270
- const [comment] = context.admin.listCommentsByRepliesKey(segment);
279
+ const comment = context.admin.getComment(key);
271
280
  if (comment !== undefined) {
272
281
  const post = context.store.getBySlug(comment.slug);
273
- return post === undefined ? undefined : repliesIn(context, post, comment.id);
282
+ return post === undefined ? undefined : repliesIn(context, post, key);
274
283
  }
275
- for (const activity of context.admin.listRepliesByKey(segment)) {
284
+ for (const activity of context.admin.listRepliesByKey(key)) {
276
285
  const reply = replyFrom(activity);
277
286
  if (reply === undefined)
278
287
  continue;
279
288
  const post = documentNamed(context, reply.id);
280
- return post === undefined ? undefined : repliesIn(context, post, reply.id);
289
+ return post === undefined ? undefined : repliesIn(context, post, key);
281
290
  }
282
291
  return undefined;
283
292
  }
@@ -285,17 +294,17 @@ function repliesTo(context, segment) {
285
294
  * The direct replies of one reply in a document's thread, or of the document
286
295
  * itself, while the document is served and the reply is one a reader sees.
287
296
  */
288
- function repliesIn(context, post, id) {
297
+ function repliesIn(context, post, key) {
289
298
  if (!isServed(post, context.store.now()))
290
299
  return undefined;
291
300
  const thread = postConversation(context, post).replies;
292
- const reply = id === null ? null : visibleReply(thread, id);
301
+ const reply = key === null ? null : visibleReply(thread, key);
293
302
  if (reply === undefined)
294
303
  return undefined;
295
304
  return {
296
305
  post,
297
306
  reply,
298
- replies: directReplies(reply?.replies ?? thread).map((entry) => ({ ...entry, parent: reply })),
307
+ replies: repliesFed(reply?.replies ?? thread).map((entry) => ({ ...entry, parent: reply })),
299
308
  };
300
309
  }
301
310
  function upstreamsOf(context, document) {
@@ -367,16 +376,16 @@ function documentNamed(context, url, depth = 0) {
367
376
  return undefined;
368
377
  }
369
378
  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);
379
+ const key = commentKeyAt(named.pathname);
380
+ if (key !== undefined) {
381
+ const comment = context.admin.getComment(key);
373
382
  return comment === undefined ? undefined : context.store.getBySlug(comment.slug);
374
383
  }
375
384
  const permalink = permalinkOfObjectId(`${named.origin}${named.pathname}`, context.baseUrl);
376
385
  const found = permalink === undefined ? undefined : context.store.getByPermalink(permalink);
377
386
  return found ?? context.store.getByStoredObjectId(named.href);
378
387
  }
379
- const [comment] = context.admin.listCommentsAt(named.href);
388
+ const comment = context.admin.listCommentsAt(named.href)[0] ?? heldComment(context.admin, named.href);
380
389
  if (comment !== undefined)
381
390
  return context.store.getBySlug(comment.slug);
382
391
  for (const activity of context.admin.listActivitiesAbout([named.href])) {
@@ -447,7 +456,7 @@ export function commentNames(id, permalink, baseUrl) {
447
456
  /** A reply post as the thread's own shape, answering `inReplyTo`. */
448
457
  function replyPostInteraction(context, post, id, inReplyTo) {
449
458
  return {
450
- id,
459
+ ...unstored(id),
451
460
  source: 'post',
452
461
  kind: 'reply',
453
462
  author: documentAuthor(context, post),
@@ -464,27 +473,32 @@ function documentAuthor(context, post) {
464
473
  const users = context.users();
465
474
  const site = readSiteSettings(context.contentDir);
466
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) {
467
480
  return {
468
- name: author?.name ?? site.title,
481
+ name: author?.name ?? fallback,
469
482
  handle: null,
470
483
  url: author?.url ?? null,
471
484
  avatar: author?.avatar ?? null,
472
485
  actorId: null,
473
486
  feed: author?.username === undefined ? null : authorFeedHref(author.username, 'rss'),
487
+ username: author?.username ?? null,
474
488
  };
475
489
  }
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) {
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) {
478
492
  const post = new URL(absoluteUrl(document.permalink, baseUrl));
479
493
  if (url.origin !== post.origin)
480
494
  return undefined;
481
- const onItsPage = commentIdAt(url.pathname);
495
+ const onItsPage = commentKeyAt(url.pathname);
482
496
  if (onItsPage !== undefined)
483
497
  return onItsPage;
484
498
  const anchor = `#${commentAnchor('')}`;
485
499
  if (url.pathname !== post.pathname || !url.hash.startsWith(anchor))
486
500
  return undefined;
487
- return decoded(url.hash.slice(anchor.length));
501
+ return url.hash.slice(anchor.length);
488
502
  }
489
503
  /**
490
504
  * What a reply answers, from the top of the thread down to its parent.
@@ -554,7 +568,7 @@ function siteConversation(context, limit) {
554
568
  offset += page.length;
555
569
  for (const activity of page) {
556
570
  const reply = replyFrom(activity);
557
- if (reply === undefined)
571
+ if (reply === undefined || heldComment(context.admin, reply.id) !== undefined)
558
572
  continue;
559
573
  // A note answering another note is as much this post's as one answering
560
574
  // the post, so the post is found the way a reply post finds its thread.
@@ -576,26 +590,31 @@ function siteConversation(context, limit) {
576
590
  said.push({ ...quote, post });
577
591
  }
578
592
  }
579
- for (const stored of context.admin.listComments({
580
- status: 'approved',
581
- limit: limit * OVERSCAN,
582
- })) {
583
- const post = context.store.getBySlug(stored.slug);
584
- if (!shown(post))
585
- continue;
586
- said.push({ ...interactionOf(stored, post.permalink), post });
587
- }
588
593
  // Each post's thread, read once: where each reply sits and what it carries
589
594
  // is the thread's to say, so the site's feed and the post's agree.
590
595
  const threads = new Map();
591
596
  const placedOn = (post) => {
592
597
  let found = threads.get(post.path);
593
598
  if (found === undefined) {
594
- found = new Map(placed(postConversation(context, post).replies).map((reply) => [reply.id, reply]));
599
+ found = new Map(saidIn(postConversation(context, post)).map((reply) => [reply.id, reply]));
595
600
  threads.set(post.path, found);
596
601
  }
597
602
  return found;
598
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.
607
+ for (const stored of context.admin.listComments({
608
+ status: 'approved',
609
+ limit: limit * OVERSCAN,
610
+ })) {
611
+ const post = context.store.getBySlug(stored.slug);
612
+ if (!shown(post))
613
+ continue;
614
+ const entry = placedOn(post).get(stored.id);
615
+ if (entry !== undefined)
616
+ said.push({ ...entry, post });
617
+ }
599
618
  // A reply post is in the feed of the thread it is shown in, unlisted or
600
619
  // not: the thread is listed, and the reply post is one of its answers.
601
620
  for (const answer of context.store.listReplyPosts({ limit: limit * OVERSCAN })) {
@@ -606,7 +625,7 @@ function siteConversation(context, limit) {
606
625
  if (reply !== undefined)
607
626
  said.push({ ...reply, post });
608
627
  }
609
- return newestFirst(said, limit).map((entry) => {
628
+ return newestFirst(said.filter(conversational), limit).map((entry) => {
610
629
  const thread = placedOn(entry.post);
611
630
  return {
612
631
  ...(thread.get(entry.id) ?? { ...entry, parent: null }),
@@ -619,7 +638,7 @@ function siteConversation(context, limit) {
619
638
  function directCounts(context, documents) {
620
639
  return new Map(documents.map((document) => [
621
640
  document.permalink,
622
- directReplies(postConversation(context, document).replies).length,
641
+ repliesFed(postConversation(context, document).replies).length,
623
642
  ]));
624
643
  }
625
644
  /**
@@ -653,7 +672,7 @@ function quotesOf(admin, approvals, objectId, naming) {
653
672
  if (quote?.id !== approval.quote || quote.quoted !== objectId)
654
673
  continue;
655
674
  quotes.push({
656
- id: quote.id,
675
+ ...unstored(quote.id),
657
676
  source: 'activitypub',
658
677
  kind: 'mention',
659
678
  author: naming(quote.actorId),
@@ -674,7 +693,7 @@ function quotesOf(admin, approvals, objectId, naming) {
674
693
  /** One logged fediverse reply as the thread's own shape. */
675
694
  function federatedInteraction(reply, author) {
676
695
  return {
677
- id: reply.id,
696
+ ...unstored(reply.id),
678
697
  source: 'activitypub',
679
698
  kind: 'reply',
680
699
  author,
@@ -688,24 +707,33 @@ function federatedInteraction(reply, author) {
688
707
  replies: [],
689
708
  };
690
709
  }
710
+ function unstored(id) {
711
+ return { id, key: repliesKey(id), guid: id, migrated: false };
712
+ }
691
713
  /** One stored comment as the thread's own shape. */
692
- 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);
693
717
  return {
694
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,
695
724
  source: comment.source,
696
725
  kind: comment.kind,
697
- author: {
698
- name: comment.author.name,
699
- // A commenter has no fediverse identity: they gave a name, maybe a
700
- // website, and nothing that names them anywhere else.
701
- handle: null,
702
- url: comment.author.url,
703
- // Only a webmention has one: it came out of the source page's `h-card`,
704
- // and a form asks nobody for a picture.
705
- avatar: comment.author.avatar === null ? null : avatarHref(comment.author.avatar),
706
- actorId: null,
707
- feed: comment.source === 'webmention' ? comment.author.url : null,
708
- },
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),
709
737
  // Where it can be read. A comment written here has a page of its own
710
738
  // (TASK-318); a webmention lives on the page it was sent from, and its
711
739
  // `url` says so. A theme that prints `url` for a fediverse reply prints a
@@ -725,46 +753,53 @@ function interactionOf(comment, permalink) {
725
753
  };
726
754
  }
727
755
  /**
728
- * 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.
729
768
  *
730
769
  * Prefixed rather than bare so a comment can never collide with a heading
731
770
  * anchor the post's own Markdown produced.
732
771
  */
733
- export function commentAnchor(id) {
734
- return `comment-${id}`;
772
+ export function commentAnchor(key) {
773
+ return `comment-${key}`;
735
774
  }
736
- /** The id of the comment whose page a path is, or `undefined` (TASK-319). */
737
- export function commentIdAt(pathname) {
775
+ /** The key of the comment whose page a path is, or `undefined` (TASK-319). */
776
+ export function commentKeyAt(pathname) {
738
777
  if (!pathname.startsWith(COMMENT_PAGE_PREFIX) || !pathname.endsWith('/'))
739
778
  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
- }
779
+ const key = pathname.slice(COMMENT_PAGE_PREFIX.length, -1);
780
+ return isKey(key) ? key : undefined;
752
781
  }
753
- /**
754
- * Everything said in a conversation, at every depth and in no order: the
755
- * replies and the mentions.
756
- *
757
- * The likes and the boosts are left out because they say nothing. A feed item
758
- * is somebody's words, and "@ada liked this" has none to carry — a reader
759
- * polling a comments feed would get an entry with an empty body and no way to
760
- * tell what it was for.
761
- */
782
+ /** What a post's comments feed carries, at every depth and in no order. */
762
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) {
763
788
  return [
764
789
  ...conversation.mentions.map((mention) => ({ ...mention, parent: null })),
765
790
  ...placed(conversation.replies),
766
791
  ];
767
792
  }
793
+ /**
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.
799
+ */
800
+ function conversational(entry) {
801
+ return !entry.migrated && (entry.kind === 'reply' || entry.kind === 'mention');
802
+ }
768
803
  /** Every visible reply of a thread at any depth, each with the reply it sits under. */
769
804
  function placed(replies, parent = null) {
770
805
  return directReplies(replies).flatMap((reply) => [
@@ -780,15 +815,9 @@ function placed(replies, parent = null) {
780
815
  function directReplies(replies) {
781
816
  return replies.flatMap((reply) => (isWithheld(reply) ? directReplies(reply.replies) : [reply]));
782
817
  }
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)));
818
+ /** The direct replies a feed carries and a count advertises. */
819
+ function repliesFed(replies) {
820
+ return directReplies(replies).filter(conversational);
792
821
  }
793
822
  /**
794
823
  * Interactions as a comments feed carries them: newest first, at most `limit`,
@@ -807,10 +836,10 @@ export function feedComments(said, options) {
807
836
  ? thread === undefined
808
837
  ? undefined
809
838
  : feedGuid(thread, baseUrl)
810
- : replyGuid(entry.parent, baseUrl);
811
- const replies = directReplies(entry.replies);
839
+ : entry.parent.guid;
840
+ const replies = repliesFed(entry.replies);
812
841
  return {
813
- id: replyGuid(entry, baseUrl),
842
+ id: entry.guid,
814
843
  url: absoluteUrl(entry.url ?? '/', baseUrl),
815
844
  author: entry.author.name,
816
845
  published: entry.published,
@@ -821,7 +850,7 @@ export function feedComments(said, options) {
821
850
  : {
822
851
  replies: {
823
852
  count: replies.length,
824
- feed: absoluteUrl(repliesHrefOf(entry, baseUrl), baseUrl),
853
+ feed: absoluteUrl(repliesFeedPath(entry.key), baseUrl),
825
854
  },
826
855
  }),
827
856
  ...(entry.author.feed === null
@@ -955,7 +984,8 @@ function threadOf(said, under = said.root) {
955
984
  const list = siblings(said.held.get(id)?.inReplyTo ?? said.root, id, true);
956
985
  if (list === undefined)
957
986
  return undefined;
958
- const entry = { id, withheld: true, replies: [] };
987
+ const key = isKey(id) ? id : repliesKey(id);
988
+ const entry = { id, key, withheld: true, replies: [] };
959
989
  list.push(entry);
960
990
  withheld.set(id, entry);
961
991
  return entry;
@@ -965,12 +995,12 @@ function threadOf(said, under = said.root) {
965
995
  }
966
996
  return top;
967
997
  }
968
- /** The entry of a thread with this id that a reader can see, at any depth. */
969
- export function visibleReply(replies, id) {
998
+ /** The entry of a thread with this key that a reader can see, at any depth. */
999
+ export function visibleReply(replies, key) {
970
1000
  for (const reply of replies) {
971
- if (!isWithheld(reply) && reply.id === id)
1001
+ if (!isWithheld(reply) && reply.key === key)
972
1002
  return reply;
973
- const found = visibleReply(reply.replies, id);
1003
+ const found = visibleReply(reply.replies, key);
974
1004
  if (found !== undefined)
975
1005
  return found;
976
1006
  }
@@ -1021,10 +1051,10 @@ const REACTIONS = {
1021
1051
  * stops when a round finds nothing new, which it must, because a row is
1022
1052
  * collected once and there are finitely many.
1023
1053
  */
1024
- function activitiesAround(admin, objectId) {
1054
+ function activitiesAround(admin, objectIds) {
1025
1055
  const collected = new Map();
1026
- const asked = new Set([objectId]);
1027
- let frontier = [objectId];
1056
+ const asked = new Set(objectIds);
1057
+ let frontier = [...asked];
1028
1058
  while (frontier.length > 0) {
1029
1059
  const next = [];
1030
1060
  for (const activity of admin.listActivitiesAbout(frontier)) {
@@ -1106,6 +1136,7 @@ function authorNaming(admin) {
1106
1136
  avatar: icon === null ? null : avatarHref(icon),
1107
1137
  actorId,
1108
1138
  feed: null,
1139
+ username: null,
1109
1140
  };
1110
1141
  known.set(actorId, author);
1111
1142
  return author;