diff --git a/src/app/globals.css b/src/app/globals.css index 1aafb4c..e143da7 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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; } diff --git a/src/components/App.tsx b/src/components/App.tsx index d578158..d2fb73c 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -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 setState({ kind: "in", person })} />; } - // Placeholder shell. The home feed is a later task; sign-out is wired here. return (
@@ -52,6 +52,7 @@ export function App() { {signingOut ? "Signing out..." : "Sign out"}
+ setState({ kind: "out" })} />
); } diff --git a/src/components/Feed.tsx b/src/components/Feed.tsx new file mode 100644 index 0000000..1721273 --- /dev/null +++ b/src/components/Feed.tsx @@ -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([]); + const [status, setStatus] = useState("loading"); + const [nextCursor, setNextCursor] = useState(null); + const [loadingMore, setLoadingMore] = useState(false); + const [loadMoreFailed, setLoadMoreFailed] = useState(false); + const [pending, setPending] = useState([]); + const sentinel = useRef(null); + const busy = useRef(false); + const shown = useRef>(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 | 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 ( + (status === "error" ? void loadFirst() : void loadMore())} + onShowNew={showNew} + sentinelRef={sentinel} + /> + ); +} diff --git a/src/components/FeedView.tsx b/src/components/FeedView.tsx new file mode 100644 index 0000000..3b57da4 --- /dev/null +++ b/src/components/FeedView.tsx @@ -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; + now?: number; +} + +function Spinner() { + return ; +} + +export function FeedView(p: FeedViewProps) { + if (p.status === "loading") { + return ( +
+
+ +
+
+ ); + } + if (p.status === "error") { + return ( +
+
+

The feed did not load.

+ +
+
+ ); + } + return ( +
+ {p.newCount > 0 ? ( + + ) : null} + {p.posts.length === 0 ? ( +

No posts yet.

+ ) : ( + p.posts.map((post) => ) + )} + {p.loadMoreFailed ? ( +
+

The feed did not load.

+ +
+ ) : null} + {p.loadingMore ? ( +
+ +
+ ) : null} +
+ ); +} diff --git a/src/components/PostCard.tsx b/src/components/PostCard.tsx new file mode 100644 index 0000000..8b9e0a7 --- /dev/null +++ b/src/components/PostCard.tsx @@ -0,0 +1,37 @@ +import { initials, relativeTime, type CardPost } from "@/lib/feed-model"; + +export function PostCard({ post, now }: { post: CardPost; now?: number }) { + return ( +
+
+ {post.authorPictureUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : ( + + )} +
+ {post.authorName} + +
+
+ {post.text ?

{post.text}

: null} + {post.media.map((m, i) => + m.src ? ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {m.kind +
+ ) : null, + )} +
+ + {post.likesCount} + + {post.commentsCount} comments +
+
+ ); +} diff --git a/src/lib/feed-client.ts b/src/lib/feed-client.ts new file mode 100644 index 0000000..4e1646f --- /dev/null +++ b/src/lib/feed-client.ts @@ -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 { + 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); + } +} diff --git a/src/lib/feed-model.ts b/src/lib/feed-model.ts new file mode 100644 index 0000000..b8520ea --- /dev/null +++ b/src/lib/feed-model.ts @@ -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; + +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(); + 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" }); +} diff --git a/tests/feed-view.test.ts b/tests/feed-view.test.ts new file mode 100644 index 0000000..c12554f --- /dev/null +++ b/tests/feed-view.test.ts @@ -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 = {}) => ({ + 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) { + 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 }); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index a09f61e..af3b4a9 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -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"),