Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions app/chat/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className='flex items-center justify-center h-screen'>
Loading...
</div>
)
}

return (
<>
<OfflineQueueProcessor />
<div className='flex h-screen bg-gray-100 dark:bg-gray-900'>
<div className='w-1/3 border-r border-gray-200 dark:border-gray-700'>
<ConversationList />
</div>
<div className='w-2/3'>
<ChatWindow />
</div>
</div>
</>
)
}
11 changes: 9 additions & 2 deletions app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,25 @@
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()

Check warning on line 10 in app/page.tsx

View workflow job for this annotation

GitHub Actions / build-and-test

'session' is assigned a value but never used

return (
<main className='flex min-h-screen flex-col items-center justify-center p-4 md:p-8'>
<MatchModal />

<div className='w-full max-w-2xl text-center mb-8'>
<h1 className='text-4xl font-bold'>Chordially</h1>
{status === 'authenticated' && (
<Link
href='/chat'
className='text-blue-500 hover:underline mt-4 inline-block'
>
Go to Chat
</Link>
)}
</div>

{status === 'authenticated' ? <SwipeDeck /> : <SpotifyConnect />}
</main>
)
Expand Down
68 changes: 68 additions & 0 deletions components/chat/ChatWindow.tsx
Original file line number Diff line number Diff line change
@@ -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<Message[]> => {
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<HTMLDivElement>(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 (
<div className='flex items-center justify-center h-full text-gray-500'>
Select a conversation to start chatting
</div>
)
}

if (isLoading)
return (
<div className='flex items-center justify-center h-full'>
Loading messages...
</div>
)

// 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 (
<div className='flex flex-col h-full'>
<div className='flex-1 p-4 overflow-y-auto'>
{allMessages.map((msg) => (
<MessageBubble key={msg.id || msg.tempId} message={msg} />
))}
<div ref={messagesEndRef} />
</div>
<div className='p-4 border-t border-gray-200 dark:border-gray-700'>
<MessageInput matchId={selectedMatchId} />
</div>
</div>
)
}
51 changes: 51 additions & 0 deletions components/chat/ConversationList.tsx
Original file line number Diff line number Diff line change
@@ -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<Match[]> => {
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 <div>Loading conversations...</div>

return (
<div className='h-full overflow-y-auto'>
<h2 className='p-4 text-xl font-bold border-b border-gray-200 dark:border-gray-700'>
Matches
</h2>
<ul>
{matches?.map((match) => (
<li
key={match.id}
onClick={() => 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' : ''}`}
>
<Image
src={match.user.avatarUrl}
alt={match.user.name}
width={50}
height={50}
className='rounded-full'
/>
<div className='ml-4'>
<p className='font-semibold'>{match.user.name}</p>
<p className='text-sm text-gray-500'>Start chatting...</p>
</div>
</li>
))}
</ul>
</div>
)
}
22 changes: 22 additions & 0 deletions components/chat/MessageBubble.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className={`flex ${alignment} mb-4`}>
<div className={`rounded-lg px-4 py-2 max-w-sm ${colors}`}>
<p>{message.text}</p>
{message.status && (
<p className='text-xs text-right opacity-75 mt-1'>
{message.status}...
</p>
)}
</div>
</div>
)
}
95 changes: 95 additions & 0 deletions components/chat/MessageInput.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<form onSubmit={handleSubmit} className='flex'>
<input
type='text'
value={text}
onChange={(e) => 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'
/>
<button type='submit' className='bg-blue-500 text-white p-2 rounded-r-lg'>
Send
</button>
</form>
)
}
61 changes: 61 additions & 0 deletions components/chat/OfflineQueueProcessor.tsx
Original file line number Diff line number Diff line change
@@ -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
}
3 changes: 2 additions & 1 deletion components/shared/SpotifyConnect.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
'use client'

import { useSession, signIn, signOut } from 'next-auth/react'
Expand Down Expand Up @@ -45,7 +46,7 @@ export default function SpotifyConnect() {
<h2 className='text-2xl font-semibold mb-4'>Your Top 5 Artists:</h2>
{topArtists ? (
<ul>
{topArtists.slice(0, 5).map((artist) => (
{topArtists.slice(0, 5).map((artist: any) => (
<li key={artist.id} className='mb-2'>
{artist.name}
</li>
Expand Down
3 changes: 2 additions & 1 deletion components/shared/SwipeDeck.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
'use client'

import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
Expand Down Expand Up @@ -45,7 +46,7 @@ export default function SwipeDeck() {
const { isLoading, isError } = useQuery({
queryKey: ['potentialMatches'],
queryFn: fetchPotentialMatches,
onSuccess: (data) => {
onSuccess: (data: UserProfile[]) => {
setUsers(data)
},
})
Expand Down
Loading
Loading