Skip to content

Commit 79d4994

Browse files
NiallJoeMaherclaude
andcommitted
feat(relaunch): follow discussions + All/Following tabs + sort filter
Discussions page drops the topic-tag row for the feed pattern: All / Following tabs (deep-linkable via ?view=) + a FilterPill sort (Recent/Active/Top). New post_follow table (migration 0030) + discussion.follow/unfollow/isFollowing; a +Follow ⇄ ✓Following toggle on the thread. New comments notify the post's followers (NEW_COMMENT_ON_FOLLOWED_POST, de-duped vs author/parent/commenter), rendered on the notifications page. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 7786f89 commit 79d4994

11 files changed

Lines changed: 6681 additions & 51 deletions

File tree

app/(app)/discussions/_client.tsx

Lines changed: 85 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -6,30 +6,59 @@ import { useSearchParams, useRouter } from "next/navigation";
66
import { signIn, useSession } from "next-auth/react";
77
import { api } from "@/server/trpc/react";
88
import { FeedItemLoading } from "@/components/Feed";
9+
import { FilterPill, type Option } from "@/components/Feed/Filters";
910
import { UnifiedContentCard } from "@/components/UnifiedContentCard";
1011
import { useShellActions } from "@/components/Create/ShellActionsProvider";
1112

13+
type View = "all" | "following";
14+
type Sort = "recent" | "active" | "top";
15+
16+
const sortOptions: Option[] = [
17+
{ value: "recent", label: "Recent" },
18+
{ value: "active", label: "Active" },
19+
{ value: "top", label: "Top" },
20+
];
21+
1222
/**
1323
* Discussions — community threads (questions + discussions), same row style as
14-
* the feed. Mirrors ui_kits/app/AppShell.jsx → Discussions.
24+
* the feed. All / Following tabs (deep-linkable via ?view=) + a sort filter,
25+
* mirroring the feed's tab + FilterPill pattern.
1526
*/
1627
const DiscussionsPage = () => {
1728
const searchParams = useSearchParams();
1829
const router = useRouter();
1930
const { data: session } = useSession();
2031
const { openCompose } = useShellActions();
2132

22-
const tag = searchParams?.get("tag") || null;
33+
const view: View =
34+
searchParams?.get("view") === "following" ? "following" : "all";
35+
const sortParam = searchParams?.get("sort");
36+
const sort: Sort =
37+
sortParam === "active" || sortParam === "top" ? sortParam : "recent";
38+
39+
// Build a ?view=&sort= query string, omitting defaults to keep URLs clean.
40+
const setParams = (next: { view?: View; sort?: Sort }) => {
41+
const params = new URLSearchParams();
42+
const nextView = next.view ?? view;
43+
const nextSort = next.sort ?? sort;
44+
if (nextView === "following") params.set("view", "following");
45+
if (nextSort !== "recent") params.set("sort", nextSort);
46+
const qs = params.toString();
47+
router.replace(qs ? `/discussions?${qs}` : "/discussions", {
48+
scroll: false,
49+
});
50+
};
2351

2452
const { status, data, isFetchingNextPage, fetchNextPage, hasNextPage } =
25-
api.content.getFeed.useInfiniteQuery(
26-
{ limit: 25, sort: "recent", kinds: ["DISCUSSION", "QUESTION"], tag },
27-
{ getNextPageParam: (lastPage) => lastPage.nextCursor },
53+
api.discussion.list.useInfiniteQuery(
54+
{ limit: 25, view, sort },
55+
{
56+
getNextPageParam: (lastPage) => lastPage.nextCursor,
57+
// Following tab is meaningless signed-out — skip the query.
58+
enabled: view === "all" || !!session,
59+
},
2860
);
2961

30-
const { data: popularData } = api.tag.getPopular.useQuery({ limit: 6 });
31-
const topics = popularData?.data ?? [];
32-
3362
const { ref, inView } = useInView();
3463
useEffect(() => {
3564
if (inView && hasNextPage) fetchNextPage();
@@ -38,6 +67,13 @@ const DiscussionsPage = () => {
3867
const empty =
3968
status === "success" && data.pages.every((p) => p.items.length === 0);
4069

70+
const tabClass = (active: boolean) =>
71+
`whitespace-nowrap border-b-2 px-1 pb-2 text-sm font-semibold transition-colors ${
72+
active
73+
? "border-accent text-fg"
74+
: "border-transparent text-muted hover:text-fg"
75+
}`;
76+
4177
return (
4278
<div>
4379
<div className="flex flex-wrap items-end justify-between gap-4">
@@ -61,36 +97,42 @@ const DiscussionsPage = () => {
6197
loud with other builders working with AI.
6298
</p>
6399

64-
{topics.length > 0 && (
65-
<div className="mt-5 flex flex-wrap gap-2">
100+
<div className="mt-6 flex items-center justify-between border-b border-hairline">
101+
<div className="flex items-center gap-5" role="tablist">
66102
<button
67-
onClick={() => router.push("/discussions")}
68-
className={`whitespace-nowrap rounded-full px-3 py-1 font-mono text-xs transition-colors ${
69-
!tag
70-
? "bg-accent text-on-accent"
71-
: "border border-hairline text-muted hover:text-fg"
72-
}`}
103+
role="tab"
104+
aria-selected={view === "all"}
105+
data-testid="discussions-tab-all"
106+
onClick={() => setParams({ view: "all" })}
107+
className={tabClass(view === "all")}
73108
>
74109
All
75110
</button>
76-
{topics.map((t) => {
77-
const on = tag === t.slug;
78-
return (
79-
<button
80-
key={t.slug}
81-
onClick={() => router.push(`/discussions?tag=${t.slug}`)}
82-
className={`whitespace-nowrap rounded-full px-3 py-1 font-mono text-xs transition-colors ${
83-
on
84-
? "bg-accent text-on-accent"
85-
: "border border-hairline text-muted hover:text-fg"
86-
}`}
87-
>
88-
{t.title}
89-
</button>
90-
);
91-
})}
111+
<button
112+
role="tab"
113+
aria-selected={view === "following"}
114+
data-testid="discussions-tab-following"
115+
onClick={() => {
116+
if (!session) return signIn();
117+
setParams({ view: "following" });
118+
}}
119+
className={tabClass(view === "following")}
120+
>
121+
Following
122+
</button>
123+
</div>
124+
<div className="pb-1">
125+
<FilterPill
126+
testId="discussions-sort"
127+
label="Sort discussions"
128+
value={sort}
129+
options={sortOptions}
130+
isDefault={sort === "recent"}
131+
align="right"
132+
onChange={(next) => setParams({ sort: next as Sort })}
133+
/>
92134
</div>
93-
)}
135+
</div>
94136

95137
<section className="mt-6 space-y-3">
96138
{status === "pending" &&
@@ -134,9 +176,17 @@ const DiscussionsPage = () => {
134176
</Fragment>
135177
))}
136178

137-
{empty && (
179+
{empty && view === "following" && (
180+
<div className="rounded-lg border border-dashed border-hairline p-12 text-center font-mono text-sm text-faint">
181+
{"// "}you&apos;re not following any discussions yet — open a thread
182+
and hit Follow
183+
</div>
184+
)}
185+
186+
{empty && view === "all" && (
138187
<div className="rounded-lg border border-dashed border-hairline p-12 text-center font-mono text-sm text-faint">
139-
{"// "}nothing here yet — {session?.user ? "start one" : "sign in to start one"}
188+
{"// "}nothing here yet —{" "}
189+
{session?.user ? "start one" : "sign in to start one"}
140190
</div>
141191
)}
142192

app/(app)/notifications/_client.tsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
NEW_REPLY_TO_YOUR_COMMENT,
1111
NEW_FOLLOWER,
1212
POST_APPROVED,
13+
NEW_COMMENT_ON_FOLLOWED_POST,
1314
} from "@/utils/notifications";
1415
import { api } from "@/server/trpc/react";
1516

@@ -151,6 +152,7 @@ const Notifications = () => {
151152
NEW_REPLY_TO_YOUR_COMMENT,
152153
NEW_FOLLOWER,
153154
POST_APPROVED,
155+
NEW_COMMENT_ON_FOLLOWED_POST,
154156
].includes(type)
155157
)
156158
return null;
@@ -160,9 +162,11 @@ const Notifications = () => {
160162
? "started a discussion on your post"
161163
: type === NEW_REPLY_TO_YOUR_COMMENT
162164
? "replied to your comment"
163-
: type === POST_APPROVED
164-
? "approved your post — it's now live"
165-
: "started following you";
165+
: type === NEW_COMMENT_ON_FOLLOWED_POST
166+
? "commented on a discussion you follow"
167+
: type === POST_APPROVED
168+
? "approved your post — it's now live"
169+
: "started following you";
166170

167171
return (
168172
<div

components/Discussion/DiscussionArea.tsx

Lines changed: 53 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,29 @@ const DiscussionArea = ({ contentId, noWrapper = false }: Props) => {
4747

4848
const { data: session } = useSession();
4949
const { openReport } = useReportModal();
50+
const utils = api.useUtils();
51+
52+
const { data: isFollowing } = api.discussion.isFollowing.useQuery(
53+
{ postId: contentId },
54+
{ enabled: !!session, retry: false },
55+
);
56+
57+
const onFollowSettled = () =>
58+
utils.discussion.isFollowing.invalidate({ postId: contentId });
59+
const followMut = api.discussion.follow.useMutation({
60+
onSettled: onFollowSettled,
61+
});
62+
const unfollowMut = api.discussion.unfollow.useMutation({
63+
onSettled: onFollowSettled,
64+
});
65+
const followPending = followMut.isPending || unfollowMut.isPending;
66+
67+
const toggleFollow = () => {
68+
if (!session) return signIn();
69+
if (followPending) return;
70+
if (isFollowing) unfollowMut.mutate({ postId: contentId });
71+
else followMut.mutate({ postId: contentId });
72+
};
5073

5174
const {
5275
data: discussionsResponse,
@@ -476,18 +499,36 @@ const DiscussionArea = ({ contentId, noWrapper = false }: Props) => {
476499
</div>
477500
</div>
478501
)}
479-
{/* Sort control (the canonical "Discussion {N}" header is owned by the reader) */}
480-
{initiallyLoaded && (discussionsResponse?.count ?? 0) > 1 && (
481-
<div className="mb-4 flex items-center justify-end">
482-
<FilterPill
483-
testId="discussion-sort"
484-
label="Sort comments"
485-
value={sortOrder}
486-
options={sortOptions}
487-
isDefault={sortOrder === "top"}
488-
align="right"
489-
onChange={(next) => setSortOrder(next as SortOrder)}
490-
/>
502+
{/* Follow toggle + sort control (the canonical "Discussion {N}" header is
503+
owned by the reader) */}
504+
{initiallyLoaded && (
505+
<div className="mb-4 flex items-center justify-between gap-3">
506+
<button
507+
type="button"
508+
onClick={toggleFollow}
509+
disabled={followPending}
510+
aria-pressed={!!isFollowing}
511+
data-testid="discussion-follow"
512+
className={`inline-flex items-center gap-1.5 rounded-full px-4 py-1.5 text-sm font-semibold transition-colors disabled:opacity-60 ${
513+
isFollowing
514+
? "border border-hairline text-fg hover:border-accent/50"
515+
: "bg-accent text-on-accent hover:bg-accent-soft"
516+
}`}
517+
>
518+
<span aria-hidden="true">{isFollowing ? "✓" : "+"}</span>
519+
{isFollowing ? "Following" : "Follow"}
520+
</button>
521+
{(discussionsResponse?.count ?? 0) > 1 && (
522+
<FilterPill
523+
testId="discussion-sort"
524+
label="Sort comments"
525+
value={sortOrder}
526+
options={sortOptions}
527+
isDefault={sortOrder === "top"}
528+
align="right"
529+
onChange={(next) => setSortOrder(next as SortOrder)}
530+
/>
531+
)}
491532
</div>
492533
)}
493534
<div className={discussions?.length ? "mb-8" : ""}>
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
CREATE TABLE "post_follow" (
2+
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
3+
"user_id" text NOT NULL,
4+
"post_id" uuid NOT NULL,
5+
"created_at" timestamp(3) with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL
6+
);
7+
--> statement-breakpoint
8+
ALTER TABLE "post_follow" ADD CONSTRAINT "post_follow_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
9+
ALTER TABLE "post_follow" ADD CONSTRAINT "post_follow_post_id_posts_id_fk" FOREIGN KEY ("post_id") REFERENCES "public"."posts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
10+
CREATE UNIQUE INDEX "post_follow_pair_idx" ON "post_follow" USING btree ("user_id","post_id");--> statement-breakpoint
11+
CREATE INDEX "post_follow_post_idx" ON "post_follow" USING btree ("post_id");

0 commit comments

Comments
 (0)