188 lines
6.6 KiB
TypeScript
188 lines
6.6 KiB
TypeScript
"use client";
|
|
|
|
import type { ReactNode, Ref } from "react";
|
|
import { canDelete, type CardPost } from "@/lib/feed-model";
|
|
import type { DiscoverExpert, DiscoverMinnect } from "@/lib/discover";
|
|
import { Composer, type ComposerProps } from "./Composer";
|
|
import { GrowButton, IconButton } from "./Buttons";
|
|
import { PostCard } from "./PostCard";
|
|
import { useCompactLayout } from "./layout-context";
|
|
import { MobileFeed, type CurrentSlide } from "./MobileFeed";
|
|
|
|
export type FeedStatus = "loading" | "ready" | "error";
|
|
|
|
export interface FeedViewProps {
|
|
posts: CardPost[];
|
|
status: FeedStatus;
|
|
loadingMore: boolean;
|
|
loadMoreFailed: boolean;
|
|
newCount: number;
|
|
onRetry: () => void;
|
|
onShowNew: () => void;
|
|
/** Reloads the first page; the header shows the button when it is given. */
|
|
onRefresh?: () => void;
|
|
refreshing?: boolean;
|
|
/** The last refresh failed; the posts stay and a small message shows in the header. */
|
|
refreshFailed?: boolean;
|
|
onToggleLike?: (post: CardPost) => void;
|
|
likeBusy?: ReadonlySet<string>;
|
|
likeErrors?: ReadonlySet<string>;
|
|
onSignedOut?: () => void;
|
|
/** The signed-in person's feed user id; delete is offered only on their own posts. */
|
|
feedUserId?: string | null;
|
|
onDelete?: (post: CardPost) => void;
|
|
deleteBusy?: ReadonlySet<string>;
|
|
deleteErrors?: ReadonlySet<string>;
|
|
composer?: ComposerProps;
|
|
sentinelRef?: Ref<HTMLDivElement>;
|
|
/** The scrolling feed list; the page itself never scrolls. */
|
|
listRef?: Ref<HTMLDivElement>;
|
|
now?: number;
|
|
/** Another page can load; the phone feed ends with a loading slide. */
|
|
hasMore?: boolean;
|
|
/** Phone layout only: experts for the featured slide after the second post. */
|
|
featuredExperts?: DiscoverExpert[];
|
|
/** Phone layout only: trending minnects for the Discover sheet. */
|
|
trendingMinnects?: DiscoverMinnect[];
|
|
/** Phone layout only: the Find experts panel shown in the Discover sheet. */
|
|
searchPanel?: ReactNode;
|
|
/** Phone layout only: the slide on screen changed (for the dock). */
|
|
onCurrentChange?: (slide: CurrentSlide) => void;
|
|
/** Phone layout only: the dock's comments button, for the post on screen. */
|
|
onOpenComments?: (postId: string) => void;
|
|
/** Phone layout only: the dock's new-post button. */
|
|
onOpenComposer?: () => void;
|
|
/** Phone layout only: the dock's More button (the Discover sheet). */
|
|
onOpenMore?: () => void;
|
|
}
|
|
|
|
function Spinner() {
|
|
return <span className="spinner" role="status" aria-label="Loading" />;
|
|
}
|
|
|
|
/** Shimmer placeholder card shown while the next page loads. */
|
|
function SkeletonCard() {
|
|
return (
|
|
<div className="feed-skel" role="status" aria-label="Loading more posts" data-testid="feed-skeleton">
|
|
<div className="feed-skel-head">
|
|
<span className="feed-skel-dot" />
|
|
<span className="feed-skel-lines">
|
|
<span className="feed-skel-line feed-skel-w40" />
|
|
<span className="feed-skel-line feed-skel-w25" />
|
|
</span>
|
|
</div>
|
|
<span className="feed-skel-line feed-skel-w100" />
|
|
<span className="feed-skel-line feed-skel-w80" />
|
|
<span className="feed-skel-media" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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, refreshFailed }: { children: ReactNode; composer?: ReactNode; onRefresh?: () => void; busy?: boolean; refreshFailed?: boolean }) {
|
|
return (
|
|
<section className="tile panel feed" aria-labelledby="h-feed">
|
|
<div className="panel-head feed-head">
|
|
<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}
|
|
</div>
|
|
{composer}
|
|
{children}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
export function FeedView(p: FeedViewProps) {
|
|
// At 760px and below the feed is the full-screen snap reel instead of the panel.
|
|
const compact = useCompactLayout();
|
|
if (compact) return <MobileFeed {...p} />;
|
|
if (p.status === "loading") {
|
|
return (
|
|
<FeedPanel onRefresh={p.onRefresh} busy>
|
|
<div className="feed-center">
|
|
<Spinner />
|
|
</div>
|
|
</FeedPanel>
|
|
);
|
|
}
|
|
if (p.status === "error") {
|
|
return (
|
|
<FeedPanel onRefresh={p.onRefresh}>
|
|
<div className="feed-message" role="alert">
|
|
<p>The feed did not load.</p>
|
|
<button type="button" onClick={p.onRetry}>Try again</button>
|
|
</div>
|
|
</FeedPanel>
|
|
);
|
|
}
|
|
return (
|
|
<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">
|
|
{p.newCount > 0 ? (
|
|
<div className="feed-pill">
|
|
<GrowButton label={p.newCount === 1 ? "1 new post" : `${p.newCount} new posts`} onClick={p.onShowNew} />
|
|
</div>
|
|
) : null}
|
|
{p.posts.length === 0 ? (
|
|
<p className="feed-message">No posts yet.</p>
|
|
) : (
|
|
p.posts.map((post, i) => <PostCard
|
|
key={post.key}
|
|
post={post}
|
|
index={i}
|
|
now={p.now}
|
|
onToggleLike={p.onToggleLike}
|
|
likeBusy={p.likeBusy?.has(post.key)}
|
|
likeError={p.likeErrors?.has(post.key)}
|
|
onSignedOut={p.onSignedOut}
|
|
canDelete={canDelete(post, p.feedUserId)}
|
|
onDelete={p.onDelete}
|
|
deleteBusy={p.deleteBusy?.has(post.key)}
|
|
deleteError={p.deleteErrors?.has(post.key)}
|
|
/>)
|
|
)}
|
|
{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 ? <SkeletonCard /> : null}
|
|
<div ref={p.sentinelRef} className="sentinel" aria-hidden="true" />
|
|
</div>
|
|
</FeedPanel>
|
|
);
|
|
}
|