Merge Feed browser: Read the home feed into develop

Home feed proxy with cursor paging (GET /api/feed), post cards with timestamp-sorted interleaved order + infinite scroll + 45s polling, and HLS video playback. Tasks emrgilhzp, q12xo172i, vycxexra8.
This commit is contained in:
2026-10-19 14:03:36 -06:00
18 changed files with 980 additions and 1 deletions
+7
View File
@@ -8,6 +8,7 @@
"name": "frontend-helper",
"version": "0.1.0",
"dependencies": {
"hls.js": "1.7.3",
"next": "15.5.28",
"react": "19.0.0",
"react-dom": "19.0.0"
@@ -4321,6 +4322,12 @@
"node": ">= 0.4"
}
},
"node_modules/hls.js": {
"version": "1.7.3",
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.7.3.tgz",
"integrity": "sha512-MsPlx6yVW4Qv4C7mEVou4/gk/5cN2dVxOTnrNjPSmyH2f0Ln+/UkG10Ax1PE6OvrmTVPKgQYozTtBaPOuCzq7A==",
"license": "Apache-2.0"
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
+1
View File
@@ -11,6 +11,7 @@
"test": "vitest run"
},
"dependencies": {
"hls.js": "1.7.3",
"next": "15.5.28",
"react": "19.0.0",
"react-dom": "19.0.0"
+21
View File
@@ -0,0 +1,21 @@
import { NextResponse, type NextRequest } from "next/server";
import { SESSION_COOKIE } from "@/lib/cookie";
import { clampLimit, getHomeFeedPage } from "@/lib/feed";
// GET /api/feed?cursor=<nextCursor>&limit=<n>
// Same-origin proxy for the Minnect home feed. Returns { items, nextCursor }.
// 401 { error: "sign_in" } when the session is missing or dead.
export const runtime = "nodejs";
export async function GET(req: NextRequest) {
const sessionId = req.cookies.get(SESSION_COOKIE)?.value;
const cursor = req.nextUrl.searchParams.get("cursor") || null;
const limit = clampLimit(req.nextUrl.searchParams.get("limit"));
const result = await getHomeFeedPage(sessionId, cursor, limit);
if (!result.ok) {
return NextResponse.json({ error: result.error }, { status: result.status });
}
return NextResponse.json({ items: result.items, nextCursor: result.nextCursor });
}
+22
View File
@@ -13,3 +13,25 @@ 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-video { background: #000; border-radius: 8px; overflow: hidden; }
.post-video video { display: block; width: 100%; max-height: 520px; background: #000; }
.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>
);
}
+42
View File
@@ -0,0 +1,42 @@
import { VideoPlayer } from "@/components/VideoPlayer";
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.kind === "video" && m.hlsUrl ? (
<div className="post-media post-video" key={i}>
<VideoPlayer hlsUrl={m.hlsUrl} poster={m.src} label={`Video from ${post.authorName}`} />
</div>
) : 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>
);
}
+69
View File
@@ -0,0 +1,69 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { choosePlayback, supportsNativeHls } from "@/lib/video-playback";
export interface VideoPlayerProps {
hlsUrl: string;
poster: string | null;
label: string;
}
/**
* Real <video controls>. Native HLS where the browser has it, otherwise hls.js,
* loaded only when a video card mounts. Never autoplays.
*/
export function VideoPlayer({ hlsUrl, poster, label }: VideoPlayerProps) {
const ref = useRef<HTMLVideoElement>(null);
const [failed, setFailed] = useState(false);
useEffect(() => {
const video = ref.current;
if (!video) return;
let destroy: (() => void) | undefined;
let cancelled = false;
const native = supportsNativeHls(video);
if (native) {
if (choosePlayback(hlsUrl, { nativeHls: true, hlsJsSupported: false }) === "native") {
video.src = hlsUrl;
}
return;
}
import("hls.js")
.then(({ default: Hls }) => {
if (cancelled) return;
const mode = choosePlayback(hlsUrl, { nativeHls: false, hlsJsSupported: Hls.isSupported() });
if (mode !== "hlsjs") {
setFailed(true);
return;
}
const hls = new Hls();
hls.on(Hls.Events.ERROR, (_e, data) => {
if (data.fatal) setFailed(true);
});
hls.loadSource(hlsUrl);
hls.attachMedia(video);
destroy = () => hls.destroy();
})
.catch(() => setFailed(true));
return () => {
cancelled = true;
destroy?.();
};
}, [hlsUrl]);
if (failed && poster) {
// eslint-disable-next-line @next/next/no-img-element
return <img src={poster} alt="Video thumbnail" loading="lazy" />;
}
return (
<video
ref={ref}
controls
playsInline
preload="none"
poster={poster ?? undefined}
aria-label={label}
/>
);
}
+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);
}
}
+150
View File
@@ -0,0 +1,150 @@
// 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;
/** Mux HLS playlist (videos only). Null when the video is not ready yet. */
hlsUrl?: 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";
}
/** The HLS URL only when it is https on stream.mux.com. */
export function muxHlsUrl(v: unknown): string | null {
const s = str(v);
if (!s) return null;
try {
const u = new URL(s);
return u.protocol === "https:" && u.hostname === "stream.mux.com" ? s : null;
} catch {
return null;
}
}
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") {
const thumb = str(m.thumbnailUrl);
const hls = muxHlsUrl(m.hlsUrl);
if (thumb || hls) out.push({ kind: "video", src: thumb, hlsUrl: hls });
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" });
}
+90
View File
@@ -0,0 +1,90 @@
import { FEED_BASE_URL } from "./env";
import { ensureFreshSession, refreshSession } from "./session-refresh";
// Home-feed proxy logic. Reads one page of the person's home feed from Minnect
// with their server-held feed token. Tokens never leave the server and are
// never logged. Card-field extraction is a later task; items pass through raw.
export const DEFAULT_LIMIT = 20;
export const MAX_LIMIT = 50;
export type FeedPageResult =
| { ok: true; items: unknown[]; nextCursor: string | null }
| { ok: false; status: 401 | 502; error: "sign_in" | "feed_unavailable" };
interface FeedOptions {
fetchImpl?: typeof fetch;
now?: number;
}
/** Clamp a requested page size to 1..50, defaulting to 20. */
export function clampLimit(raw: string | number | null | undefined): number {
const n = typeof raw === "number" ? raw : Number.parseInt(raw ?? "", 10);
if (!Number.isFinite(n) || n < 1) return DEFAULT_LIMIT;
return Math.min(Math.floor(n), MAX_LIMIT);
}
function buildUrl(cursor: string | null, limit: number): string {
const params = new URLSearchParams({ limit: String(limit) });
if (cursor) params.set("cursor", cursor);
return `${FEED_BASE_URL}/api/v1/feeds/home/items?${params.toString()}`;
}
async function callFeed(
token: string,
cursor: string | null,
limit: number,
doFetch: typeof fetch,
): Promise<Response> {
return doFetch(buildUrl(cursor, limit), {
method: "GET",
headers: { Accept: "application/json", Authorization: `Bearer ${token}` },
});
}
export async function getHomeFeedPage(
sessionId: string | undefined,
cursor: string | null,
limit: number,
options: FeedOptions = {},
): Promise<FeedPageResult> {
const signIn = { ok: false, status: 401, error: "sign_in" } as const;
const doFetch = options.fetchImpl ?? fetch;
let session = await ensureFreshSession(sessionId, options);
if (!session || !sessionId) return signIn;
let res: Response;
try {
res = await callFeed(session.feedAccessToken, cursor, limit, doFetch);
if (res.status === 401) {
// One refresh, then one retry. A second 401 means sign in again.
session = await refreshSession(sessionId, options);
if (!session) return signIn;
res = await callFeed(session.feedAccessToken, cursor, limit, doFetch);
if (res.status === 401) return signIn;
}
} catch {
return { ok: false, status: 502, error: "feed_unavailable" };
}
if (res.status !== 200) {
return { ok: false, status: 502, error: "feed_unavailable" };
}
let body: unknown;
try {
body = await res.json();
} catch {
return { ok: false, status: 502, error: "feed_unavailable" };
}
const root = body && typeof body === "object" ? (body as Record<string, unknown>) : {};
const meta =
root.metadata && typeof root.metadata === "object"
? (root.metadata as Record<string, unknown>)
: {};
return {
ok: true,
items: Array.isArray(root.data) ? root.data : [],
nextCursor: typeof meta.nextCursor === "string" && meta.nextCursor ? meta.nextCursor : null,
};
}
+21
View File
@@ -0,0 +1,21 @@
// Chooses how a feed video plays. Pure, so it is unit-testable without a browser.
export type Playback = "none" | "native" | "hlsjs";
export interface PlaybackEnv {
/** True when the browser can play .m3u8 directly (Safari). */
nativeHls: boolean;
/** True when hls.js (Media Source Extensions) is supported. */
hlsJsSupported: boolean;
}
/** No hlsUrl: thumbnail only. Native HLS wins; hls.js is the fallback. */
export function choosePlayback(hlsUrl: string | null | undefined, env: PlaybackEnv): Playback {
if (!hlsUrl) return "none";
if (env.nativeHls) return "native";
return env.hlsJsSupported ? "hlsjs" : "none";
}
export function supportsNativeHls(video: Pick<HTMLVideoElement, "canPlayType">): boolean {
return video.canPlayType("application/vnd.apple.mpegurl") !== "";
}
+40
View File
@@ -0,0 +1,40 @@
import { describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
import { GET } from "@/app/api/feed/route";
import { createSession } from "@/lib/session-store";
describe("GET /api/feed", () => {
it("no cookie -> 401 sign_in", async () => {
const res = await GET(new NextRequest("http://localhost/api/feed"));
expect(res.status).toBe(401);
expect(await res.json()).toEqual({ error: "sign_in" });
});
it("signed in: page then next page with cursor; limit capped at 50", async () => {
const sid = createSession({
profileAccessToken: "p", profileAccessTokenExpiresAt: null, profileRefreshToken: "r",
feedAccessToken: "T", feedAccessTokenExpiresAt: null,
person: { userId: "u", name: "A", email: "a@b.co", pictureUrl: null }, createdAt: Date.now(),
});
const urls: string[] = [];
const orig = globalThis.fetch;
globalThis.fetch = vi.fn(async (url: string) => {
urls.push(String(url));
const next = urls.length === 1 ? "C1" : null;
return new Response(JSON.stringify({ data: [{ n: urls.length }], metadata: { nextCursor: next } }), { status: 200 });
}) as unknown as typeof fetch;
try {
const h = { cookie: `mfb_session=${sid}` };
const r1 = await GET(new NextRequest("http://localhost/api/feed", { headers: h }));
expect(await r1.json()).toEqual({ items: [{ n: 1 }], nextCursor: "C1" });
const r2 = await GET(new NextRequest("http://localhost/api/feed?cursor=C1&limit=500", { headers: h }));
expect(await r2.json()).toEqual({ items: [{ n: 2 }], nextCursor: null });
expect(new URL(urls[0]).searchParams.has("cursor")).toBe(false);
const u2 = new URL(urls[1]);
expect(u2.searchParams.get("cursor")).toBe("C1");
expect(u2.searchParams.get("limit")).toBe("50");
} finally {
globalThis.fetch = orig;
}
});
});
+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 });
});
});
+72
View File
@@ -0,0 +1,72 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { clampLimit, getHomeFeedPage } from "@/lib/feed";
import { createSession } from "@/lib/session-store";
const json = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
let sid: string;
beforeEach(() => {
sid = createSession({
profileAccessToken: "p",
profileAccessTokenExpiresAt: null,
profileRefreshToken: "r",
feedAccessToken: "FEEDTOK",
feedAccessTokenExpiresAt: null,
person: { userId: "u", name: "Ann", email: "a@b.co", pictureUrl: null },
createdAt: Date.now(),
});
});
describe("getHomeFeedPage", () => {
it("no session is sign_in and nothing is fetched", async () => {
const f = vi.fn();
expect(await getHomeFeedPage(undefined, null, 20, { fetchImpl: f as unknown as typeof fetch }))
.toEqual({ ok: false, status: 401, error: "sign_in" });
expect(f).not.toHaveBeenCalled();
});
it("first page: items + nextCursor, no cursor upstream, bearer token, home path", async () => {
const f = vi.fn(async () => json({ data: [{ id: 1 }], metadata: { nextCursor: "C1" } }));
const r = await getHomeFeedPage(sid, null, 20, { fetchImpl: f as unknown as typeof fetch });
expect(r).toEqual({ ok: true, items: [{ id: 1 }], nextCursor: "C1" });
const [url, init] = f.mock.calls[0] as unknown as [string, RequestInit];
const u = new URL(url);
expect(u.pathname).toBe("/api/v1/feeds/home/items");
expect(u.searchParams.get("limit")).toBe("20");
expect(u.searchParams.has("cursor")).toBe(false);
expect((init.headers as Record<string, string>).Authorization).toBe("Bearer FEEDTOK");
});
it("forwards the cursor on a later page; null nextCursor at the end", async () => {
const f = vi.fn(async () => json({ data: [], metadata: { nextCursor: null } }));
const r = await getHomeFeedPage(sid, "X", 20, { fetchImpl: f as unknown as typeof fetch });
expect(r).toEqual({ ok: true, items: [], nextCursor: null });
const u = new URL((f.mock.calls[0] as unknown as [string])[0]);
expect(u.searchParams.get("cursor")).toBe("X");
});
it("feed 401 twice is sign_in", async () => {
const f = vi.fn(async (url: string) =>
String(url).includes("/feeds/home/items") ? json({}, 401) : json({}, 500),
);
const r = await getHomeFeedPage(sid, null, 20, { fetchImpl: f as unknown as typeof fetch });
expect(r).toEqual({ ok: false, status: 401, error: "sign_in" });
});
it("upstream 500 is feed_unavailable", async () => {
const f = vi.fn(async () => json({}, 500));
const r = await getHomeFeedPage(sid, null, 20, { fetchImpl: f as unknown as typeof fetch });
expect(r).toEqual({ ok: false, status: 502, error: "feed_unavailable" });
});
});
describe("clampLimit", () => {
it("defaults to 20, caps at 50", () => {
expect(clampLimit(null)).toBe(20);
expect(clampLimit("abc")).toBe(20);
expect(clampLimit("999")).toBe(50);
expect(clampLimit("10")).toBe(10);
expect(clampLimit("0")).toBe(20);
});
});
+55
View File
@@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { PostCard } from "@/components/PostCard";
import { toCardPosts } from "@/lib/feed-model";
import { choosePlayback, supportsNativeHls } from "@/lib/video-playback";
const HLS = "https://stream.mux.com/abc.m3u8";
const item = (media: Record<string, unknown>) => ({
type: "post",
publishedAt: "2026-10-19T10:00:00Z",
post: { id: "1", content: "hi", author: { displayName: "Ann" }, mediaItems: [media] },
});
const html = (media: Record<string, unknown>) =>
renderToStaticMarkup(createElement(PostCard, { post: toCardPosts([item(media)])[0] }));
describe("choosePlayback", () => {
it("no hlsUrl means no player", () => {
expect(choosePlayback(null, { nativeHls: true, hlsJsSupported: true })).toBe("none");
expect(choosePlayback(undefined, { nativeHls: false, hlsJsSupported: true })).toBe("none");
});
it("uses native HLS where supported, hls.js otherwise", () => {
expect(choosePlayback(HLS, { nativeHls: true, hlsJsSupported: true })).toBe("native");
expect(choosePlayback(HLS, { nativeHls: false, hlsJsSupported: true })).toBe("hlsjs");
});
it("falls back to none when neither is available", () => {
expect(choosePlayback(HLS, { nativeHls: false, hlsJsSupported: false })).toBe("none");
});
it("detects native HLS from canPlayType", () => {
expect(supportsNativeHls({ canPlayType: () => "maybe" })).toBe(true);
expect(supportsNativeHls({ canPlayType: () => "" })).toBe(false);
});
});
describe("video media in the card", () => {
const video = { mediaType: "video", thumbnailUrl: "https://i/v.jpg" };
it("hlsUrl present renders an accessible controls player with the poster", () => {
const out = html({ ...video, hlsUrl: HLS });
expect(out).toContain("<video");
expect(out).toContain("controls");
expect(out).toContain('poster="https://i/v.jpg"');
expect(out).toContain('aria-label="Video from Ann"');
expect(out).not.toContain("autoplay");
});
it("hlsUrl absent shows the thumbnail only, no player", () => {
const out = html(video);
expect(out).not.toContain("<video");
expect(out).toContain('src="https://i/v.jpg"');
});
it("rejects an hlsUrl not on stream.mux.com", () => {
const out = html({ ...video, hlsUrl: "https://evil.example/x.m3u8" });
expect(out).not.toContain("<video");
expect(out).toContain('src="https://i/v.jpg"');
});
});
+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"),