Skip to content

Commit 50e8356

Browse files
authored
feat: #858 chat feature (#859)
1 parent 55ad76e commit 50e8356

File tree

9 files changed

+588
-10
lines changed

9 files changed

+588
-10
lines changed

frontend/src/App.tsx

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,12 @@ import DonationPopup from '@/components/DonationPopup.tsx'
77
import InstallPrompt from '@/components/InstallPrompt.tsx'
88
import { useToast } from '@/hooks/use-toast.ts'
99
import { useAuthSSO, useUser } from '@/services/auth/useAuth'
10+
import { ChatManager } from './components/chat/ChatManager.tsx'
1011
import ErrorAlert from './components/ErrorAlert.tsx'
1112
import { Header } from './components/Header.tsx'
1213
import { Spinner } from './components/Spinner.tsx'
1314
import { Toaster } from './components/ui/toaster.tsx'
15+
import { ChatProvider } from './context/ChatProvider.tsx'
1416
import { DialogContext } from './context/DialogContext.ts'
1517
import DeckView from './pages/decks/DeckView.tsx'
1618

@@ -109,12 +111,15 @@ function App() {
109111
return (
110112
<ErrorBoundary FallbackComponent={ErrorAlert}>
111113
<DialogContext.Provider value={dialogContextValue}>
112-
<Toaster />
113-
<RouterProvider router={router} />
114-
<InstallPrompt />
115-
<DonationPopup />
116-
{/* Add React Query DevTools (only in development) */}
117-
{process.env.NODE_ENV === 'development' && <ReactQueryDevtools initialIsOpen={false} />}
114+
<ChatProvider>
115+
<Toaster />
116+
<RouterProvider router={router} />
117+
<InstallPrompt />
118+
<DonationPopup />
119+
<ChatManager />
120+
{/* Add React Query DevTools (only in development) */}
121+
{process.env.NODE_ENV === 'development' && <ReactQueryDevtools initialIsOpen={false} />}
122+
</ChatProvider>
118123
</DialogContext.Provider>
119124
</ErrorBoundary>
120125
)
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
import { Minus, Send, X } from 'lucide-react'
2+
import { useEffect, useRef, useState } from 'react'
3+
import { useChatContext } from '@/context/ChatContext'
4+
import { supabase } from '@/lib/supabase'
5+
import { useAccount } from '@/services/account/useAccount'
6+
import { useMarkAsRead, useMessages, useSendMessage } from '@/services/chat/useChat'
7+
import type { MessageRow } from '@/types'
8+
9+
function FriendAvatar({ name }: { name: string }) {
10+
const initials = name.slice(0, 2).toUpperCase()
11+
return <div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-neutral-600 text-xs font-semibold text-neutral-200">{initials}</div>
12+
}
13+
14+
interface ChatBoxProps {
15+
friendId: string
16+
username: string
17+
minimized: boolean
18+
unreadCount: number
19+
position: number
20+
isMobile: boolean
21+
}
22+
23+
export function ChatBox({ friendId, username, minimized, unreadCount, position, isMobile }: ChatBoxProps) {
24+
const { data: account } = useAccount()
25+
const myFriendId = account?.friend_id
26+
const { closeChat, toggleMinimize, expandExclusive, clearUnread } = useChatContext()
27+
const { data: messages = [] } = useMessages(friendId)
28+
const sendMessage = useSendMessage()
29+
const markAsRead = useMarkAsRead()
30+
const [input, setInput] = useState('')
31+
const messagesEndRef = useRef<HTMLDivElement>(null)
32+
const channelRef = useRef<ReturnType<typeof supabase.channel> | null>(null)
33+
const [localMessages, setLocalMessages] = useState<MessageRow[]>([])
34+
35+
// Sync local messages from query data
36+
useEffect(() => {
37+
setLocalMessages(messages)
38+
}, [messages])
39+
40+
// Auto-scroll to bottom
41+
useEffect(() => {
42+
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
43+
}, [localMessages, minimized])
44+
45+
// Mark as read when chat is opened/expanded
46+
useEffect(() => {
47+
if (!minimized && myFriendId && friendId) {
48+
markAsRead.mutate({ theirFriendId: friendId })
49+
clearUnread(friendId)
50+
}
51+
}, [minimized, myFriendId, friendId])
52+
53+
// Subscribe to real-time chat channel
54+
useEffect(() => {
55+
if (!myFriendId) {
56+
return
57+
}
58+
59+
const sortedIds = [myFriendId, friendId].sort()
60+
const channelName = `chat:${sortedIds[0]}:${sortedIds[1]}`
61+
const channel = supabase.channel(channelName)
62+
channelRef.current = channel
63+
64+
channel
65+
.on('broadcast', { event: 'message' }, ({ payload }) => {
66+
const newMsg: MessageRow = {
67+
id: payload.id,
68+
sender_friend_id: payload.sender_friend_id,
69+
receiver_friend_id: payload.receiver_friend_id,
70+
content: payload.content,
71+
created_at: new Date(payload.created_at),
72+
read_at: payload.read_at ? new Date(payload.read_at) : null,
73+
}
74+
setLocalMessages((prev) => {
75+
// Avoid duplicate if already in list
76+
if (prev.some((m) => m.id === newMsg.id)) {
77+
return prev
78+
}
79+
return [...prev, newMsg]
80+
})
81+
})
82+
.subscribe()
83+
84+
return () => {
85+
channel.unsubscribe()
86+
channelRef.current = null
87+
}
88+
}, [myFriendId, friendId])
89+
90+
const handleSend = async () => {
91+
const content = input.trim()
92+
if (!content || !myFriendId) {
93+
return
94+
}
95+
setInput('')
96+
97+
try {
98+
await sendMessage.mutateAsync({ receiverFriendId: friendId, content })
99+
100+
// Broadcast to chat channel
101+
const sortedIds = [myFriendId, friendId].sort()
102+
const channelName = `chat:${sortedIds[0]}:${sortedIds[1]}`
103+
const newMsg: MessageRow = {
104+
id: Date.now(),
105+
sender_friend_id: myFriendId,
106+
receiver_friend_id: friendId,
107+
content,
108+
created_at: new Date(),
109+
read_at: null,
110+
}
111+
setLocalMessages((prev) => [...prev, newMsg])
112+
113+
await supabase.channel(channelName).send({
114+
type: 'broadcast',
115+
event: 'message',
116+
payload: { ...newMsg, created_at: newMsg.created_at.toISOString() },
117+
})
118+
119+
// Broadcast to receiver's inbox
120+
await supabase.channel(`inbox:${friendId}`).send({
121+
type: 'broadcast',
122+
event: 'message',
123+
payload: { sender_friend_id: myFriendId, sender_username: account?.username ?? myFriendId },
124+
})
125+
} catch {
126+
// Silently fail — message not sent
127+
}
128+
}
129+
130+
const handleHeaderClick = () => {
131+
if (isMobile && minimized) {
132+
expandExclusive(friendId)
133+
} else {
134+
toggleMinimize(friendId)
135+
}
136+
}
137+
138+
// Each minimized header is ~44px tall (h-7 avatar + py-2 padding)
139+
const HEADER_H = 44
140+
const style = isMobile ? { left: 8, right: 8, bottom: minimized ? position * HEADER_H : 0 } : { right: 16 + position * 332, width: 316, bottom: 0 }
141+
142+
return (
143+
<div className="fixed z-50 flex flex-col rounded-t-xl border border-neutral-700 bg-neutral-900 shadow-2xl" style={style}>
144+
{/* Header */}
145+
<div className="flex items-center gap-2 rounded-t-xl border-b border-neutral-700 bg-neutral-800 px-3 py-2">
146+
<button type="button" onClick={handleHeaderClick} className="flex min-w-0 flex-1 cursor-pointer items-center gap-2 text-left">
147+
<FriendAvatar name={username} />
148+
<span className="flex-1 truncate text-sm font-medium">{username}</span>
149+
{minimized && unreadCount > 0 && (
150+
<span className="flex h-5 min-w-5 items-center justify-center rounded-full bg-blue-600 px-1.5 text-xs font-bold text-white">{unreadCount}</span>
151+
)}
152+
</button>
153+
<button type="button" onClick={handleHeaderClick} className="rounded p-0.5 text-neutral-400 hover:bg-neutral-700 hover:text-neutral-200">
154+
<Minus className="h-4 w-4" />
155+
</button>
156+
<button
157+
type="button"
158+
onClick={(e) => {
159+
e.stopPropagation()
160+
closeChat(friendId)
161+
}}
162+
className="rounded p-0.5 text-neutral-400 hover:bg-neutral-700 hover:text-red-400"
163+
>
164+
<X className="h-4 w-4" />
165+
</button>
166+
</div>
167+
168+
{/* Body */}
169+
{!minimized && (
170+
<>
171+
<div className="flex h-72 flex-col gap-2 overflow-y-auto p-3">
172+
{localMessages.length === 0 && <p className="text-center text-xs text-neutral-500 mt-auto mb-auto">No messages yet. Say hi!</p>}
173+
{localMessages.map((msg, i) => {
174+
const isOwn = msg.sender_friend_id === myFriendId
175+
return (
176+
<div key={msg.id ?? i} className={`flex ${isOwn ? 'justify-end' : 'justify-start'}`}>
177+
<div
178+
className={`max-w-[80%] rounded-2xl px-3 py-1.5 text-sm ${
179+
isOwn ? 'rounded-br-sm bg-blue-600 text-white' : 'rounded-bl-sm bg-neutral-700 text-neutral-100'
180+
}`}
181+
>
182+
{msg.content}
183+
</div>
184+
</div>
185+
)
186+
})}
187+
<div ref={messagesEndRef} />
188+
</div>
189+
190+
{/* Footer */}
191+
<div className="flex gap-2 border-t border-neutral-700 p-2">
192+
<input
193+
className="flex-1 rounded-lg border border-neutral-700 bg-neutral-800 px-3 py-1.5 text-sm text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
194+
placeholder="Type a message..."
195+
value={input}
196+
onChange={(e) => setInput(e.target.value)}
197+
onKeyDown={(e) => e.key === 'Enter' && !e.shiftKey && handleSend()}
198+
/>
199+
<button
200+
type="button"
201+
onClick={handleSend}
202+
disabled={!input.trim() || sendMessage.isPending}
203+
className="rounded-lg bg-blue-600 p-2 text-white hover:bg-blue-700 disabled:opacity-40"
204+
>
205+
<Send className="h-4 w-4" />
206+
</button>
207+
</div>
208+
</>
209+
)}
210+
</div>
211+
)
212+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { useEffect, useState } from 'react'
2+
import { useChatContext } from '@/context/ChatContext'
3+
import { ChatBox } from './ChatBox'
4+
5+
function useIsMobile() {
6+
const [isMobile, setIsMobile] = useState(() => window.matchMedia('(max-width: 639px)').matches)
7+
8+
useEffect(() => {
9+
const mq = window.matchMedia('(max-width: 639px)')
10+
const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches)
11+
mq.addEventListener('change', handler)
12+
return () => mq.removeEventListener('change', handler)
13+
}, [])
14+
15+
return isMobile
16+
}
17+
18+
export function ChatManager() {
19+
const { openChats } = useChatContext()
20+
const isMobile = useIsMobile()
21+
22+
if (isMobile) {
23+
const expandedChat = openChats.find((c) => !c.minimized)
24+
25+
if (expandedChat) {
26+
// Only render the expanded chat; minimized ones are hidden until it closes
27+
return (
28+
<ChatBox
29+
key={expandedChat.friendId}
30+
friendId={expandedChat.friendId}
31+
username={expandedChat.username}
32+
minimized={false}
33+
unreadCount={expandedChat.unreadCount}
34+
position={0}
35+
isMobile
36+
/>
37+
)
38+
}
39+
40+
// All minimized: stack headers vertically, one above the other
41+
return (
42+
<>
43+
{openChats.map((chat, index) => (
44+
<ChatBox
45+
key={chat.friendId}
46+
friendId={chat.friendId}
47+
username={chat.username}
48+
minimized={true}
49+
unreadCount={chat.unreadCount}
50+
position={index}
51+
isMobile
52+
/>
53+
))}
54+
</>
55+
)
56+
}
57+
58+
// Desktop: side-by-side
59+
return (
60+
<>
61+
{openChats.map((chat, index) => (
62+
<ChatBox
63+
key={chat.friendId}
64+
friendId={chat.friendId}
65+
username={chat.username}
66+
minimized={chat.minimized}
67+
unreadCount={chat.unreadCount}
68+
position={index}
69+
isMobile={false}
70+
/>
71+
))}
72+
</>
73+
)
74+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { createContext, useContext } from 'react'
2+
3+
export interface OpenChat {
4+
friendId: string
5+
username: string
6+
minimized: boolean
7+
unreadCount: number
8+
}
9+
10+
export interface ChatContextType {
11+
openChats: OpenChat[]
12+
openChat: (friendId: string, username: string) => void
13+
closeChat: (friendId: string) => void
14+
toggleMinimize: (friendId: string) => void
15+
expandExclusive: (friendId: string) => void
16+
incrementUnread: (friendId: string) => void
17+
clearUnread: (friendId: string) => void
18+
}
19+
20+
export const ChatContext = createContext<ChatContextType>({
21+
openChats: [],
22+
openChat: () => {},
23+
closeChat: () => {},
24+
toggleMinimize: () => {},
25+
expandExclusive: () => {},
26+
incrementUnread: () => {},
27+
clearUnread: () => {},
28+
})
29+
30+
export function useChatContext() {
31+
return useContext(ChatContext)
32+
}

0 commit comments

Comments
 (0)