bo5tym52p: List and create comments on post cards

This commit is contained in:
2026-10-19 14:29:07 -06:00
parent de535a500d
commit 5a33546af2
11 changed files with 596 additions and 2 deletions
+70
View File
@@ -0,0 +1,70 @@
// Browser-side comment calls. Each throws CommentError naming the action.
import { appendComment, type CommentItem } from "./comment-model";
export type CommentAction = "load" | "post";
export class CommentError extends Error {
constructor(public readonly action: CommentAction, public readonly signedOut: boolean) {
super(signedOut ? "sign_in" : `comment_${action}_failed`);
}
}
export function commentErrorText(action: CommentAction): string {
return action === "load" ? "Could not load comments." : "Could not post comment.";
}
async function parse(res: Response, action: CommentAction): Promise<Record<string, unknown>> {
if (res.status === 401) throw new CommentError(action, true);
if (!res.ok) throw new CommentError(action, false);
try {
return (await res.json()) as Record<string, unknown>;
} catch {
throw new CommentError(action, false);
}
}
export async function loadComments(postId: string, fetchImpl: typeof fetch = fetch): Promise<CommentItem[]> {
let res: Response;
try {
res = await fetchImpl(`/api/posts/${encodeURIComponent(postId)}/comments`, { credentials: "same-origin" });
} catch {
throw new CommentError("load", false);
}
const b = await parse(res, "load");
return Array.isArray(b.comments) ? (b.comments as CommentItem[]) : [];
}
/** Post a comment and return the list as it should now read. Throws and returns nothing on failure. */
export async function postComment(
postId: string,
text: string,
current: CommentItem[],
fetchImpl: typeof fetch = fetch,
): Promise<CommentItem[]> {
const content = text.trim();
if (!content) return current;
let res: Response;
try {
res = await fetchImpl(`/api/posts/${encodeURIComponent(postId)}/comments`, {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content }),
});
} catch {
throw new CommentError("post", false);
}
const b = await parse(res, "post");
const made = b.comment as CommentItem | null | undefined;
if (made && typeof made === "object") return appendComment(current, made);
// The feed did not return the new comment; read the list back.
try {
return await loadComments(postId, fetchImpl);
} catch {
return appendComment(current, {
key: `local#${current.length}`, id: null, authorName: "You",
createdAt: new Date().toISOString(), removed: false, content,
});
}
}
+71
View File
@@ -0,0 +1,71 @@
// Pure comment model: normalise raw feed comments into what the panel shows.
// A comment with deletedAt set is "removed" and never carries its old text.
export interface CommentItem {
key: string;
id: string | null;
authorName: string;
createdAt: string | null;
removed: boolean;
/** Empty when removed. */
content: string;
}
type Obj = Record<string, unknown>;
function isObj(v: unknown): v is Obj {
return typeof v === "object" && v !== null && !Array.isArray(v);
}
function str(v: unknown): string | null {
return typeof v === "string" && v.trim() ? v : null;
}
function nameOf(author: Obj): string {
const display = str(author.displayName);
if (display) return display.trim();
const full = [str(author.firstName), str(author.lastName)].filter(Boolean).join(" ").trim();
return full || "Unknown";
}
export function toComment(raw: unknown, index = 0): CommentItem | null {
if (!isObj(raw)) return null;
const id = typeof raw.id === "string" || typeof raw.id === "number" ? String(raw.id) : null;
const createdAt = str(raw.createdAt);
const removed = raw.deletedAt !== null && raw.deletedAt !== undefined && raw.deletedAt !== false && raw.deletedAt !== "";
const author = isObj(raw.author) ? raw.author : isObj(raw.user) ? raw.user : {};
return {
key: id ?? `${createdAt ?? "unknown"}#${index}`,
id,
authorName: nameOf(author),
createdAt,
removed,
content: removed ? "" : typeof raw.content === "string" ? raw.content : "",
};
}
/** Find the comment array in a feed response body. */
export function commentsFromBody(body: unknown): unknown[] {
const root = isObj(body) ? body : {};
const data = root.data;
const candidates: unknown[] = [
data, isObj(data) ? data.comments : null, isObj(data) ? data.items : null,
root.comments, root.items,
];
for (const c of candidates) if (Array.isArray(c)) return c;
return [];
}
export function toComments(raws: unknown[]): CommentItem[] {
const out: CommentItem[] = [];
raws.forEach((r, i) => {
const c = toComment(r, i);
if (c) out.push(c);
});
return out;
}
/** Add a comment to the end of the list unless its id is already there. */
export function appendComment(list: CommentItem[], c: CommentItem): CommentItem[] {
if (c.id && list.some((x) => x.id === c.id)) return list;
return [...list, c];
}
+116
View File
@@ -0,0 +1,116 @@
import { FEED_BASE_URL } from "./env";
import { commentsFromBody, toComment, toComments, type CommentItem } from "./comment-model";
import { ensureFreshSession, refreshSession } from "./session-refresh";
// Comment list / create proxy. One feed call per request with the person's
// server-held feed token, plus one refresh-and-retry after a 401 rejection.
// Neither the token nor comment text is logged.
export const MAX_COMMENT_LENGTH = 5_000;
const POST_ID = /^[A-Za-z0-9_-]{1,128}$/;
type Err<E extends string> = { ok: false; status: 400 | 401 | 502; error: "sign_in" | "bad_post" | E };
export type ListCommentsResult = { ok: true; comments: CommentItem[] } | Err<"comments_unavailable">;
export type CreateCommentResult =
| { ok: true; comment: CommentItem | null }
| Err<"empty_comment" | "comment_failed">;
interface Options {
fetchImpl?: typeof fetch;
now?: number;
}
function rec(v: unknown): Record<string, unknown> {
return v && typeof v === "object" && !Array.isArray(v) ? (v as Record<string, unknown>) : {};
}
function url(postId: string, query = "") {
return `${FEED_BASE_URL}/api/v1/posts/${encodeURIComponent(postId)}/comments${query}`;
}
async function callWithRefresh(
sessionId: string,
first: Awaited<ReturnType<typeof ensureFreshSession>>,
make: (token: string) => Promise<Response>,
options: Options,
): Promise<Response | "sign_in"> {
let session = first;
if (!session) return "sign_in";
let res = await make(session.feedAccessToken);
if (res.status === 401) {
session = await refreshSession(sessionId, options);
if (!session) return "sign_in";
res = await make(session.feedAccessToken);
if (res.status === 401) return "sign_in";
}
return res;
}
export async function listComments(
sessionId: string | undefined,
postId: string,
options: Options = {},
): Promise<ListCommentsResult> {
const signIn = { ok: false, status: 401, error: "sign_in" } as const;
const failed = { ok: false, status: 502, error: "comments_unavailable" } as const;
const doFetch = options.fetchImpl ?? fetch;
const session = await ensureFreshSession(sessionId, options);
if (!session || !sessionId) return signIn;
if (!POST_ID.test(postId)) return { ok: false, status: 400, error: "bad_post" };
try {
const res = await callWithRefresh(
sessionId, session,
(t) => doFetch(url(postId, "?limit=20&page=1"), {
method: "GET", headers: { Accept: "application/json", Authorization: `Bearer ${t}` },
}),
options,
);
if (res === "sign_in") return signIn;
if (res.status !== 200) return failed;
return { ok: true, comments: toComments(commentsFromBody(await res.json())) };
} catch {
return failed;
}
}
export async function createComment(
sessionId: string | undefined,
postId: string,
content: unknown,
options: Options = {},
): Promise<CreateCommentResult> {
const signIn = { ok: false, status: 401, error: "sign_in" } as const;
const failed = { ok: false, status: 502, error: "comment_failed" } as const;
const doFetch = options.fetchImpl ?? fetch;
const session = await ensureFreshSession(sessionId, options);
if (!session || !sessionId) return signIn;
if (!POST_ID.test(postId)) return { ok: false, status: 400, error: "bad_post" };
if (typeof content !== "string" || !content.trim() || content.length > MAX_COMMENT_LENGTH) {
return { ok: false, status: 400, error: "empty_comment" };
}
try {
const res = await callWithRefresh(
sessionId, session,
(t) => doFetch(url(postId), {
method: "POST",
headers: { Accept: "application/json", "Content-Type": "application/json", Authorization: `Bearer ${t}` },
body: JSON.stringify({ content, type: "text", status: "published" }),
}),
options,
);
if (res === "sign_in") return signIn;
if (res.status !== 200 && res.status !== 201) return failed;
let comment: CommentItem | null = null;
try {
const body = rec(await res.json());
const data = rec(body.data);
const raw = Object.keys(rec(data.comment)).length ? data.comment : data.id !== undefined ? data : null;
comment = raw ? toComment(raw) : null;
} catch {
comment = null;
}
return { ok: true, comment };
} catch {
return failed;
}
}