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",
|
||||
"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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 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; }
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
|
||||
import { loadSession, signOut, type Person } from "@/lib/auth-client";
|
||||
import { SignInForm } from "./SignInForm";
|
||||
import { Logo } from "./Logo";
|
||||
import { Feed } from "./Feed";
|
||||
|
||||
type State = { kind: "loading" } | { kind: "out" } | { kind: "in"; person: Person };
|
||||
|
||||
@@ -39,7 +40,6 @@ export function App() {
|
||||
if (state.kind === "out") {
|
||||
return <SignInForm onSignedIn={(person) => setState({ kind: "in", person })} />;
|
||||
}
|
||||
// Placeholder shell. The home feed is a later task; sign-out is wired here.
|
||||
return (
|
||||
<main className="shell">
|
||||
<header>
|
||||
@@ -52,6 +52,7 @@ export function App() {
|
||||
{signingOut ? "Signing out..." : "Sign out"}
|
||||
</button>
|
||||
</header>
|
||||
<Feed onSignedOut={() => setState({ kind: "out" })} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { fetchFeedPage, FeedLoadError } from "@/lib/feed-client";
|
||||
import { mergePosts, toCardPosts, type CardPost } from "@/lib/feed-model";
|
||||
import { FeedView, type FeedStatus } from "./FeedView";
|
||||
|
||||
const POLL_MS = 45_000;
|
||||
|
||||
export function Feed({ onSignedOut }: { onSignedOut: () => void }) {
|
||||
const [posts, setPosts] = useState<CardPost[]>([]);
|
||||
const [status, setStatus] = useState<FeedStatus>("loading");
|
||||
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [loadMoreFailed, setLoadMoreFailed] = useState(false);
|
||||
const [pending, setPending] = useState<CardPost[]>([]);
|
||||
const sentinel = useRef<HTMLDivElement | null>(null);
|
||||
const busy = useRef(false);
|
||||
const shown = useRef<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
shown.current = new Set(posts.map((p) => p.key));
|
||||
}, [posts]);
|
||||
|
||||
const fail = useCallback(
|
||||
(e: unknown) => {
|
||||
if (e instanceof FeedLoadError && e.signedOut) onSignedOut();
|
||||
},
|
||||
[onSignedOut],
|
||||
);
|
||||
|
||||
const loadFirst = useCallback(async () => {
|
||||
if (busy.current) return;
|
||||
busy.current = true;
|
||||
setStatus("loading");
|
||||
setLoadMoreFailed(false);
|
||||
try {
|
||||
const page = await fetchFeedPage(null);
|
||||
setPosts(mergePosts(toCardPosts(page.items)));
|
||||
setNextCursor(page.nextCursor);
|
||||
setPending([]);
|
||||
setStatus("ready");
|
||||
} catch (e) {
|
||||
fail(e);
|
||||
setStatus("error");
|
||||
} finally {
|
||||
busy.current = false;
|
||||
}
|
||||
}, [fail]);
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
if (busy.current || !nextCursor) return;
|
||||
busy.current = true;
|
||||
setLoadingMore(true);
|
||||
setLoadMoreFailed(false);
|
||||
try {
|
||||
const page = await fetchFeedPage(nextCursor);
|
||||
setPosts((cur) => mergePosts(cur, toCardPosts(page.items)));
|
||||
setNextCursor(page.nextCursor);
|
||||
} catch (e) {
|
||||
fail(e);
|
||||
setLoadMoreFailed(true);
|
||||
} finally {
|
||||
busy.current = false;
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}, [nextCursor, fail]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadFirst();
|
||||
}, [loadFirst]);
|
||||
|
||||
// Infinite scroll. A fresh observer per change re-checks a sentinel that is
|
||||
// still in view after a page lands.
|
||||
useEffect(() => {
|
||||
const el = sentinel.current;
|
||||
if (!el || !nextCursor || status !== "ready" || loadMoreFailed) return;
|
||||
const io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries.some((e) => e.isIntersecting)) void loadMore();
|
||||
},
|
||||
{ rootMargin: "400px" },
|
||||
);
|
||||
io.observe(el);
|
||||
return () => io.disconnect();
|
||||
}, [nextCursor, status, loadMoreFailed, loadMore, posts.length]);
|
||||
|
||||
// Real-time: poll the first page while the tab is visible.
|
||||
useEffect(() => {
|
||||
if (status !== "ready") return;
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
const poll = async () => {
|
||||
try {
|
||||
const page = await fetchFeedPage(null);
|
||||
const fresh = toCardPosts(page.items).filter((p) => !shown.current.has(p.key));
|
||||
if (fresh.length) setPending((cur) => mergePosts(cur, fresh));
|
||||
} catch (e) {
|
||||
fail(e);
|
||||
}
|
||||
};
|
||||
const start = () => {
|
||||
if (timer === null) timer = setInterval(() => void poll(), POLL_MS);
|
||||
};
|
||||
const stop = () => {
|
||||
if (timer !== null) clearInterval(timer);
|
||||
timer = null;
|
||||
};
|
||||
const onVisibility = () => {
|
||||
if (document.visibilityState === "visible") {
|
||||
void poll();
|
||||
start();
|
||||
} else {
|
||||
stop();
|
||||
}
|
||||
};
|
||||
if (document.visibilityState === "visible") start();
|
||||
document.addEventListener("visibilitychange", onVisibility);
|
||||
return () => {
|
||||
stop();
|
||||
document.removeEventListener("visibilitychange", onVisibility);
|
||||
};
|
||||
}, [status, fail]);
|
||||
|
||||
function showNew() {
|
||||
setPosts((cur) => mergePosts(cur, pending));
|
||||
setPending([]);
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}
|
||||
|
||||
return (
|
||||
<FeedView
|
||||
posts={posts}
|
||||
status={status}
|
||||
loadingMore={loadingMore}
|
||||
loadMoreFailed={loadMoreFailed}
|
||||
newCount={pending.length}
|
||||
onRetry={() => (status === "error" ? void loadFirst() : void loadMore())}
|
||||
onShowNew={showNew}
|
||||
sentinelRef={sentinel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { Ref } from "react";
|
||||
import type { CardPost } from "@/lib/feed-model";
|
||||
import { PostCard } from "./PostCard";
|
||||
|
||||
export type FeedStatus = "loading" | "ready" | "error";
|
||||
|
||||
export interface FeedViewProps {
|
||||
posts: CardPost[];
|
||||
status: FeedStatus;
|
||||
loadingMore: boolean;
|
||||
loadMoreFailed: boolean;
|
||||
newCount: number;
|
||||
onRetry: () => void;
|
||||
onShowNew: () => void;
|
||||
sentinelRef?: Ref<HTMLDivElement>;
|
||||
now?: number;
|
||||
}
|
||||
|
||||
function Spinner() {
|
||||
return <span className="spinner" role="status" aria-label="Loading" />;
|
||||
}
|
||||
|
||||
export function FeedView(p: FeedViewProps) {
|
||||
if (p.status === "loading") {
|
||||
return (
|
||||
<section className="feed">
|
||||
<div className="feed-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
if (p.status === "error") {
|
||||
return (
|
||||
<section className="feed">
|
||||
<div className="feed-message" role="alert">
|
||||
<p>The feed did not load.</p>
|
||||
<button type="button" onClick={p.onRetry}>Try again</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<section className="feed">
|
||||
{p.newCount > 0 ? (
|
||||
<button type="button" className="new-posts" onClick={p.onShowNew}>
|
||||
{p.newCount === 1 ? "1 new post" : `${p.newCount} new posts`}
|
||||
</button>
|
||||
) : null}
|
||||
{p.posts.length === 0 ? (
|
||||
<p className="feed-message">No posts yet.</p>
|
||||
) : (
|
||||
p.posts.map((post) => <PostCard key={post.key} post={post} now={p.now} />)
|
||||
)}
|
||||
{p.loadMoreFailed ? (
|
||||
<div className="feed-message" role="alert">
|
||||
<p>The feed did not load.</p>
|
||||
<button type="button" onClick={p.onRetry}>Try again</button>
|
||||
</div>
|
||||
) : null}
|
||||
{p.loadingMore ? (
|
||||
<div className="feed-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
) : null}
|
||||
<div ref={p.sentinelRef} className="sentinel" aria-hidden="true" />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,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";
|
||||
|
||||
export default defineConfig({
|
||||
esbuild: { jsx: "automatic" },
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "src"),
|
||||
|
||||
Reference in New Issue
Block a user