263 lines
8.0 KiB
TypeScript
263 lines
8.0 KiB
TypeScript
import * as z from "zod/v4-mini";
|
|
import type { Ref } from "../core";
|
|
import { asRef, pathOf } from "../core";
|
|
import type { LiveBridge } from "../host/bridge";
|
|
import { toIsoOrEpoch } from "../host/dates";
|
|
import { chunk, pages } from "../host/paging";
|
|
import { paragraph, toEmoji } from "../host/quill";
|
|
import type {
|
|
ArtifactCommentDto,
|
|
ArtifactCommentReactionDto,
|
|
ArtifactCommentsQuery,
|
|
} from "../host/wire";
|
|
import { ARTIFACT_COMMENTS_MAX_LIMIT } from "../host/wire";
|
|
import { userOf } from "../people/read";
|
|
import { byIds, clampLimit } from "../select";
|
|
import type { Session } from "../session";
|
|
import { resolve } from "../session";
|
|
import { getTasks } from "../tasks/read";
|
|
import { fromHost } from "../validate";
|
|
import type { Comment, CommentSelector } from "./comments";
|
|
import { CommentSchema } from "./schema";
|
|
|
|
type CommentsInput = Omit<
|
|
Extract<ArtifactCommentsQuery, { parent: unknown }>,
|
|
"cursor" | "limit"
|
|
>;
|
|
|
|
/**
|
|
* The comments under the selector's parent, or every comment under its
|
|
* ancestor task. A reply read along with its root sits under that root,
|
|
* named as far as the result lists it.
|
|
*/
|
|
export async function readComments(
|
|
s: Session,
|
|
selector: CommentSelector,
|
|
): Promise<Comment[]> {
|
|
const limit = clampLimit(selector.limit);
|
|
const parent = await commentParent(s, selector);
|
|
const wire = await commentPages(
|
|
s.host,
|
|
{
|
|
parent: {
|
|
kind: parent.kind === "task" ? "task" : "thread",
|
|
id: parent.id,
|
|
},
|
|
includeReplies: selector.ancestor !== undefined,
|
|
resolved: selector.resolved,
|
|
assignedTo: selector.assignee && memberId(s, selector.assignee),
|
|
createdBy: selector.creator && memberId(s, selector.creator),
|
|
textSearch: selector.body?.contains,
|
|
orderDirection: selector.orderBy && (selector.orderBy.direction ?? "asc"),
|
|
},
|
|
limit,
|
|
);
|
|
const parentPath = [...pathOf(parent), asRef(parent)];
|
|
const roots = new Map(wire.map((dto) => [dto.id, commentRef(dto)]));
|
|
const comments = await mapComments(s, wire, (dto) =>
|
|
dto.parentId === parent.id
|
|
? parentPath
|
|
: [
|
|
...parentPath,
|
|
roots.get(dto.parentId) ?? {
|
|
kind: "comment",
|
|
id: dto.parentId,
|
|
name: "",
|
|
url: "",
|
|
},
|
|
],
|
|
);
|
|
return fromHost(z.array(CommentSchema), comments.slice(0, limit), "comments");
|
|
}
|
|
|
|
/** The selector's `parent`, or its `ancestor` task, as a full asset: a bare ref or id is read once for its path. */
|
|
function commentParent(
|
|
s: Session,
|
|
{ parent, ancestor }: CommentSelector,
|
|
): Promise<Ref<"task" | "comment">> {
|
|
return parent?.kind === "comment"
|
|
? resolve(s, "comment", parent, getComments)
|
|
: resolve(s, "task", parent ?? ancestor!, getTasks);
|
|
}
|
|
|
|
/**
|
|
* Exactly these comments, each under its task and, for a reply, its root
|
|
* comment. A comment elsewhere (a chat, a doc) or on a task the viewer
|
|
* cannot see is left out.
|
|
*/
|
|
export async function getComments(
|
|
s: Session,
|
|
ids: string[],
|
|
): Promise<Comment[]> {
|
|
const wire = await commentsByIds(s.host, ids);
|
|
const byId = await withRoots(s.host, wire);
|
|
const taskIds = wire.flatMap((dto) => taskIdOf(dto, byId) ?? []);
|
|
const tasks = new Map(
|
|
(await getTasks(s, [...new Set(taskIds)])).map((task) => [task.id, task]),
|
|
);
|
|
const comments = await mapComments(s, wire, (dto) => {
|
|
const task = tasks.get(taskIdOf(dto, byId) ?? "");
|
|
if (!task) {
|
|
return undefined;
|
|
}
|
|
const taskPath = [...pathOf(task), asRef(task)];
|
|
return dto.parentKind === "thread"
|
|
? [...taskPath, commentRef(byId.get(dto.parentId)!)]
|
|
: taskPath;
|
|
});
|
|
return fromHost(z.array(CommentSchema), byIds(comments, ids), "comments");
|
|
}
|
|
|
|
/** `wire` by id, plus the root comments its replies hang off, read where `wire` lacks them. */
|
|
async function withRoots(
|
|
host: LiveBridge,
|
|
wire: ArtifactCommentDto[],
|
|
): Promise<Map<string, ArtifactCommentDto>> {
|
|
const byId = new Map(wire.map((dto) => [dto.id, dto]));
|
|
const missing = wire
|
|
.filter((dto) => dto.parentKind === "thread" && !byId.has(dto.parentId))
|
|
.map((dto) => dto.parentId);
|
|
for (const root of await commentsByIds(host, [...new Set(missing)])) {
|
|
byId.set(root.id, root);
|
|
}
|
|
return byId;
|
|
}
|
|
|
|
/** The task a comment sits on, through its root for a reply; none for a comment elsewhere. */
|
|
function taskIdOf(
|
|
dto: ArtifactCommentDto,
|
|
byId: ReadonlyMap<string, ArtifactCommentDto>,
|
|
): string | undefined {
|
|
const onTask = dto.parentKind === "thread" ? byId.get(dto.parentId) : dto;
|
|
return onTask?.parentKind === "task" ? onTask.parentId : undefined;
|
|
}
|
|
|
|
async function commentsByIds(
|
|
host: LiveBridge,
|
|
ids: string[],
|
|
): Promise<ArtifactCommentDto[]> {
|
|
const results = await Promise.all(
|
|
chunk(ids, ARTIFACT_COMMENTS_MAX_LIMIT).map((batch) =>
|
|
host.query("comments", { ids: batch }),
|
|
),
|
|
);
|
|
return results.flatMap((result) => result.comments);
|
|
}
|
|
|
|
function memberId(s: Session, user: Ref<"user"> | "me"): string {
|
|
return user === "me" ? s.host.viewer().id : user.id;
|
|
}
|
|
|
|
async function commentPages(
|
|
host: LiveBridge,
|
|
input: CommentsInput,
|
|
limit: number,
|
|
): Promise<ArtifactCommentDto[]> {
|
|
const comments: ArtifactCommentDto[] = [];
|
|
for await (const page of pages((cursor) =>
|
|
host.query("comments", {
|
|
...input,
|
|
limit: Math.min(ARTIFACT_COMMENTS_MAX_LIMIT, limit - comments.length),
|
|
cursor,
|
|
}),
|
|
)) {
|
|
comments.push(...page.comments);
|
|
if (comments.length >= limit) {
|
|
break;
|
|
}
|
|
}
|
|
return comments;
|
|
}
|
|
|
|
/** Maps `wire` under the ancestors `place` gives each comment; one it places nowhere is left out. */
|
|
function mapComments(
|
|
s: Session,
|
|
wire: ArtifactCommentDto[],
|
|
place: (dto: ArtifactCommentDto) => Ref[] | undefined,
|
|
): Promise<Comment[]> {
|
|
return Promise.all(
|
|
wire.flatMap((dto) => {
|
|
const ancestors = place(dto);
|
|
return ancestors
|
|
? [mapComment(s, dto, ancestors as Comment["ancestors"])]
|
|
: [];
|
|
}),
|
|
);
|
|
}
|
|
|
|
function commentRef(dto: ArtifactCommentDto): Ref<"comment"> {
|
|
return {
|
|
kind: "comment",
|
|
id: dto.id,
|
|
name: preview(dto.bodyText),
|
|
url: dto.url,
|
|
};
|
|
}
|
|
|
|
async function mapComment(
|
|
s: Session,
|
|
wire: ArtifactCommentDto,
|
|
parentPath: Comment["ancestors"],
|
|
): Promise<Comment> {
|
|
const [creator, assignee, resolvedBy, reactions] = await Promise.all([
|
|
userOf(s, wire.creator),
|
|
wire.assigneeId ? s.member(wire.assigneeId) : undefined,
|
|
wire.resolved && wire.resolvedById
|
|
? s.member(wire.resolvedById)
|
|
: undefined,
|
|
mapReactions(s, wire.reactions),
|
|
]);
|
|
return {
|
|
kind: "comment",
|
|
id: wire.id,
|
|
name: preview(wire.bodyText),
|
|
url: wire.url,
|
|
ancestors: parentPath,
|
|
createdAt: toIsoOrEpoch(wire.createdAt),
|
|
updatedAt: toIsoOrEpoch(wire.updatedAt),
|
|
type: toCommentType(wire.commentType),
|
|
body: wire.bodyQuill ?? paragraph(wire.bodyMarkdown || wire.bodyText),
|
|
creator,
|
|
assignee,
|
|
resolvedBy,
|
|
reactions,
|
|
};
|
|
}
|
|
|
|
/** One entry per emoji, in first-seen order; reactors the workspace cannot name are left out. */
|
|
async function mapReactions(
|
|
s: Session,
|
|
wire: readonly ArtifactCommentReactionDto[],
|
|
): Promise<Comment["reactions"]> {
|
|
const byEmoji = new Map<string, Ref<"user">[]>();
|
|
for (const reaction of wire) {
|
|
const emoji = toEmoji(reaction.emoji);
|
|
const users = byEmoji.get(emoji) ?? [];
|
|
byEmoji.set(emoji, users);
|
|
const user = reaction.userId ? await s.member(reaction.userId) : undefined;
|
|
if (user && !users.some((known) => known.id === user.id)) {
|
|
users.push(user);
|
|
}
|
|
}
|
|
return [...byEmoji].map(([emoji, users]) => ({ emoji, users }));
|
|
}
|
|
const PREVIEW_MAX_LENGTH = 120;
|
|
|
|
/** A comment's `name`: its body on one line, cut to a headline. */
|
|
export function preview(text: string): string {
|
|
const line = text.replace(/\s+/g, " ").trim();
|
|
return line.length > PREVIEW_MAX_LENGTH
|
|
? `${line.slice(0, PREVIEW_MAX_LENGTH - 1)}…`
|
|
: line;
|
|
}
|
|
|
|
function toCommentType(value: string): Comment["type"] {
|
|
switch (value) {
|
|
case "ai":
|
|
case "activity":
|
|
return value;
|
|
default:
|
|
return "message";
|
|
}
|
|
}
|