feat: add real-time socket updates for moderation events
Enhance socket server and useRoomData hook to support: - User-specific channels for personal notifications (kicks, bans, invitations) - Socket connections maintained when authenticated (not just in rooms) - Real-time moderation event handling (kicked, banned, reported, invited) - ModerationEvent types and handlers in useRoomData This enables users to receive invitations and moderation notifications anywhere in the app, including the arcade lobby, without page refresh. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { io, type Socket } from 'socket.io-client'
|
||||
import { useViewerId } from './useViewerId'
|
||||
|
||||
@@ -26,62 +27,200 @@ export interface RoomData {
|
||||
memberPlayers: Record<string, RoomPlayer[]> // userId -> players
|
||||
}
|
||||
|
||||
export interface CreateRoomParams {
|
||||
name: string
|
||||
gameName: string
|
||||
creatorName?: string
|
||||
gameConfig?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface JoinRoomResult {
|
||||
member: RoomMember
|
||||
room: RoomData
|
||||
activePlayers: RoomPlayer[]
|
||||
autoLeave?: {
|
||||
roomIds: string[]
|
||||
message: string
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Query key factory for rooms
|
||||
*/
|
||||
export const roomKeys = {
|
||||
all: ['rooms'] as const,
|
||||
current: () => [...roomKeys.all, 'current'] as const,
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the user's current room
|
||||
*/
|
||||
async function fetchCurrentRoom(): Promise<RoomData | null> {
|
||||
const response = await fetch('/api/arcade/rooms/current')
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) return null
|
||||
throw new Error('Failed to fetch current room')
|
||||
}
|
||||
const data = await response.json()
|
||||
if (!data.room) return null
|
||||
return {
|
||||
id: data.room.id,
|
||||
name: data.room.name,
|
||||
code: data.room.code,
|
||||
gameName: data.room.gameName,
|
||||
members: data.members || [],
|
||||
memberPlayers: data.memberPlayers || {},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new room
|
||||
*/
|
||||
async function createRoomApi(params: CreateRoomParams): Promise<RoomData> {
|
||||
const response = await fetch('/api/arcade/rooms', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: params.name,
|
||||
gameName: params.gameName,
|
||||
creatorName: params.creatorName || 'Player',
|
||||
gameConfig: params.gameConfig || { difficulty: 6 },
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error || 'Failed to create room')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return {
|
||||
id: data.room.id,
|
||||
name: data.room.name,
|
||||
code: data.room.code,
|
||||
gameName: data.room.gameName,
|
||||
members: data.members || [],
|
||||
memberPlayers: data.memberPlayers || {},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Join a room
|
||||
*/
|
||||
async function joinRoomApi(params: {
|
||||
roomId: string
|
||||
displayName?: string
|
||||
}): Promise<JoinRoomResult> {
|
||||
const response = await fetch(`/api/arcade/rooms/${params.roomId}/join`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ displayName: params.displayName || 'Player' }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error || 'Failed to join room')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return {
|
||||
...data,
|
||||
room: {
|
||||
id: data.room.id,
|
||||
name: data.room.name,
|
||||
code: data.room.code,
|
||||
gameName: data.room.gameName,
|
||||
members: data.members || [],
|
||||
memberPlayers: data.memberPlayers || {},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Leave a room
|
||||
*/
|
||||
async function leaveRoomApi(roomId: string): Promise<void> {
|
||||
const response = await fetch(`/api/arcade/rooms/${roomId}/leave`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error || 'Failed to leave room')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get room by join code
|
||||
*/
|
||||
async function getRoomByCodeApi(code: string): Promise<RoomData> {
|
||||
const response = await fetch(`/api/arcade/rooms/code/${code}`)
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
throw new Error('Room not found')
|
||||
}
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error || 'Failed to find room')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return {
|
||||
id: data.room.id,
|
||||
name: data.room.name,
|
||||
code: data.room.code,
|
||||
gameName: data.room.gameName,
|
||||
members: data.members || [],
|
||||
memberPlayers: data.memberPlayers || {},
|
||||
}
|
||||
}
|
||||
|
||||
export interface ModerationEvent {
|
||||
type: 'kicked' | 'banned' | 'report' | 'invitation'
|
||||
data: {
|
||||
roomId?: string
|
||||
kickedBy?: string
|
||||
bannedBy?: string
|
||||
reason?: string
|
||||
reportId?: string
|
||||
reporterName?: string
|
||||
reportedUserName?: string
|
||||
reportedUserId?: string
|
||||
// Invitation fields
|
||||
invitationId?: string
|
||||
invitedBy?: string
|
||||
invitedByName?: string
|
||||
invitationType?: 'manual' | 'auto-unban' | 'auto-create'
|
||||
message?: string
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch and subscribe to the user's current room data
|
||||
* Returns null if user is not in any room
|
||||
*/
|
||||
export function useRoomData() {
|
||||
const { data: userId, isPending: isUserIdPending } = useViewerId()
|
||||
const { data: userId } = useViewerId()
|
||||
const queryClient = useQueryClient()
|
||||
const [socket, setSocket] = useState<Socket | null>(null)
|
||||
const [roomData, setRoomData] = useState<RoomData | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [hasAttemptedFetch, setHasAttemptedFetch] = useState(false)
|
||||
const [moderationEvent, setModerationEvent] = useState<ModerationEvent | null>(null)
|
||||
|
||||
// Fetch the user's current room
|
||||
// Fetch current room with TanStack Query
|
||||
const {
|
||||
data: roomData,
|
||||
isLoading,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
queryKey: roomKeys.current(),
|
||||
queryFn: fetchCurrentRoom,
|
||||
enabled: !!userId,
|
||||
staleTime: 30000, // Consider data fresh for 30 seconds
|
||||
})
|
||||
|
||||
// Initialize socket connection when user is authenticated (regardless of room membership)
|
||||
useEffect(() => {
|
||||
if (!userId) {
|
||||
setRoomData(null)
|
||||
setHasAttemptedFetch(false)
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
setHasAttemptedFetch(false)
|
||||
|
||||
// Fetch current room data
|
||||
fetch('/api/arcade/rooms/current')
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error('Failed to fetch current room')
|
||||
return res.json()
|
||||
})
|
||||
.then((data) => {
|
||||
if (data.room) {
|
||||
const roomData = {
|
||||
id: data.room.id,
|
||||
name: data.room.name,
|
||||
code: data.room.code,
|
||||
gameName: data.room.gameName,
|
||||
members: data.members || [],
|
||||
memberPlayers: data.memberPlayers || {},
|
||||
}
|
||||
setRoomData(roomData)
|
||||
} else {
|
||||
setRoomData(null)
|
||||
}
|
||||
setIsLoading(false)
|
||||
setHasAttemptedFetch(true)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('[useRoomData] Failed to fetch room data:', error)
|
||||
setRoomData(null)
|
||||
setIsLoading(false)
|
||||
setHasAttemptedFetch(true)
|
||||
})
|
||||
}, [userId])
|
||||
|
||||
// Initialize socket connection when user has a room
|
||||
useEffect(() => {
|
||||
if (!roomData?.id || !userId) {
|
||||
if (socket) {
|
||||
socket.disconnect()
|
||||
setSocket(null)
|
||||
@@ -92,8 +231,13 @@ export function useRoomData() {
|
||||
const sock = io({ path: '/api/socket' })
|
||||
|
||||
sock.on('connect', () => {
|
||||
// Join the room to receive updates
|
||||
sock.emit('join-room', { roomId: roomData.id, userId })
|
||||
// Always join user-specific channel for personal notifications (invitations, bans, kicks)
|
||||
sock.emit('join-user-channel', { userId })
|
||||
|
||||
// Join room channel only if user is in a room
|
||||
if (roomData?.id) {
|
||||
sock.emit('join-room', { roomId: roomData.id, userId })
|
||||
}
|
||||
})
|
||||
|
||||
sock.on('disconnect', () => {
|
||||
@@ -104,24 +248,26 @@ export function useRoomData() {
|
||||
|
||||
return () => {
|
||||
if (sock.connected) {
|
||||
// Leave the room before disconnecting
|
||||
sock.emit('leave-room', { roomId: roomData.id, userId })
|
||||
// Leave the room before disconnecting (if in a room)
|
||||
if (roomData?.id) {
|
||||
sock.emit('leave-room', { roomId: roomData.id, userId })
|
||||
}
|
||||
sock.disconnect()
|
||||
}
|
||||
}
|
||||
}, [roomData?.id, userId])
|
||||
}, [userId, roomData?.id])
|
||||
|
||||
// Subscribe to real-time updates via socket
|
||||
useEffect(() => {
|
||||
if (!socket || !roomData?.id) return
|
||||
if (!socket) return
|
||||
|
||||
const handleRoomJoined = (data: {
|
||||
roomId: string
|
||||
members: RoomMember[]
|
||||
memberPlayers: Record<string, RoomPlayer[]>
|
||||
}) => {
|
||||
if (data.roomId === roomData.id) {
|
||||
setRoomData((prev) => {
|
||||
if (data.roomId === roomData?.id) {
|
||||
queryClient.setQueryData<RoomData | null>(roomKeys.current(), (prev) => {
|
||||
if (!prev) return null
|
||||
return {
|
||||
...prev,
|
||||
@@ -138,8 +284,8 @@ export function useRoomData() {
|
||||
members: RoomMember[]
|
||||
memberPlayers: Record<string, RoomPlayer[]>
|
||||
}) => {
|
||||
if (data.roomId === roomData.id) {
|
||||
setRoomData((prev) => {
|
||||
if (data.roomId === roomData?.id) {
|
||||
queryClient.setQueryData<RoomData | null>(roomKeys.current(), (prev) => {
|
||||
if (!prev) return null
|
||||
return {
|
||||
...prev,
|
||||
@@ -156,8 +302,8 @@ export function useRoomData() {
|
||||
members: RoomMember[]
|
||||
memberPlayers: Record<string, RoomPlayer[]>
|
||||
}) => {
|
||||
if (data.roomId === roomData.id) {
|
||||
setRoomData((prev) => {
|
||||
if (data.roomId === roomData?.id) {
|
||||
queryClient.setQueryData<RoomData | null>(roomKeys.current(), (prev) => {
|
||||
if (!prev) return null
|
||||
return {
|
||||
...prev,
|
||||
@@ -172,8 +318,8 @@ export function useRoomData() {
|
||||
roomId: string
|
||||
memberPlayers: Record<string, RoomPlayer[]>
|
||||
}) => {
|
||||
if (data.roomId === roomData.id) {
|
||||
setRoomData((prev) => {
|
||||
if (data.roomId === roomData?.id) {
|
||||
queryClient.setQueryData<RoomData | null>(roomKeys.current(), (prev) => {
|
||||
if (!prev) return null
|
||||
return {
|
||||
...prev,
|
||||
@@ -183,32 +329,191 @@ export function useRoomData() {
|
||||
}
|
||||
}
|
||||
|
||||
// Moderation event handlers
|
||||
const handleKickedFromRoom = (data: { roomId: string; kickedBy: string }) => {
|
||||
console.log('[useRoomData] User was kicked from room:', data)
|
||||
setModerationEvent({
|
||||
type: 'kicked',
|
||||
data: {
|
||||
roomId: data.roomId,
|
||||
kickedBy: data.kickedBy,
|
||||
},
|
||||
})
|
||||
// Clear room data since user was kicked
|
||||
queryClient.setQueryData(roomKeys.current(), null)
|
||||
}
|
||||
|
||||
const handleBannedFromRoom = (data: { roomId: string; bannedBy: string; reason: string }) => {
|
||||
console.log('[useRoomData] User was banned from room:', data)
|
||||
setModerationEvent({
|
||||
type: 'banned',
|
||||
data: {
|
||||
roomId: data.roomId,
|
||||
bannedBy: data.bannedBy,
|
||||
reason: data.reason,
|
||||
},
|
||||
})
|
||||
// Clear room data since user was banned
|
||||
queryClient.setQueryData(roomKeys.current(), null)
|
||||
}
|
||||
|
||||
const handleReportSubmitted = (data: {
|
||||
roomId: string
|
||||
report: {
|
||||
id: string
|
||||
reporterName: string
|
||||
reportedUserName: string
|
||||
reportedUserId: string
|
||||
reason: string
|
||||
createdAt: Date
|
||||
}
|
||||
}) => {
|
||||
console.log('[useRoomData] New report submitted:', data)
|
||||
setModerationEvent({
|
||||
type: 'report',
|
||||
data: {
|
||||
roomId: data.roomId,
|
||||
reportId: data.report.id,
|
||||
reporterName: data.report.reporterName,
|
||||
reportedUserName: data.report.reportedUserName,
|
||||
reportedUserId: data.report.reportedUserId,
|
||||
reason: data.report.reason,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const handleInvitationReceived = (data: {
|
||||
invitation: {
|
||||
id: string
|
||||
roomId: string
|
||||
invitedBy: string
|
||||
invitedByName: string
|
||||
invitationType?: 'manual' | 'auto-unban' | 'auto-create'
|
||||
message?: string
|
||||
createdAt: Date
|
||||
}
|
||||
}) => {
|
||||
console.log('[useRoomData] Room invitation received:', data)
|
||||
setModerationEvent({
|
||||
type: 'invitation',
|
||||
data: {
|
||||
roomId: data.invitation.roomId,
|
||||
invitationId: data.invitation.id,
|
||||
invitedBy: data.invitation.invitedBy,
|
||||
invitedByName: data.invitation.invitedByName,
|
||||
invitationType: data.invitation.invitationType,
|
||||
message: data.invitation.message,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
socket.on('room-joined', handleRoomJoined)
|
||||
socket.on('member-joined', handleMemberJoined)
|
||||
socket.on('member-left', handleMemberLeft)
|
||||
socket.on('room-players-updated', handleRoomPlayersUpdated)
|
||||
socket.on('kicked-from-room', handleKickedFromRoom)
|
||||
socket.on('banned-from-room', handleBannedFromRoom)
|
||||
socket.on('report-submitted', handleReportSubmitted)
|
||||
socket.on('room-invitation-received', handleInvitationReceived)
|
||||
|
||||
return () => {
|
||||
socket.off('room-joined', handleRoomJoined)
|
||||
socket.off('member-joined', handleMemberJoined)
|
||||
socket.off('member-left', handleMemberLeft)
|
||||
socket.off('room-players-updated', handleRoomPlayersUpdated)
|
||||
socket.off('kicked-from-room', handleKickedFromRoom)
|
||||
socket.off('banned-from-room', handleBannedFromRoom)
|
||||
socket.off('report-submitted', handleReportSubmitted)
|
||||
socket.off('room-invitation-received', handleInvitationReceived)
|
||||
}
|
||||
}, [socket, roomData?.id])
|
||||
}, [socket, roomData?.id, queryClient])
|
||||
|
||||
// Function to notify room members of player updates
|
||||
const notifyRoomOfPlayerUpdate = () => {
|
||||
const notifyRoomOfPlayerUpdate = useCallback(() => {
|
||||
if (socket && roomData?.id && userId) {
|
||||
console.log('[useRoomData] Notifying room of player update')
|
||||
socket.emit('players-updated', { roomId: roomData.id, userId })
|
||||
}
|
||||
}
|
||||
}, [socket, roomData?.id, userId])
|
||||
|
||||
/**
|
||||
* Generate a shareable URL for the room using the join code
|
||||
*/
|
||||
const getRoomShareUrl = useCallback((code: string): string => {
|
||||
return `${window.location.origin}/join/${code.toUpperCase()}`
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Clear the moderation event after it's been handled
|
||||
*/
|
||||
const clearModerationEvent = useCallback(() => {
|
||||
setModerationEvent(null)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
roomData,
|
||||
// Loading if: userId is pending, currently fetching, or have userId but haven't tried fetching yet
|
||||
isLoading: isUserIdPending || isLoading || (!!userId && !hasAttemptedFetch),
|
||||
// Data
|
||||
roomData: roomData ?? null,
|
||||
isLoading,
|
||||
isInRoom: !!roomData,
|
||||
moderationEvent,
|
||||
// Actions
|
||||
refetch,
|
||||
getRoomShareUrl,
|
||||
notifyRoomOfPlayerUpdate,
|
||||
clearModerationEvent,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook: Create a room
|
||||
*/
|
||||
export function useCreateRoom() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: createRoomApi,
|
||||
onSuccess: (newRoom) => {
|
||||
// Optimistically set the cache with the new room data
|
||||
queryClient.setQueryData(roomKeys.current(), newRoom)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook: Join a room
|
||||
*/
|
||||
export function useJoinRoom() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: joinRoomApi,
|
||||
onSuccess: (result) => {
|
||||
// Optimistically set the cache with the joined room data
|
||||
queryClient.setQueryData(roomKeys.current(), result.room)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook: Leave a room
|
||||
*/
|
||||
export function useLeaveRoom() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: leaveRoomApi,
|
||||
onSuccess: () => {
|
||||
// Optimistically clear the room data
|
||||
queryClient.setQueryData(roomKeys.current(), null)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook: Get room by code
|
||||
*/
|
||||
export function useGetRoomByCode() {
|
||||
return useMutation({
|
||||
mutationFn: getRoomByCodeApi,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -359,6 +359,18 @@ export function initializeSocketServer(httpServer: HTTPServer) {
|
||||
}
|
||||
})
|
||||
|
||||
// User Channel: Join (for moderation events)
|
||||
socket.on('join-user-channel', async ({ userId }: { userId: string }) => {
|
||||
console.log(`👤 User ${userId} joining user-specific channel`)
|
||||
try {
|
||||
// Join user-specific channel for moderation notifications
|
||||
socket.join(`user:${userId}`)
|
||||
console.log(`✅ User ${userId} joined user channel`)
|
||||
} catch (error) {
|
||||
console.error('Error joining user channel:', error)
|
||||
}
|
||||
})
|
||||
|
||||
// Room: Leave
|
||||
socket.on('leave-room', async ({ roomId, userId }: { roomId: string; userId: string }) => {
|
||||
console.log(`🚪 User ${userId} leaving room ${roomId}`)
|
||||
|
||||
Reference in New Issue
Block a user