Feed browser: Desktop bento layout #3
+15
-2
@@ -25,6 +25,7 @@ export function Feed({
|
|||||||
const [status, setStatus] = useState<FeedStatus>("loading");
|
const [status, setStatus] = useState<FeedStatus>("loading");
|
||||||
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
||||||
const [loadingMore, setLoadingMore] = useState(false);
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
const [loadMoreFailed, setLoadMoreFailed] = useState(false);
|
const [loadMoreFailed, setLoadMoreFailed] = useState(false);
|
||||||
const [pending, setPending] = useState<CardPost[]>([]);
|
const [pending, setPending] = useState<CardPost[]>([]);
|
||||||
const [likeBusy, setLikeBusy] = useState<ReadonlySet<string>>(new Set());
|
const [likeBusy, setLikeBusy] = useState<ReadonlySet<string>>(new Set());
|
||||||
@@ -54,10 +55,12 @@ export function Feed({
|
|||||||
[onSignedOut],
|
[onSignedOut],
|
||||||
);
|
);
|
||||||
|
|
||||||
const loadFirst = useCallback(async () => {
|
// quiet: a refresh keeps the list on screen instead of swapping in the spinner.
|
||||||
|
const loadFirst = useCallback(async (quiet = false) => {
|
||||||
if (busy.current) return;
|
if (busy.current) return;
|
||||||
busy.current = true;
|
busy.current = true;
|
||||||
setStatus("loading");
|
if (quiet) setRefreshing(true);
|
||||||
|
else setStatus("loading");
|
||||||
setLoadMoreFailed(false);
|
setLoadMoreFailed(false);
|
||||||
try {
|
try {
|
||||||
const page = await fetchFeedPage(null);
|
const page = await fetchFeedPage(null);
|
||||||
@@ -70,6 +73,7 @@ export function Feed({
|
|||||||
setStatus("error");
|
setStatus("error");
|
||||||
} finally {
|
} finally {
|
||||||
busy.current = false;
|
busy.current = false;
|
||||||
|
setRefreshing(false);
|
||||||
}
|
}
|
||||||
}, [fail]);
|
}, [fail]);
|
||||||
|
|
||||||
@@ -240,6 +244,13 @@ export function Feed({
|
|||||||
list.current?.scrollTo({ top: 0, behavior: calm ? "auto" : "smooth" });
|
list.current?.scrollTo({ top: 0, behavior: calm ? "auto" : "smooth" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
if (busy.current) return;
|
||||||
|
const calm = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||||
|
list.current?.scrollTo({ top: 0, behavior: calm ? "auto" : "smooth" });
|
||||||
|
void loadFirst(true);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FeedView
|
<FeedView
|
||||||
posts={posts}
|
posts={posts}
|
||||||
@@ -249,6 +260,8 @@ export function Feed({
|
|||||||
newCount={pending.length}
|
newCount={pending.length}
|
||||||
onRetry={() => (status === "error" ? void loadFirst() : void loadMore())}
|
onRetry={() => (status === "error" ? void loadFirst() : void loadMore())}
|
||||||
onShowNew={showNew}
|
onShowNew={showNew}
|
||||||
|
onRefresh={refresh}
|
||||||
|
refreshing={refreshing}
|
||||||
onToggleLike={(p) => void toggleLike(p)}
|
onToggleLike={(p) => void toggleLike(p)}
|
||||||
likeBusy={likeBusy}
|
likeBusy={likeBusy}
|
||||||
likeErrors={likeErrors}
|
likeErrors={likeErrors}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { ReactNode, Ref } from "react";
|
import type { ReactNode, Ref } from "react";
|
||||||
import { canDelete, type CardPost } from "@/lib/feed-model";
|
import { canDelete, type CardPost } from "@/lib/feed-model";
|
||||||
import { Composer, type ComposerProps } from "./Composer";
|
import { Composer, type ComposerProps } from "./Composer";
|
||||||
import { GrowButton } from "./Buttons";
|
import { GrowButton, IconButton } from "./Buttons";
|
||||||
import { PostCard } from "./PostCard";
|
import { PostCard } from "./PostCard";
|
||||||
|
|
||||||
export type FeedStatus = "loading" | "ready" | "error";
|
export type FeedStatus = "loading" | "ready" | "error";
|
||||||
@@ -14,6 +14,9 @@ export interface FeedViewProps {
|
|||||||
newCount: number;
|
newCount: number;
|
||||||
onRetry: () => void;
|
onRetry: () => void;
|
||||||
onShowNew: () => void;
|
onShowNew: () => void;
|
||||||
|
/** Reloads the first page; the header shows the button when it is given. */
|
||||||
|
onRefresh?: () => void;
|
||||||
|
refreshing?: boolean;
|
||||||
onToggleLike?: (post: CardPost) => void;
|
onToggleLike?: (post: CardPost) => void;
|
||||||
likeBusy?: ReadonlySet<string>;
|
likeBusy?: ReadonlySet<string>;
|
||||||
likeErrors?: ReadonlySet<string>;
|
likeErrors?: ReadonlySet<string>;
|
||||||
@@ -52,11 +55,44 @@ function SkeletonCard() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function FeedPanel({ children, composer }: { children: ReactNode; composer?: ReactNode }) {
|
function RefreshIcon({ spinning }: { spinning: boolean }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
className={spinning ? "refresh-icon refresh-icon-spin" : "refresh-icon"}
|
||||||
|
>
|
||||||
|
<path d="M21 12a9 9 0 1 1-2.64-6.36" />
|
||||||
|
<path d="M21 3v6h-6" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RefreshButton({ onRefresh, busy }: { onRefresh: () => void; busy?: boolean }) {
|
||||||
|
return (
|
||||||
|
<IconButton
|
||||||
|
label="Refresh feed"
|
||||||
|
shape="round"
|
||||||
|
size="sm"
|
||||||
|
icon={<RefreshIcon spinning={!!busy} />}
|
||||||
|
disabled={busy}
|
||||||
|
aria-busy={busy || undefined}
|
||||||
|
onClick={onRefresh}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FeedPanel({ children, composer, onRefresh, busy }: { children: ReactNode; composer?: ReactNode; onRefresh?: () => void; busy?: boolean }) {
|
||||||
return (
|
return (
|
||||||
<section className="tile panel feed" aria-labelledby="h-feed">
|
<section className="tile panel feed" aria-labelledby="h-feed">
|
||||||
<div className="panel-head feed-head">
|
<div className="panel-head feed-head">
|
||||||
<h2 id="h-feed" className="type-title type-title-strong">Home feed</h2>
|
<h2 id="h-feed" className="type-title type-title-strong">Home feed</h2>
|
||||||
|
{onRefresh ? <RefreshButton onRefresh={onRefresh} busy={busy} /> : null}
|
||||||
</div>
|
</div>
|
||||||
{composer}
|
{composer}
|
||||||
{children}
|
{children}
|
||||||
@@ -67,7 +103,7 @@ function FeedPanel({ children, composer }: { children: ReactNode; composer?: Rea
|
|||||||
export function FeedView(p: FeedViewProps) {
|
export function FeedView(p: FeedViewProps) {
|
||||||
if (p.status === "loading") {
|
if (p.status === "loading") {
|
||||||
return (
|
return (
|
||||||
<FeedPanel>
|
<FeedPanel onRefresh={p.onRefresh} busy>
|
||||||
<div className="feed-center">
|
<div className="feed-center">
|
||||||
<Spinner />
|
<Spinner />
|
||||||
</div>
|
</div>
|
||||||
@@ -76,7 +112,7 @@ export function FeedView(p: FeedViewProps) {
|
|||||||
}
|
}
|
||||||
if (p.status === "error") {
|
if (p.status === "error") {
|
||||||
return (
|
return (
|
||||||
<FeedPanel>
|
<FeedPanel onRefresh={p.onRefresh}>
|
||||||
<div className="feed-message" role="alert">
|
<div className="feed-message" role="alert">
|
||||||
<p>The feed did not load.</p>
|
<p>The feed did not load.</p>
|
||||||
<button type="button" onClick={p.onRetry}>Try again</button>
|
<button type="button" onClick={p.onRetry}>Try again</button>
|
||||||
@@ -85,7 +121,7 @@ export function FeedView(p: FeedViewProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<FeedPanel composer={p.composer ? <Composer {...p.composer} /> : null}>
|
<FeedPanel composer={p.composer ? <Composer {...p.composer} /> : null} onRefresh={p.onRefresh} busy={p.refreshing}>
|
||||||
<div ref={p.listRef} className="feed-list panel-scroll fade-edges">
|
<div ref={p.listRef} className="feed-list panel-scroll fade-edges">
|
||||||
{p.newCount > 0 ? (
|
{p.newCount > 0 ? (
|
||||||
<div className="feed-pill">
|
<div className="feed-pill">
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { HexAvatar } from "@/components/HexAvatar";
|
|||||||
import { CommentsSection } from "@/components/CommentsSection";
|
import { CommentsSection } from "@/components/CommentsSection";
|
||||||
import { VideoPlayer } from "@/components/VideoPlayer";
|
import { VideoPlayer } from "@/components/VideoPlayer";
|
||||||
import { IconButton, SweepButton } from "@/components/Buttons";
|
import { IconButton, SweepButton } from "@/components/Buttons";
|
||||||
import { formatDuration, relativeTime, type CardMedia, type CardPost } from "@/lib/feed-model";
|
import { commentsLabel, formatDuration, relativeTime, type CardMedia, type CardPost } from "@/lib/feed-model";
|
||||||
|
|
||||||
export interface PostCardProps {
|
export interface PostCardProps {
|
||||||
post: CardPost;
|
post: CardPost;
|
||||||
@@ -206,7 +206,7 @@ export function PostCard({ post, now, index = 0, onToggleLike, likeBusy, likeErr
|
|||||||
onClick={() => setOpen((o) => !o)}
|
onClick={() => setOpen((o) => !o)}
|
||||||
>
|
>
|
||||||
<CommentIcon />
|
<CommentIcon />
|
||||||
{post.commentsCount} comments
|
{commentsLabel(post.commentsCount)}
|
||||||
</button>
|
</button>
|
||||||
{likeError ? (
|
{likeError ? (
|
||||||
<span className="post-error" role="alert">Could not update like.</span>
|
<span className="post-error" role="alert">Could not update like.</span>
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export function FeedPreview() {
|
|||||||
// Fixture switches for the new-posts pill and the loading-more skeleton.
|
// Fixture switches for the new-posts pill and the loading-more skeleton.
|
||||||
const [newCount, setNewCount] = useState(0);
|
const [newCount, setNewCount] = useState(0);
|
||||||
const [loadingMore, setLoadingMore] = useState(false);
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
const list = useRef<HTMLDivElement | null>(null);
|
const list = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
// The comments calls answer from the fixtures; the preview never reaches the network.
|
// The comments calls answer from the fixtures; the preview never reaches the network.
|
||||||
@@ -99,6 +100,15 @@ export function FeedPreview() {
|
|||||||
setNewCount(0);
|
setNewCount(0);
|
||||||
list.current?.scrollTo({ top: 0, behavior: "smooth" });
|
list.current?.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
}}
|
}}
|
||||||
|
refreshing={refreshing}
|
||||||
|
onRefresh={() => {
|
||||||
|
list.current?.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
|
setRefreshing(true);
|
||||||
|
window.setTimeout(() => {
|
||||||
|
setPosts(toCardPosts(FIXTURE_FEED_ITEMS));
|
||||||
|
setRefreshing(false);
|
||||||
|
}, 600);
|
||||||
|
}}
|
||||||
onToggleLike={toggleLike}
|
onToggleLike={toggleLike}
|
||||||
feedUserId={FIXTURE_PERSON.feedUserId}
|
feedUserId={FIXTURE_PERSON.feedUserId}
|
||||||
onDelete={(post) => setPosts((cur) => cur.filter((p) => p.key !== post.key))}
|
onDelete={(post) => setPosts((cur) => cur.filter((p) => p.key !== post.key))}
|
||||||
|
|||||||
@@ -194,6 +194,11 @@ export function upgradeVideos(current: CardPost[], fresh: CardPost[]): CardPost[
|
|||||||
return changed ? next : current;
|
return changed ? next : current;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** "1 comment", "0 comments", "2 comments". */
|
||||||
|
export function commentsLabel(count: number): string {
|
||||||
|
return count === 1 ? "1 comment" : `${count} comments`;
|
||||||
|
}
|
||||||
|
|
||||||
export function relativeTime(publishedAt: string | null, now: number = Date.now()): string {
|
export function relativeTime(publishedAt: string | null, now: number = Date.now()): string {
|
||||||
const t = publishedAt ? Date.parse(publishedAt) : NaN;
|
const t = publishedAt ? Date.parse(publishedAt) : NaN;
|
||||||
if (!Number.isFinite(t)) return "";
|
if (!Number.isFinite(t)) return "";
|
||||||
|
|||||||
@@ -61,3 +61,11 @@
|
|||||||
.feed-skel-w80 { width: 80%; }
|
.feed-skel-w80 { width: 80%; }
|
||||||
.feed-skel-w100 { width: 100%; }
|
.feed-skel-w100 { width: 100%; }
|
||||||
.feed-skel-media { display: block; aspect-ratio: 16 / 9; border-radius: 12px; background: var(--tile-3); }
|
.feed-skel-media { display: block; aspect-ratio: 16 / 9; border-radius: 12px; background: var(--tile-3); }
|
||||||
|
|
||||||
|
/* Refresh button in the feed header. Its disabled state keeps full opacity, so
|
||||||
|
the spinning icon is the only sign that a reload is running. */
|
||||||
|
.refresh-icon { display: block; }
|
||||||
|
.refresh-icon-spin { animation: spin .8s linear infinite; }
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.refresh-icon-spin { animation: none !important; }
|
||||||
|
}
|
||||||
|
|||||||
+42
-2
@@ -1,9 +1,9 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { createElement } from "react";
|
import { createElement } from "react";
|
||||||
import { renderToStaticMarkup } from "react-dom/server";
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
import { FeedView, type FeedViewProps } from "@/components/FeedView";
|
import { FeedView, RefreshButton, type FeedViewProps } from "@/components/FeedView";
|
||||||
import { fetchFeedPage, FeedLoadError } from "@/lib/feed-client";
|
import { fetchFeedPage, FeedLoadError } from "@/lib/feed-client";
|
||||||
import { formatDuration, mergePosts, relativeTime, toCardPost, toCardPosts, upgradeVideos } from "@/lib/feed-model";
|
import { commentsLabel, formatDuration, mergePosts, relativeTime, toCardPost, toCardPosts, upgradeVideos } from "@/lib/feed-model";
|
||||||
|
|
||||||
const NOW = Date.parse("2026-10-19T12:00:00Z");
|
const NOW = Date.parse("2026-10-19T12:00:00Z");
|
||||||
const post = (id: string, publishedAt: string, extra: Record<string, unknown> = {}) => ({
|
const post = (id: string, publishedAt: string, extra: Record<string, unknown> = {}) => ({
|
||||||
@@ -205,3 +205,43 @@ describe("video duration", () => {
|
|||||||
expect(dur({})).toBeUndefined();
|
expect(dur({})).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("refresh button", () => {
|
||||||
|
it("is in the Home feed header with its accessible name", () => {
|
||||||
|
const html = render({ onRefresh: () => {} });
|
||||||
|
expect(html).toContain('aria-label="Refresh feed"');
|
||||||
|
expect(html.indexOf("Home feed")).toBeLessThan(html.indexOf("Refresh feed"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is absent without a handler", () => {
|
||||||
|
expect(render({})).not.toContain("Refresh feed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("triggers a reload when clicked", () => {
|
||||||
|
const onRefresh = vi.fn();
|
||||||
|
const el = RefreshButton({ onRefresh }) as { props: { onClick: () => void } };
|
||||||
|
el.props.onClick();
|
||||||
|
expect(onRefresh).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is disabled and spinning while a reload runs", () => {
|
||||||
|
const html = render({ onRefresh: () => {}, refreshing: true });
|
||||||
|
expect(html).toMatch(/<button[^>]*disabled[^>]*aria-label="Refresh feed"|<button[^>]*aria-label="Refresh feed"[^>]*disabled/);
|
||||||
|
expect(html).toContain("refresh-icon-spin");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("comments label", () => {
|
||||||
|
it("is singular for 1 and plural otherwise", () => {
|
||||||
|
expect(commentsLabel(1)).toBe("1 comment");
|
||||||
|
expect(commentsLabel(0)).toBe("0 comments");
|
||||||
|
expect(commentsLabel(2)).toBe("2 comments");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders singular on a one-comment post", () => {
|
||||||
|
const posts = toCardPosts([{ ...post("1", "2026-10-19T11:00:00Z"), commentsCount: 1 }]);
|
||||||
|
const html = render({ posts });
|
||||||
|
expect(html).toContain("1 comment<");
|
||||||
|
expect(html).not.toContain("1 comments");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user