Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions web/src/components/community/CommunityFeedClient.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -232,3 +232,55 @@ describe("the wall's first paint", () => {
expect(grid(container).style.getPropertyValue("--feed-slot")).toBe(`${MOBILE_FEED_VIEW.slot}px`);
});
});

// "Liked" is the viewer's own list. Empty, it used to borrow the wall's panel
// and say nobody had hung anything, over a wall that had thirty patterns.
describe("an empty Liked tab", () => {
it("says nothing is liked yet and links back to the wall", () => {
search = new URLSearchParams("sort=liked");
render(<CommunityFeedClient items={[]} total={0} sort="liked" signedIn />);
expect(screen.getByText("You haven't liked a pattern yet.")).toBeInTheDocument();
expect(screen.getByText(/Press the heart on a pattern and it is kept here/)).toBeInTheDocument();
expect(screen.queryByText("Nobody has hung anything here yet.")).not.toBeInTheDocument();
expect(screen.queryByRole("link", { name: /Pattern Lab/ })).not.toBeInTheDocument();
expect(screen.getByRole("link", { name: "Browse the wall →" })).toHaveAttribute(
"href",
"/community/patterns",
);
});

it("drops only the filter when none of the likes is flashable", () => {
search = new URLSearchParams("sort=liked&hw=1");
render(<CommunityFeedClient items={[]} total={0} sort="liked" hardwareOnly signedIn />);
expect(screen.getByText("None of the patterns you liked is flashable yet.")).toBeInTheDocument();
expect(screen.queryByText("Nothing flashable yet.")).not.toBeInTheDocument();
expect(screen.getByRole("link", { name: "Every pattern you liked →" })).toHaveAttribute(
"href",
"/community/patterns?sort=liked",
);
});

it("asks a signed-out visitor to sign in, and keeps the filter on the way back", () => {
search = new URLSearchParams("sort=liked&hw=1&page=2");
render(<CommunityFeedClient items={[]} total={0} sort="liked" hardwareOnly />);
expect(screen.getByText("Sign in to see the patterns you liked.")).toBeInTheDocument();
expect(screen.getByRole("link", { name: "Browse the wall →" })).toHaveAttribute(
"href",
"/community/patterns?hw=1",
);
});

it("says a search ran over the likes only", () => {
search = new URLSearchParams("sort=liked&q=zzz");
render(<CommunityFeedClient items={[]} total={0} sort="liked" signedIn q="zzz" />);
expect(screen.getByText("Nothing matches “zzz”.")).toBeInTheDocument();
expect(screen.getByText(/Only the patterns you liked were searched/)).toBeInTheDocument();
});

it("leaves the empty wall's own panel alone", () => {
search = new URLSearchParams();
render(<CommunityFeedClient items={[]} total={0} signedIn />);
expect(screen.getByText("Nobody has hung anything here yet.")).toBeInTheDocument();
expect(screen.queryByText(/Only the patterns you liked/)).not.toBeInTheDocument();
});
});
42 changes: 41 additions & 1 deletion web/src/components/community/CommunityFeedClient.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
"use client";

import { useCallback, useEffect, useRef, useState, useTransition } from "react";
import Link from "next/link";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import PatternCard, { PatternCardItem } from "./PatternCard";
import FeedControls from "./FeedControls";
import FeedControls, { feedHref } from "./FeedControls";
import { COMMUNITY_FETCH_INIT, communityApiUrl } from "@/lib/community/apiBase";
import {
FEED_SLOT_KEY,
Expand Down Expand Up @@ -263,6 +264,8 @@ function FeedWall({
const sentinelRef = useRef<HTMLDivElement | null>(null);

const isMobile = useIsMobile();
const pathname = usePathname();
const params = useSearchParams();

// The zoom. Starts at the default for a hydration-stable first paint, then
// adopts whatever this browser chose last time — a frame later, so the
Expand Down Expand Up @@ -519,12 +522,49 @@ function FeedWall({
<span className={styles.emptyTitle}>Nothing matches “{searched}”.</span>
<span className={styles.emptyBody}>
Titles and handles are searched as typed. Start with @ to search handles only.
{sort === "liked" && " Only the patterns you liked were searched."}
{hardwareOnly && " Only flashable patterns were searched — drop the filter to search all of them."}
</span>
<button type="button" className={styles.feedSearchClear} onClick={onClearSearch}>
Clear search
</button>
</div>
) : total === 0 && sort === "liked" ? (
// "Liked" lists the viewer's own hearts, not the wall. The wall's
// empty panel said nobody had hung anything over a wall of thirty
// patterns, and sent people to Pattern Lab for a list a heart fills.
<div className={styles.emptyPanel}>
<span className={styles.emptyKicker}>Patterns · liked</span>
<span className={styles.emptyTitle}>
{!signedIn
? "Sign in to see the patterns you liked."
: hardwareOnly
? "None of the patterns you liked is flashable yet."
: "You haven't liked a pattern yet."}
</span>
<span className={styles.emptyBody}>
{signedIn && hardwareOnly
? "Drop the filter to see every pattern you liked."
: "Press the heart on a pattern and it is kept here, on any device you sign in from."}
</span>
{signedIn && hardwareOnly ? (
<Link
className={styles.emptyCta}
href={feedHref(pathname, params, { hw: null })}
scroll={false}
>
Every pattern you liked →
</Link>
) : (
<Link
className={styles.emptyCta}
href={feedHref(pathname, params, { sort: null })}
scroll={false}
>
Browse the wall →
</Link>
)}
</div>
) : total === 0 ? (
<div className={styles.emptyPanel}>
<span className={styles.emptyKicker}>Patterns · empty</span>
Expand Down
42 changes: 25 additions & 17 deletions web/src/components/community/FeedControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,30 @@ const SORTS = [
{ id: "decks", label: "In decks" },
] as const;

/** The wall's address with `patch` laid over its query, a null dropping the
* key. The tabs below and the wall's empty panels both link this way. */
export function feedHref(
pathname: string,
params: { toString(): string },
patch: Record<string, string | null>,
): string {
const next = new URLSearchParams(params.toString());
// Parameters from the paged feed: page and size from 22 July 2026
// (e146de6), view from the Large/Small card toggle on 25 July (5e5d50c).
// Infinite scroll retired all three on 2 August (c4a57f1). A bookmark
// saved in those days still carries them, so every link followed from it
// drops them.
next.delete("page");
next.delete("size");
next.delete("view");
for (const [key, value] of Object.entries(patch)) {
if (value === null) next.delete(key);
else next.set(key, value);
}
const query = next.toString();
return query ? `${pathname}?${query}` : pathname;
}

export default function FeedControls({
sort,
hardwareOnly,
Expand All @@ -46,23 +70,7 @@ export default function FeedControls({
const pathname = usePathname();
const params = useSearchParams();

const hrefWith = (patch: Record<string, string | null>) => {
const next = new URLSearchParams(params.toString());
// Parameters from the paged feed: page and size from 22 July 2026
// (e146de6), view from the Large/Small card toggle on 25 July (5e5d50c).
// Infinite scroll retired all three on 2 August (c4a57f1). A bookmark
// saved in those days still carries them, so every link followed from it
// drops them.
next.delete("page");
next.delete("size");
next.delete("view");
for (const [key, value] of Object.entries(patch)) {
if (value === null) next.delete(key);
else next.set(key, value);
}
const query = next.toString();
return query ? `${pathname}?${query}` : pathname;
};
const hrefWith = (patch: Record<string, string | null>) => feedHref(pathname, params, patch);

return (
<div className={styles.feedControls}>
Expand Down
Loading