Skip to content

Commit 2da2cb1

Browse files
committed
feat: Add direct chat, room members, and user presence features
Introduces support for direct chat creation and retrieval, room member management, and user presence tracking (online, offline, away). Adds interfaces for RoomMember and UserPresence, methods for subscribing to message updates, and functions to update, fetch, and subscribe to user presence changes. Enhances getChatRooms to include last message and member details.
1 parent 8c26c6f commit 2da2cb1

1 file changed

Lines changed: 243 additions & 2 deletions

File tree

lib/chat-supabase.ts

Lines changed: 243 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,25 @@ export interface ChatRoom {
1212
created_by?: string
1313
created_at: string
1414
updated_at: string
15+
last_message?: Message
16+
members?: RoomMember[]
17+
}
18+
19+
export interface RoomMember {
20+
room_id: string
21+
user_id: string
22+
user_email: string
23+
user_name?: string
24+
joined_at: string
25+
last_read_at?: string
26+
}
27+
28+
export interface UserPresence {
29+
user_id: string
30+
email: string
31+
name?: string
32+
status: 'online' | 'offline' | 'away'
33+
last_seen: string
1534
}
1635

1736
export async function createChatRoom(room: Omit<ChatRoom, 'id' | 'created_at' | 'updated_at'>) {
@@ -26,6 +45,71 @@ export async function createChatRoom(room: Omit<ChatRoom, 'id' | 'created_at' |
2645
return data
2746
}
2847

48+
// Create or get direct chat room between two users
49+
export async function getOrCreateDirectChat(userId1: string, userId2: string) {
50+
const supabase = createClient()
51+
52+
// Check if direct chat already exists between these users
53+
const { data: existingRooms, error: searchError } = await supabase
54+
.from('chat_room_members')
55+
.select('room_id, chat_rooms!inner(id, type, name)')
56+
.eq('user_id', userId1)
57+
58+
if (searchError) throw searchError
59+
60+
// Find a direct chat that includes both users
61+
for (const roomData of existingRooms || []) {
62+
const { data: members } = await supabase
63+
.from('chat_room_members')
64+
.select('user_id')
65+
.eq('room_id', roomData.room_id)
66+
67+
const memberIds = members?.map(m => m.user_id) || []
68+
69+
// Check if this is a direct chat with exactly these two users
70+
if (memberIds.length === 2 &&
71+
memberIds.includes(userId1) &&
72+
memberIds.includes(userId2) &&
73+
(roomData.chat_rooms as any).type === 'direct') {
74+
// Fetch full room data
75+
const { data: room } = await supabase
76+
.from('chat_rooms')
77+
.select('*')
78+
.eq('id', roomData.room_id)
79+
.single()
80+
81+
return room
82+
}
83+
}
84+
85+
// No existing direct chat, create one
86+
const { data: user2Data } = await supabase
87+
.from('users')
88+
.select('email, name')
89+
.eq('id', userId2)
90+
.single()
91+
92+
const chatName = user2Data?.name || user2Data?.email || 'Direct Chat'
93+
94+
const { data: newRoom, error: createError } = await supabase
95+
.from('chat_rooms')
96+
.insert({
97+
name: chatName,
98+
type: 'direct',
99+
created_by: userId1
100+
})
101+
.select()
102+
.single()
103+
104+
if (createError) throw createError
105+
106+
// Add both users as members
107+
await addRoomMember(newRoom.id, userId1)
108+
await addRoomMember(newRoom.id, userId2)
109+
110+
return newRoom
111+
}
112+
29113
export async function getChatRooms(userId: string) {
30114
const supabase = createClient()
31115

@@ -43,12 +127,34 @@ export async function getChatRooms(userId: string) {
43127

44128
const { data: rooms, error: roomsError } = await supabase
45129
.from('chat_rooms')
46-
.select('*')
130+
.select(`
131+
*,
132+
members:chat_room_members(user_id, user_email, user_name, joined_at, last_read_at)
133+
`)
47134
.in('id', roomIds)
48135
.order('updated_at', { ascending: false })
49136

50137
if (roomsError) throw roomsError
51-
return rooms || []
138+
139+
// Get last message for each room
140+
const roomsWithMessages = await Promise.all(
141+
(rooms || []).map(async (room) => {
142+
const { data: lastMsg } = await supabase
143+
.from('messages')
144+
.select('*')
145+
.eq('room_id', room.id)
146+
.order('created_at', { ascending: false })
147+
.limit(1)
148+
.single()
149+
150+
return {
151+
...room,
152+
last_message: lastMsg || undefined
153+
}
154+
})
155+
)
156+
157+
return roomsWithMessages
52158
}
53159

54160
export async function addRoomMember(roomId: string, userId: string) {
@@ -225,6 +331,141 @@ export function subscribeToMessages(roomId: string, callback: (message: Message)
225331
}
226332
}
227333

334+
// Subscribe to message updates (edits/deletes)
335+
export function subscribeToMessageUpdates(roomId: string, onUpdate: (message: Message) => void, onDelete: (messageId: string) => void) {
336+
const supabase = createClient()
337+
338+
const subscription = supabase
339+
.channel(`room_updates:${roomId}`)
340+
.on(
341+
'postgres_changes',
342+
{
343+
event: 'UPDATE',
344+
schema: 'public',
345+
table: 'messages',
346+
filter: `room_id=eq.${roomId}`
347+
},
348+
(payload) => {
349+
onUpdate(payload.new as Message)
350+
}
351+
)
352+
.on(
353+
'postgres_changes',
354+
{
355+
event: 'DELETE',
356+
schema: 'public',
357+
table: 'messages',
358+
filter: `room_id=eq.${roomId}`
359+
},
360+
(payload) => {
361+
onDelete(payload.old.id)
362+
}
363+
)
364+
.subscribe()
365+
366+
return () => {
367+
subscription.unsubscribe()
368+
}
369+
}
370+
371+
// ============================================
372+
// USER PRESENCE
373+
// ============================================
374+
375+
export async function updateUserPresence(userId: string, email: string, name: string | undefined, status: 'online' | 'offline' | 'away') {
376+
const supabase = createClient()
377+
378+
const { data, error } = await supabase
379+
.from('user_presence')
380+
.upsert({
381+
user_id: userId,
382+
email,
383+
name,
384+
status,
385+
last_seen: new Date().toISOString()
386+
})
387+
.select()
388+
.single()
389+
390+
if (error) throw error
391+
return data
392+
}
393+
394+
export async function getUserPresence(userId: string): Promise<UserPresence | null> {
395+
const supabase = createClient()
396+
397+
const { data, error } = await supabase
398+
.from('user_presence')
399+
.select('*')
400+
.eq('user_id', userId)
401+
.single()
402+
403+
if (error) return null
404+
return data
405+
}
406+
407+
export async function getAllUsersPresence(): Promise<UserPresence[]> {
408+
const supabase = createClient()
409+
410+
const { data, error } = await supabase
411+
.from('user_presence')
412+
.select('*')
413+
.order('email', { ascending: true })
414+
415+
if (error) return []
416+
return data || []
417+
}
418+
419+
export async function getCollaboratorsPresence(userIds: string[]): Promise<UserPresence[]> {
420+
const supabase = createClient()
421+
422+
if (userIds.length === 0) return []
423+
424+
const { data, error } = await supabase
425+
.from('user_presence')
426+
.select('*')
427+
.in('user_id', userIds)
428+
429+
if (error) return []
430+
return data || []
431+
}
432+
433+
// Subscribe to presence changes
434+
export function subscribeToPresence(callback: (presence: UserPresence) => void) {
435+
const supabase = createClient()
436+
437+
const subscription = supabase
438+
.channel('user_presence_changes')
439+
.on(
440+
'postgres_changes',
441+
{
442+
event: '*',
443+
schema: 'public',
444+
table: 'user_presence'
445+
},
446+
(payload) => {
447+
if (payload.eventType === 'INSERT' || payload.eventType === 'UPDATE') {
448+
callback(payload.new as UserPresence)
449+
}
450+
}
451+
)
452+
.subscribe()
453+
454+
return () => {
455+
subscription.unsubscribe()
456+
}
457+
}
458+
459+
// Set user as online when they join
460+
export async function setUserOnline(userId: string, email: string, name?: string) {
461+
return updateUserPresence(userId, email, name, 'online')
462+
}
463+
464+
// Set user as offline when they leave
465+
export async function setUserOffline(userId: string, email: string, name?: string) {
466+
return updateUserPresence(userId, email, name, 'offline')
467+
}
468+
228469
// ============================================
229470
// COMMENTS
230471
// ============================================

0 commit comments

Comments
 (0)