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:
Generated
+7
@@ -8,6 +8,7 @@
|
|||||||
"name": "frontend-helper",
|
"name": "frontend-helper",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"hls.js": "1.7.3",
|
||||||
"next": "15.5.28",
|
"next": "15.5.28",
|
||||||
"react": "19.0.0",
|
"react": "19.0.0",
|
||||||
"react-dom": "19.0.0"
|
"react-dom": "19.0.0"
|
||||||
@@ -4321,6 +4322,12 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/ignore": {
|
||||||
"version": "5.3.2",
|
"version": "5.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"test": "vitest run"
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"hls.js": "1.7.3",
|
||||||
"next": "15.5.28",
|
"next": "15.5.28",
|
||||||
"react": "19.0.0",
|
"react": "19.0.0",
|
||||||
"react-dom": "19.0.0"
|
"react-dom": "19.0.0"
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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 { 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 div { display: flex; flex-direction: column; font-size: 14px; }
|
||||||
.shell header span { color: var(--grey); }
|
.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; }
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
|
|||||||
import { loadSession, signOut, type Person } from "@/lib/auth-client";
|
import { loadSession, signOut, type Person } from "@/lib/auth-client";
|
||||||
import { SignInForm } from "./SignInForm";
|
import { SignInForm } from "./SignInForm";
|
||||||
import { Logo } from "./Logo";
|
import { Logo } from "./Logo";
|
||||||
|
import { Feed } from "./Feed";
|
||||||
|
|
||||||
type State = { kind: "loading" } | { kind: "out" } | { kind: "in"; person: Person };
|
type State = { kind: "loading" } | { kind: "out" } | { kind: "in"; person: Person };
|
||||||
|
|
||||||
@@ -39,7 +40,6 @@ export function App() {
|
|||||||
if (state.kind === "out") {
|
if (state.kind === "out") {
|
||||||
return <SignInForm onSignedIn={(person) => setState({ kind: "in", person })} />;
|
return <SignInForm onSignedIn={(person) => setState({ kind: "in", person })} />;
|
||||||
}
|
}
|
||||||
// Placeholder shell. The home feed is a later task; sign-out is wired here.
|
|
||||||
return (
|
return (
|
||||||
<main className="shell">
|
<main className="shell">
|
||||||
<header>
|
<header>
|
||||||
@@ -52,6 +52,7 @@ export function App() {
|
|||||||
{signingOut ? "Signing out..." : "Sign out"}
|
{signingOut ? "Signing out..." : "Sign out"}
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
<Feed onSignedOut={() => setState({ kind: "out" })} />
|
||||||
</main>
|
</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,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">♥</span> {post.likesCount}
|
||||||
|
</span>
|
||||||
|
<span aria-label={`${post.commentsCount} comments`}>{post.commentsCount} comments</span>
|
||||||
|
</footer>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,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" });
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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") !== "";
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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"');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,7 @@ import { defineConfig } from "vitest/config";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
esbuild: { jsx: "automatic" },
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
"@": path.resolve(__dirname, "src"),
|
"@": path.resolve(__dirname, "src"),
|
||||||
|
|||||||
Reference in New Issue
Block a user