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
+20
View File
@@ -13,3 +13,23 @@ button:disabled { opacity: .5; cursor: not-allowed; }
.shell header { display: flex; align-items: center; gap: 12px; padding: 12px 16px; background: #fff; border-bottom: 1px solid var(--line); }
.shell header div { display: flex; flex-direction: column; font-size: 14px; }
.shell header span { color: var(--grey); }
.shell header { position: sticky; top: 0; z-index: 5; }
.shell header button { margin: 0 0 0 auto; padding: 8px 14px; background: #fff; color: var(--ink); border: 1px solid var(--ink); }
.feed { max-width: 640px; margin: 0 auto; padding: 16px; display: flex; flex-direction: column; gap: 16px; }
.post { background: #fff; border: 1px solid var(--line); border-radius: 8px; padding: 16px; display: flex; flex-direction: column; gap: 12px; }
.post-head { display: flex; align-items: center; gap: 12px; }
.avatar { width: 40px; height: 40px; border-radius: 8px; object-fit: cover; background: var(--line); flex: none; }
.avatar-initials { display: grid; place-items: center; background: var(--ink); color: #fff; font-weight: 500; font-size: 14px; }
.post-who { display: flex; flex-direction: column; font-size: 14px; min-width: 0; }
.post-who time { color: var(--grey); font-size: 13px; }
.post-text { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; line-height: 1.5; }
.post-media img { display: block; width: 100%; height: auto; border-radius: 8px; }
.post-stats { display: flex; gap: 16px; color: var(--grey); font-size: 14px; }
.heart { color: var(--red); }
.feed-message { text-align: center; color: var(--grey); padding: 32px 16px; }
.feed-message button { margin-top: 8px; }
.feed-center { display: grid; place-items: center; padding: 16px; }
.spinner { width: 24px; height: 24px; border: 3px solid var(--line); border-top-color: var(--red); border-radius: 50%; animation: spin .8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.new-posts { position: sticky; top: 68px; z-index: 4; align-self: center; margin: 0; padding: 8px 16px; border-radius: 999px; }
.sentinel { height: 1px; }
+2 -1
View File
@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
import { loadSession, signOut, type Person } from "@/lib/auth-client";
import { SignInForm } from "./SignInForm";
import { Logo } from "./Logo";
import { Feed } from "./Feed";
type State = { kind: "loading" } | { kind: "out" } | { kind: "in"; person: Person };
@@ -39,7 +40,6 @@ export function App() {
if (state.kind === "out") {
return <SignInForm onSignedIn={(person) => setState({ kind: "in", person })} />;
}
// Placeholder shell. The home feed is a later task; sign-out is wired here.
return (
<main className="shell">
<header>
@@ -52,6 +52,7 @@ export function App() {
{signingOut ? "Signing out..." : "Sign out"}
</button>
</header>
<Feed onSignedOut={() => setState({ kind: "out" })} />
</main>
);
}
+142
View File
@@ -0,0 +1,142 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { fetchFeedPage, FeedLoadError } from "@/lib/feed-client";
import { mergePosts, toCardPosts, type CardPost } from "@/lib/feed-model";
import { FeedView, type FeedStatus } from "./FeedView";
const POLL_MS = 45_000;
export function Feed({ onSignedOut }: { onSignedOut: () => void }) {
const [posts, setPosts] = useState<CardPost[]>([]);
const [status, setStatus] = useState<FeedStatus>("loading");
const [nextCursor, setNextCursor] = useState<string | null>(null);
const [loadingMore, setLoadingMore] = useState(false);
const [loadMoreFailed, setLoadMoreFailed] = useState(false);
const [pending, setPending] = useState<CardPost[]>([]);
const sentinel = useRef<HTMLDivElement | null>(null);
const busy = useRef(false);
const shown = useRef<Set<string>>(new Set());
useEffect(() => {
shown.current = new Set(posts.map((p) => p.key));
}, [posts]);
const fail = useCallback(
(e: unknown) => {
if (e instanceof FeedLoadError && e.signedOut) onSignedOut();
},
[onSignedOut],
);
const loadFirst = useCallback(async () => {
if (busy.current) return;
busy.current = true;
setStatus("loading");
setLoadMoreFailed(false);
try {
const page = await fetchFeedPage(null);
setPosts(mergePosts(toCardPosts(page.items)));
setNextCursor(page.nextCursor);
setPending([]);
setStatus("ready");
} catch (e) {
fail(e);
setStatus("error");
} finally {
busy.current = false;
}
}, [fail]);
const loadMore = useCallback(async () => {
if (busy.current || !nextCursor) return;
busy.current = true;
setLoadingMore(true);
setLoadMoreFailed(false);
try {
const page = await fetchFeedPage(nextCursor);
setPosts((cur) => mergePosts(cur, toCardPosts(page.items)));
setNextCursor(page.nextCursor);
} catch (e) {
fail(e);
setLoadMoreFailed(true);
} finally {
busy.current = false;
setLoadingMore(false);
}
}, [nextCursor, fail]);
useEffect(() => {
void loadFirst();
}, [loadFirst]);
// Infinite scroll. A fresh observer per change re-checks a sentinel that is
// still in view after a page lands.
useEffect(() => {
const el = sentinel.current;
if (!el || !nextCursor || status !== "ready" || loadMoreFailed) return;
const io = new IntersectionObserver(
(entries) => {
if (entries.some((e) => e.isIntersecting)) void loadMore();
},
{ rootMargin: "400px" },
);
io.observe(el);
return () => io.disconnect();
}, [nextCursor, status, loadMoreFailed, loadMore, posts.length]);
// Real-time: poll the first page while the tab is visible.
useEffect(() => {
if (status !== "ready") return;
let timer: ReturnType<typeof setInterval> | null = null;
const poll = async () => {
try {
const page = await fetchFeedPage(null);
const fresh = toCardPosts(page.items).filter((p) => !shown.current.has(p.key));
if (fresh.length) setPending((cur) => mergePosts(cur, fresh));
} catch (e) {
fail(e);
}
};
const start = () => {
if (timer === null) timer = setInterval(() => void poll(), POLL_MS);
};
const stop = () => {
if (timer !== null) clearInterval(timer);
timer = null;
};
const onVisibility = () => {
if (document.visibilityState === "visible") {
void poll();
start();
} else {
stop();
}
};
if (document.visibilityState === "visible") start();
document.addEventListener("visibilitychange", onVisibility);
return () => {
stop();
document.removeEventListener("visibilitychange", onVisibility);
};
}, [status, fail]);
function showNew() {
setPosts((cur) => mergePosts(cur, pending));
setPending([]);
window.scrollTo({ top: 0, behavior: "smooth" });
}
return (
<FeedView
posts={posts}
status={status}
loadingMore={loadingMore}
loadMoreFailed={loadMoreFailed}
newCount={pending.length}
onRetry={() => (status === "error" ? void loadFirst() : void loadMore())}
onShowNew={showNew}
sentinelRef={sentinel}
/>
);
}
+69
View File
@@ -0,0 +1,69 @@
import type { Ref } from "react";
import type { CardPost } from "@/lib/feed-model";
import { PostCard } from "./PostCard";
export type FeedStatus = "loading" | "ready" | "error";
export interface FeedViewProps {
posts: CardPost[];
status: FeedStatus;
loadingMore: boolean;
loadMoreFailed: boolean;
newCount: number;
onRetry: () => void;
onShowNew: () => void;
sentinelRef?: Ref<HTMLDivElement>;
now?: number;
}
function Spinner() {
return <span className="spinner" role="status" aria-label="Loading" />;
}
export function FeedView(p: FeedViewProps) {
if (p.status === "loading") {
return (
<section className="feed">
<div className="feed-center">
<Spinner />
</div>
</section>
);
}
if (p.status === "error") {
return (
<section className="feed">
<div className="feed-message" role="alert">
<p>The feed did not load.</p>
<button type="button" onClick={p.onRetry}>Try again</button>
</div>
</section>
);
}
return (
<section className="feed">
{p.newCount > 0 ? (
<button type="button" className="new-posts" onClick={p.onShowNew}>
{p.newCount === 1 ? "1 new post" : `${p.newCount} new posts`}
</button>
) : null}
{p.posts.length === 0 ? (
<p className="feed-message">No posts yet.</p>
) : (
p.posts.map((post) => <PostCard key={post.key} post={post} now={p.now} />)
)}
{p.loadMoreFailed ? (
<div className="feed-message" role="alert">
<p>The feed did not load.</p>
<button type="button" onClick={p.onRetry}>Try again</button>
</div>
) : null}
{p.loadingMore ? (
<div className="feed-center">
<Spinner />
</div>
) : null}
<div ref={p.sentinelRef} className="sentinel" aria-hidden="true" />
</section>
);
}
+37
View File
@@ -0,0 +1,37 @@
import { initials, relativeTime, type CardPost } from "@/lib/feed-model";
export function PostCard({ post, now }: { post: CardPost; now?: number }) {
return (
<article className="post" data-testid="post-card">
<header className="post-head">
{post.authorPictureUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img className="avatar" src={post.authorPictureUrl} alt="" width={40} height={40} />
) : (
<span className="avatar avatar-initials" aria-hidden="true">
{initials(post.authorName)}
</span>
)}
<div className="post-who">
<strong>{post.authorName}</strong>
<time dateTime={post.publishedAt ?? undefined}>{relativeTime(post.publishedAt, now)}</time>
</div>
</header>
{post.text ? <p className="post-text">{post.text}</p> : null}
{post.media.map((m, i) =>
m.src ? (
<div className={m.kind === "video" ? "post-media post-video" : "post-media"} key={i}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={m.src} alt={m.kind === "video" ? "Video thumbnail" : ""} loading="lazy" />
</div>
) : null,
)}
<footer className="post-stats">
<span aria-label={`${post.likesCount} likes`}>
<span className="heart" aria-hidden="true">&#9829;</span> {post.likesCount}
</span>
<span aria-label={`${post.commentsCount} comments`}>{post.commentsCount} comments</span>
</footer>
</article>
);
}
+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" });
}
+139
View File
@@ -0,0 +1,139 @@
import { describe, expect, it, vi } from "vitest";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { FeedView, type FeedViewProps } from "@/components/FeedView";
import { fetchFeedPage, FeedLoadError } from "@/lib/feed-client";
import { mergePosts, relativeTime, toCardPosts } from "@/lib/feed-model";
const NOW = Date.parse("2026-10-19T12:00:00Z");
const post = (id: string, publishedAt: string, extra: Record<string, unknown> = {}) => ({
type: "post",
publishedAt,
likesCount: 3,
commentsCount: 2,
post: {
id,
content: `text ${id}\nsecond line`,
author: { id: "a", displayName: `Author ${id}`, profilePictureUrl: "https://img/a.jpg" },
mediaItems: [],
...extra,
},
});
function render(over: Partial<FeedViewProps>) {
const props: FeedViewProps = {
posts: [], status: "ready", loadingMore: false, loadMoreFailed: false,
newCount: 0, onRetry: () => {}, onShowNew: () => {}, now: NOW, ...over,
};
return renderToStaticMarkup(createElement(FeedView, props));
}
describe("feed model", () => {
it("skips non-post items and posts without a post object", () => {
const items = [
{ type: "trending-conversations", conversations: [] },
{ type: "post", publishedAt: "2026-10-19T11:00:00Z" },
post("1", "2026-10-19T11:00:00Z"),
];
expect(toCardPosts(items).map((p) => p.id)).toEqual(["1"]);
});
it("sorts strictly by publishedAt descending, interleaving own posts", () => {
// API returns own posts first; they must land in time order.
const items = [
post("own-old", "2026-10-19T08:00:00Z"),
post("own-new", "2026-10-19T11:30:00Z"),
post("other-mid", "2026-10-19T10:00:00Z"),
post("other-new", "2026-10-19T11:45:00Z"),
];
expect(mergePosts(toCardPosts(items)).map((p) => p.id)).toEqual([
"other-new", "own-new", "other-mid", "own-old",
]);
});
it("dedupes and re-sorts across pages", () => {
const a = toCardPosts([post("1", "2026-10-19T10:00:00Z")]);
const b = toCardPosts([post("1", "2026-10-19T10:00:00Z"), post("2", "2026-10-19T11:00:00Z")]);
expect(mergePosts(a, b).map((p) => p.id)).toEqual(["2", "1"]);
});
it("falls back to first+last name and picks the image url in order", () => {
const [p] = toCardPosts([
post("1", "2026-10-19T10:00:00Z", {
author: { firstName: "Ada", lastName: "Lovelace" },
mediaItems: [
{ order: 2, mediaType: "image", imageUrl: "https://i/2.jpg" },
{ order: 1, mediaType: "image", imageMediumUrl: "https://i/1m.jpg", imageUrl: "https://i/1.jpg" },
{ order: 3, mediaType: "image", imageMediumWebpUrl: "https://i/3.webp", imageUrl: "https://i/3.jpg" },
{ order: 4, mediaType: "video", thumbnailUrl: "https://i/v.jpg", hlsUrl: "https://stream.mux.com/x" },
],
}),
]);
expect(p.authorName).toBe("Ada Lovelace");
expect(p.media.map((m) => m.src)).toEqual([
"https://i/1m.jpg", "https://i/2.jpg", "https://i/3.webp", "https://i/v.jpg",
]);
});
it("formats relative time", () => {
expect(relativeTime("2026-10-19T11:55:00Z", NOW)).toBe("5m ago");
expect(relativeTime("2026-10-19T09:00:00Z", NOW)).toBe("3h ago");
});
});
describe("FeedView", () => {
it("renders cards with full multi-line text, counts and avatar", () => {
const long = "x".repeat(2000);
const posts = toCardPosts([
post("1", "2026-10-19T11:00:00Z", { content: `line one\nline two ${long}` }),
post("2", "2026-10-19T10:00:00Z", { author: { displayName: "No Pic" } }),
]);
const html = render({ posts });
expect((html.match(/data-testid="post-card"/g) ?? []).length).toBe(2);
expect(html).toContain("line one\nline two " + long);
expect(html).toContain('src="https://img/a.jpg"');
expect(html).toContain("NP"); // initials tile
expect(html).toContain("1h ago");
expect(html).toContain("3 likes");
expect(html).toContain("2 comments");
});
it("does not render a non-post item", () => {
const posts = toCardPosts([{ type: "trending-conversations" }, post("1", "2026-10-19T11:00:00Z")]);
const html = render({ posts });
expect((html.match(/data-testid="post-card"/g) ?? []).length).toBe(1);
expect(html).not.toContain("trending");
});
it("empty state", () => {
expect(render({ posts: [] })).toContain("No posts yet.");
});
it("error state with retry", () => {
const html = render({ status: "error" });
expect(html).toContain("The feed did not load.");
expect(html).toContain("Try again");
});
it("spinner while loading more and a new-posts control", () => {
const html = render({ posts: toCardPosts([post("1", "2026-10-19T11:00:00Z")]), loadingMore: true, newCount: 2 });
expect(html).toContain('class="spinner"');
expect(html).toContain("2 new posts");
expect(html).not.toContain("Load more");
});
});
describe("fetchFeedPage", () => {
it("returns items + nextCursor and sends the cursor", async () => {
const f = vi.fn(async () => new Response(JSON.stringify({ items: [1], nextCursor: "C" }), { status: 200 }));
const page = await fetchFeedPage("X", f as unknown as typeof fetch);
expect(page).toEqual({ items: [1], nextCursor: "C" });
expect(String((f.mock.calls[0] as unknown[])[0])).toContain("cursor=X");
});
it("401 -> signedOut, 500 -> not", async () => {
const mk = (s: number) => (async () => new Response("{}", { status: s })) as unknown as typeof fetch;
await expect(fetchFeedPage(null, mk(401))).rejects.toMatchObject({ signedOut: true });
await expect(fetchFeedPage(null, mk(500))).rejects.toBeInstanceOf(FeedLoadError);
await expect(fetchFeedPage(null, mk(500))).rejects.toMatchObject({ signedOut: false });
});
});
+1
View File
@@ -2,6 +2,7 @@ import { defineConfig } from "vitest/config";
import path from "node:path";
export default defineConfig({
esbuild: { jsx: "automatic" },
resolve: {
alias: {
"@": path.resolve(__dirname, "src"),