diff --git a/src/app/(protected)/ProtectedLayoutClient.module.scss b/src/app/(protected)/ProtectedLayoutClient.module.scss index 189d5baa..5f819536 100644 --- a/src/app/(protected)/ProtectedLayoutClient.module.scss +++ b/src/app/(protected)/ProtectedLayoutClient.module.scss @@ -12,6 +12,8 @@ display: flex; flex-direction: column; min-height: 0; + height: calc(100dvh - 59px); + max-height: calc(100dvh - 59px); overflow: hidden; } diff --git a/src/app/api/contests/rooms/[id]/ready/route.ts b/src/app/api/contests/rooms/[id]/ready/route.ts index 09b27d6e..9e8c7e05 100644 --- a/src/app/api/contests/rooms/[id]/ready/route.ts +++ b/src/app/api/contests/rooms/[id]/ready/route.ts @@ -4,6 +4,7 @@ import { auth } from "@/lib/auth"; import { getRedis } from "@/lib/redis"; import ContestRoom from "@/models/ContestRoom"; import ContestTeam from "@/models/ContestTeam"; +import User from "@/models/User"; import dbConnect from "@/lib/mongodb"; import ContestMatch from "@/models/ContestMatch"; import { publishRoom } from "@/lib/contests/events"; @@ -13,6 +14,8 @@ import { contestRoomStateSchema, parseContestRoomProblems, } from "@/lib/contests/runtime"; +import { getDisplayName } from "@/lib/contests/names"; +import { appendActivityLog } from "@/lib/contests/activityLog"; import { errorToLogMetadata, logger } from "@/lib/utils"; import { parseRouteParams } from "@/lib/api/result"; import { contestIdParamsSchema } from "@/lib/api/schemas/contestRoute"; @@ -76,11 +79,14 @@ export async function POST( const readyAdded = await redis.sAdd(`room:${roomId}:ready_users`, userId); if (readyAdded) { + const readyName = await getDisplayName(redis, userId, userTeamId); + // Publish individual ready state await publishRoom(roomId, { type: "room.user_ready", roomId, userId, + readyName, }); // Check if this user's entire team is ready @@ -179,6 +185,13 @@ export async function POST( scores, }); + await appendActivityLog(redis, `room:${roomId}:activity_log`, { + icon: "info", + text: state.type === "arena" ? "Arena match started! Good luck." : "Match started! Good luck.", + color: "text-on-surface", + eventType: "room.state_sync" + }); + // Enqueue time limit job const timeLimitSecs = parseInt(state.timeLimit || "3600", 10); await reconciliationQueue.add( diff --git a/src/app/api/contests/stream/route.ts b/src/app/api/contests/stream/route.ts index 0f1c2c9b..c4a5f54f 100644 --- a/src/app/api/contests/stream/route.ts +++ b/src/app/api/contests/stream/route.ts @@ -11,7 +11,10 @@ import { reconciliationQueue } from "@/lib/contests/queues"; import { contestRoomStateSchema, parseContestRoomProblems, + storedActivityEntrySchema, } from "@/lib/contests/runtime"; +import { getDisplayName } from "@/lib/contests/names"; +import { appendActivityLog } from "@/lib/contests/activityLog"; import { parseSearchParams } from "@/lib/api/result"; import { contestStreamQuerySchema } from "@/lib/api/schemas/contestRoute"; @@ -41,6 +44,8 @@ export async function GET(request: NextRequest) { : []; const redis = await getRedis(); + + const initialSyncEvents: any[] = []; for (const room of activeRooms) { const roomId = room._id.toString(); @@ -94,6 +99,30 @@ export async function GET(request: NextRequest) { cancelledForfeit: cancelled, }); + let displayName = "Unknown"; + let teamIdForUser: string | null = null; + const allTeamsForName = await redis.sMembers(`room:${roomId}:teams`); + for (const tId of allTeamsForName) { + const isMember = await redis.sIsMember(`team:${tId}:users`, userId); + if (isMember) { + teamIdForUser = tId; + break; + } + } + displayName = await getDisplayName(redis, userId, teamIdForUser); + + + const text = cancelled + ? `${displayName} reconnected. Forfeiture cancelled.` + : `${displayName} connected${currentStatus === "waiting" ? " (Not Ready)" : ""}.`; + + await appendActivityLog(redis, `room:${roomId}:activity_log`, { + icon: "person", + text, + color: "text-secondary", + eventType: "presence.online" + }); + // Send a full state resync directly to the reconnecting user so they catch up on any // changes that happened while they were disconnected (missed SSE events). if (currentStatus === "active" || currentStatus === "waiting") { @@ -117,16 +146,32 @@ export async function GET(request: NextRequest) { ? await redis.hGetAll(`room:${roomId}:locks`) : {}; - await publishUser(userId, { - type: "room.state_sync", - roomId, - state: stateObj, - problems, - scores, - locks, + const sharedLogRaw = await redis.lRange(`room:${roomId}:activity_log`, 0, 49); + const userLogRaw = await redis.lRange(`room:${roomId}:activity_log:${userId}`, 0, 49); + + const mergedLog = [...sharedLogRaw, ...userLogRaw] + .map(str => { + try { return storedActivityEntrySchema.parse(JSON.parse(str)); } + catch (e) { return null; } + }) + .filter((entry): entry is NonNullable => entry !== null) + .sort((a, b) => b.timestamp - a.timestamp) + .slice(0, 50); + + initialSyncEvents.push({ + channel: `events:user:${userId}`, + payload: { + type: "room.state_sync", + roomId, + state: stateObj, + problems, + scores, + locks, + activityLog: mergedLog, + } }); } catch (syncErr) { - logger.error("[SSE] Failed to send reconnect state_sync:", syncErr); + logger.error("[SSE] Failed to prepare reconnect state_sync:", syncErr); } } } @@ -216,6 +261,24 @@ export async function GET(request: NextRequest) { forfeitTimeout: timeoutSeconds, }); + let displayName = "Unknown"; + let teamIdForUser: string | null = null; + const allTeamsForName = await redis.sMembers(`room:${roomId}:teams`); + for (const tId of allTeamsForName) { + const isMember = await redis.sIsMember(`team:${tId}:users`, userId); + if (isMember) { + teamIdForUser = tId; + break; + } + } + displayName = await getDisplayName(redis, userId, teamIdForUser); + await appendActivityLog(redis, `room:${roomId}:activity_log`, { + icon: "person_off", + text: `${displayName} disconnected. Match will be forfeited in ${timeoutSeconds}s.`, + color: "text-error", + eventType: "presence.offline" + }); + await reconciliationQueue.add( "mid_match_disconnect_timeout", { @@ -232,10 +295,46 @@ export async function GET(request: NextRequest) { } else { // Publish offline status without scheduling forfeit await publishRoom(roomId, { type: "presence.offline", userId }); + + let displayName = "Unknown"; + let teamIdForUser: string | null = null; + const allTeamsForName = await redis.sMembers(`room:${roomId}:teams`); + for (const tId of allTeamsForName) { + const isMember = await redis.sIsMember(`team:${tId}:users`, userId); + if (isMember) { + teamIdForUser = tId; + break; + } + } + displayName = await getDisplayName(redis, userId, teamIdForUser); + await appendActivityLog(redis, `room:${roomId}:activity_log`, { + icon: "person_off", + text: `${displayName} disconnected.`, + color: "text-error", + eventType: "presence.offline" + }); } } else { // If room is not active (Eg. waiting), just publish offline status normally await publishRoom(roomId, { type: "presence.offline", userId }); + + let displayName = "Unknown"; + let teamIdForUser: string | null = null; + const allTeamsForName = await redis.sMembers(`room:${roomId}:teams`); + for (const tId of allTeamsForName) { + const isMember = await redis.sIsMember(`team:${tId}:users`, userId); + if (isMember) { + teamIdForUser = tId; + break; + } + } + displayName = await getDisplayName(redis, userId, teamIdForUser); + await appendActivityLog(redis, `room:${roomId}:activity_log`, { + icon: "person_off", + text: `${displayName} disconnected.`, + color: "text-error", + eventType: "presence.offline" + }); } } } catch (err) { @@ -285,6 +384,11 @@ export async function GET(request: NextRequest) { } catch (e) {} sendEvent("message", { channel, payload: parsed }); }); + + // Send the initial state syncs now that the connection is fully established + for (const ev of initialSyncEvents) { + sendEvent("message", ev); + } } catch (err) { logger.error("[SSE] Failed to subscribe to Redis channels:", err); controller.error(err); diff --git a/src/app/api/contests/sync/route.ts b/src/app/api/contests/sync/route.ts index 8b4d5e0b..34c013ed 100644 --- a/src/app/api/contests/sync/route.ts +++ b/src/app/api/contests/sync/route.ts @@ -4,6 +4,8 @@ import { auth } from "@/lib/auth"; import { getRedis } from "@/lib/redis"; import { publishUser } from "@/lib/contests/events"; import { cfSyncQueue } from "@/lib/contests/queues"; +import User from "@/models/User"; +import { appendActivityLog } from "@/lib/contests/activityLog"; import { logger } from "@/lib/utils"; import { parseJson } from "@/lib/api/result"; import { contestSyncSchema } from "@/lib/api/schemas/contestRoute"; @@ -90,6 +92,13 @@ export async function POST(request: NextRequest) { // 5. Publish event to user await publishUser(userId, { type: "sync.queued", position, problemId }); + await appendActivityLog(redis, `room:${roomId}:activity_log:${userId}`, { + icon: "sync", + text: "Submission queued for verification...", + color: "text-secondary", + eventType: "sync.queued" + }); + // 6. Return 202 return jsonOk({ queued: true }, { status: 202 }); } catch (error: unknown) { diff --git a/src/components/contests/ArenaRoomClient.tsx b/src/components/contests/ArenaRoomClient.tsx index e2903db1..a3cb5d2a 100644 --- a/src/components/contests/ArenaRoomClient.tsx +++ b/src/components/contests/ArenaRoomClient.tsx @@ -57,11 +57,44 @@ const ACTIVITY_ICON_MAP: Record = { info: Info, gavel: Gavel, lock: Lock, - sync: RefreshCw, + switch: RefreshCw, check_circle: CircleCheck, - error: CircleAlert, + warning: CircleAlert, person: User, person_off: UserX, + team: Users, +}; + +const filterActivitySpam = (feed: RoomActivityDto[]) => { + const result: RoomActivityDto[] = []; + const skipIndex = new Set(); + + for (let i = 0; i < feed.length; i++) { + if (skipIndex.has(i)) continue; + + const current = feed[i]; + if (current.icon === "person") { + const nameMatch = current.text.match(/^(.+?) (connected|reconnected)/); + if (nameMatch) { + const name = nameMatch[1]; + let isSpam = false; + + for (let j = i + 1; j < feed.length; j++) { + const older = feed[j]; + if (current.timestamp - older.timestamp > 6000) break; + + if (older.icon === "person_off" && older.text.startsWith(name + " disconnected")) { + skipIndex.add(j); + isSpam = true; + break; + } + } + if (isSpam) continue; + } + } + result.push(current); + } + return result; }; // SVG sources (matching Lucide icons) for browser desktop notifications @@ -215,10 +248,16 @@ export default function ArenaRoomClient({ }, [syncCooldown]); // Notification permission state - used to render the "Enable Notifications" button - const [notifGranted, setNotifGranted] = useState( - typeof Notification !== "undefined" && - Notification.permission === "granted", - ); + const [notifGranted, setNotifGranted] = useState(true); // Default to true to prevent hydration mismatch + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + setNotifGranted( + typeof Notification !== "undefined" && + Notification.permission === "granted", + ); + }, []); useEffect(() => { const lastSyncStr = localStorage.getItem(`sync_${roomId}_${userId}`); @@ -275,6 +314,7 @@ export default function ArenaRoomClient({ ) { break; } + const prevStatus = matchStateRef.current; matchStateRef.current = nextStatus; setMatchState(nextStatus); if (payload.state.startTime) @@ -284,7 +324,19 @@ export default function ArenaRoomClient({ if (payload.problems) setProblems(payload.problems); if (payload.scores) setScores(payload.scores); if (payload.locks) setLocks(payload.locks); - if (nextStatus === "active") { + if (payload.activityLog && payload.activityLog.length > 0) { + setActivityFeed( + filterActivitySpam( + payload.activityLog.map((entry: any, i: number) => ({ + icon: entry.icon, + text: entry.text, + timestamp: entry.timestamp, + color: entry.color, + id: entry.timestamp + i, + })) + ) + ); + } else if (nextStatus === "active" && prevStatus === "waiting") { addActivity("info", "Arena match started! Good luck."); } break; @@ -345,32 +397,32 @@ export default function ArenaRoomClient({ ); break; case "sync.detected": - if (payload.problemId) { + if (payload.problemId && payload.userId === stateRef.current.userId) { const problemId = payload.problemId; setSyncingMap((prev) => ({ ...prev, [problemId]: false })); } if (payload.verdict === "OK") { addActivity( "check_circle", - `Valid AC detected! +${payload.pointsAwarded || 100} pts`, + `${payload.solverName || "Someone"} solved it! +${payload.pointsAwarded || 100} pts`, "text-primary", ); } else { addActivity( - "error", - `Submission failed: ${payload.verdict}`, + "warning", + `${payload.solverName || "Someone"}'s submission failed: ${payload.verdict}`, "text-error", ); } break; case "sync.failed": - if (payload.problemId) { + if (payload.problemId && payload.userId === stateRef.current.userId) { const problemId = payload.problemId; setSyncingMap((prev) => ({ ...prev, [problemId]: false })); } addActivity( - "error", - `Sync failed: ${payload.reason || payload.verdict || "Unknown error"}`, + "warning", + `${payload.solverName || "Someone"}'s sync failed: ${payload.reason || payload.verdict || "Unknown error"}`, "text-error", ); break; @@ -383,6 +435,9 @@ export default function ArenaRoomClient({ if (payload.userId === stateRef.current.userId) { setIsReady(true); } + if (payload.readyName) { + addActivity("person", `${payload.readyName} is ready`, "text-secondary"); + } break; case "presence.online": { const uName = getMemberName(payload.userId); @@ -444,8 +499,29 @@ export default function ArenaRoomClient({ text: string, color: string = "text-on-surface", ) => { - setActivityFeed((prev) => - [ + let isSpam = false; + if (icon === "person") { + const nameMatch = text.match(/^(.+?) (connected|reconnected)/); + if (nameMatch) { + const name = nameMatch[1]; + const now = Date.now(); + for (const older of activityFeed) { + if (now - older.timestamp > 6000) break; + if (older.icon === "person_off" && older.text.startsWith(name + " disconnected")) { + isSpam = true; + break; + } + } + } + } + + if (!isSpam) { + // Fire a matching desktop notification + sendBrowserNotification(icon, text); + } + + setActivityFeed((prev) => { + const updated = [ { icon, text, @@ -454,10 +530,9 @@ export default function ArenaRoomClient({ id: Date.now() + Math.random(), }, ...prev, - ].slice(0, 15), - ); - // Fire a matching desktop notification - sendBrowserNotification(icon, text); + ]; + return filterActivitySpam(updated).slice(0, 50); + }); }; const handleReady = async () => { @@ -820,13 +895,13 @@ export default function ArenaRoomClient({ {/* Right Sidebar (Activity Log) */}
-
+

Activity Feed

- {typeof Notification !== "undefined" && !notifGranted && ( + {mounted && typeof Notification !== "undefined" && !notifGranted && (