Compare commits

...

11 Commits

Author SHA1 Message Date
semantic-release-bot
059a9fe750 chore(release): 3.7.0 [skip ci]
## [3.7.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.6.3...v3.7.0) (2025-10-14)

### Features

* add prominent join request approval notifications for room moderators ([036da6d](036da6de66))
2025-10-14 13:00:09 +00:00
Thomas Hallock
036da6de66 feat: add prominent join request approval notifications for room moderators
- Add 'join-request' type to ModerationEvent interface
- Add socket listener for 'join-request-submitted' event in useRoomData
- Update join request POST endpoint to broadcast socket event to room members
- Add prominent toast notification with inline approve/deny buttons in ModerationNotifications
- Toast appears immediately when host receives join request (not buried in settings)
- Approve/deny actions handled directly from toast with API calls

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 07:59:10 -05:00
semantic-release-bot
556e5e4ca0 chore(release): 3.6.3 [skip ci]
## [3.6.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.6.2...v3.6.3) (2025-10-14)

### Bug Fixes

* update locked room terminology and allow existing members ([1ddf985](1ddf985938))
2025-10-14 12:50:45 +00:00
Thomas Hallock
1ddf985938 fix: update locked room terminology and allow existing members
Update locked room terminology and implementation:
- Change description from "No members" to "No new members"
- Allow existing members to continue using locked rooms
- Only block new members from joining locked rooms
- Update join API to check membership before rejecting

This clarifies that "locked" means no NEW members, but existing members
can continue to participate in the room.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 07:49:52 -05:00
semantic-release-bot
8c851462de chore(release): 3.6.2 [skip ci]
## [3.6.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.6.1...v3.6.2) (2025-10-14)

### Bug Fixes

* allow join with pending invitation for restricted rooms ([85b2cf9](85b2cf9816))
2025-10-14 12:48:24 +00:00
Thomas Hallock
85b2cf9816 fix: allow join with pending invitation for restricted rooms
Remove premature check that blocked access to restricted rooms. Now:
- Frontend no longer blocks restricted room access upfront
- Backend API checks for pending invitation
- Users with valid invitations can join successfully
- Users without invitations get appropriate error message

This fixes the issue where users with pending invitations couldn't join
restricted rooms via the join link.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 07:47:28 -05:00
semantic-release-bot
4c6eb01f1e chore(release): 3.6.1 [skip ci]
## [3.6.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.6.0...v3.6.1) (2025-10-14)

### Bug Fixes

* join user socket channel to receive approval notifications ([7d08fdd](7d08fdd906))

### Code Refactoring

* remove redundant polling from approval notifications ([0d4f400](0d4f400dca))
2025-10-14 12:44:53 +00:00
Thomas Hallock
7d08fdd906 fix: join user socket channel to receive approval notifications
The socket wasn't receiving join-request-approved events because it hadn't
joined the user-specific channel. Now:

- Fetch viewer ID from /api/viewer endpoint
- Emit 'join-user-channel' with userId on socket connect
- Socket joins `user:${userId}` room to receive moderation events
- Approval notifications now trigger automatic room join

This completes the real-time approval notification flow.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 07:43:49 -05:00
Thomas Hallock
0d4f400dca refactor: remove redundant polling from approval notifications
Remove polling interval that checked every 5 seconds for approval status.
The socket.io listener provides real-time notifications, making polling
unnecessary and wasteful.

Now relies solely on socket.io for instant approval notifications, which:
- Reduces network traffic
- Simplifies code
- Provides faster response time

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 07:40:02 -05:00
semantic-release-bot
396b6c07c7 chore(release): 3.6.0 [skip ci]
## [3.6.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.5.0...v3.6.0) (2025-10-14)

### Features

* add socket listener and polling for approval notifications ([35b4a72](35b4a72c8b))
2025-10-14 12:38:33 +00:00
Thomas Hallock
35b4a72c8b feat: add socket listener and polling for approval notifications
When users request to join an approval-only room, they now receive real-time
notifications when their request is approved:

- Add socket.io-client listener for 'join-request-approved' events
- Implement polling fallback (every 5 seconds) to check approval status
- Automatically join room when approval is detected via socket or polling
- Apply to both share link page and JoinRoomModal

This completes the approval flow - users no longer need to reload the page
to see if their join request was approved.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 07:37:28 -05:00
9 changed files with 528 additions and 27 deletions

View File

@@ -1,3 +1,43 @@
## [3.7.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.6.3...v3.7.0) (2025-10-14)
### Features
* add prominent join request approval notifications for room moderators ([036da6d](https://github.com/antialias/soroban-abacus-flashcards/commit/036da6de66ca7d3f459c55df657b04a9e88d9cd3))
## [3.6.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.6.2...v3.6.3) (2025-10-14)
### Bug Fixes
* update locked room terminology and allow existing members ([1ddf985](https://github.com/antialias/soroban-abacus-flashcards/commit/1ddf985938d9542fe26e44da58234f3d4e3c9543))
## [3.6.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.6.1...v3.6.2) (2025-10-14)
### Bug Fixes
* allow join with pending invitation for restricted rooms ([85b2cf9](https://github.com/antialias/soroban-abacus-flashcards/commit/85b2cf98167ccf632ab634a94eb436e1eb584614))
## [3.6.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.6.0...v3.6.1) (2025-10-14)
### Bug Fixes
* join user socket channel to receive approval notifications ([7d08fdd](https://github.com/antialias/soroban-abacus-flashcards/commit/7d08fdd90643920857eda09998ac01afbae74154))
### Code Refactoring
* remove redundant polling from approval notifications ([0d4f400](https://github.com/antialias/soroban-abacus-flashcards/commit/0d4f400dca02ad9497522c24fded8b6d07d85fd2))
## [3.6.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.5.0...v3.6.0) (2025-10-14)
### Features
* add socket listener and polling for approval notifications ([35b4a72](https://github.com/antialias/soroban-abacus-flashcards/commit/35b4a72c8b2f80a74b5d2fe02b048d4ec4d1d6f2))
## [3.5.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.4.0...v3.5.0) (2025-10-14)

View File

@@ -2,6 +2,7 @@ import { type NextRequest, NextResponse } from 'next/server'
import { createJoinRequest, getPendingJoinRequests } from '@/lib/arcade/room-join-requests'
import { getRoomById } from '@/lib/arcade/room-manager'
import { getRoomMembers } from '@/lib/arcade/room-membership'
import { getSocketIO } from '@/lib/socket-io'
import { getViewerId } from '@/lib/viewer'
type RouteContext = {
@@ -87,6 +88,29 @@ export async function POST(req: NextRequest, context: RouteContext) {
`[Join Requests] Created request for user ${viewerId} (${displayName}) to join room ${roomId}`
)
// Broadcast to all members in the room (particularly the host) via socket
const io = await getSocketIO()
if (io) {
try {
io.to(`room:${roomId}`).emit('join-request-submitted', {
roomId,
request: {
id: request.id,
userId: request.userId,
userName: request.userName,
createdAt: request.requestedAt,
},
})
console.log(
`[Join Requests] Broadcasted join-request-submitted for user ${viewerId} to room ${roomId}`
)
} catch (socketError) {
// Log but don't fail the request if socket broadcast fails
console.error('[Join Requests] Failed to broadcast join-request-submitted:', socketError)
}
}
return NextResponse.json({ request }, { status: 201 })
} catch (error: any) {
console.error('Failed to create join request:', error)

View File

@@ -1,13 +1,13 @@
import bcrypt from 'bcryptjs'
import { type NextRequest, NextResponse } from 'next/server'
import { getRoomById, touchRoom } from '@/lib/arcade/room-manager'
import { addRoomMember, getRoomMembers } from '@/lib/arcade/room-membership'
import { getActivePlayers, getRoomActivePlayers } from '@/lib/arcade/player-manager'
import { isUserBanned } from '@/lib/arcade/room-moderation'
import { getInvitation } from '@/lib/arcade/room-invitations'
import { getJoinRequest } from '@/lib/arcade/room-join-requests'
import { getViewerId } from '@/lib/viewer'
import { getRoomById, touchRoom } from '@/lib/arcade/room-manager'
import { addRoomMember, getRoomMembers } from '@/lib/arcade/room-membership'
import { isUserBanned } from '@/lib/arcade/room-moderation'
import { getSocketIO } from '@/lib/socket-io'
import bcrypt from 'bcryptjs'
import { getViewerId } from '@/lib/viewer'
type RouteContext = {
params: Promise<{ roomId: string }>
@@ -38,10 +38,21 @@ export async function POST(req: NextRequest, context: RouteContext) {
return NextResponse.json({ error: 'You are banned from this room' }, { status: 403 })
}
// Check if user is already a member (for locked room access)
const members = await getRoomMembers(roomId)
const isExistingMember = members.some((m) => m.userId === viewerId)
// Validate access mode
switch (room.accessMode) {
case 'locked':
return NextResponse.json({ error: 'This room is locked' }, { status: 403 })
// Allow existing members to continue using the room, but block new members
if (!isExistingMember) {
return NextResponse.json(
{ error: 'This room is locked and not accepting new members' },
{ status: 403 }
)
}
break
case 'retired':
return NextResponse.json({ error: 'This room has been retired' }, { status: 410 })
@@ -86,8 +97,6 @@ export async function POST(req: NextRequest, context: RouteContext) {
}
break
}
case 'open':
default:
// No additional checks needed
break

View File

@@ -2,6 +2,7 @@
import { useRouter } from 'next/navigation'
import { useCallback, useEffect, useState } from 'react'
import { io } from 'socket.io-client'
import { useGetRoomByCode, useJoinRoom, useRoomData } from '@/hooks/useRoomData'
import { getRoomDisplayWithEmoji } from '@/utils/room-display'
@@ -276,21 +277,17 @@ export default function JoinRoomPage({ params }: { params: { code: string } }) {
return
}
if (room.accessMode === 'restricted') {
setError('This room is invitation-only')
return
}
if (room.accessMode === 'approval-only') {
setShowApprovalPrompt(true)
return
}
// For restricted rooms, try to join - the API will check for invitation
// If user is in a different room, show confirmation
if (roomData) {
setShowConfirmation(true)
} else {
// Otherwise, auto-join (for open rooms)
// Otherwise, auto-join (for open rooms and restricted rooms with invitation)
handleJoin(room.id)
}
})
@@ -351,6 +348,60 @@ export default function JoinRoomPage({ params }: { params: { code: string } }) {
}
}
// Socket listener for approval notifications
useEffect(() => {
if (!approvalRequested || !targetRoomData) return
console.log('[Join Page] Setting up approval listener for room:', targetRoomData.id)
let socket: ReturnType<typeof io> | null = null
// Fetch viewer ID and set up socket
const setupSocket = async () => {
try {
// Get current user's viewer ID
const res = await fetch('/api/viewer')
if (!res.ok) {
console.error('[Join Page] Failed to get viewer ID')
return
}
const { viewerId } = await res.json()
console.log('[Join Page] Got viewer ID:', viewerId)
// Connect socket
socket = io({ path: '/api/socket' })
socket.on('connect', () => {
console.log('[Join Page] Socket connected, joining user channel')
// Join user-specific channel to receive moderation events
socket?.emit('join-user-channel', { userId: viewerId })
})
socket.on('join-request-approved', (data: { roomId: string; requestId: string }) => {
console.log('[Join Page] Request approved via socket!', data)
if (data.roomId === targetRoomData.id) {
console.log('[Join Page] Joining room automatically...')
handleJoin(targetRoomData.id)
}
})
socket.on('connect_error', (error) => {
console.error('[Join Page] Socket connection error:', error)
})
} catch (error) {
console.error('[Join Page] Error setting up socket:', error)
}
}
setupSocket()
return () => {
console.log('[Join Page] Cleaning up approval listener')
socket?.disconnect()
}
}, [approvalRequested, targetRoomData, handleJoin])
// Only show error page for non-password and non-approval errors
if (error && !showPasswordPrompt && !showApprovalPrompt) {
return (

View File

@@ -1,4 +1,5 @@
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { io } from 'socket.io-client'
import { Modal } from '@/components/common/Modal'
import type { schema } from '@/db'
import { useRoomData } from '@/hooks/useRoomData'
@@ -76,18 +77,14 @@ export function JoinRoomModal({ isOpen, onClose, onSuccess }: JoinRoomModalProps
return
}
if (room.accessMode === 'restricted') {
setError('This room is invitation-only. Please ask the host for an invitation.')
setIsLoading(false)
return
}
if (room.accessMode === 'approval-only') {
setNeedsApproval(true)
setIsLoading(false)
return
}
// For restricted rooms, try to join - the API will check for invitation
if (room.accessMode === 'password') {
// Check if password is provided
if (!needsPassword) {
@@ -142,6 +139,67 @@ export function JoinRoomModal({ isOpen, onClose, onSuccess }: JoinRoomModalProps
}
}
// Socket listener for approval notifications
useEffect(() => {
if (!approvalRequested || !roomInfo) return
console.log('[JoinRoomModal] Setting up approval listener for room:', roomInfo.id)
let socket: ReturnType<typeof io> | null = null
// Fetch viewer ID and set up socket
const setupSocket = async () => {
try {
// Get current user's viewer ID
const res = await fetch('/api/viewer')
if (!res.ok) {
console.error('[JoinRoomModal] Failed to get viewer ID')
return
}
const { viewerId } = await res.json()
console.log('[JoinRoomModal] Got viewer ID:', viewerId)
// Connect socket
socket = io({ path: '/api/socket' })
socket.on('connect', () => {
console.log('[JoinRoomModal] Socket connected, joining user channel')
// Join user-specific channel to receive moderation events
socket?.emit('join-user-channel', { userId: viewerId })
})
socket.on('join-request-approved', async (data: { roomId: string; requestId: string }) => {
console.log('[JoinRoomModal] Request approved via socket!', data)
if (data.roomId === roomInfo.id) {
console.log('[JoinRoomModal] Joining room automatically...')
try {
await joinRoom(roomInfo.id)
handleClose()
onSuccess?.()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to join room')
setIsLoading(false)
}
}
})
socket.on('connect_error', (error) => {
console.error('[JoinRoomModal] Socket connection error:', error)
})
} catch (error) {
console.error('[JoinRoomModal] Error setting up socket:', error)
}
}
setupSocket()
return () => {
console.log('[JoinRoomModal] Cleaning up approval listener')
socket?.disconnect()
}
}, [approvalRequested, roomInfo, joinRoom, handleClose, onSuccess])
return (
<Modal isOpen={isOpen} onClose={handleClose}>
<div

View File

@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import * as Toast from '@radix-ui/react-toast'
import { useQueryClient } from '@tanstack/react-query'
import { useRouter } from 'next/navigation'
import { useEffect, useState } from 'react'
import { Modal } from '@/components/common/Modal'
import type { ModerationEvent } from '@/hooks/useRoomData'
import { useJoinRoom } from '@/hooks/useRoomData'
@@ -25,8 +26,11 @@ export function ModerationNotifications({
onClose,
}: ModerationNotificationsProps) {
const router = useRouter()
const queryClient = useQueryClient()
const [showToast, setShowToast] = useState(false)
const [showJoinRequestToast, setShowJoinRequestToast] = useState(false)
const [isAcceptingInvitation, setIsAcceptingInvitation] = useState(false)
const [isProcessingRequest, setIsProcessingRequest] = useState(false)
const { mutateAsync: joinRoom } = useJoinRoom()
// Handle report toast (for hosts)
@@ -42,6 +46,77 @@ export function ModerationNotifications({
}
}, [moderationEvent, onClose])
// Handle join request toast (for hosts)
useEffect(() => {
if (moderationEvent?.type === 'join-request') {
setShowJoinRequestToast(true)
}
}, [moderationEvent])
// Handle approve join request
const handleApprove = async () => {
if (!moderationEvent?.data.requestId || !moderationEvent?.data.roomId) return
setIsProcessingRequest(true)
try {
const response = await fetch(
`/api/arcade/rooms/${moderationEvent.data.roomId}/join-requests/${moderationEvent.data.requestId}/approve`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
}
)
if (!response.ok) {
throw new Error('Failed to approve join request')
}
// Close toast and event
setShowJoinRequestToast(false)
onClose()
// Invalidate join requests query to refresh the list
queryClient.invalidateQueries({ queryKey: ['join-requests'] })
} catch (error) {
console.error('Failed to approve join request:', error)
alert(error instanceof Error ? error.message : 'Failed to approve request')
} finally {
setIsProcessingRequest(false)
}
}
// Handle deny join request
const handleDeny = async () => {
if (!moderationEvent?.data.requestId || !moderationEvent?.data.roomId) return
setIsProcessingRequest(true)
try {
const response = await fetch(
`/api/arcade/rooms/${moderationEvent.data.roomId}/join-requests/${moderationEvent.data.requestId}/deny`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
}
)
if (!response.ok) {
throw new Error('Failed to deny join request')
}
// Close toast and event
setShowJoinRequestToast(false)
onClose()
// Invalidate join requests query to refresh the list
queryClient.invalidateQueries({ queryKey: ['join-requests'] })
} catch (error) {
console.error('Failed to deny join request:', error)
alert(error instanceof Error ? error.message : 'Failed to deny request')
} finally {
setIsProcessingRequest(false)
}
}
// Kicked modal
if (moderationEvent?.type === 'kicked') {
return (
@@ -391,6 +466,223 @@ export function ModerationNotifications({
)
}
// Join request toast (for hosts)
if (moderationEvent?.type === 'join-request') {
return (
<Toast.Provider swipeDirection="right" duration={Infinity}>
<Toast.Root
open={showJoinRequestToast}
onOpenChange={(open) => {
if (!open) {
setShowJoinRequestToast(false)
onClose()
}
}}
style={{
background:
'linear-gradient(135deg, rgba(59, 130, 246, 0.97), rgba(37, 99, 235, 0.97))',
border: '2px solid rgba(59, 130, 246, 0.6)',
borderRadius: '12px',
padding: '16px',
boxShadow: '0 8px 24px rgba(0, 0, 0, 0.4)',
display: 'flex',
gap: '12px',
alignItems: 'flex-start',
minWidth: '350px',
maxWidth: '450px',
transition: 'all 0.2s ease',
}}
>
<div style={{ fontSize: '24px', flexShrink: 0 }}></div>
<div style={{ flex: 1 }}>
<Toast.Title
style={{
fontSize: '15px',
fontWeight: 'bold',
color: 'white',
marginBottom: '4px',
}}
>
Join Request
</Toast.Title>
<Toast.Description
style={{
fontSize: '13px',
color: 'rgba(255, 255, 255, 0.9)',
marginBottom: '12px',
}}
>
<strong>{moderationEvent.data.requesterName}</strong> wants to join your room
</Toast.Description>
{/* Action buttons */}
<div style={{ display: 'flex', gap: '8px' }}>
<button
type="button"
disabled={isProcessingRequest}
onClick={(e) => {
e.stopPropagation()
handleDeny()
}}
style={{
flex: 1,
padding: '8px 12px',
background: isProcessingRequest
? 'rgba(75, 85, 99, 0.3)'
: 'rgba(255, 255, 255, 0.2)',
color: 'white',
border: '1px solid rgba(255, 255, 255, 0.3)',
borderRadius: '8px',
fontSize: '13px',
fontWeight: '600',
cursor: isProcessingRequest ? 'not-allowed' : 'pointer',
transition: 'all 0.2s ease',
opacity: isProcessingRequest ? 0.5 : 1,
}}
onMouseEnter={(e) => {
if (!isProcessingRequest) {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.25)'
}
}}
onMouseLeave={(e) => {
if (!isProcessingRequest) {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.2)'
}
}}
>
Deny
</button>
<button
type="button"
disabled={isProcessingRequest}
onClick={(e) => {
e.stopPropagation()
handleApprove()
}}
style={{
flex: 1,
padding: '8px 12px',
background: isProcessingRequest
? 'rgba(75, 85, 99, 0.3)'
: 'rgba(34, 197, 94, 0.9)',
color: 'white',
border: '1px solid rgba(34, 197, 94, 0.8)',
borderRadius: '8px',
fontSize: '13px',
fontWeight: '600',
cursor: isProcessingRequest ? 'not-allowed' : 'pointer',
transition: 'all 0.2s ease',
opacity: isProcessingRequest ? 0.5 : 1,
}}
onMouseEnter={(e) => {
if (!isProcessingRequest) {
e.currentTarget.style.background = 'rgba(34, 197, 94, 1)'
}
}}
onMouseLeave={(e) => {
if (!isProcessingRequest) {
e.currentTarget.style.background = 'rgba(34, 197, 94, 0.9)'
}
}}
>
{isProcessingRequest ? 'Processing...' : 'Approve'}
</button>
</div>
</div>
<Toast.Close
style={{
background: 'rgba(255, 255, 255, 0.2)',
border: 'none',
borderRadius: '50%',
width: '24px',
height: '24px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
color: 'white',
fontSize: '16px',
lineHeight: 1,
flexShrink: 0,
}}
>
×
</Toast.Close>
</Toast.Root>
<Toast.Viewport
style={{
position: 'fixed',
top: '80px',
right: '20px',
display: 'flex',
flexDirection: 'column',
gap: '10px',
zIndex: 10001,
maxWidth: '100vw',
margin: 0,
listStyle: 'none',
outline: 'none',
}}
/>
<style
dangerouslySetInnerHTML={{
__html: `
@keyframes slideIn {
from {
transform: translateX(calc(100% + 25px));
}
to {
transform: translateX(0);
}
}
@keyframes slideOut {
from {
transform: translateX(0);
}
to {
transform: translateX(calc(100% + 25px));
}
}
@keyframes hide {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
[data-state='open'] {
animation: slideIn 150ms cubic-bezier(0.16, 1, 0.3, 1);
}
[data-state='closed'] {
animation: hide 100ms ease-in, slideOut 200ms cubic-bezier(0.32, 0, 0.67, 0);
}
[data-swipe='move'] {
transform: translateX(var(--radix-toast-swipe-move-x));
}
[data-swipe='cancel'] {
transform: translateX(0);
transition: transform 200ms ease-out;
}
[data-swipe='end'] {
animation: slideOut 100ms ease-out;
}
`,
}}
/>
</Toast.Provider>
)
}
// Invitation modal
if (moderationEvent?.type === 'invitation') {
const invitationType = moderationEvent.data.invitationType

View File

@@ -1352,7 +1352,7 @@ export function ModerationPanel({
label: 'Restricted',
desc: 'Invite only',
},
{ value: 'locked', emoji: '🔒', label: 'Locked', desc: 'No members' },
{ value: 'locked', emoji: '🔒', label: 'Locked', desc: 'No new members' },
{ value: 'retired', emoji: '🏁', label: 'Retired', desc: 'Closed' },
].map((mode) => (
<button

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useCallback, useEffect, useState } from 'react'
import { io, type Socket } from 'socket.io-client'
import { useViewerId } from './useViewerId'
@@ -190,7 +190,7 @@ async function getRoomByCodeApi(code: string): Promise<RoomData> {
}
export interface ModerationEvent {
type: 'kicked' | 'banned' | 'report' | 'invitation'
type: 'kicked' | 'banned' | 'report' | 'invitation' | 'join-request'
data: {
roomId?: string
kickedBy?: string
@@ -206,6 +206,10 @@ export interface ModerationEvent {
invitedByName?: string
invitationType?: 'manual' | 'auto-unban' | 'auto-create'
message?: string
// Join request fields
requestId?: string
requesterId?: string
requesterName?: string
}
}
@@ -420,6 +424,27 @@ export function useRoomData() {
})
}
const handleJoinRequestSubmitted = (data: {
roomId: string
request: {
id: string
userId: string
userName: string
createdAt: Date
}
}) => {
console.log('[useRoomData] New join request submitted:', data)
setModerationEvent({
type: 'join-request',
data: {
roomId: data.roomId,
requestId: data.request.id,
requesterId: data.request.userId,
requesterName: data.request.userName,
},
})
}
socket.on('room-joined', handleRoomJoined)
socket.on('member-joined', handleMemberJoined)
socket.on('member-left', handleMemberLeft)
@@ -428,6 +453,7 @@ export function useRoomData() {
socket.on('banned-from-room', handleBannedFromRoom)
socket.on('report-submitted', handleReportSubmitted)
socket.on('room-invitation-received', handleInvitationReceived)
socket.on('join-request-submitted', handleJoinRequestSubmitted)
return () => {
socket.off('room-joined', handleRoomJoined)
@@ -438,6 +464,7 @@ export function useRoomData() {
socket.off('banned-from-room', handleBannedFromRoom)
socket.off('report-submitted', handleReportSubmitted)
socket.off('room-invitation-received', handleInvitationReceived)
socket.off('join-request-submitted', handleJoinRequestSubmitted)
}
}, [socket, roomData?.id, queryClient])

View File

@@ -1,6 +1,6 @@
{
"name": "soroban-monorepo",
"version": "3.5.0",
"version": "3.7.0",
"private": true,
"description": "Beautiful Soroban Flashcard Generator - Monorepo",
"workspaces": [