From 203bb1c73c0a7e76c266aeced532afef50fb0656 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Jan 2026 21:08:46 +0000 Subject: [PATCH] feat: add private posts visibility feature Implement a comprehensive private posts feature that allows users to create posts visible only to themselves. Changes: - Add visibility column to posts table schema with index - Create VisibilitySelector component with public/private toggle - Update Composer to support visibility selection via showVisibility prop - Modify createPost action to store visibility and skip indexing private posts - Filter private posts from repo listings (client-side based on auth) - Filter private posts from search results (server-side with session check) - Add PrivatePostGate component for access control on post detail pages - Display lock icon and "Private" badge on private posts in listings and headers - Update all post queries to include visibility field --- app/[owner]/[repo]/[postNumber]/page.tsx | 89 ++++++++++--------- .../[repo]/[postNumber]/post-composer.tsx | 1 + .../[repo]/[postNumber]/post-header.tsx | 29 +++--- .../[postNumber]/post-metadata-context.tsx | 4 + .../[repo]/[postNumber]/private-post-gate.tsx | 53 +++++++++++ .../[repo]/category/[categorySlug]/page.tsx | 1 + app/[owner]/[repo]/new-post-composer.tsx | 4 +- app/[owner]/[repo]/page.tsx | 1 + app/[owner]/[repo]/repo-content.tsx | 19 +++- app/[owner]/[repo]/repo-posts-section.tsx | 18 ++-- app/api/search/hybrid/route.ts | 23 +++-- components/composer.tsx | 78 +++++++++------- components/visibility-selector.tsx | 62 +++++++++++++ lib/actions/posts.ts | 51 ++++++----- lib/db/schema.ts | 3 + 15 files changed, 319 insertions(+), 117 deletions(-) create mode 100644 app/[owner]/[repo]/[postNumber]/private-post-gate.tsx create mode 100644 components/visibility-selector.tsx diff --git a/app/[owner]/[repo]/[postNumber]/page.tsx b/app/[owner]/[repo]/[postNumber]/page.tsx index 29caf1e..4a88754 100644 --- a/app/[owner]/[repo]/[postNumber]/page.tsx +++ b/app/[owner]/[repo]/[postNumber]/page.tsx @@ -22,6 +22,7 @@ import { CommentThreadClient } from "./comment-thread-client" import { PostComposer } from "./post-composer" import { PostHeader } from "./post-header" import { PostMetadataProvider } from "./post-metadata-context" +import { PrivatePostGate } from "./private-post-gate" const githubCompareSchema = z.object({ ahead_by: z.number(), @@ -156,6 +157,7 @@ export default async function PostPage({ authorId: posts.authorId, createdAt: posts.createdAt, updatedAt: posts.updatedAt, + visibility: posts.visibility, gitContexts: posts.gitContexts, category: { id: categories.id, @@ -322,48 +324,51 @@ export default async function PostPage({ .find((c) => c.authorId.startsWith("llm_"))?.authorId return ( - - -
- - -
- + + + +
+ + +
+ +
-
- -
-

- END OF POST -

- - - - + +
+

+ END OF POST +

+ + + + + ) } diff --git a/app/[owner]/[repo]/[postNumber]/post-composer.tsx b/app/[owner]/[repo]/[postNumber]/post-composer.tsx index b0f02db..83bbc0f 100644 --- a/app/[owner]/[repo]/[postNumber]/post-composer.tsx +++ b/app/[owner]/[repo]/[postNumber]/post-composer.tsx @@ -67,6 +67,7 @@ export function PostComposer({ }} options={{ asking: askingOptions }} placeholder="Follow up" + showVisibility={false} storageKey={`post-composer:${postId}:${threadCommentId ?? "main"}`} />
diff --git a/app/[owner]/[repo]/[postNumber]/post-header.tsx b/app/[owner]/[repo]/[postNumber]/post-header.tsx index 2998240..09d51f2 100644 --- a/app/[owner]/[repo]/[postNumber]/post-header.tsx +++ b/app/[owner]/[repo]/[postNumber]/post-header.tsx @@ -1,6 +1,6 @@ "use client" -import { ChevronRight, TagIcon } from "lucide-react" +import { ChevronRight, LockIcon, TagIcon } from "lucide-react" import Link from "next/link" import { useRouter } from "next/navigation" import { type ReactNode, useTransition } from "react" @@ -24,7 +24,8 @@ export function PostHeader({ repo: string postNumber: number }) { - const { title, category, gitContext, archivedRefs } = usePostMetadata() + const { title, category, gitContext, archivedRefs, visibility } = + usePostMetadata() const hasArchivedRefs = archivedRefs.length > 0 return ( @@ -50,14 +51,22 @@ export function PostHeader({ )} - {typeof title === "string" ? ( - {title || `Post #${postNumber}`} - ) : ( -

- Generating title... - -

- )} +
+ {typeof title === "string" ? ( + {title || `Post #${postNumber}`} + ) : ( +

+ Generating title... + +

+ )} + {visibility === "private" && ( + + + Private + + )} +
{gitContext ? (
diff --git a/app/[owner]/[repo]/[postNumber]/post-metadata-context.tsx b/app/[owner]/[repo]/[postNumber]/post-metadata-context.tsx index 5e8325d..9c4a319 100644 --- a/app/[owner]/[repo]/[postNumber]/post-metadata-context.tsx +++ b/app/[owner]/[repo]/[postNumber]/post-metadata-context.tsx @@ -30,6 +30,7 @@ type PostMetadata = { categories: Category[] gitContext: GitContextData | null staleInfo: StaleInfo + visibility: "private" | null isPolling: boolean archivedRefs: string[] selectedRef: string | null @@ -51,6 +52,7 @@ export function PostMetadataProvider({ initialCategory, initialGitContext, staleInfo, + visibility, archivedRefs, categories, children, @@ -63,6 +65,7 @@ export function PostMetadataProvider({ initialCategory: Category | null initialGitContext: GitContextData | null staleInfo: StaleInfo + visibility: "private" | null archivedRefs: string[] categories: Category[] children: React.ReactNode @@ -139,6 +142,7 @@ export function PostMetadataProvider({ categories, gitContext, staleInfo, + visibility, isPolling, archivedRefs, selectedRef, diff --git a/app/[owner]/[repo]/[postNumber]/private-post-gate.tsx b/app/[owner]/[repo]/[postNumber]/private-post-gate.tsx new file mode 100644 index 0000000..504d4d0 --- /dev/null +++ b/app/[owner]/[repo]/[postNumber]/private-post-gate.tsx @@ -0,0 +1,53 @@ +"use client" + +import { LockIcon } from "lucide-react" +import type { ReactNode } from "react" +import { Container } from "@/components/container" +import { authClient } from "@/lib/auth-client" + +type PrivatePostGateProps = { + visibility: "private" | null + authorId: string + children: ReactNode +} + +export function PrivatePostGate({ + visibility, + authorId, + children, +}: PrivatePostGateProps) { + const { data: auth, isPending } = authClient.useSession() + const userId = auth?.user?.id + + if (visibility !== "private") { + return <>{children} + } + + if (isPending) { + return ( + +
+

Loading...

+
+
+ ) + } + + if (!userId || userId !== authorId) { + return ( + +
+ +
+

Private Post

+

+ This post is private and only visible to its author. +

+
+
+
+ ) + } + + return <>{children} +} diff --git a/app/[owner]/[repo]/category/[categorySlug]/page.tsx b/app/[owner]/[repo]/category/[categorySlug]/page.tsx index 70680fc..31aad05 100644 --- a/app/[owner]/[repo]/category/[categorySlug]/page.tsx +++ b/app/[owner]/[repo]/category/[categorySlug]/page.tsx @@ -94,6 +94,7 @@ export default async function CategoryPage({ authorUsername: comments.authorUsername, rootCommentId: posts.rootCommentId, createdAt: posts.createdAt, + visibility: posts.visibility, commentCount: sql`( SELECT COUNT(*) FROM comments WHERE comments.post_id = ${posts.id} )`.as("comment_count"), diff --git a/app/[owner]/[repo]/new-post-composer.tsx b/app/[owner]/[repo]/new-post-composer.tsx index 3f34029..44d6296 100644 --- a/app/[owner]/[repo]/new-post-composer.tsx +++ b/app/[owner]/[repo]/new-post-composer.tsx @@ -35,7 +35,7 @@ export function NewPostComposer({ onAskingChange={(asking) => { localStorage.setItem(PREFERRED_LLM_KEY, asking.id) }} - onSubmit={async ({ value, options }) => { + onSubmit={async ({ value, visibility, options }) => { const result = await createPost({ owner, repo, @@ -46,6 +46,7 @@ export function NewPostComposer({ }, seekingAnswerFrom: options.asking.id, categoryId, + visibility: visibility === "private" ? "private" : null, }) router.push(`/${owner}/${repo}/${result.postNumber}`) }} @@ -53,6 +54,7 @@ export function NewPostComposer({ asking: askingOptions, }} placeholder="Ask or search" + showVisibility storageKey={`new-post-composer:${owner}:${repo}`} /> ) diff --git a/app/[owner]/[repo]/page.tsx b/app/[owner]/[repo]/page.tsx index 56f3774..755209c 100644 --- a/app/[owner]/[repo]/page.tsx +++ b/app/[owner]/[repo]/page.tsx @@ -74,6 +74,7 @@ export default async function RepoPage({ authorUsername: comments.authorUsername, rootCommentId: posts.rootCommentId, createdAt: posts.createdAt, + visibility: posts.visibility, commentCount: sql`( SELECT COUNT(*) FROM comments WHERE comments.post_id = ${posts.id} )`.as("comment_count"), diff --git a/app/[owner]/[repo]/repo-content.tsx b/app/[owner]/[repo]/repo-content.tsx index 2f089c8..3f91935 100644 --- a/app/[owner]/[repo]/repo-content.tsx +++ b/app/[owner]/[repo]/repo-content.tsx @@ -2,9 +2,10 @@ import type { InferSelectModel } from "drizzle-orm" import { useRouter } from "next/navigation" -import { useEffect, useState } from "react" +import { useEffect, useMemo, useState } from "react" import { Composer, type ComposerProps } from "@/components/composer" import { createPost } from "@/lib/actions/posts" +import { authClient } from "@/lib/auth-client" import type { categories } from "@/lib/db/schema" import { RepoPostsSection } from "./repo-posts-section" @@ -19,6 +20,7 @@ type PostListItem = { authorUsername: string | null rootCommentId: string | null createdAt: number + visibility: "private" | null commentCount: number reactionCount: number } @@ -45,6 +47,8 @@ export function RepoContent({ const router = useRouter() const [searchQuery, setSearchQuery] = useState("") const [defaultLlmId, setDefaultLlmId] = useState() + const { data: auth } = authClient.useSession() + const userId = auth?.user?.id useEffect(() => { const saved = localStorage.getItem(PREFERRED_LLM_KEY) @@ -53,6 +57,13 @@ export function RepoContent({ } }, [askingOptions]) + const filteredPosts = useMemo(() => { + return posts.filter((post) => { + if (post.visibility !== "private") return true + return userId && post.authorId === userId + }) + }, [posts, userId]) + return ( <>
@@ -63,7 +74,7 @@ export function RepoContent({ localStorage.setItem(PREFERRED_LLM_KEY, asking.id) }} onChange={setSearchQuery} - onSubmit={async ({ value, options }) => { + onSubmit={async ({ value, visibility, options }) => { const result = await createPost({ owner, repo, @@ -74,6 +85,7 @@ export function RepoContent({ }, seekingAnswerFrom: options.asking.id, categoryId, + visibility: visibility === "private" ? "private" : null, }) router.push(`/${owner}/${repo}/${result.postNumber}`) }} @@ -81,6 +93,7 @@ export function RepoContent({ asking: askingOptions, }} placeholder="Ask or search" + showVisibility storageKey={`new-post-composer:${owner}:${repo}`} />
@@ -89,7 +102,7 @@ export function RepoContent({ categoriesById={categoriesById} categoryId={categoryId} owner={owner} - posts={posts} + posts={filteredPosts} repo={repo} searchQuery={searchQuery} /> diff --git a/app/[owner]/[repo]/repo-posts-section.tsx b/app/[owner]/[repo]/repo-posts-section.tsx index 618a024..5eec6b0 100644 --- a/app/[owner]/[repo]/repo-posts-section.tsx +++ b/app/[owner]/[repo]/repo-posts-section.tsx @@ -1,7 +1,7 @@ "use client" import type { InferSelectModel } from "drizzle-orm" -import { AsteriskIcon, SparklesIcon } from "lucide-react" +import { AsteriskIcon, LockIcon, SparklesIcon } from "lucide-react" import Link from "next/link" import { useCallback, useEffect, useRef, useState } from "react" import { RelativeTime } from "@/components/relative-time" @@ -23,6 +23,7 @@ type PostListItem = { authorUsername: string | null rootCommentId: string | null createdAt: number + visibility: "private" | null commentCount: number reactionCount: number } @@ -225,10 +226,17 @@ function LatestPosts({ className="group mr-3 flex grow items-center gap-1 overflow-hidden text-dim hover:underline" href={`/${owner}/${repo}/${post.number}`} > - + {post.visibility === "private" ? ( + + ) : ( + + )} {post.title || `Post #${post.number}`} diff --git a/app/api/search/hybrid/route.ts b/app/api/search/hybrid/route.ts index 2f4d5e5..ce3d70c 100644 --- a/app/api/search/hybrid/route.ts +++ b/app/api/search/hybrid/route.ts @@ -1,4 +1,6 @@ -import { and, eq, inArray, sql } from "drizzle-orm" +import { and, eq, inArray, isNull, or, sql } from "drizzle-orm" +import { headers } from "next/headers" +import { auth } from "@/lib/auth" import { db } from "@/lib/db/client" import { comments, posts } from "@/lib/db/schema" import { @@ -16,6 +18,7 @@ type PostWithHighlight = { authorUsername: string | null rootCommentId: string | null createdAt: number + visibility: "private" | null commentCount: number reactionCount: number highlight: string | null @@ -24,7 +27,8 @@ type PostWithHighlight = { async function enrichPosts( searchResults: PostSearchResult[], owner: string, - repo: string + repo: string, + userId?: string ): Promise { if (searchResults.length === 0) return [] @@ -40,6 +44,7 @@ async function enrichPosts( authorUsername: comments.authorUsername, rootCommentId: posts.rootCommentId, createdAt: posts.createdAt, + visibility: posts.visibility, commentCount: sql`( SELECT COUNT(*) FROM comments WHERE comments.post_id = ${posts.id} )`.as("comment_count"), @@ -66,6 +71,10 @@ async function enrichPosts( return postIds .map((id) => postsById[id]) .filter(Boolean) + .filter((post) => { + if (post.visibility !== "private") return true + return userId && post.authorId === userId + }) .map((post) => ({ ...post, highlight: highlightsByPostId[post.id] ?? null, @@ -73,7 +82,10 @@ async function enrichPosts( } export async function POST(request: Request) { - const body = await request.json() + const [body, session] = await Promise.all([ + request.json(), + auth.api.getSession({ headers: await headers() }), + ]) const { query, owner, repo, type, categoryId } = body as { query: string owner: string @@ -81,6 +93,7 @@ export async function POST(request: Request) { type: "text" | "semantic" categoryId?: string } + const userId = session?.user?.id if (!query?.trim()) { return Response.json({ posts: [] }) @@ -91,7 +104,7 @@ export async function POST(request: Request) { perPage: 20, categoryId, }) - const textPosts = await enrichPosts(textResults, owner, repo) + const textPosts = await enrichPosts(textResults, owner, repo, userId) return Response.json({ posts: textPosts }) } @@ -100,7 +113,7 @@ export async function POST(request: Request) { perPage: 5, categoryId, }) - const semanticPosts = await enrichPosts(semanticResults, owner, repo) + const semanticPosts = await enrichPosts(semanticResults, owner, repo, userId) return Response.json({ posts: semanticPosts }) } diff --git a/components/composer.tsx b/components/composer.tsx index 1ead143..205f033 100644 --- a/components/composer.tsx +++ b/components/composer.tsx @@ -12,6 +12,10 @@ import { useTransition, } from "react" import { Menu } from "@/components/ui/menu" +import { + type Visibility, + VisibilitySelector, +} from "@/components/visibility-selector" import { authClient } from "@/lib/auth-client" import { useDialogStore } from "@/lib/stores/dialogs" import { cn } from "@/lib/utils" @@ -31,6 +35,7 @@ export type ComposerProps = { } onSubmit: (params: { value: string + visibility: Visibility options: { [K in keyof ComposerProps["options"]]: ComposerProps["options"][K][number] } @@ -39,6 +44,7 @@ export type ComposerProps = { defaultAskingId?: string onAskingChange?: (asking: ComposerProps["options"]["asking"][number]) => void onChange?: (value: string) => void + showVisibility?: boolean } type AskingOption = ComposerProps["options"]["asking"][number] @@ -52,6 +58,7 @@ export const Composer = ({ defaultAskingId, onAskingChange, onChange, + showVisibility = false, }: ComposerProps) => { const textareaRef = useRef(null) const { data: auth } = authClient.useSession() @@ -73,6 +80,7 @@ export const Composer = ({ }) const [defaultAskingIdSet, setDefaultAskingIdSet] = useState(false) const [isScrollable, setIsScrollable] = useState(false) + const [visibility, setVisibility] = useState("public") const adjustTextareaHeight = useCallback(() => { const textarea = textareaRef.current @@ -118,7 +126,7 @@ export const Composer = ({ return } startTransition(async () => { - await onSubmit({ value, options: { asking: selectedAsking } }) + await onSubmit({ value, visibility, options: { asking: selectedAsking } }) .then(() => { form.reset() sessionStorage.removeItem(storageKey) @@ -126,6 +134,7 @@ export const Composer = ({ textareaRef.current.style.height = "auto" } setIsScrollable(false) + setVisibility("public") }) .catch((e) => { console.error(e) @@ -258,36 +267,45 @@ export const Composer = ({ )}
- + } + } + type={isSignedIn ? "submit" : "button"} + > + {isSignedIn + ? isPending + ? "Posting..." + : "Post" + : isPending + ? "Logging in..." + : "Log In"} + + ) diff --git a/components/visibility-selector.tsx b/components/visibility-selector.tsx new file mode 100644 index 0000000..a7a8ac7 --- /dev/null +++ b/components/visibility-selector.tsx @@ -0,0 +1,62 @@ +"use client" + +import { GlobeIcon, LockIcon } from "lucide-react" +import { Menu } from "@/components/ui/menu" +import { cn } from "@/lib/utils" + +export type Visibility = "public" | "private" + +type VisibilitySelectorProps = { + value: Visibility + onChange: (value: Visibility) => void + disabled?: boolean +} + +const options: { value: Visibility; label: string; icon: typeof GlobeIcon }[] = + [ + { value: "public", label: "Public", icon: GlobeIcon }, + { value: "private", label: "Private", icon: LockIcon }, + ] + +export function VisibilitySelector({ + value, + onChange, + disabled, +}: VisibilitySelectorProps) { + const selected = options.find((o) => o.value === value) ?? options[0] + const Icon = selected.icon + + if (disabled) { + return ( + + + {selected.label} + + ) + } + + return ( + + + + {selected.label} + + + {options.map((option) => { + const OptionIcon = option.icon + return ( + onChange(option.value)}> + + {option.label} + + ) + })} + + + ) +} diff --git a/lib/actions/posts.ts b/lib/actions/posts.ts index f96ddae..1f363cf 100644 --- a/lib/actions/posts.ts +++ b/lib/actions/posts.ts @@ -165,6 +165,7 @@ export async function createPost(data: { content: AgentUIMessage seekingAnswerFrom?: string | null categoryId?: string + visibility?: "private" | null }) { const session = await getSessionOrThrow() await checkMessageRateLimit(session.user.id) @@ -215,6 +216,7 @@ export async function createPost(data: { authorId: session.user.id, rootCommentId: commentId, categoryId: data.categoryId, + visibility: data.visibility, createdAt: now, updatedAt: now, }) @@ -290,8 +292,12 @@ export async function createPost(data: { .map((p) => p.text) .join("\n\n") - // Index post first so category agent can update it - await indexPost(newPost, 1) + const isPrivate = data.visibility === "private" + + // Index post first so category agent can update it (skip for private posts) + if (!isPrivate) { + await indexPost(newPost, 1) + } if (contentText) { await runCategoryAgent({ @@ -303,25 +309,28 @@ export async function createPost(data: { }) } - waitUntil( - (async () => { - const [comment] = await db - .select() - .from(comments) - .where(eq(comments.id, commentId)) - .limit(1) - if (comment) { - await indexComment( - comment, - data.owner, - data.repo, - newPost.number, - newPost.categoryId, - true - ) - } - })() - ) + // Skip indexing comments for private posts + if (!isPrivate) { + waitUntil( + (async () => { + const [comment] = await db + .select() + .from(comments) + .where(eq(comments.id, commentId)) + .limit(1) + if (comment) { + await indexComment( + comment, + data.owner, + data.repo, + newPost.number, + newPost.categoryId, + true + ) + } + })() + ) + } waitUntil( createMentions({ diff --git a/lib/db/schema.ts b/lib/db/schema.ts index b17d68a..f2124db 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -18,6 +18,8 @@ export const posts = p.pgTable( createdAt: p.bigint("created_at", { mode: "number" }).notNull(), updatedAt: p.bigint("updated_at", { mode: "number" }).notNull(), + + visibility: p.varchar({ length: 32 }).$type<"private" | null>(), }, (table) => [ p @@ -27,6 +29,7 @@ export const posts = p.pgTable( .index("idx_posts_owner_repo") .on(table.owner, table.repo, table.id.desc()), p.index("idx_posts_author").on(table.authorId), + p.index("idx_posts_visibility").on(table.visibility), ] )