feat: add ability to share chats (#594)

This commit is contained in:
Jeremy 2024-12-06 13:36:56 +03:00 committed by GitHub
parent 64d1aad2bb
commit 4d901ba066
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 877 additions and 66 deletions

View file

@ -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 });
}

View file

@ -18,6 +18,7 @@ export default async function Page(props: { params: Promise<{ id: string }> }) {
const session = await auth();
if (chat.visibility === 'private') {
if (!session || !session.user) {
return notFound();
}
@ -25,6 +26,7 @@ export default async function Page(props: { params: Promise<{ id: string }> }) {
if (session.user.id !== chat.userId) {
return notFound();
}
}
const messagesFromDb = await getMessagesByChatId({
id,
@ -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}
/>
);
}

View file

@ -20,6 +20,8 @@ export default async function Page() {
id={id}
initialMessages={[]}
selectedModelId={selectedModelId}
selectedVisibilityType="private"
isReadonly={false}
/>
);
}

View file

@ -18,6 +18,7 @@ interface BlockMessagesProps {
reload: (
chatRequestOptions?: ChatRequestOptions,
) => Promise<string | null | undefined>;
isReadonly: boolean;
}
function PureBlockMessages({
@ -29,6 +30,7 @@ function PureBlockMessages({
messages,
setMessages,
reload,
isReadonly,
}: BlockMessagesProps) {
const [messagesContainerRef, messagesEndRef] =
useScrollToBottom<HTMLDivElement>();
@ -53,6 +55,7 @@ function PureBlockMessages({
}
setMessages={setMessages}
reload={reload}
isReadonly={isReadonly}
/>
))}

View file

@ -60,6 +60,7 @@ function PureBlock({
setMessages,
reload,
votes,
isReadonly,
}: {
chatId: string;
input: string;
@ -86,6 +87,7 @@ function PureBlock({
reload: (
chatRequestOptions?: ChatRequestOptions,
) => Promise<string | null | undefined>;
isReadonly: boolean;
}) {
const {
data: documents,
@ -292,6 +294,7 @@ function PureBlock({
messages={messages}
setMessages={setMessages}
reload={reload}
isReadonly={isReadonly}
/>
<form className="flex flex-row gap-2 relative items-end w-full px-4 pb-4">

View file

@ -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 (
<header className="flex sticky top-0 bg-background py-1.5 items-center px-2 md:px-2 gap-2">
<SidebarToggle />
{(!open || windowWidth < 768) && (
<Tooltip>
<TooltipTrigger asChild>
@ -39,10 +51,22 @@ function PureChatHeader({ selectedModelId }: { selectedModelId: string }) {
<TooltipContent>New Chat</TooltipContent>
</Tooltip>
)}
{!isReadonly && (
<ModelSelector
selectedModelId={selectedModelId}
className="order-1 md:order-2"
/>
)}
{!isReadonly && (
<VisibilitySelector
chatId={chatId}
selectedVisibilityType={selectedVisibilityType}
className="order-1 md:order-3"
/>
)}
<Button
className="bg-zinc-900 dark:bg-zinc-100 hover:bg-zinc-800 dark:hover:bg-zinc-200 text-zinc-50 dark:text-zinc-900 hidden md:flex py-1.5 px-2 h-fit md:h-[34px] order-4 md:ml-auto"
asChild

View file

@ -15,15 +15,20 @@ import { Block, type UIBlock } from './block';
import { BlockStreamHandler } from './block-stream-handler';
import { MultimodalInput } from './multimodal-input';
import { Messages } from './messages';
import { VisibilityType } from './visibility-selector';
export function Chat({
id,
initialMessages,
selectedModelId,
selectedVisibilityType,
isReadonly,
}: {
id: string;
initialMessages: Array<Message>;
selectedModelId: string;
selectedVisibilityType: VisibilityType;
isReadonly: boolean;
}) {
const { mutate } = useSWRConfig();
@ -74,7 +79,12 @@ export function Chat({
return (
<>
<div className="flex flex-col min-w-0 h-dvh bg-background">
<ChatHeader selectedModelId={selectedModelId} />
<ChatHeader
chatId={id}
selectedModelId={selectedModelId}
selectedVisibilityType={selectedVisibilityType}
isReadonly={isReadonly}
/>
<Messages
chatId={id}
@ -85,9 +95,11 @@ export function Chat({
messages={messages}
setMessages={setMessages}
reload={reload}
isReadonly={isReadonly}
/>
<form className="flex mx-auto px-4 bg-background pb-4 md:pb-6 gap-2 w-full md:max-w-3xl">
{!isReadonly && (
<MultimodalInput
chatId={id}
input={input}
@ -101,6 +113,7 @@ export function Chat({
setMessages={setMessages}
append={append}
/>
)}
</form>
</div>
@ -122,6 +135,7 @@ export function Chat({
setMessages={setMessages}
reload={reload}
votes={votes}
isReadonly={isReadonly}
/>
)}
</AnimatePresence>

View file

@ -2,6 +2,7 @@ import { memo, type SetStateAction } from 'react';
import type { UIBlock } from './block';
import { FileIcon, LoaderIcon, MessageIcon, PencilEditIcon } from './icons';
import { toast } from 'sonner';
const getActionText = (
type: 'create' | 'update' | 'request-suggestions',
@ -26,18 +27,27 @@ interface DocumentToolResultProps {
result: { id: string; title: string };
block: UIBlock;
setBlock: (value: SetStateAction<UIBlock>) => void;
isReadonly: boolean;
}
function PureDocumentToolResult({
type,
result,
setBlock,
isReadonly,
}: DocumentToolResultProps) {
return (
<button
type="button"
className="bg-background cursor-pointer border py-2 px-3 rounded-xl w-fit flex flex-row gap-3 items-start"
onClick={(event) => {
if (isReadonly) {
toast.error(
'Viewing files in shared chats is currently not supported.',
);
return;
}
const rect = event.currentTarget.getBoundingClientRect();
const boundingBox = {
@ -79,14 +89,27 @@ interface DocumentToolCallProps {
type: 'create' | 'update' | 'request-suggestions';
args: { title: string };
setBlock: (value: SetStateAction<UIBlock>) => void;
isReadonly: boolean;
}
function PureDocumentToolCall({ type, args, setBlock }: DocumentToolCallProps) {
function PureDocumentToolCall({
type,
args,
setBlock,
isReadonly,
}: DocumentToolCallProps) {
return (
<button
type="button"
className="cursor pointer w-fit border py-2 px-3 rounded-xl flex flex-row items-start justify-between gap-3"
onClick={(event) => {
if (isReadonly) {
toast.error(
'Viewing files in shared chats is currently not supported.',
);
return;
}
const rect = event.currentTarget.getBoundingClientRect();
const boundingBox = {

View file

@ -855,3 +855,79 @@ export const CheckCirclFillIcon = ({ size = 16 }: { size?: number }) => {
</svg>
);
};
export const GlobeIcon = ({ size = 16 }: { size?: number }) => {
return (
<svg
height={size}
strokeLinejoin="round"
viewBox="0 0 16 16"
width={size}
style={{ color: 'currentcolor' }}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M10.268 14.0934C11.9051 13.4838 13.2303 12.2333 13.9384 10.6469C13.1192 10.7941 12.2138 10.9111 11.2469 10.9925C11.0336 12.2005 10.695 13.2621 10.268 14.0934ZM8 16C12.4183 16 16 12.4183 16 8C16 3.58172 12.4183 0 8 0C3.58172 0 0 3.58172 0 8C0 12.4183 3.58172 16 8 16ZM8.48347 14.4823C8.32384 14.494 8.16262 14.5 8 14.5C7.83738 14.5 7.67616 14.494 7.51654 14.4823C7.5132 14.4791 7.50984 14.4759 7.50647 14.4726C7.2415 14.2165 6.94578 13.7854 6.67032 13.1558C6.41594 12.5744 6.19979 11.8714 6.04101 11.0778C6.67605 11.1088 7.33104 11.125 8 11.125C8.66896 11.125 9.32395 11.1088 9.95899 11.0778C9.80021 11.8714 9.58406 12.5744 9.32968 13.1558C9.05422 13.7854 8.7585 14.2165 8.49353 14.4726C8.49016 14.4759 8.4868 14.4791 8.48347 14.4823ZM11.4187 9.72246C12.5137 9.62096 13.5116 9.47245 14.3724 9.28806C14.4561 8.87172 14.5 8.44099 14.5 8C14.5 7.55901 14.4561 7.12828 14.3724 6.71194C13.5116 6.52755 12.5137 6.37904 11.4187 6.27753C11.4719 6.83232 11.5 7.40867 11.5 8C11.5 8.59133 11.4719 9.16768 11.4187 9.72246ZM10.1525 6.18401C10.2157 6.75982 10.25 7.36805 10.25 8C10.25 8.63195 10.2157 9.24018 10.1525 9.81598C9.46123 9.85455 8.7409 9.875 8 9.875C7.25909 9.875 6.53877 9.85455 5.84749 9.81598C5.7843 9.24018 5.75 8.63195 5.75 8C5.75 7.36805 5.7843 6.75982 5.84749 6.18401C6.53877 6.14545 7.25909 6.125 8 6.125C8.74091 6.125 9.46123 6.14545 10.1525 6.18401ZM11.2469 5.00748C12.2138 5.08891 13.1191 5.20593 13.9384 5.35306C13.2303 3.7667 11.9051 2.51622 10.268 1.90662C10.695 2.73788 11.0336 3.79953 11.2469 5.00748ZM8.48347 1.51771C8.4868 1.52089 8.49016 1.52411 8.49353 1.52737C8.7585 1.78353 9.05422 2.21456 9.32968 2.84417C9.58406 3.42562 9.80021 4.12856 9.95899 4.92219C9.32395 4.89118 8.66896 4.875 8 4.875C7.33104 4.875 6.67605 4.89118 6.04101 4.92219C6.19978 4.12856 6.41594 3.42562 6.67032 2.84417C6.94578 2.21456 7.2415 1.78353 7.50647 1.52737C7.50984 1.52411 7.51319 1.52089 7.51653 1.51771C7.67615 1.50597 7.83738 1.5 8 1.5C8.16262 1.5 8.32384 1.50597 8.48347 1.51771ZM5.73202 1.90663C4.0949 2.51622 2.76975 3.7667 2.06159 5.35306C2.88085 5.20593 3.78617 5.08891 4.75309 5.00748C4.96639 3.79953 5.30497 2.73788 5.73202 1.90663ZM4.58133 6.27753C3.48633 6.37904 2.48837 6.52755 1.62761 6.71194C1.54392 7.12828 1.5 7.55901 1.5 8C1.5 8.44099 1.54392 8.87172 1.62761 9.28806C2.48837 9.47245 3.48633 9.62096 4.58133 9.72246C4.52807 9.16768 4.5 8.59133 4.5 8C4.5 7.40867 4.52807 6.83232 4.58133 6.27753ZM4.75309 10.9925C3.78617 10.9111 2.88085 10.7941 2.06159 10.6469C2.76975 12.2333 4.0949 13.4838 5.73202 14.0934C5.30497 13.2621 4.96639 12.2005 4.75309 10.9925Z"
fill="currentColor"
></path>
</svg>
);
};
export const LockIcon = ({ size = 16 }: { size?: number }) => {
return (
<svg
height={size}
strokeLinejoin="round"
viewBox="0 0 16 16"
width={size}
style={{ color: 'currentcolor' }}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M10 4.5V6H6V4.5C6 3.39543 6.89543 2.5 8 2.5C9.10457 2.5 10 3.39543 10 4.5ZM4.5 6V4.5C4.5 2.567 6.067 1 8 1C9.933 1 11.5 2.567 11.5 4.5V6H12.5H14V7.5V12.5C14 13.8807 12.8807 15 11.5 15H4.5C3.11929 15 2 13.8807 2 12.5V7.5V6H3.5H4.5ZM11.5 7.5H10H6H4.5H3.5V12.5C3.5 13.0523 3.94772 13.5 4.5 13.5H11.5C12.0523 13.5 12.5 13.0523 12.5 12.5V7.5H11.5Z"
fill="currentColor"
></path>
</svg>
);
};
export const EyeIcon = ({ size = 16 }: { size?: number }) => {
return (
<svg
height={size}
strokeLinejoin="round"
viewBox="0 0 16 16"
width={size}
style={{ color: 'currentcolor' }}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M4.02168 4.76932C6.11619 2.33698 9.88374 2.33698 11.9783 4.76932L14.7602 7.99999L11.9783 11.2307C9.88374 13.663 6.1162 13.663 4.02168 11.2307L1.23971 7.99999L4.02168 4.76932ZM13.1149 3.79054C10.422 0.663244 5.57797 0.663247 2.88503 3.79054L-0.318359 7.5106V8.48938L2.88503 12.2094C5.57797 15.3367 10.422 15.3367 13.1149 12.2094L16.3183 8.48938V7.5106L13.1149 3.79054ZM6.49997 7.99999C6.49997 7.17157 7.17154 6.49999 7.99997 6.49999C8.82839 6.49999 9.49997 7.17157 9.49997 7.99999C9.49997 8.82842 8.82839 9.49999 7.99997 9.49999C7.17154 9.49999 6.49997 8.82842 6.49997 7.99999ZM7.99997 4.99999C6.34311 4.99999 4.99997 6.34314 4.99997 7.99999C4.99997 9.65685 6.34311 11 7.99997 11C9.65682 11 11 9.65685 11 7.99999C11 6.34314 9.65682 4.99999 7.99997 4.99999Z"
fill="currentColor"
></path>
</svg>
);
};
export const ShareIcon = ({ size = 16 }: { size?: number }) => {
return (
<svg
height={size}
strokeLinejoin="round"
viewBox="0 0 16 16"
width={size}
style={{ color: 'currentcolor' }}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M15 11.25V10.5H13.5V11.25V12.75C13.5 13.1642 13.1642 13.5 12.75 13.5H3.25C2.83579 13.5 2.5 13.1642 2.5 12.75L2.5 3.25C2.5 2.83579 2.83579 2.5 3.25 2.5H5.75H6.5V1H5.75H3.25C2.00736 1 1 2.00736 1 3.25V12.75C1 13.9926 2.00736 15 3.25 15H12.75C13.9926 15 15 13.9926 15 12.75V11.25ZM15 5.5L10.5 1V4C7.46243 4 5 6.46243 5 9.5V10L5.05855 9.91218C6.27146 8.09281 8.31339 7 10.5 7V10L15 5.5Z"
fill="currentColor"
></path>
</svg>
);
};

View file

@ -29,6 +29,7 @@ const PurePreviewMessage = ({
isLoading,
setMessages,
reload,
isReadonly,
}: {
chatId: string;
message: Message;
@ -42,6 +43,7 @@ const PurePreviewMessage = ({
reload: (
chatRequestOptions?: ChatRequestOptions,
) => Promise<string | null | undefined>;
isReadonly: boolean;
}) => {
const [mode, setMode] = useState<'view' | 'edit'>('view');
@ -81,7 +83,7 @@ const PurePreviewMessage = ({
{message.content && mode === 'view' && (
<div className="flex flex-row gap-2 items-start">
{message.role === 'user' && (
{message.role === 'user' && !isReadonly && (
<Tooltip>
<TooltipTrigger asChild>
<Button
@ -141,6 +143,7 @@ const PurePreviewMessage = ({
result={result}
block={block}
setBlock={setBlock}
isReadonly={isReadonly}
/>
) : toolName === 'updateDocument' ? (
<DocumentToolResult
@ -148,6 +151,7 @@ const PurePreviewMessage = ({
result={result}
block={block}
setBlock={setBlock}
isReadonly={isReadonly}
/>
) : toolName === 'requestSuggestions' ? (
<DocumentToolResult
@ -155,6 +159,7 @@ const PurePreviewMessage = ({
result={result}
block={block}
setBlock={setBlock}
isReadonly={isReadonly}
/>
) : (
<pre>{JSON.stringify(result, null, 2)}</pre>
@ -176,18 +181,21 @@ const PurePreviewMessage = ({
type="create"
args={args}
setBlock={setBlock}
isReadonly={isReadonly}
/>
) : toolName === 'updateDocument' ? (
<DocumentToolCall
type="update"
args={args}
setBlock={setBlock}
isReadonly={isReadonly}
/>
) : toolName === 'requestSuggestions' ? (
<DocumentToolCall
type="request-suggestions"
args={args}
setBlock={setBlock}
isReadonly={isReadonly}
/>
) : null}
</div>
@ -196,6 +204,7 @@ const PurePreviewMessage = ({
</div>
)}
{!isReadonly && (
<MessageActions
key={`action-${message.id}`}
chatId={chatId}
@ -203,6 +212,7 @@ const PurePreviewMessage = ({
vote={vote}
isLoading={isLoading}
/>
)}
</div>
</div>
</motion.div>

View file

@ -19,6 +19,7 @@ interface MessagesProps {
reload: (
chatRequestOptions?: ChatRequestOptions,
) => Promise<string | null | undefined>;
isReadonly: boolean;
}
function PureMessages({
@ -30,6 +31,7 @@ function PureMessages({
messages,
setMessages,
reload,
isReadonly,
}: MessagesProps) {
const [messagesContainerRef, messagesEndRef] =
useScrollToBottom<HTMLDivElement>();
@ -56,6 +58,7 @@ function PureMessages({
}
setMessages={setMessages}
reload={reload}
isReadonly={isReadonly}
/>
))}

View file

@ -25,7 +25,7 @@ export function ModelSelector({
const [optimisticModelId, setOptimisticModelId] =
useOptimistic(selectedModelId);
const selectModel = useMemo(
const selectedModel = useMemo(
() => models.find((model) => model.id === optimisticModelId),
[optimisticModelId],
);
@ -40,7 +40,7 @@ export function ModelSelector({
)}
>
<Button variant="outline" className="md:px-2 md:h-[34px]">
{selectModel?.label}
{selectedModel?.label}
<ChevronDownIcon />
</Button>
</DropdownMenuTrigger>

View file

@ -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,7 +69,13 @@ const PureChatItem = ({
isActive: boolean;
onDelete: (chatId: string) => void;
setOpenMobile: (open: boolean) => void;
}) => (
}) => {
const { visibilityType, setVisibilityType } = useChatVisibility({
chatId: chat.id,
initialVisibility: chat.visibility,
});
return (
<SidebarMenuItem>
<SidebarMenuButton asChild isActive={isActive}>
<Link href={`/chat/${chat.id}`} onClick={() => setOpenMobile(false)}>
@ -74,7 +93,43 @@ const PureChatItem = ({
<span className="sr-only">More</span>
</SidebarMenuAction>
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end">
<DropdownMenuSub>
<DropdownMenuSubTrigger className="cursor-pointer">
<ShareIcon />
<span>Share</span>
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
<DropdownMenuItem
className="cursor-pointer flex-row justify-between"
onClick={() => {
setVisibilityType('private');
}}
>
<div className="flex flex-row gap-2 items-center">
<LockIcon size={12} />
<span>Private</span>
</div>
{visibilityType === 'private' ? <CheckCirclFillIcon /> : null}
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer flex-row justify-between"
onClick={() => {
setVisibilityType('public');
}}
>
<div className="flex flex-row gap-2 items-center">
<GlobeIcon />
<span>Public</span>
</div>
{visibilityType === 'public' ? <CheckCirclFillIcon /> : null}
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
<DropdownMenuItem
className="cursor-pointer text-destructive focus:bg-destructive/15 focus:text-destructive dark:text-red-500"
onSelect={() => onDelete(chat.id)}
@ -85,7 +140,8 @@ const PureChatItem = ({
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
);
);
};
export const ChatItem = memo(PureChatItem, (prevProps, nextProps) => {
if (prevProps.isActive !== nextProps.isActive) return false;

View file

@ -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: <LockIcon />,
},
{
id: 'public',
label: 'Public',
description: 'Anyone with the link can access this chat',
icon: <GlobeIcon />,
},
];
export function VisibilitySelector({
chatId,
className,
selectedVisibilityType,
}: {
chatId: string;
selectedVisibilityType: VisibilityType;
} & React.ComponentProps<typeof Button>) {
const [open, setOpen] = useState(false);
const { visibilityType, setVisibilityType } = useChatVisibility({
chatId,
initialVisibility: selectedVisibilityType,
});
const selectedVisibility = useMemo(
() => visibilities.find((visibility) => visibility.id === visibilityType),
[visibilityType],
);
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger
asChild
className={cn(
'w-fit data-[state=open]:bg-accent data-[state=open]:text-accent-foreground',
className,
)}
>
<Button
variant="outline"
className="hidden md:flex md:px-2 md:h-[34px]"
>
{selectedVisibility?.icon}
{selectedVisibility?.label}
<ChevronDownIcon />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-[300px]">
{visibilities.map((visibility) => (
<DropdownMenuItem
key={visibility.id}
onSelect={() => {
setVisibilityType(visibility.id);
setOpen(false);
}}
className="gap-4 group/item flex flex-row justify-between items-center"
data-active={visibility.id === visibilityType}
>
<div className="flex flex-col gap-1 items-start">
{visibility.label}
{visibility.description && (
<div className="text-xs text-muted-foreground">
{visibility.description}
</div>
)}
</div>
<div className="text-primary dark:text-primary-foreground opacity-0 group-data-[active=true]/item:opacity-100">
<CheckCirclFillIcon />
</div>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}

View file

@ -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<Chat> = 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<Array<Chat>>(
'/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 };
}

View file

@ -0,0 +1 @@
ALTER TABLE "Chat" ADD COLUMN "visibility" varchar DEFAULT 'private' NOT NULL;

View file

@ -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": {}
}
}

View file

@ -22,6 +22,13 @@
"when": 1730725226313,
"tag": "0002_wandering_riptide",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1733403031014,
"tag": "0003_cloudy_glorian",
"breakpoints": true
}
]
}

View file

@ -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;
}
}

View file

@ -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<typeof chat>;