q12xo172i: Render post cards in the signed-in home feed

This commit is contained in:
2026-10-19 14:01:17 -06:00
parent 20af24b25d
commit 18e0386a99
9 changed files with 581 additions and 1 deletions
+37
View File
@@ -0,0 +1,37 @@
// Browser-side reader for the same-origin GET /api/feed route.
export interface FeedPage {
items: unknown[];
nextCursor: string | null;
}
export class FeedLoadError extends Error {
constructor(public readonly signedOut: boolean) {
super(signedOut ? "sign_in" : "feed_unavailable");
}
}
export async function fetchFeedPage(
cursor: string | null,
fetchImpl: typeof fetch = fetch,
): Promise<FeedPage> {
const qs = new URLSearchParams({ limit: "20" });
if (cursor) qs.set("cursor", cursor);
let res: Response;
try {
res = await fetchImpl(`/api/feed?${qs.toString()}`, { credentials: "same-origin" });
} catch {
throw new FeedLoadError(false);
}
if (res.status === 401) throw new FeedLoadError(true);
if (!res.ok) throw new FeedLoadError(false);
try {
const body = (await res.json()) as { items?: unknown; nextCursor?: unknown };
return {
items: Array.isArray(body.items) ? body.items : [],
nextCursor: typeof body.nextCursor === "string" && body.nextCursor ? body.nextCursor : null,
};
} catch {
throw new FeedLoadError(false);
}
}
+134
View File
@@ -0,0 +1,134 @@
// Pure feed model: pick renderable posts out of raw feed items, normalise the
// card fields, and keep one list sorted strictly by publishedAt, newest first.
export interface CardMedia {
kind: "image" | "video";
/** Image source (images) or poster (videos). May be null for a bare video. */
src: string | null;
}
export interface CardPost {
key: string;
id: string | null;
authorName: string;
authorPictureUrl: string | null;
publishedAt: string | null;
time: number;
text: string;
likesCount: number;
commentsCount: number;
media: CardMedia[];
}
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 num(v: unknown): number {
return typeof v === "number" && Number.isFinite(v) ? v : 0;
}
export function initials(name: string): string {
const parts = name.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) return "?";
const first = parts[0][0] ?? "";
const last = parts.length > 1 ? (parts[parts.length - 1][0] ?? "") : "";
return (first + last).toUpperCase();
}
function authorName(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";
}
function toMedia(raw: unknown): CardMedia[] {
if (!Array.isArray(raw)) return [];
const items = raw
.filter(isObj)
.map((m, i) => ({ m, i, order: typeof m.order === "number" ? m.order : i }))
.sort((a, b) => a.order - b.order || a.i - b.i);
const out: CardMedia[] = [];
for (const { m } of items) {
if (m.mediaType === "video") {
out.push({ kind: "video", src: str(m.thumbnailUrl) });
continue;
}
const src = str(m.imageMediumWebpUrl) ?? str(m.imageMediumUrl) ?? str(m.imageUrl);
if (src) out.push({ kind: "image", src });
}
return out;
}
/** The card for one raw feed item, or null when it is not a post. */
export function toCardPost(item: unknown, index = 0): CardPost | null {
if (!isObj(item) || item.type !== "post" || !isObj(item.post)) return null;
const post = item.post;
const author = isObj(post.author) ? post.author : {};
const publishedAt = str(item.publishedAt);
const t = publishedAt ? Date.parse(publishedAt) : NaN;
const id =
typeof post.id === "string" || typeof post.id === "number" ? String(post.id) : null;
return {
key: id ?? `${publishedAt ?? "unknown"}#${index}`,
id,
authorName: authorName(author),
authorPictureUrl: str(author.profilePictureUrl),
publishedAt,
time: Number.isFinite(t) ? t : Number.NEGATIVE_INFINITY,
text: typeof post.content === "string" ? post.content : "",
likesCount: num(item.likesCount ?? post.likesCount),
commentsCount: num(item.commentsCount ?? post.commentsCount),
media: toMedia(post.mediaItems),
};
}
export function toCardPosts(items: unknown[]): CardPost[] {
const out: CardPost[] = [];
items.forEach((item, i) => {
const p = toCardPost(item, i);
if (p) out.push(p);
});
return out;
}
/** Newest first by publishedAt. Ties keep their given order. */
export function sortByPublishedDesc(posts: CardPost[]): CardPost[] {
return posts
.map((p, i) => ({ p, i }))
.sort((a, b) => (a.p.time === b.p.time ? a.i - b.i : b.p.time > a.p.time ? 1 : -1))
.map((x) => x.p);
}
/** Merge lists, drop duplicates (first wins), and sort newest first. */
export function mergePosts(...lists: CardPost[][]): CardPost[] {
const seen = new Set<string>();
const all: CardPost[] = [];
for (const list of lists) {
for (const p of list) {
if (seen.has(p.key)) continue;
seen.add(p.key);
all.push(p);
}
}
return sortByPublishedDesc(all);
}
export function relativeTime(publishedAt: string | null, now: number = Date.now()): string {
const t = publishedAt ? Date.parse(publishedAt) : NaN;
if (!Number.isFinite(t)) return "";
const s = Math.max(0, Math.floor((now - t) / 1000));
if (s < 60) return "just now";
const m = Math.floor(s / 60);
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
const d = Math.floor(h / 24);
if (d < 7) return `${d}d ago`;
return new Date(t).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
}