From 69a11436f5445d4853ff60cd21a3ba38c487d6cd Mon Sep 17 00:00:00 2001 From: Cesar Alcazar Date: Tue, 20 Oct 2026 08:08:38 -0600 Subject: [PATCH] le8i2digy: Add a refresh button to the Home feed header and singularize one comment --- src/components/Feed.tsx | 17 ++++++++++++-- src/components/FeedView.tsx | 46 +++++++++++++++++++++++++++++++++---- src/components/PostCard.tsx | 4 ++-- src/dev/FeedPreview.tsx | 10 ++++++++ src/lib/feed-model.ts | 5 ++++ src/styles/feed.css | 8 +++++++ tests/feed-view.test.ts | 44 +++++++++++++++++++++++++++++++++-- 7 files changed, 123 insertions(+), 11 deletions(-) diff --git a/src/components/Feed.tsx b/src/components/Feed.tsx index cf72f82..d1a8f2a 100644 --- a/src/components/Feed.tsx +++ b/src/components/Feed.tsx @@ -25,6 +25,7 @@ export function Feed({ const [status, setStatus] = useState("loading"); const [nextCursor, setNextCursor] = useState(null); const [loadingMore, setLoadingMore] = useState(false); + const [refreshing, setRefreshing] = useState(false); const [loadMoreFailed, setLoadMoreFailed] = useState(false); const [pending, setPending] = useState([]); const [likeBusy, setLikeBusy] = useState>(new Set()); @@ -54,10 +55,12 @@ export function Feed({ [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; busy.current = true; - setStatus("loading"); + if (quiet) setRefreshing(true); + else setStatus("loading"); setLoadMoreFailed(false); try { const page = await fetchFeedPage(null); @@ -70,6 +73,7 @@ export function Feed({ setStatus("error"); } finally { busy.current = false; + setRefreshing(false); } }, [fail]); @@ -240,6 +244,13 @@ export function Feed({ 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 ( (status === "error" ? void loadFirst() : void loadMore())} onShowNew={showNew} + onRefresh={refresh} + refreshing={refreshing} onToggleLike={(p) => void toggleLike(p)} likeBusy={likeBusy} likeErrors={likeErrors} diff --git a/src/components/FeedView.tsx b/src/components/FeedView.tsx index 34c867d..74d7ff5 100644 --- a/src/components/FeedView.tsx +++ b/src/components/FeedView.tsx @@ -1,7 +1,7 @@ import type { ReactNode, Ref } from "react"; import { canDelete, type CardPost } from "@/lib/feed-model"; import { Composer, type ComposerProps } from "./Composer"; -import { GrowButton } from "./Buttons"; +import { GrowButton, IconButton } from "./Buttons"; import { PostCard } from "./PostCard"; export type FeedStatus = "loading" | "ready" | "error"; @@ -14,6 +14,9 @@ export interface FeedViewProps { newCount: number; onRetry: () => void; onShowNew: () => void; + /** Reloads the first page; the header shows the button when it is given. */ + onRefresh?: () => void; + refreshing?: boolean; onToggleLike?: (post: CardPost) => void; likeBusy?: ReadonlySet; likeErrors?: ReadonlySet; @@ -52,11 +55,44 @@ function SkeletonCard() { ); } -function FeedPanel({ children, composer }: { children: ReactNode; composer?: ReactNode }) { +function RefreshIcon({ spinning }: { spinning: boolean }) { + return ( + + ); +} + +export function RefreshButton({ onRefresh, busy }: { onRefresh: () => void; busy?: boolean }) { + return ( + } + disabled={busy} + aria-busy={busy || undefined} + onClick={onRefresh} + /> + ); +} + +function FeedPanel({ children, composer, onRefresh, busy }: { children: ReactNode; composer?: ReactNode; onRefresh?: () => void; busy?: boolean }) { return (

Home feed

+ {onRefresh ? : null}
{composer} {children} @@ -67,7 +103,7 @@ function FeedPanel({ children, composer }: { children: ReactNode; composer?: Rea export function FeedView(p: FeedViewProps) { if (p.status === "loading") { return ( - +
@@ -76,7 +112,7 @@ export function FeedView(p: FeedViewProps) { } if (p.status === "error") { return ( - +

The feed did not load.

@@ -85,7 +121,7 @@ export function FeedView(p: FeedViewProps) { ); } return ( - : null}> + : null} onRefresh={p.onRefresh} busy={p.refreshing}>
{p.newCount > 0 ? (
diff --git a/src/components/PostCard.tsx b/src/components/PostCard.tsx index 57db693..247cc1e 100644 --- a/src/components/PostCard.tsx +++ b/src/components/PostCard.tsx @@ -5,7 +5,7 @@ import { HexAvatar } from "@/components/HexAvatar"; import { CommentsSection } from "@/components/CommentsSection"; import { VideoPlayer } from "@/components/VideoPlayer"; 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 { post: CardPost; @@ -206,7 +206,7 @@ export function PostCard({ post, now, index = 0, onToggleLike, likeBusy, likeErr onClick={() => setOpen((o) => !o)} > - {post.commentsCount} comments + {commentsLabel(post.commentsCount)} {likeError ? ( Could not update like. diff --git a/src/dev/FeedPreview.tsx b/src/dev/FeedPreview.tsx index 0708ce8..3a1d4e2 100644 --- a/src/dev/FeedPreview.tsx +++ b/src/dev/FeedPreview.tsx @@ -29,6 +29,7 @@ export function FeedPreview() { // Fixture switches for the new-posts pill and the loading-more skeleton. const [newCount, setNewCount] = useState(0); const [loadingMore, setLoadingMore] = useState(false); + const [refreshing, setRefreshing] = useState(false); const list = useRef(null); // The comments calls answer from the fixtures; the preview never reaches the network. @@ -99,6 +100,15 @@ export function FeedPreview() { setNewCount(0); 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} feedUserId={FIXTURE_PERSON.feedUserId} onDelete={(post) => setPosts((cur) => cur.filter((p) => p.key !== post.key))} diff --git a/src/lib/feed-model.ts b/src/lib/feed-model.ts index 74b2e5a..d24faef 100644 --- a/src/lib/feed-model.ts +++ b/src/lib/feed-model.ts @@ -194,6 +194,11 @@ export function upgradeVideos(current: CardPost[], fresh: CardPost[]): CardPost[ 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 { const t = publishedAt ? Date.parse(publishedAt) : NaN; if (!Number.isFinite(t)) return ""; diff --git a/src/styles/feed.css b/src/styles/feed.css index c3efaed..c7e0d3e 100644 --- a/src/styles/feed.css +++ b/src/styles/feed.css @@ -61,3 +61,11 @@ .feed-skel-w80 { width: 80%; } .feed-skel-w100 { width: 100%; } .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; } +} diff --git a/tests/feed-view.test.ts b/tests/feed-view.test.ts index 2b5a6a4..3f229f5 100644 --- a/tests/feed-view.test.ts +++ b/tests/feed-view.test.ts @@ -1,9 +1,9 @@ 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 { FeedView, RefreshButton, type FeedViewProps } from "@/components/FeedView"; 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 post = (id: string, publishedAt: string, extra: Record = {}) => ({ @@ -205,3 +205,43 @@ describe("video duration", () => { 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(/]*disabled[^>]*aria-label="Refresh feed"|]*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"); + }); +});