~james/core

core/web/src/lib/components/comment/comments.ts -rw-r--r-- 8.5 KiB
452f17b7 — james chore(import): snapshot james.dad.town/core at f70c71c7696a 15 hours ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
import type { BobbinContext } from "#lib/api/client.ts";
import type { CommentListPage } from "#lib/api/comment.ts";
import type * as OrgTangledEmbedCommit from "#lib/api/lexicons/types/org/tangled/embed/commit.ts";
import { COMMENT_AUTHOR_DOCS, REACTIONS } from "#lib/api/descriptors.ts";
import { enrich, handleOf, target, type Sidecar } from "#lib/api/enrich.ts";
import { INVALID_HANDLE } from "#lib/api/identity.ts";
import { COMMENT_COLLECTION } from "#lib/api/comment.ts";
import {
	moderationTargets,
	subjectModeration,
	type Moderation
} from "#lib/api/moderationLabels.ts";
import { didFromUri, rkeyFromUri } from "#lib/api/uri.ts";
import type { CommitSummary } from "#lib/api/repo.ts";
import { reactionGroupsOf, type ReactionGroup } from "#lib/components/reaction/reactions.ts";

export interface CommentView {
	uri: string;
	cid?: string;
	rkey: string;
	authorDid: string;
	authorHandle: string;
	moderation?: Moderation;
	createdAt: string;
	// the appview's comment header shows "Edited <time>" in place of the created one
	editedAt?: string;
	body: string;
	// carried so an edit rewrites the record without dropping what it pinned
	embed?: OrgTangledEmbedCommit.View;
	reactions?: ReactionGroup[];
	deleted?: boolean;
	unloaded?: boolean;
}

export interface CommentThread {
	self: CommentView;
	replies: CommentView[];
}

export interface ThreadInput {
	comment: CommentView;
	replyTo: string | null;
	// lets us reply to a parent even after it's deleted
	replyToCid?: string;
}

const byCreatedAt = (a: CommentView, b: CommentView) => a.createdAt.localeCompare(b.createdAt);

// stand-in for a deleted parent
function deletedComment(uri: string, createdAt: string, cid?: string): CommentView {
	return {
		uri,
		cid,
		rkey: rkeyFromUri(uri),
		authorDid: "",
		authorHandle: "",
		createdAt,
		body: "",
		deleted: true
	};
}

function buildCommentThreads(inputs: ThreadInput[], complete = true): CommentThread[] {
	const threads = new Map<string, CommentThread>();

	for (const { comment, replyTo } of inputs) {
		if (replyTo === null) {
			threads.set(comment.uri, { self: comment, replies: [] });
		}
	}

	for (const { comment, replyTo, replyToCid } of inputs) {
		if (replyTo === null) continue;
		let parent = threads.get(replyTo);
		if (!parent) {
			const self = deletedComment(replyTo, comment.createdAt);
			parent = { self: complete ? self : { ...self, unloaded: true }, replies: [] };
			threads.set(replyTo, parent);
		}
		if (parent.self.deleted && replyToCid && !parent.self.cid) {
			parent.self.cid = replyToCid;
		}
		parent.replies.push(comment);
	}

	const list = [...threads.values()].sort((a, b) => byCreatedAt(a.self, b.self));
	for (const thread of list) thread.replies.sort(byCreatedAt);
	return list;
}

export interface CommentGroup {
	/** version this group follows; undefined for the comments that predate v0 */
	version?: number;
	comments: CommentView[];
}

/**
 * A comment belongs to the newest version created at or before it. There is no
 * "commented on version N" field on the record, and there will not be one --
 * createdAt on both sides is the only ordering there is.
 *
 * `versions` must be in chronological order, as `pull.versions` is.
 */
export function groupCommentsByVersion(
	comments: CommentView[],
	versions: { createdAt: string }[]
): CommentGroup[] {
	// slot 0 holds anything older than v0 and renders without a header
	const groups: CommentGroup[] = [
		{ comments: [] },
		...versions.map((_, version) => ({ version, comments: [] }))
	];
	// parsed rather than string-compared: a boundary must not turn on whether the
	// two sides happen to share an offset or the same fractional precision
	const at = (createdAt: string) => Date.parse(createdAt);
	// both sides ascend, so one walk places every comment -- slot never goes back
	let slot = 0;
	for (const comment of [...comments].sort((a, b) => at(a.createdAt) - at(b.createdAt))) {
		while (slot < versions.length && at(versions[slot].createdAt) <= at(comment.createdAt))
			slot++;
		groups[slot].comments.push(comment);
	}
	// v0 lands with the pull itself, so slot 0 is empty in practice -- drop it then
	return groups.filter((group) => group.version !== undefined || group.comments.length);
}

export function countCommentsByCommit(comments: CommentView[]): Map<string, number> {
	const counts = new Map<string, number>();
	for (const { embed } of comments) {
		if (!embed) continue;
		const oid = embed.commit;
		counts.set(oid, (counts.get(oid) ?? 0) + 1);
	}
	return counts;
}

export function filterCommentsByCommit(
	comments: CommentView[],
	commits: CommitSummary[],
	filter?: (commit: CommitSummary) => boolean
): CommentView[] {
	if (!filter) return comments;
	return comments.filter((comment) => {
		// a filter narrows to commits, so a comment pinned to none has nothing to match
		const embed = comment.embed;
		if (!embed) return false;
		const commit = commits.find(
			(c) => c.hash === embed.commit || (!!embed.change && c.changeId === embed.change)
		);
		return !!commit && filter(commit);
	});
}

// knot comment uris name the repo, so the enrich has to fetch the editor's doc too
export const COMMENT_AUTHOR_TARGETS = ["items[].uri", "items[].editor"];

export const COMMENT_ENRICH = [
	target(COMMENT_AUTHOR_DOCS, COMMENT_AUTHOR_TARGETS),
	target(REACTIONS, ["items[].uri"]),
	...moderationTargets(COMMENT_COLLECTION, {
		records: ["items[].uri"],
		authors: COMMENT_AUTHOR_TARGETS
	})
];

type CommentRef = Pick<CommentListPage["items"][number], "uri" | "editor">;

export const commentAuthorOf = (
	docs: Sidecar,
	item: CommentRef
): { did: string; handle: string } => {
	const did = item.editor ?? didFromUri(item.uri);
	return { did, handle: handleOf(docs, did, COMMENT_AUTHOR_DOCS) };
};

export const commentModerationOf = (docs: Sidecar, item: CommentRef): Moderation | undefined =>
	subjectModeration(docs, COMMENT_COLLECTION, {
		uri: item.uri,
		author: commentAuthorOf(docs, item).did
	});

const toThreadInputs = (
	items: CommentListPage["items"],
	docs: Sidecar,
	extra?: (uri: string) => Partial<CommentView>
): ThreadInput[] =>
	items.map((item): ThreadInput => {
		const author = commentAuthorOf(docs, item);
		return {
			comment: {
				uri: item.uri,
				cid: item.cid,
				rkey: rkeyFromUri(item.uri),
				authorDid: author.did,
				authorHandle: author.handle ?? INVALID_HANDLE,
				moderation: commentModerationOf(docs, item),
				createdAt: item.createdAt,
				body: item.body?.text ?? "",
				embed: item.embed,
				...extra?.(item.uri)
			},
			replyTo: item.replyTo?.uri ?? null,
			replyToCid: item.replyTo?.cid
		};
	});

export interface CommentPage {
	inputs: ThreadInput[];
	cursor: string | null;
	total: number;
}

export const NO_COMMENTS: CommentPage = { inputs: [], cursor: null, total: 0 };

export const loadCommentPage = async (
	ctx: BobbinContext,
	subjectUri: string,
	viewerDid: string | undefined,
	cursor?: string
): Promise<CommentPage> => {
	const { output, data } = await enrich<CommentListPage>(ctx, {
		xrpc: "org.tangled.feed.listComments",
		params: { subject: subjectUri, order: "desc", limit: 100, ...(cursor ? { cursor } : {}) },
		enrich: COMMENT_ENRICH,
		viewer: viewerDid
	});
	const items = output.items.toReversed();
	return {
		inputs: toThreadInputs(items, data, (uri) => ({ reactions: reactionGroupsOf(data, uri) })),
		cursor: items.length > 0 ? (output.cursor ?? null) : null,
		total: output.total ?? items.length
	};
};

export const withOlderPage = (shown: CommentPage, older: CommentPage): CommentPage => {
	const uris = new Set(shown.inputs.map(({ comment }) => comment.uri));
	return {
		inputs: [...older.inputs.filter(({ comment }) => !uris.has(comment.uri)), ...shown.inputs],
		cursor: older.cursor,
		total: shown.total
	};
};

export const threadsOf = (page: CommentPage): CommentThread[] =>
	buildCommentThreads(page.inputs, page.cursor === null);

export const withComment = (page: CommentPage, added: ThreadInput): CommentPage => ({
	...page,
	inputs: [...page.inputs, added],
	total: page.total + 1
});

export const withEdited = (page: CommentPage, { comment }: ThreadInput): CommentPage => ({
	...page,
	inputs: page.inputs.map((input) =>
		input.comment.uri === comment.uri ? { ...input, comment } : input
	)
});

// keeps a removed parent as deleted when it has replies, otherwise threadsOf marks it unloaded
export const withRemoved = (page: CommentPage, uri: string): CommentPage => {
	const replied = page.inputs.some(({ replyTo }) => replyTo === uri);
	return {
		...page,
		inputs: page.inputs.flatMap((input) =>
			input.comment.uri !== uri
				? [input]
				: replied
					? [{ ...input, comment: { ...input.comment, body: "", deleted: true } }]
					: []
		),
		total: page.total - 1
	};
};