@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.
- package/README.md +469 -84
- package/admin/layouts/shell.njk +6 -0
- package/admin/pages/comments/all.njk +3 -1
- package/admin/pages/documents/editor.njk +4 -0
- package/admin/pages/federation/followers.njk +8 -2
- package/admin/pages/plugins/installed.njk +1 -1
- package/admin/pages/tools/dev-mode.njk +47 -0
- package/admin/static/admin.css +8 -0
- package/admin/static/editor-actions.js +13 -9
- package/admin/static/editor.js +31 -17
- package/dist/admin/comments.d.ts +2 -0
- package/dist/admin/comments.d.ts.map +1 -1
- package/dist/admin/comments.js +19 -49
- package/dist/admin/comments.js.map +1 -1
- package/dist/admin/dev-mode.d.ts +9 -0
- package/dist/admin/dev-mode.d.ts.map +1 -0
- package/dist/admin/dev-mode.js +42 -0
- package/dist/admin/dev-mode.js.map +1 -0
- package/dist/admin/documents.d.ts +5 -0
- package/dist/admin/documents.d.ts.map +1 -1
- package/dist/admin/documents.js +45 -17
- package/dist/admin/documents.js.map +1 -1
- package/dist/admin/editor-actions.js +3 -1
- package/dist/admin/editor-actions.js.map +1 -1
- package/dist/admin/federation.d.ts +21 -1
- package/dist/admin/federation.d.ts.map +1 -1
- package/dist/admin/federation.js +19 -7
- package/dist/admin/federation.js.map +1 -1
- package/dist/admin/menu.d.ts.map +1 -1
- package/dist/admin/menu.js +2 -0
- package/dist/admin/menu.js.map +1 -1
- package/dist/admin/own-paragraph.d.ts +7 -0
- package/dist/admin/own-paragraph.d.ts.map +1 -0
- package/dist/admin/own-paragraph.js +9 -0
- package/dist/admin/own-paragraph.js.map +1 -0
- package/dist/admin/plugins.d.ts.map +1 -1
- package/dist/admin/plugins.js +19 -8
- package/dist/admin/plugins.js.map +1 -1
- package/dist/admin/preview.d.ts.map +1 -1
- package/dist/admin/preview.js +4 -1
- package/dist/admin/preview.js.map +1 -1
- package/dist/admin/routes.d.ts.map +1 -1
- package/dist/admin/routes.js +4 -0
- package/dist/admin/routes.js.map +1 -1
- package/dist/admin/settings-email.d.ts.map +1 -1
- package/dist/admin/settings-email.js +4 -0
- package/dist/admin/settings-email.js.map +1 -1
- package/dist/admin/store.d.ts +68 -12
- package/dist/admin/store.d.ts.map +1 -1
- package/dist/admin/store.js +167 -18
- package/dist/admin/store.js.map +1 -1
- package/dist/admin/templates.d.ts +1 -0
- package/dist/admin/templates.d.ts.map +1 -1
- package/dist/admin/templates.js +1 -0
- package/dist/admin/templates.js.map +1 -1
- package/dist/admin/uploads.d.ts +7 -1
- package/dist/admin/uploads.d.ts.map +1 -1
- package/dist/admin/uploads.js +33 -30
- package/dist/admin/uploads.js.map +1 -1
- package/dist/cli.d.ts +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +78 -12
- package/dist/cli.js.map +1 -1
- package/dist/comments/form.d.ts +3 -1
- package/dist/comments/form.d.ts.map +1 -1
- package/dist/comments/form.js +2 -1
- package/dist/comments/form.js.map +1 -1
- package/dist/comments/index.d.ts +2 -2
- package/dist/comments/index.d.ts.map +1 -1
- package/dist/comments/index.js +1 -1
- package/dist/comments/index.js.map +1 -1
- package/dist/comments/records.d.ts +25 -20
- package/dist/comments/records.d.ts.map +1 -1
- package/dist/comments/records.js +113 -33
- package/dist/comments/records.js.map +1 -1
- package/dist/comments/reply-notices.d.ts +42 -0
- package/dist/comments/reply-notices.d.ts.map +1 -0
- package/dist/comments/reply-notices.js +76 -0
- package/dist/comments/reply-notices.js.map +1 -0
- package/dist/comments/reply-post.d.ts +49 -0
- package/dist/comments/reply-post.d.ts.map +1 -0
- package/dist/comments/reply-post.js +66 -0
- package/dist/comments/reply-post.js.map +1 -0
- package/dist/comments/routes.d.ts +6 -1
- package/dist/comments/routes.d.ts.map +1 -1
- package/dist/comments/routes.js +49 -21
- package/dist/comments/routes.js.map +1 -1
- package/dist/comments/submission.d.ts +8 -25
- package/dist/comments/submission.d.ts.map +1 -1
- package/dist/comments/submission.js +19 -38
- package/dist/comments/submission.js.map +1 -1
- package/dist/comments/viewer.d.ts.map +1 -1
- package/dist/comments/viewer.js +1 -0
- package/dist/comments/viewer.js.map +1 -1
- package/dist/config.d.ts +21 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +2 -0
- package/dist/config.js.map +1 -1
- package/dist/content/citation.d.ts +1 -0
- package/dist/content/citation.d.ts.map +1 -1
- package/dist/content/citation.js +2 -0
- package/dist/content/citation.js.map +1 -1
- package/dist/content/markdown.d.ts.map +1 -1
- package/dist/content/markdown.js +33 -1
- package/dist/content/markdown.js.map +1 -1
- package/dist/content/migrated.d.ts +6 -0
- package/dist/content/migrated.d.ts.map +1 -0
- package/dist/content/migrated.js +12 -0
- package/dist/content/migrated.js.map +1 -0
- package/dist/content/original.d.ts +8 -0
- package/dist/content/original.d.ts.map +1 -0
- package/dist/content/original.js +15 -0
- package/dist/content/original.js.map +1 -0
- package/dist/content/parser.d.ts +5 -0
- package/dist/content/parser.d.ts.map +1 -1
- package/dist/content/parser.js +1 -1
- package/dist/content/parser.js.map +1 -1
- package/dist/content/schedule.d.ts +7 -0
- package/dist/content/schedule.d.ts.map +1 -1
- package/dist/content/schedule.js +11 -2
- package/dist/content/schedule.js.map +1 -1
- package/dist/content/store.d.ts +43 -11
- package/dist/content/store.d.ts.map +1 -1
- package/dist/content/store.js +170 -10
- package/dist/content/store.js.map +1 -1
- package/dist/content/sync.d.ts +8 -0
- package/dist/content/sync.d.ts.map +1 -1
- package/dist/content/sync.js +172 -57
- package/dist/content/sync.js.map +1 -1
- package/dist/content/video.d.ts +12 -0
- package/dist/content/video.d.ts.map +1 -0
- package/dist/content/video.js +91 -0
- package/dist/content/video.js.map +1 -0
- package/dist/dev-mode.d.ts +28 -0
- package/dist/dev-mode.d.ts.map +1 -0
- package/dist/dev-mode.js +64 -0
- package/dist/dev-mode.js.map +1 -0
- package/dist/drain.d.ts +9 -3
- package/dist/drain.d.ts.map +1 -1
- package/dist/drain.js +24 -3
- package/dist/drain.js.map +1 -1
- package/dist/federation/article.d.ts.map +1 -1
- package/dist/federation/article.js +2 -1
- package/dist/federation/article.js.map +1 -1
- package/dist/federation/backfill.d.ts +114 -0
- package/dist/federation/backfill.d.ts.map +1 -0
- package/dist/federation/backfill.js +312 -0
- package/dist/federation/backfill.js.map +1 -0
- package/dist/federation/delivery.d.ts +2 -0
- package/dist/federation/delivery.d.ts.map +1 -1
- package/dist/federation/delivery.js +46 -16
- package/dist/federation/delivery.js.map +1 -1
- package/dist/federation/inbox.d.ts.map +1 -1
- package/dist/federation/inbox.js +14 -4
- package/dist/federation/inbox.js.map +1 -1
- package/dist/federation/mount.d.ts.map +1 -1
- package/dist/federation/mount.js +8 -4
- package/dist/federation/mount.js.map +1 -1
- package/dist/federation/records.d.ts +13 -0
- package/dist/federation/records.d.ts.map +1 -1
- package/dist/federation/records.js +49 -10
- package/dist/federation/records.js.map +1 -1
- package/dist/federation/relays.d.ts.map +1 -1
- package/dist/federation/relays.js +16 -1
- package/dist/federation/relays.js.map +1 -1
- package/dist/federation/replies.d.ts +6 -0
- package/dist/federation/replies.d.ts.map +1 -1
- package/dist/federation/replies.js +18 -1
- package/dist/federation/replies.js.map +1 -1
- package/dist/index.d.ts +13 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +95 -14
- package/dist/index.js.map +1 -1
- package/dist/indexnow.d.ts +1 -1
- package/dist/indexnow.d.ts.map +1 -1
- package/dist/indexnow.js +7 -0
- package/dist/indexnow.js.map +1 -1
- package/dist/init.d.ts +1 -1
- package/dist/init.d.ts.map +1 -1
- package/dist/init.js +1 -7
- package/dist/init.js.map +1 -1
- package/dist/mail/service.d.ts +3 -1
- package/dist/mail/service.d.ts.map +1 -1
- package/dist/mail/service.js +17 -0
- package/dist/mail/service.js.map +1 -1
- package/dist/notify.d.ts +1 -1
- package/dist/notify.d.ts.map +1 -1
- package/dist/notify.js +7 -0
- package/dist/notify.js.map +1 -1
- package/dist/plugin.d.ts +67 -4
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js.map +1 -1
- package/dist/plugins/folder.d.ts +2 -0
- package/dist/plugins/folder.d.ts.map +1 -1
- package/dist/plugins/folder.js +26 -5
- package/dist/plugins/folder.js.map +1 -1
- package/dist/plugins/manage.d.ts +8 -6
- package/dist/plugins/manage.d.ts.map +1 -1
- package/dist/plugins/manage.js +1 -4
- package/dist/plugins/manage.js.map +1 -1
- package/dist/plugins/site.d.ts.map +1 -1
- package/dist/plugins/site.js +56 -0
- package/dist/plugins/site.js.map +1 -1
- package/dist/supervisor/control.d.ts +15 -0
- package/dist/supervisor/control.d.ts.map +1 -0
- package/dist/supervisor/control.js +89 -0
- package/dist/supervisor/control.js.map +1 -0
- package/dist/supervisor/primary.d.ts +18 -4
- package/dist/supervisor/primary.d.ts.map +1 -1
- package/dist/supervisor/primary.js +128 -16
- package/dist/supervisor/primary.js.map +1 -1
- package/dist/supervisor/protocol.d.ts +12 -0
- package/dist/supervisor/protocol.d.ts.map +1 -1
- package/dist/supervisor/protocol.js +21 -2
- package/dist/supervisor/protocol.js.map +1 -1
- package/dist/supervisor/supervision.d.ts +1 -0
- package/dist/supervisor/supervision.d.ts.map +1 -1
- package/dist/supervisor/supervision.js +14 -1
- package/dist/supervisor/supervision.js.map +1 -1
- package/dist/supervisor/worker.d.ts.map +1 -1
- package/dist/supervisor/worker.js +31 -6
- package/dist/supervisor/worker.js.map +1 -1
- package/dist/web/context.d.ts +22 -0
- package/dist/web/context.d.ts.map +1 -1
- package/dist/web/context.js +20 -4
- package/dist/web/context.js.map +1 -1
- package/dist/web/conversation.d.ts +155 -16
- package/dist/web/conversation.d.ts.map +1 -1
- package/dist/web/conversation.js +602 -135
- package/dist/web/conversation.js.map +1 -1
- package/dist/web/documents.d.ts +0 -22
- package/dist/web/documents.d.ts.map +1 -1
- package/dist/web/documents.js +1 -34
- package/dist/web/documents.js.map +1 -1
- package/dist/web/feed-item.d.ts +19 -14
- package/dist/web/feed-item.d.ts.map +1 -1
- package/dist/web/feed-item.js +10 -9
- package/dist/web/feed-item.js.map +1 -1
- package/dist/web/feed-rss.d.ts +2 -2
- package/dist/web/feed-rss.d.ts.map +1 -1
- package/dist/web/feed-rss.js +36 -12
- package/dist/web/feed-rss.js.map +1 -1
- package/dist/web/feed-source.d.ts +30 -1
- package/dist/web/feed-source.d.ts.map +1 -1
- package/dist/web/feed-source.js +25 -0
- package/dist/web/feed-source.js.map +1 -1
- package/dist/web/feeds.d.ts +1 -1
- package/dist/web/feeds.d.ts.map +1 -1
- package/dist/web/feeds.js +7 -2
- package/dist/web/feeds.js.map +1 -1
- package/dist/web/guids.d.ts +59 -0
- package/dist/web/guids.d.ts.map +1 -0
- package/dist/web/guids.js +79 -0
- package/dist/web/guids.js.map +1 -0
- package/dist/web/index.d.ts +5 -6
- package/dist/web/index.d.ts.map +1 -1
- package/dist/web/index.js +3 -3
- package/dist/web/index.js.map +1 -1
- package/dist/web/redirects.d.ts +29 -10
- package/dist/web/redirects.d.ts.map +1 -1
- package/dist/web/redirects.js +99 -53
- package/dist/web/redirects.js.map +1 -1
- package/dist/web/render.d.ts +19 -9
- package/dist/web/render.d.ts.map +1 -1
- package/dist/web/render.js +44 -42
- package/dist/web/render.js.map +1 -1
- package/dist/web/routes.d.ts +2 -2
- package/dist/web/routes.d.ts.map +1 -1
- package/dist/web/routes.js +94 -31
- package/dist/web/routes.js.map +1 -1
- package/dist/webmention/index.d.ts +1 -1
- package/dist/webmention/index.d.ts.map +1 -1
- package/dist/webmention/index.js +1 -1
- package/dist/webmention/index.js.map +1 -1
- package/dist/webmention/links.d.ts +55 -0
- package/dist/webmention/links.d.ts.map +1 -1
- package/dist/webmention/links.js +162 -0
- package/dist/webmention/links.js.map +1 -1
- package/dist/webmention/microformats.d.ts +15 -0
- package/dist/webmention/microformats.d.ts.map +1 -1
- package/dist/webmention/microformats.js +63 -1
- package/dist/webmention/microformats.js.map +1 -1
- package/dist/webmention/pingback.js +2 -1
- package/dist/webmention/pingback.js.map +1 -1
- package/dist/webmention/receive.d.ts +5 -0
- package/dist/webmention/receive.d.ts.map +1 -1
- package/dist/webmention/receive.js +99 -3
- package/dist/webmention/receive.js.map +1 -1
- package/dist/webmention/reply-context.d.ts +15 -0
- package/dist/webmention/reply-context.d.ts.map +1 -1
- package/dist/webmention/reply-context.js +29 -0
- package/dist/webmention/reply-context.js.map +1 -1
- package/dist/webmention/reply-contexts.d.ts +6 -0
- package/dist/webmention/reply-contexts.d.ts.map +1 -1
- package/dist/webmention/reply-contexts.js +16 -10
- package/dist/webmention/reply-contexts.js.map +1 -1
- package/dist/webmention/routes.d.ts.map +1 -1
- package/dist/webmention/routes.js +2 -1
- package/dist/webmention/routes.js.map +1 -1
- package/dist/webmention/service.d.ts +31 -2
- package/dist/webmention/service.d.ts.map +1 -1
- package/dist/webmention/service.js +183 -33
- package/dist/webmention/service.js.map +1 -1
- package/package.json +1 -2
- package/themes/default/README.md +390 -75
- package/themes/default/layouts/base.njk +19 -2
- package/themes/default/layouts/comment.njk +90 -0
- package/themes/default/layouts/front-page.njk +9 -47
- package/themes/default/layouts/page.njk +2 -0
- package/themes/default/layouts/post.njk +22 -4
- package/themes/default/partials/backlinks.njk +19 -0
- package/themes/default/partials/body-end.njk +1 -0
- package/themes/default/partials/cited-page.njk +3 -1
- package/themes/default/partials/comment-form.njk +10 -0
- package/themes/default/partials/comment.njk +79 -0
- package/themes/default/partials/conversation.njk +14 -59
- package/themes/default/partials/head-end.njk +1 -0
- package/themes/default/src/style.css +46 -0
- package/themes/default/static/style.css +75 -0
package/dist/web/conversation.js
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
|
+
import { readSiteJson, readSiteSettings } from '../admin/settings.js';
|
|
1
2
|
import { avatarHref } from '../avatars/avatars.js';
|
|
3
|
+
import { answerable, commentPolicyOf } from '../comments/policy.js';
|
|
2
4
|
import { postLabel, postTypeOf } from '../content/post-type.js';
|
|
3
5
|
import { RSVP_LABELS } from '../content/rsvp.js';
|
|
4
6
|
import { readAllQuoteAuthorizations } from '../federation/quotes.js';
|
|
5
7
|
import { actorHandle, guessedName, quoteFrom, replyFrom, REPLY_ACTIVITY_TYPE, } from '../federation/replies.js';
|
|
6
|
-
import {
|
|
8
|
+
import { authorContext, authorFeedHref, 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) =>
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
210
|
-
|
|
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 (
|
|
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 (
|
|
584
|
+
if (!shown(post))
|
|
233
585
|
continue;
|
|
234
586
|
said.push({ ...interactionOf(stored, post.permalink), post });
|
|
235
587
|
}
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
|
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
|
|
351
|
-
//
|
|
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 ??
|
|
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
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
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
|
|
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
|
-
*
|
|
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(
|
|
484
|
-
const
|
|
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 ??
|
|
488
|
-
if (!withdrawn.has(reply.id))
|
|
489
|
-
|
|
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
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
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
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
*/
|
|
509
|
-
function surviving(reply, targets, byId, objectId) {
|
|
510
|
-
let target = targets.get(reply.id);
|
|
511
|
-
for (let step = 0; step <= targets.size; step += 1) {
|
|
512
|
-
if (target === undefined)
|
|
513
|
-
return undefined;
|
|
514
|
-
if (target === objectId)
|
|
515
|
-
return null;
|
|
516
|
-
const parent = byId.get(target);
|
|
517
|
-
if (parent !== undefined)
|
|
518
|
-
return parent === reply ? undefined : parent;
|
|
519
|
-
// The target is not a reply the site is showing: either a note its author
|
|
520
|
-
// deleted, whose own target this reply moves to, or nothing to do with the
|
|
521
|
-
// post at all.
|
|
522
|
-
target = targets.get(target);
|
|
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;
|