diff --git a/app/chat/page.tsx b/app/chat/page.tsx new file mode 100644 index 0000000..7731110 --- /dev/null +++ b/app/chat/page.tsx @@ -0,0 +1,38 @@ +'use client' + +import ChatWindow from '@/components/chat/ChatWindow' +import ConversationList from '@/components/chat/ConversationList' +import { useSession } from 'next-auth/react' +import { redirect } from 'next/navigation' +import OfflineQueueProcessor from '@/components/chat/OfflineQueueProcessor' + +export default function ChatPage() { + const { status } = useSession({ + required: true, + onUnauthenticated() { + redirect('/') + }, + }) + + if (status === 'loading') { + return ( +
+ Loading... +
+ ) + } + + return ( + <> + +
+
+ +
+
+ +
+
+ + ) +} diff --git a/app/page.tsx b/app/page.tsx index 5bd625d..83570fb 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -4,6 +4,7 @@ import MatchModal from '@/components/shared/MatchModal' import SpotifyConnect from '@/components/shared/SpotifyConnect' import SwipeDeck from '@/components/shared/SwipeDeck' import { useSession } from 'next-auth/react' +import Link from 'next/link' export default function Home() { const { data: session, status } = useSession() @@ -11,11 +12,17 @@ export default function Home() { return (
-

Chordially

+ {status === 'authenticated' && ( + + Go to Chat + + )}
- {status === 'authenticated' ? : }
) diff --git a/components/chat/ChatWindow.tsx b/components/chat/ChatWindow.tsx new file mode 100644 index 0000000..12e09a2 --- /dev/null +++ b/components/chat/ChatWindow.tsx @@ -0,0 +1,68 @@ +'use client' + +import type { Message } from '@/lib/api-schema' +import { useAuthStore } from '@/stores/auth-store' +import { useQuery } from '@tanstack/react-query' +import { useEffect, useRef } from 'react' +import MessageBubble from './MessageBubble' +import MessageInput from './MessageInput' + +const fetchMessages = async (matchId: string): Promise => { + const res = await fetch(`/api/matches/${matchId}/messages`) + if (!res.ok) throw new Error('Failed to fetch messages') + return res.json() +} + +export default function ChatWindow() { + const { selectedMatchId } = useAuthStore() + const messageQueue = useAuthStore((state) => state.messageQueue) + const messagesEndRef = useRef(null) + + const { data: messages, isLoading } = useQuery({ + queryKey: ['messages', selectedMatchId], + queryFn: () => fetchMessages(selectedMatchId!), + enabled: !!selectedMatchId, + refetchInterval: 3000, + }) + + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) + }, [messages, messageQueue]) + + if (!selectedMatchId) { + return ( +
+ Select a conversation to start chatting +
+ ) + } + + if (isLoading) + return ( +
+ Loading messages... +
+ ) + + // Combine messages from the server and any pending messages from the offline queue + const allMessages = [ + ...(messages || []), + ...messageQueue.filter((m) => m.matchId === selectedMatchId), + ].sort( + (a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime() + ) + + return ( +
+
+ {allMessages.map((msg) => ( + + ))} +
+
+
+ +
+
+ ) +} diff --git a/components/chat/ConversationList.tsx b/components/chat/ConversationList.tsx new file mode 100644 index 0000000..18adf29 --- /dev/null +++ b/components/chat/ConversationList.tsx @@ -0,0 +1,51 @@ +'use client' + +import type { Match } from '@/lib/api-schema' +import { useAuthStore } from '@/stores/auth-store' +import { useQuery } from '@tanstack/react-query' +import Image from 'next/image' + +const fetchMatches = async (): Promise => { + const res = await fetch('/api/matches') + if (!res.ok) throw new Error('Failed to fetch matches') + return res.json() +} + +export default function ConversationList() { + const { data: matches, isLoading } = useQuery({ + queryKey: ['matches'], + queryFn: fetchMatches, + }) + const { selectedMatchId, selectMatch } = useAuthStore() + + if (isLoading) return
Loading conversations...
+ + return ( +
+

+ Matches +

+
    + {matches?.map((match) => ( +
  • selectMatch(match.id)} + className={`flex items-center p-4 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-800 ${selectedMatchId === match.id ? 'bg-gray-300 dark:bg-gray-700' : ''}`} + > + {match.user.name} +
    +

    {match.user.name}

    +

    Start chatting...

    +
    +
  • + ))} +
+
+ ) +} diff --git a/components/chat/MessageBubble.tsx b/components/chat/MessageBubble.tsx new file mode 100644 index 0000000..37e94ed --- /dev/null +++ b/components/chat/MessageBubble.tsx @@ -0,0 +1,22 @@ +import type { Message } from '@/lib/api-schema' + +export default function MessageBubble({ message }: { message: Message }) { + const isMe = message.senderId === 'me' + const alignment = isMe ? 'justify-end' : 'justify-start' + const colors = isMe + ? 'bg-blue-500 text-white' + : 'bg-gray-300 dark:bg-gray-600 text-black dark:text-white' + + return ( +
+
+

{message.text}

+ {message.status && ( +

+ {message.status}... +

+ )} +
+
+ ) +} diff --git a/components/chat/MessageInput.tsx b/components/chat/MessageInput.tsx new file mode 100644 index 0000000..08b0596 --- /dev/null +++ b/components/chat/MessageInput.tsx @@ -0,0 +1,95 @@ +'use client' + +import type { Message } from '@/lib/api-schema' +import { useAuthStore } from '@/stores/auth-store' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { useState } from 'react' + +const postMessage = async ({ + matchId, + text, + tempId, +}: { + matchId: string + text: string + tempId: string +}) => { + const res = await fetch(`/api/matches/${matchId}/messages`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text, tempId }), + }) + if (!res.ok) throw new Error('Failed to send message') + return res.json() +} + +export default function MessageInput({ matchId }: { matchId: string }) { + const [text, setText] = useState('') + const queryClient = useQueryClient() + const { addMessageToQueue, removeMessageFromQueue } = useAuthStore() + + const mutation = useMutation({ + mutationFn: postMessage, + onSuccess: (newMessage) => { + if (newMessage.tempId) { + removeMessageFromQueue(newMessage.tempId) + } + + queryClient.invalidateQueries({ queryKey: ['messages', matchId] }) + }, + onError: (error, variables) => { + console.error('Message failed to send:', error) + + const failedMessage: Message = { + id: variables.tempId, + tempId: variables.tempId, + matchId: variables.matchId, + senderId: 'me', + text: variables.text, + timestamp: new Date().toISOString(), + } + addMessageToQueue(failedMessage) + }, + }) + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + if (!text.trim()) return + + const tempId = crypto.randomUUID() + + queryClient.setQueryData( + ['messages', matchId], + (oldData: Message[] | undefined) => { + const optimisticMessage: Message = { + id: tempId, + tempId, + matchId, + senderId: 'me', + text, + timestamp: new Date().toISOString(), + status: 'sending', + } + return oldData ? [...oldData, optimisticMessage] : [optimisticMessage] + } + ) + + mutation.mutate({ matchId, text, tempId }) + setText('') + } + + return ( +
+ setText(e.target.value)} + placeholder='Type a message...' + className='flex-1 p-2 rounded-l-lg border border-gray-300 dark:bg-gray-800 dark:border-gray-600' + /> + +
+ ) +} diff --git a/components/chat/OfflineQueueProcessor.tsx b/components/chat/OfflineQueueProcessor.tsx new file mode 100644 index 0000000..d0306f8 --- /dev/null +++ b/components/chat/OfflineQueueProcessor.tsx @@ -0,0 +1,61 @@ +'use client' + +import { useAuthStore } from '@/stores/auth-store' +import { useIsOnline } from '@/hooks/useIsOnline' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { useEffect } from 'react' + +// +const postMessage = async (message: { + matchId: string + text: string + tempId: string +}) => { + const res = await fetch(`/api/matches/${message.matchId}/messages`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text: message.text, tempId: message.tempId }), + }) + if (!res.ok) throw new Error('Failed to send message') + return res.json() +} + +export default function OfflineQueueProcessor() { + const isOnline = useIsOnline() + const queryClient = useQueryClient() + const { messageQueue, removeMessageFromQueue, updateMessageStatusInQueue } = + useAuthStore() + + const mutation = useMutation({ + mutationFn: postMessage, + onSuccess: (sentMessage) => { + if (sentMessage.tempId) { + removeMessageFromQueue(sentMessage.tempId) + } + queryClient.invalidateQueries({ + queryKey: ['messages', sentMessage.matchId], + }) + }, + onError: (error, variables) => { + console.error('Queue processor failed to send:', error) + updateMessageStatusInQueue(variables.tempId, 'failed') + }, + }) + + useEffect(() => { + if (isOnline && messageQueue.length > 0) { + messageQueue.forEach((message) => { + if (message.status === 'failed' && message.tempId) { + updateMessageStatusInQueue(message.tempId, 'sending') + mutation.mutate({ + matchId: message.matchId, + text: message.text, + tempId: message.tempId, + }) + } + }) + } + }, [isOnline, messageQueue, mutation, updateMessageStatusInQueue]) + + return null +} diff --git a/components/shared/SpotifyConnect.tsx b/components/shared/SpotifyConnect.tsx index 4ad203b..42de412 100644 --- a/components/shared/SpotifyConnect.tsx +++ b/components/shared/SpotifyConnect.tsx @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ 'use client' import { useSession, signIn, signOut } from 'next-auth/react' @@ -45,7 +46,7 @@ export default function SpotifyConnect() {

Your Top 5 Artists:

{topArtists ? (
    - {topArtists.slice(0, 5).map((artist) => ( + {topArtists.slice(0, 5).map((artist: any) => (
  • {artist.name}
  • diff --git a/components/shared/SwipeDeck.tsx b/components/shared/SwipeDeck.tsx index b9a71df..03d57dc 100644 --- a/components/shared/SwipeDeck.tsx +++ b/components/shared/SwipeDeck.tsx @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ 'use client' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' @@ -45,7 +46,7 @@ export default function SwipeDeck() { const { isLoading, isError } = useQuery({ queryKey: ['potentialMatches'], queryFn: fetchPotentialMatches, - onSuccess: (data) => { + onSuccess: (data: UserProfile[]) => { setUsers(data) }, }) diff --git a/hooks/useIsOnline.ts b/hooks/useIsOnline.ts new file mode 100644 index 0000000..4645f12 --- /dev/null +++ b/hooks/useIsOnline.ts @@ -0,0 +1,18 @@ +'use client' + +import { useEffect, useState } from 'react' +import { onlineManager } from '@tanstack/react-query' + +export function useIsOnline() { + const [isOnline, setIsOnline] = useState(true) + + useEffect(() => { + setIsOnline(onlineManager.isOnline()) + + return onlineManager.subscribe(() => { + setIsOnline(onlineManager.isOnline()) + }) + }, []) + + return isOnline +} diff --git a/lib/api-schema.ts b/lib/api-schema.ts index 21b48b2..4a6a986 100644 --- a/lib/api-schema.ts +++ b/lib/api-schema.ts @@ -46,3 +46,18 @@ export type LoginResponse = { token: string user: UserProfile } + +export type Match = { + id: string + user: UserProfile +} + +export type Message = { + id: string + tempId?: string + matchId: string + senderId: string + text: string + timestamp: string + status?: 'sending' | 'failed' +} diff --git a/lib/msw/handlers.ts b/lib/msw/handlers.ts index 4492f6d..3cc01a1 100644 --- a/lib/msw/handlers.ts +++ b/lib/msw/handlers.ts @@ -1,4 +1,4 @@ -import { http, HttpResponse, delay } from 'msw' +import { http, HttpResponse, delay, Match, Message } from 'msw' import { faker } from '@faker-js/faker' import type { UserProfile, @@ -44,6 +44,31 @@ const createFakeUser = (): UserProfile => { } } +const FAKE_MATCHES: Match[] = Array.from({ length: 5 }, () => ({ + id: faker.string.uuid(), + user: createFakeUser(), +})) + +const FAKE_MESSAGES: Record = FAKE_MATCHES.reduce( + (acc, match) => { + acc[match.id] = Array.from( + { length: faker.number.int({ min: 5, max: 15 }) }, + (_, i) => { + const isMe = i % 2 === 0 + return { + id: faker.string.uuid(), + matchId: match.id, + senderId: isMe ? 'me' : match.user.id, + text: faker.lorem.sentence(), + timestamp: faker.date.past().toISOString(), + } + } + ) + return acc + }, + {} as Record +) + export const handlers = [ http.post('/api/auth/login', async () => { await delay(300) @@ -85,4 +110,48 @@ export const handlers = [ return HttpResponse.json(response) }), + + http.get('/api/matches', async () => { + await delay(300) + return HttpResponse.json(FAKE_MATCHES) + }), + + http.get('/api/matches/:matchId/messages', async ({ params }) => { + const { matchId } = params + await delay(400) + const messages = FAKE_MESSAGES[matchId as string] || [] + return HttpResponse.json(messages) + }), + + http.post('/api/matches/:matchId/messages', async ({ request, params }) => { + const { matchId } = params + const { text, tempId } = (await request.json()) as { + text: string + tempId: string + } + + if (Math.random() < 0.25) { + await delay(1000) + return new HttpResponse(null, { + status: 500, + statusText: 'Network Error', + }) + } + + const newMessage: Message = { + id: faker.string.uuid(), + tempId, + matchId: matchId as string, + senderId: 'me', + text, + timestamp: new Date().toISOString(), + } + + if (FAKE_MESSAGES[matchId as string]) { + FAKE_MESSAGES[matchId as string].push(newMessage) + } + + await delay(500) + return HttpResponse.json(newMessage) + }), ] diff --git a/stores/auth-store.ts b/stores/auth-store.ts index 1715d0c..263d7e9 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -1,10 +1,12 @@ import { create } from 'zustand' -import type { UserProfile } from '@/lib/api-schema' +import type { Message, UserProfile } from '@/lib/api-schema' interface AuthState { user: UserProfile | null token: string | null isAuthenticated: boolean + selectedMatchId: string | null + messageQueue: Message[] isMatchModalOpen: boolean matchedUser: UserProfile | null @@ -13,6 +15,14 @@ interface AuthState { openMatchModal: (user: UserProfile) => void closeMatchModal: () => void + + selectMatch: (matchId: string | null) => void + addMessageToQueue: (message: Message) => void + removeMessageFromQueue: (tempId: string) => void + updateMessageStatusInQueue: ( + tempId: string, + status: 'sending' | 'failed' + ) => void } export const useAuthStore = create((set) => ({ @@ -21,6 +31,8 @@ export const useAuthStore = create((set) => ({ isAuthenticated: false, isMatchModalOpen: false, matchedUser: null, + selectedMatchId: null, + messageQueue: [], login: (user, token) => set({ @@ -47,4 +59,23 @@ export const useAuthStore = create((set) => ({ isMatchModalOpen: false, matchedUser: null, }), + + selectMatch: (matchId) => set({ selectedMatchId: matchId }), + + addMessageToQueue: (message) => + set((state) => ({ + messageQueue: [...state.messageQueue, { ...message, status: 'failed' }], + })), + + removeMessageFromQueue: (tempId) => + set((state) => ({ + messageQueue: state.messageQueue.filter((m) => m.tempId !== tempId), + })), + + updateMessageStatusInQueue: (tempId, status) => + set((state) => ({ + messageQueue: state.messageQueue.map((m) => + m.tempId === tempId ? { ...m, status } : m + ), + })), }))