q12xo172i: Render post cards in the signed-in home feed
This commit is contained in:
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">♥</span> {post.likesCount}
|
||||
</span>
|
||||
<span aria-label={`${post.commentsCount} comments`}>{post.commentsCount} comments</span>
|
||||
</footer>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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" });
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user