diff --git a/app/(chat)/api/history/route.ts b/app/(chat)/api/history/route.ts index 5b796ac..acfd538 100644 --- a/app/(chat)/api/history/route.ts +++ b/app/(chat)/api/history/route.ts @@ -1,14 +1,37 @@ import { auth } from '@/app/(auth)/auth'; +import { NextRequest } from 'next/server'; import { getChatsByUserId } from '@/lib/db/queries'; -export async function GET() { +export async function GET(request: NextRequest) { + const { searchParams } = request.nextUrl; + + const limit = parseInt(searchParams.get('limit') || '10'); + const startingAfter = searchParams.get('starting_after'); + const endingBefore = searchParams.get('ending_before'); + + if (startingAfter && endingBefore) { + return Response.json( + 'Only one of starting_after or ending_before can be provided!', + { status: 400 }, + ); + } + const session = await auth(); - if (!session || !session.user) { + if (!session?.user?.id) { return Response.json('Unauthorized!', { status: 401 }); } - // biome-ignore lint: Forbidden non-null assertion. - const chats = await getChatsByUserId({ id: session.user.id! }); - return Response.json(chats); + try { + const chats = await getChatsByUserId({ + id: session.user.id, + limit, + startingAfter, + endingBefore, + }); + + return Response.json(chats); + } catch (_) { + return Response.json('Failed to fetch chats!', { status: 500 }); + } } diff --git a/components/chat.tsx b/components/chat.tsx index 4926119..f0fb979 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -10,9 +10,11 @@ import { fetcher, generateUUID } from '@/lib/utils'; import { Artifact } from './artifact'; import { MultimodalInput } from './multimodal-input'; import { Messages } from './messages'; -import { VisibilityType } from './visibility-selector'; +import type { VisibilityType } from './visibility-selector'; import { useArtifactSelector } from '@/hooks/use-artifact'; import { toast } from 'sonner'; +import { unstable_serialize } from 'swr/infinite'; +import { getChatHistoryPaginationKey } from './sidebar-history'; export function Chat({ id, @@ -47,7 +49,7 @@ export function Chat({ sendExtraMessageFields: true, generateId: generateUUID, onFinish: () => { - mutate('/api/history'); + mutate(unstable_serialize(getChatHistoryPaginationKey)); }, onError: () => { toast.error('An error occured, please try again!'); diff --git a/components/sidebar-history-item.tsx b/components/sidebar-history-item.tsx new file mode 100644 index 0000000..333156f --- /dev/null +++ b/components/sidebar-history-item.tsx @@ -0,0 +1,118 @@ +import { Chat } from '@/lib/db/schema'; +import { + SidebarMenuAction, + SidebarMenuButton, + SidebarMenuItem, +} from './ui/sidebar'; +import Link from 'next/link'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from './ui/dropdown-menu'; +import { + CheckCircleFillIcon, + GlobeIcon, + LockIcon, + MoreHorizontalIcon, + ShareIcon, + TrashIcon, +} from './icons'; +import { memo } from 'react'; +import { useChatVisibility } from '@/hooks/use-chat-visibility'; + +const PureChatItem = ({ + chat, + isActive, + onDelete, + setOpenMobile, +}: { + chat: Chat; + isActive: boolean; + onDelete: (chatId: string) => void; + setOpenMobile: (open: boolean) => void; +}) => { + const { visibilityType, setVisibilityType } = useChatVisibility({ + chatId: chat.id, + initialVisibility: chat.visibility, + }); + + 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; + return true; +}); diff --git a/components/sidebar-history.tsx b/components/sidebar-history.tsx index 6aa18c0..04b441f 100644 --- a/components/sidebar-history.tsx +++ b/components/sidebar-history.tsx @@ -1,21 +1,11 @@ 'use client'; import { isToday, isYesterday, subMonths, subWeeks } from 'date-fns'; -import Link from 'next/link'; -import { useParams, usePathname, useRouter } from 'next/navigation'; +import { useParams, useRouter } from 'next/navigation'; import type { User } from 'next-auth'; -import { memo, useEffect, useState } from 'react'; +import { useState } from 'react'; import { toast } from 'sonner'; -import useSWR from 'swr'; - -import { - CheckCircleFillIcon, - GlobeIcon, - LockIcon, - MoreHorizontalIcon, - ShareIcon, - TrashIcon, -} from '@/components/icons'; +import { motion } from 'framer-motion'; import { AlertDialog, AlertDialogAction, @@ -26,29 +16,17 @@ import { AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuPortal, - DropdownMenuSeparator, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu'; import { SidebarGroup, SidebarGroupContent, SidebarMenu, - SidebarMenuAction, - SidebarMenuButton, - SidebarMenuItem, useSidebar, } from '@/components/ui/sidebar'; import type { Chat } from '@/lib/db/schema'; import { fetcher } from '@/lib/utils'; -import { useChatVisibility } from '@/hooks/use-chat-visibility'; +import { ChatItem } from './sidebar-history-item'; +import useSWRInfinite from 'swr/infinite'; +import { LoaderIcon } from './icons'; type GroupedChats = { today: Chat[]; @@ -58,116 +36,89 @@ type GroupedChats = { older: Chat[]; }; -const PureChatItem = ({ - chat, - isActive, - onDelete, - setOpenMobile, -}: { - chat: Chat; - isActive: boolean; - onDelete: (chatId: string) => void; - setOpenMobile: (open: boolean) => void; -}) => { - const { visibilityType, setVisibilityType } = useChatVisibility({ - chatId: chat.id, - initialVisibility: chat.visibility, - }); +export interface ChatHistory { + chats: Array; + hasMore: boolean; +} - return ( - - - setOpenMobile(false)}> - {chat.title} - - +const PAGE_SIZE = 20; - - - - - More - - +const groupChatsByDate = (chats: Chat[]): GroupedChats => { + const now = new Date(); + const oneWeekAgo = subWeeks(now, 1); + const oneMonthAgo = subMonths(now, 1); - - - - - Share - - - - { - setVisibilityType('private'); - }} - > -
- - Private -
- {visibilityType === 'private' ? ( - - ) : null} -
- { - setVisibilityType('public'); - }} - > -
- - Public -
- {visibilityType === 'public' ? : null} -
-
-
-
+ return chats.reduce( + (groups, chat) => { + const chatDate = new Date(chat.createdAt); - onDelete(chat.id)} - > - - Delete - -
-
-
+ if (isToday(chatDate)) { + groups.today.push(chat); + } else if (isYesterday(chatDate)) { + groups.yesterday.push(chat); + } else if (chatDate > oneWeekAgo) { + groups.lastWeek.push(chat); + } else if (chatDate > oneMonthAgo) { + groups.lastMonth.push(chat); + } else { + groups.older.push(chat); + } + + return groups; + }, + { + today: [], + yesterday: [], + lastWeek: [], + lastMonth: [], + older: [], + } as GroupedChats, ); }; -export const ChatItem = memo(PureChatItem, (prevProps, nextProps) => { - if (prevProps.isActive !== nextProps.isActive) return false; - return true; -}); +export function getChatHistoryPaginationKey( + pageIndex: number, + previousPageData: ChatHistory, +) { + if (previousPageData && previousPageData.hasMore === false) { + return null; + } + + if (pageIndex === 0) return `/api/history?limit=${PAGE_SIZE}`; + + const firstChatFromPage = previousPageData.chats.at(-1); + + if (!firstChatFromPage) return null; + + return `/api/history?ending_before=${firstChatFromPage.id}&limit=${PAGE_SIZE}`; +} export function SidebarHistory({ user }: { user: User | undefined }) { const { setOpenMobile } = useSidebar(); const { id } = useParams(); - const pathname = usePathname(); + const { - data: history, + data: paginatedChatHistories, + setSize, + isValidating, isLoading, mutate, - } = useSWR>(user ? '/api/history' : null, fetcher, { + } = useSWRInfinite(getChatHistoryPaginationKey, fetcher, { fallbackData: [], }); - useEffect(() => { - mutate(); - }, [pathname, mutate]); - + const router = useRouter(); const [deleteId, setDeleteId] = useState(null); const [showDeleteDialog, setShowDeleteDialog] = useState(false); - const router = useRouter(); + + const hasReachedEnd = paginatedChatHistories + ? paginatedChatHistories.some((page) => page.hasMore === false) + : false; + + const hasEmptyChatHistory = paginatedChatHistories + ? paginatedChatHistories.every((page) => page.chats.length === 0) + : false; + const handleDelete = async () => { const deletePromise = fetch(`/api/chat?id=${deleteId}`, { method: 'DELETE', @@ -176,11 +127,15 @@ export function SidebarHistory({ user }: { user: User | undefined }) { toast.promise(deletePromise, { loading: 'Deleting chat...', success: () => { - mutate((history) => { - if (history) { - return history.filter((h) => h.id !== id); + mutate((chatHistories) => { + if (chatHistories) { + return chatHistories.map((chatHistory) => ({ + ...chatHistory, + chats: chatHistory.chats.filter((chat) => chat.id !== deleteId), + })); } }); + return 'Chat deleted successfully'; }, error: 'Failed to delete chat', @@ -234,7 +189,7 @@ export function SidebarHistory({ user }: { user: User | undefined }) { ); } - if (history?.length === 0) { + if (hasEmptyChatHistory) { return ( @@ -246,52 +201,23 @@ export function SidebarHistory({ user }: { user: User | undefined }) { ); } - const groupChatsByDate = (chats: Chat[]): GroupedChats => { - const now = new Date(); - const oneWeekAgo = subWeeks(now, 1); - const oneMonthAgo = subMonths(now, 1); - - return chats.reduce( - (groups, chat) => { - const chatDate = new Date(chat.createdAt); - - if (isToday(chatDate)) { - groups.today.push(chat); - } else if (isYesterday(chatDate)) { - groups.yesterday.push(chat); - } else if (chatDate > oneWeekAgo) { - groups.lastWeek.push(chat); - } else if (chatDate > oneMonthAgo) { - groups.lastMonth.push(chat); - } else { - groups.older.push(chat); - } - - return groups; - }, - { - today: [], - yesterday: [], - lastWeek: [], - lastMonth: [], - older: [], - } as GroupedChats, - ); - }; - return ( <> - {history && + {paginatedChatHistories && (() => { - const groupedChats = groupChatsByDate(history); + const chatsFromHistory = paginatedChatHistories.flatMap( + (paginatedChatHistory) => paginatedChatHistory.chats, + ); + + const groupedChats = groupChatsByDate(chatsFromHistory); return ( - <> +
{groupedChats.today.length > 0 && ( - <> +
Today
@@ -307,12 +233,12 @@ export function SidebarHistory({ user }: { user: User | undefined }) { setOpenMobile={setOpenMobile} /> ))} - +
)} {groupedChats.yesterday.length > 0 && ( - <> -
+
+
Yesterday
{groupedChats.yesterday.map((chat) => ( @@ -327,12 +253,12 @@ export function SidebarHistory({ user }: { user: User | undefined }) { setOpenMobile={setOpenMobile} /> ))} - +
)} {groupedChats.lastWeek.length > 0 && ( - <> -
+
+
Last 7 days
{groupedChats.lastWeek.map((chat) => ( @@ -347,12 +273,12 @@ export function SidebarHistory({ user }: { user: User | undefined }) { setOpenMobile={setOpenMobile} /> ))} - +
)} {groupedChats.lastMonth.length > 0 && ( - <> -
+
+
Last 30 days
{groupedChats.lastMonth.map((chat) => ( @@ -367,13 +293,13 @@ export function SidebarHistory({ user }: { user: User | undefined }) { setOpenMobile={setOpenMobile} /> ))} - +
)} {groupedChats.older.length > 0 && ( - <> -
- Older +
+
+ Older than last month
{groupedChats.older.map((chat) => ( ))} - +
)} - +
); })()} + + { + if (!isValidating && !hasReachedEnd) { + setSize((size) => size + 1); + } + }} + /> + + {hasReachedEnd ? ( +
+ You have reached the end of your chat history. +
+ ) : ( +
+
+ +
+
Loading Chats...
+
+ )} + diff --git a/hooks/use-chat-visibility.ts b/hooks/use-chat-visibility.ts index d920785..975d5a8 100644 --- a/hooks/use-chat-visibility.ts +++ b/hooks/use-chat-visibility.ts @@ -1,10 +1,14 @@ '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'; +import { unstable_serialize } from 'swr/infinite'; +import { updateChatVisibility } from '@/app/(chat)/actions'; +import { + getChatHistoryPaginationKey, + type ChatHistory, +} from '@/components/sidebar-history'; +import type { VisibilityType } from '@/components/visibility-selector'; export function useChatVisibility({ chatId, @@ -14,7 +18,7 @@ export function useChatVisibility({ initialVisibility: VisibilityType; }) { const { mutate, cache } = useSWRConfig(); - const history: Array = cache.get('/api/history')?.data; + const history: ChatHistory = cache.get('/api/history')?.data; const { data: localVisibility, mutate: setLocalVisibility } = useSWR( `${chatId}-visibility`, @@ -26,31 +30,14 @@ export function useChatVisibility({ const visibilityType = useMemo(() => { if (!history) return localVisibility; - const chat = history.find((chat) => chat.id === chatId); + const chat = history.chats.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 }, - ); + mutate(unstable_serialize(getChatHistoryPaginationKey)); updateChatVisibility({ chatId: chatId, diff --git a/lib/db/queries.ts b/lib/db/queries.ts index ebc876a..d51c5ae 100644 --- a/lib/db/queries.ts +++ b/lib/db/queries.ts @@ -1,7 +1,7 @@ import 'server-only'; import { genSaltSync, hashSync } from 'bcrypt-ts'; -import { and, asc, desc, eq, gt, gte, inArray } from 'drizzle-orm'; +import { and, asc, desc, eq, gt, gte, inArray, lt, SQL } from 'drizzle-orm'; import { drizzle } from 'drizzle-orm/postgres-js'; import postgres from 'postgres'; @@ -15,6 +15,7 @@ import { message, vote, type DBMessage, + Chat, } from './schema'; import { ArtifactKind } from '@/components/artifact'; @@ -81,13 +82,68 @@ export async function deleteChatById({ id }: { id: string }) { } } -export async function getChatsByUserId({ id }: { id: string }) { +export async function getChatsByUserId({ + id, + limit, + startingAfter, + endingBefore, +}: { + id: string; + limit: number; + startingAfter: string | null; + endingBefore: string | null; +}) { try { - return await db - .select() - .from(chat) - .where(eq(chat.userId, id)) - .orderBy(desc(chat.createdAt)); + const extendedLimit = limit + 1; + + const query = (whereCondition?: SQL) => + db + .select() + .from(chat) + .where( + whereCondition + ? and(whereCondition, eq(chat.userId, id)) + : eq(chat.userId, id), + ) + .orderBy(desc(chat.createdAt)) + .limit(extendedLimit); + + let filteredChats: Array = []; + + if (startingAfter) { + const [selectedChat] = await db + .select() + .from(chat) + .where(eq(chat.id, startingAfter)) + .limit(1); + + if (!selectedChat) { + throw new Error(`Chat with id ${startingAfter} not found`); + } + + filteredChats = await query(gt(chat.createdAt, selectedChat.createdAt)); + } else if (endingBefore) { + const [selectedChat] = await db + .select() + .from(chat) + .where(eq(chat.id, endingBefore)) + .limit(1); + + if (!selectedChat) { + throw new Error(`Chat with id ${endingBefore} not found`); + } + + filteredChats = await query(lt(chat.createdAt, selectedChat.createdAt)); + } else { + filteredChats = await query(); + } + + const hasMore = filteredChats.length > limit; + + return { + chats: hasMore ? filteredChats.slice(0, limit) : filteredChats, + hasMore, + }; } catch (error) { console.error('Failed to get chats by user from database'); throw error;