diff --git a/app/(chat)/actions.ts b/app/(chat)/actions.ts index da4c5ea..d88d8a7 100644 --- a/app/(chat)/actions.ts +++ b/app/(chat)/actions.ts @@ -7,7 +7,9 @@ import { customModel } from '@/lib/ai'; import { deleteMessagesByChatIdAfterTimestamp, getMessageById, + updateChatVisiblityById, } from '@/lib/db/queries'; +import { VisibilityType } from '@/components/visibility-selector'; export async function saveModelId(model: string) { const cookieStore = await cookies(); @@ -40,3 +42,13 @@ export async function deleteTrailingMessages({ id }: { id: string }) { timestamp: message.createdAt, }); } + +export async function updateChatVisibility({ + chatId, + visibility, +}: { + chatId: string; + visibility: VisibilityType; +}) { + await updateChatVisiblityById({ chatId, visibility }); +} diff --git a/app/(chat)/chat/[id]/page.tsx b/app/(chat)/chat/[id]/page.tsx index 1ed6d61..c76cd96 100644 --- a/app/(chat)/chat/[id]/page.tsx +++ b/app/(chat)/chat/[id]/page.tsx @@ -18,12 +18,14 @@ export default async function Page(props: { params: Promise<{ id: string }> }) { const session = await auth(); - if (!session || !session.user) { - return notFound(); - } + if (chat.visibility === 'private') { + if (!session || !session.user) { + return notFound(); + } - if (session.user.id !== chat.userId) { - return notFound(); + if (session.user.id !== chat.userId) { + return notFound(); + } } const messagesFromDb = await getMessagesByChatId({ @@ -41,6 +43,8 @@ export default async function Page(props: { params: Promise<{ id: string }> }) { id={chat.id} initialMessages={convertToUIMessages(messagesFromDb)} selectedModelId={selectedModelId} + selectedVisibilityType={chat.visibility} + isReadonly={session?.user?.id !== chat.userId} /> ); } diff --git a/app/(chat)/page.tsx b/app/(chat)/page.tsx index c837e58..787fefc 100644 --- a/app/(chat)/page.tsx +++ b/app/(chat)/page.tsx @@ -20,6 +20,8 @@ export default async function Page() { id={id} initialMessages={[]} selectedModelId={selectedModelId} + selectedVisibilityType="private" + isReadonly={false} /> ); } diff --git a/components/block-messages.tsx b/components/block-messages.tsx index 2d83068..96f0a19 100644 --- a/components/block-messages.tsx +++ b/components/block-messages.tsx @@ -18,6 +18,7 @@ interface BlockMessagesProps { reload: ( chatRequestOptions?: ChatRequestOptions, ) => Promise; + isReadonly: boolean; } function PureBlockMessages({ @@ -29,6 +30,7 @@ function PureBlockMessages({ messages, setMessages, reload, + isReadonly, }: BlockMessagesProps) { const [messagesContainerRef, messagesEndRef] = useScrollToBottom(); @@ -53,6 +55,7 @@ function PureBlockMessages({ } setMessages={setMessages} reload={reload} + isReadonly={isReadonly} /> ))} diff --git a/components/block.tsx b/components/block.tsx index d426cf9..a0cdaa5 100644 --- a/components/block.tsx +++ b/components/block.tsx @@ -60,6 +60,7 @@ function PureBlock({ setMessages, reload, votes, + isReadonly, }: { chatId: string; input: string; @@ -86,6 +87,7 @@ function PureBlock({ reload: ( chatRequestOptions?: ChatRequestOptions, ) => Promise; + isReadonly: boolean; }) { const { data: documents, @@ -292,6 +294,7 @@ function PureBlock({ messages={messages} setMessages={setMessages} reload={reload} + isReadonly={isReadonly} />
diff --git a/components/chat-header.tsx b/components/chat-header.tsx index 76ae5ac..8a4d69e 100644 --- a/components/chat-header.tsx +++ b/components/chat-header.tsx @@ -11,8 +11,19 @@ import { PlusIcon, VercelIcon } from './icons'; import { useSidebar } from './ui/sidebar'; import { memo } from 'react'; import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip'; +import { VisibilityType, VisibilitySelector } from './visibility-selector'; -function PureChatHeader({ selectedModelId }: { selectedModelId: string }) { +function PureChatHeader({ + chatId, + selectedModelId, + selectedVisibilityType, + isReadonly, +}: { + chatId: string; + selectedModelId: string; + selectedVisibilityType: VisibilityType; + isReadonly: boolean; +}) { const router = useRouter(); const { open } = useSidebar(); @@ -21,6 +32,7 @@ function PureChatHeader({ selectedModelId }: { selectedModelId: string }) { return (
+ {(!open || windowWidth < 768) && ( @@ -39,10 +51,22 @@ function PureChatHeader({ selectedModelId }: { selectedModelId: string }) { New Chat )} - + + {!isReadonly && ( + + )} + + {!isReadonly && ( + + )} + diff --git a/components/sidebar-history.tsx b/components/sidebar-history.tsx index 7b5d5b9..a18328d 100644 --- a/components/sidebar-history.tsx +++ b/components/sidebar-history.tsx @@ -8,7 +8,15 @@ import { memo, useEffect, useState } from 'react'; import { toast } from 'sonner'; import useSWR from 'swr'; -import { MoreHorizontalIcon, TrashIcon } from '@/components/icons'; +import { + CheckCirclFillIcon, + EyeIcon, + GlobeIcon, + LockIcon, + MoreHorizontalIcon, + ShareIcon, + TrashIcon, +} from '@/components/icons'; import { AlertDialog, AlertDialogAction, @@ -23,6 +31,11 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuPortal, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { @@ -36,7 +49,7 @@ import { } from '@/components/ui/sidebar'; import type { Chat } from '@/lib/db/schema'; import { fetcher } from '@/lib/utils'; -import equal from 'fast-deep-equal'; +import { useChatVisibility } from '@/hooks/use-chat-visibility'; type GroupedChats = { today: Chat[]; @@ -56,36 +69,79 @@ const PureChatItem = ({ isActive: boolean; onDelete: (chatId: string) => void; setOpenMobile: (open: boolean) => void; -}) => ( - - - setOpenMobile(false)}> - {chat.title} - - +}) => { + const { visibilityType, setVisibilityType } = useChatVisibility({ + chatId: chat.id, + initialVisibility: chat.visibility, + }); - - - - - More - - - - onDelete(chat.id)} - > - - Delete - - - - -); + return ( + + + setOpenMobile(false)}> + {chat.title} + + + + + + + + More + + + + + + + + Share + + + + { + setVisibilityType('private'); + }} + > +
+ + Private +
+ {visibilityType === 'private' ? : null} +
+ { + setVisibilityType('public'); + }} + > +
+ + Public +
+ {visibilityType === 'public' ? : null} +
+
+
+
+ + onDelete(chat.id)} + > + + Delete + +
+
+
+ ); +}; export const ChatItem = memo(PureChatItem, (prevProps, nextProps) => { if (prevProps.isActive !== nextProps.isActive) return false; diff --git a/components/visibility-selector.tsx b/components/visibility-selector.tsx new file mode 100644 index 0000000..d357752 --- /dev/null +++ b/components/visibility-selector.tsx @@ -0,0 +1,109 @@ +'use client'; + +import { ReactNode, useMemo, useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { cn } from '@/lib/utils'; + +import { + CheckCirclFillIcon, + ChevronDownIcon, + GlobeIcon, + LockIcon, +} from './icons'; +import { useChatVisibility } from '@/hooks/use-chat-visibility'; + +export type VisibilityType = 'private' | 'public'; + +const visibilities: Array<{ + id: VisibilityType; + label: string; + description: string; + icon: ReactNode; +}> = [ + { + id: 'private', + label: 'Private', + description: 'Only you can access this chat', + icon: , + }, + { + id: 'public', + label: 'Public', + description: 'Anyone with the link can access this chat', + icon: , + }, +]; + +export function VisibilitySelector({ + chatId, + className, + selectedVisibilityType, +}: { + chatId: string; + selectedVisibilityType: VisibilityType; +} & React.ComponentProps) { + const [open, setOpen] = useState(false); + + const { visibilityType, setVisibilityType } = useChatVisibility({ + chatId, + initialVisibility: selectedVisibilityType, + }); + + const selectedVisibility = useMemo( + () => visibilities.find((visibility) => visibility.id === visibilityType), + [visibilityType], + ); + + return ( + + + + + + + {visibilities.map((visibility) => ( + { + setVisibilityType(visibility.id); + setOpen(false); + }} + className="gap-4 group/item flex flex-row justify-between items-center" + data-active={visibility.id === visibilityType} + > +
+ {visibility.label} + {visibility.description && ( +
+ {visibility.description} +
+ )} +
+
+ +
+
+ ))} +
+
+ ); +} diff --git a/hooks/use-chat-visibility.ts b/hooks/use-chat-visibility.ts new file mode 100644 index 0000000..d920785 --- /dev/null +++ b/hooks/use-chat-visibility.ts @@ -0,0 +1,62 @@ +'use client'; + +import { updateChatVisibility } from '@/app/(chat)/actions'; +import { VisibilityType } from '@/components/visibility-selector'; +import { Chat } from '@/lib/db/schema'; +import { useMemo } from 'react'; +import useSWR, { useSWRConfig } from 'swr'; + +export function useChatVisibility({ + chatId, + initialVisibility, +}: { + chatId: string; + initialVisibility: VisibilityType; +}) { + const { mutate, cache } = useSWRConfig(); + const history: Array = cache.get('/api/history')?.data; + + const { data: localVisibility, mutate: setLocalVisibility } = useSWR( + `${chatId}-visibility`, + null, + { + fallbackData: initialVisibility, + }, + ); + + const visibilityType = useMemo(() => { + if (!history) return localVisibility; + const chat = history.find((chat) => chat.id === chatId); + if (!chat) return 'private'; + return chat.visibility; + }, [history, chatId, localVisibility]); + + const setVisibilityType = (updatedVisibilityType: VisibilityType) => { + setLocalVisibility(updatedVisibilityType); + + mutate>( + '/api/history', + (history) => { + return history + ? history.map((chat) => { + if (chat.id === chatId) { + return { + ...chat, + visibility: updatedVisibilityType, + }; + } + return chat; + }) + : []; + }, + { revalidate: false }, + ); + + updateChatVisibility({ + chatId: chatId, + visibility: updatedVisibilityType, + }); + }; + + return { visibilityType, setVisibilityType }; +} diff --git a/lib/db/migrations/0003_cloudy_glorian.sql b/lib/db/migrations/0003_cloudy_glorian.sql new file mode 100644 index 0000000..655c764 --- /dev/null +++ b/lib/db/migrations/0003_cloudy_glorian.sql @@ -0,0 +1 @@ +ALTER TABLE "Chat" ADD COLUMN "visibility" varchar DEFAULT 'private' NOT NULL; \ No newline at end of file diff --git a/lib/db/migrations/meta/0003_snapshot.json b/lib/db/migrations/meta/0003_snapshot.json new file mode 100644 index 0000000..fb8e35c --- /dev/null +++ b/lib/db/migrations/meta/0003_snapshot.json @@ -0,0 +1,384 @@ +{ + "id": "011efa9e-42c7-4ff6-830a-02106f6638c9", + "prevId": "b5d8e862-936f-4419-a50f-97be3e7fe665", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.Chat": { + "name": "Chat", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'private'" + } + }, + "indexes": {}, + "foreignKeys": { + "Chat_userId_User_id_fk": { + "name": "Chat_userId_User_id_fk", + "tableFrom": "Chat", + "tableTo": "User", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.Document": { + "name": "Document", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "userId": { + "name": "userId", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "Document_userId_User_id_fk": { + "name": "Document_userId_User_id_fk", + "tableFrom": "Document", + "tableTo": "User", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "Document_id_createdAt_pk": { + "name": "Document_id_createdAt_pk", + "columns": [ + "id", + "createdAt" + ] + } + }, + "uniqueConstraints": {} + }, + "public.Message": { + "name": "Message", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chatId": { + "name": "chatId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "Message_chatId_Chat_id_fk": { + "name": "Message_chatId_Chat_id_fk", + "tableFrom": "Message", + "tableTo": "Chat", + "columnsFrom": [ + "chatId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.Suggestion": { + "name": "Suggestion", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "documentId": { + "name": "documentId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "documentCreatedAt": { + "name": "documentCreatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "originalText": { + "name": "originalText", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "suggestedText": { + "name": "suggestedText", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "isResolved": { + "name": "isResolved", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "userId": { + "name": "userId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "Suggestion_userId_User_id_fk": { + "name": "Suggestion_userId_User_id_fk", + "tableFrom": "Suggestion", + "tableTo": "User", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "Suggestion_documentId_documentCreatedAt_Document_id_createdAt_fk": { + "name": "Suggestion_documentId_documentCreatedAt_Document_id_createdAt_fk", + "tableFrom": "Suggestion", + "tableTo": "Document", + "columnsFrom": [ + "documentId", + "documentCreatedAt" + ], + "columnsTo": [ + "id", + "createdAt" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "Suggestion_id_pk": { + "name": "Suggestion_id_pk", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {} + }, + "public.User": { + "name": "User", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "password": { + "name": "password", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.Vote": { + "name": "Vote", + "schema": "", + "columns": { + "chatId": { + "name": "chatId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "messageId": { + "name": "messageId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "isUpvoted": { + "name": "isUpvoted", + "type": "boolean", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "Vote_chatId_Chat_id_fk": { + "name": "Vote_chatId_Chat_id_fk", + "tableFrom": "Vote", + "tableTo": "Chat", + "columnsFrom": [ + "chatId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "Vote_messageId_Message_id_fk": { + "name": "Vote_messageId_Message_id_fk", + "tableFrom": "Vote", + "tableTo": "Message", + "columnsFrom": [ + "messageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "Vote_chatId_messageId_pk": { + "name": "Vote_chatId_messageId_pk", + "columns": [ + "chatId", + "messageId" + ] + } + }, + "uniqueConstraints": {} + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/lib/db/migrations/meta/_journal.json b/lib/db/migrations/meta/_journal.json index 686d54e..84dfebe 100644 --- a/lib/db/migrations/meta/_journal.json +++ b/lib/db/migrations/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1730725226313, "tag": "0002_wandering_riptide", "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1733403031014, + "tag": "0003_cloudy_glorian", + "breakpoints": true } ] } \ No newline at end of file diff --git a/lib/db/queries.ts b/lib/db/queries.ts index 2bd400d..79b7037 100644 --- a/lib/db/queries.ts +++ b/lib/db/queries.ts @@ -309,3 +309,18 @@ export async function deleteMessagesByChatIdAfterTimestamp({ throw error; } } + +export async function updateChatVisiblityById({ + chatId, + visibility, +}: { + chatId: string; + visibility: 'private' | 'public'; +}) { + try { + return await db.update(chat).set({ visibility }).where(eq(chat.id, chatId)); + } catch (error) { + console.error('Failed to update chat visibility in database'); + throw error; + } +} diff --git a/lib/db/schema.ts b/lib/db/schema.ts index f1b447a..23d9e82 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -26,6 +26,9 @@ export const chat = pgTable('Chat', { userId: uuid('userId') .notNull() .references(() => user.id), + visibility: varchar('visibility', { enum: ['public', 'private'] }) + .notNull() + .default('private'), }); export type Chat = InferSelectModel;