bo5tym52p: List and create comments on post cards
This commit is contained in:
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user