le8i2digy: Keep the posts and show an inline error when a refresh fails
This commit is contained in:
@@ -3,7 +3,7 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { fetchFeedPage, FeedLoadError } from "@/lib/feed-client";
|
import { fetchFeedPage, FeedLoadError } from "@/lib/feed-client";
|
||||||
import { applyLikeOutcome, LikeError, sendLike } from "@/lib/like-client";
|
import { applyLikeOutcome, LikeError, sendLike } from "@/lib/like-client";
|
||||||
import { mergePosts, toCardPosts, upgradeVideos, type CardPost } from "@/lib/feed-model";
|
import { firstLoadFailure, mergePosts, toCardPosts, upgradeVideos, type CardPost } from "@/lib/feed-model";
|
||||||
import { PostError, publishAndConfirm } from "@/lib/post-client";
|
import { PostError, publishAndConfirm } from "@/lib/post-client";
|
||||||
import { DeleteError, sendDelete } from "@/lib/delete-client";
|
import { DeleteError, sendDelete } from "@/lib/delete-client";
|
||||||
import type { ComposerProps } from "./Composer";
|
import type { ComposerProps } from "./Composer";
|
||||||
@@ -26,6 +26,7 @@ export function Feed({
|
|||||||
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 [refreshing, setRefreshing] = useState(false);
|
||||||
|
const [refreshFailed, setRefreshFailed] = 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());
|
||||||
@@ -59,6 +60,7 @@ export function Feed({
|
|||||||
const loadFirst = useCallback(async (quiet = false) => {
|
const loadFirst = useCallback(async (quiet = false) => {
|
||||||
if (busy.current) return;
|
if (busy.current) return;
|
||||||
busy.current = true;
|
busy.current = true;
|
||||||
|
setRefreshFailed(false);
|
||||||
if (quiet) setRefreshing(true);
|
if (quiet) setRefreshing(true);
|
||||||
else setStatus("loading");
|
else setStatus("loading");
|
||||||
setLoadMoreFailed(false);
|
setLoadMoreFailed(false);
|
||||||
@@ -70,7 +72,9 @@ export function Feed({
|
|||||||
setStatus("ready");
|
setStatus("ready");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
fail(e);
|
fail(e);
|
||||||
setStatus("error");
|
const out = firstLoadFailure(quiet);
|
||||||
|
if (out.fullError) setStatus("error");
|
||||||
|
setRefreshFailed(out.refreshFailed);
|
||||||
} finally {
|
} finally {
|
||||||
busy.current = false;
|
busy.current = false;
|
||||||
setRefreshing(false);
|
setRefreshing(false);
|
||||||
@@ -262,6 +266,7 @@ export function Feed({
|
|||||||
onShowNew={showNew}
|
onShowNew={showNew}
|
||||||
onRefresh={refresh}
|
onRefresh={refresh}
|
||||||
refreshing={refreshing}
|
refreshing={refreshing}
|
||||||
|
refreshFailed={refreshFailed}
|
||||||
onToggleLike={(p) => void toggleLike(p)}
|
onToggleLike={(p) => void toggleLike(p)}
|
||||||
likeBusy={likeBusy}
|
likeBusy={likeBusy}
|
||||||
likeErrors={likeErrors}
|
likeErrors={likeErrors}
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ export interface FeedViewProps {
|
|||||||
/** Reloads the first page; the header shows the button when it is given. */
|
/** Reloads the first page; the header shows the button when it is given. */
|
||||||
onRefresh?: () => void;
|
onRefresh?: () => void;
|
||||||
refreshing?: boolean;
|
refreshing?: boolean;
|
||||||
|
/** The last refresh failed; the posts stay and a small message shows in the header. */
|
||||||
|
refreshFailed?: boolean;
|
||||||
onToggleLike?: (post: CardPost) => void;
|
onToggleLike?: (post: CardPost) => void;
|
||||||
likeBusy?: ReadonlySet<string>;
|
likeBusy?: ReadonlySet<string>;
|
||||||
likeErrors?: ReadonlySet<string>;
|
likeErrors?: ReadonlySet<string>;
|
||||||
@@ -87,11 +89,12 @@ export function RefreshButton({ onRefresh, busy }: { onRefresh: () => void; busy
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function FeedPanel({ children, composer, onRefresh, busy }: { children: ReactNode; composer?: ReactNode; onRefresh?: () => void; busy?: boolean }) {
|
function FeedPanel({ children, composer, onRefresh, busy, refreshFailed }: { children: ReactNode; composer?: ReactNode; onRefresh?: () => void; busy?: boolean; refreshFailed?: 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>
|
||||||
|
{refreshFailed ? <span className="feed-refresh-error" role="alert">Could not refresh. Try again.</span> : null}
|
||||||
{onRefresh ? <RefreshButton onRefresh={onRefresh} busy={busy} /> : null}
|
{onRefresh ? <RefreshButton onRefresh={onRefresh} busy={busy} /> : null}
|
||||||
</div>
|
</div>
|
||||||
{composer}
|
{composer}
|
||||||
@@ -121,7 +124,7 @@ export function FeedView(p: FeedViewProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<FeedPanel composer={p.composer ? <Composer {...p.composer} /> : null} onRefresh={p.onRefresh} busy={p.refreshing}>
|
<FeedPanel composer={p.composer ? <Composer {...p.composer} /> : null} onRefresh={p.onRefresh} busy={p.refreshing} refreshFailed={p.refreshFailed}>
|
||||||
<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">
|
||||||
|
|||||||
@@ -194,6 +194,11 @@ export function upgradeVideos(current: CardPost[], fresh: CardPost[]): CardPost[
|
|||||||
return changed ? next : current;
|
return changed ? next : current;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A failed first-page load: a refresh keeps the posts and flags an inline error; an initial load shows the full error state. */
|
||||||
|
export function firstLoadFailure(refresh: boolean): { fullError: boolean; refreshFailed: boolean } {
|
||||||
|
return { fullError: !refresh, refreshFailed: refresh };
|
||||||
|
}
|
||||||
|
|
||||||
/** "1 comment", "0 comments", "2 comments". */
|
/** "1 comment", "0 comments", "2 comments". */
|
||||||
export function commentsLabel(count: number): string {
|
export function commentsLabel(count: number): string {
|
||||||
return count === 1 ? "1 comment" : `${count} comments`;
|
return count === 1 ? "1 comment" : `${count} comments`;
|
||||||
|
|||||||
@@ -69,3 +69,4 @@
|
|||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.refresh-icon-spin { animation: none !important; }
|
.refresh-icon-spin { animation: none !important; }
|
||||||
}
|
}
|
||||||
|
.feed-refresh-error { margin-left: auto; font-size: 13px; color: var(--danger, #e5484d); }
|
||||||
|
|||||||
+23
-1
@@ -3,7 +3,7 @@ import { createElement } from "react";
|
|||||||
import { renderToStaticMarkup } from "react-dom/server";
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
import { FeedView, RefreshButton, 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 { commentsLabel, formatDuration, mergePosts, relativeTime, toCardPost, toCardPosts, upgradeVideos } from "@/lib/feed-model";
|
import { commentsLabel, firstLoadFailure, 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> = {}) => ({
|
||||||
@@ -245,3 +245,25 @@ describe("comments label", () => {
|
|||||||
expect(html).not.toContain("1 comments");
|
expect(html).not.toContain("1 comments");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("failed refresh versus failed first load", () => {
|
||||||
|
it("a failed refresh keeps the posts and shows an inline error with a usable button", () => {
|
||||||
|
const posts = toCardPosts([post("1", "2026-10-19T11:00:00Z")]);
|
||||||
|
const html = render({ posts, onRefresh: () => {}, refreshFailed: true });
|
||||||
|
expect(html).toContain("Could not refresh. Try again.");
|
||||||
|
expect(html).toContain("text 1");
|
||||||
|
expect(html).not.toContain("The feed did not load.");
|
||||||
|
expect(html).not.toMatch(/<button[^>]*disabled[^>]*aria-label="Refresh feed"|<button[^>]*aria-label="Refresh feed"[^>]*disabled/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a failed first load shows the full error state", () => {
|
||||||
|
const html = render({ status: "error", onRefresh: () => {} });
|
||||||
|
expect(html).toContain("The feed did not load.");
|
||||||
|
expect(html).not.toContain("Could not refresh");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the loader maps a failure to the right outcome", () => {
|
||||||
|
expect(firstLoadFailure(true)).toEqual({ fullError: false, refreshFailed: true });
|
||||||
|
expect(firstLoadFailure(false)).toEqual({ fullError: true, refreshFailed: false });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user