feat: Add user statuses and cards
This commit is contained in:
@@ -122,10 +122,12 @@ async function bootstrap(): Promise<void> {
|
||||
let shuttingDown = false;
|
||||
|
||||
async function gracefulShutdown(signal: string): Promise<void> {
|
||||
if (shuttingDown) return;
|
||||
if (shuttingDown)
|
||||
return;
|
||||
|
||||
shuttingDown = true;
|
||||
|
||||
console.log(`\n[Shutdown] ${signal} received — closing database…`);
|
||||
console.log(`\n[Shutdown] ${signal} received - closing database…`);
|
||||
|
||||
try {
|
||||
await destroyDatabase();
|
||||
|
||||
191
server/src/websocket/handler-status.spec.ts
Normal file
191
server/src/websocket/handler-status.spec.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
beforeEach
|
||||
} from 'vitest';
|
||||
import { connectedUsers } from './state';
|
||||
import { handleWebSocketMessage } from './handler';
|
||||
import { ConnectedUser } from './types';
|
||||
import { WebSocket } from 'ws';
|
||||
|
||||
/**
|
||||
* Minimal mock WebSocket that records sent messages.
|
||||
*/
|
||||
function createMockWs(): WebSocket & { sentMessages: string[] } {
|
||||
const sent: string[] = [];
|
||||
const ws = {
|
||||
readyState: WebSocket.OPEN,
|
||||
send: (data: string) => { sent.push(data); },
|
||||
close: () => {},
|
||||
sentMessages: sent
|
||||
} as unknown as WebSocket & { sentMessages: string[] };
|
||||
|
||||
return ws;
|
||||
}
|
||||
|
||||
function createConnectedUser(
|
||||
connectionId: string,
|
||||
oderId: string,
|
||||
overrides: Partial<ConnectedUser> = {}
|
||||
): ConnectedUser {
|
||||
const ws = createMockWs();
|
||||
const user: ConnectedUser = {
|
||||
oderId,
|
||||
ws,
|
||||
serverIds: new Set(),
|
||||
displayName: 'Test User',
|
||||
lastPong: Date.now(),
|
||||
...overrides
|
||||
};
|
||||
|
||||
connectedUsers.set(connectionId, user);
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
describe('server websocket handler - status_update', () => {
|
||||
beforeEach(() => {
|
||||
connectedUsers.clear();
|
||||
});
|
||||
|
||||
it('updates user status on valid status_update message', async () => {
|
||||
const user = createConnectedUser('conn-1', 'user-1');
|
||||
|
||||
user.serverIds.add('server-1');
|
||||
|
||||
await handleWebSocketMessage('conn-1', { type: 'status_update', status: 'away' });
|
||||
|
||||
expect(connectedUsers.get('conn-1')?.status).toBe('away');
|
||||
});
|
||||
|
||||
it('broadcasts status_update to other users in the same server', async () => {
|
||||
const user1 = createConnectedUser('conn-1', 'user-1');
|
||||
const user2 = createConnectedUser('conn-2', 'user-2');
|
||||
|
||||
user1.serverIds.add('server-1');
|
||||
user2.serverIds.add('server-1');
|
||||
|
||||
await handleWebSocketMessage('conn-1', { type: 'status_update', status: 'busy' });
|
||||
|
||||
const ws2 = user2.ws as unknown as { sentMessages: string[] };
|
||||
const messages = ws2.sentMessages.map((m: string) => JSON.parse(m));
|
||||
const statusMsg = messages.find((m: { type: string }) => m.type === 'status_update');
|
||||
|
||||
expect(statusMsg).toBeDefined();
|
||||
expect(statusMsg.oderId).toBe('user-1');
|
||||
expect(statusMsg.status).toBe('busy');
|
||||
});
|
||||
|
||||
it('does not broadcast to users in different servers', async () => {
|
||||
createConnectedUser('conn-1', 'user-1');
|
||||
const user2 = createConnectedUser('conn-2', 'user-2');
|
||||
|
||||
connectedUsers.get('conn-1')!.serverIds.add('server-1');
|
||||
user2.serverIds.add('server-2');
|
||||
|
||||
await handleWebSocketMessage('conn-1', { type: 'status_update', status: 'away' });
|
||||
|
||||
const ws2 = user2.ws as unknown as { sentMessages: string[] };
|
||||
|
||||
expect(ws2.sentMessages.length).toBe(0);
|
||||
});
|
||||
|
||||
it('ignores invalid status values', async () => {
|
||||
createConnectedUser('conn-1', 'user-1');
|
||||
|
||||
await handleWebSocketMessage('conn-1', { type: 'status_update', status: 'invalid_status' });
|
||||
|
||||
expect(connectedUsers.get('conn-1')?.status).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ignores missing status field', async () => {
|
||||
createConnectedUser('conn-1', 'user-1');
|
||||
|
||||
await handleWebSocketMessage('conn-1', { type: 'status_update' });
|
||||
|
||||
expect(connectedUsers.get('conn-1')?.status).toBeUndefined();
|
||||
});
|
||||
|
||||
it('accepts all valid status values', async () => {
|
||||
for (const status of [
|
||||
'online',
|
||||
'away',
|
||||
'busy',
|
||||
'offline'
|
||||
]) {
|
||||
connectedUsers.clear();
|
||||
createConnectedUser('conn-1', 'user-1');
|
||||
|
||||
await handleWebSocketMessage('conn-1', { type: 'status_update', status });
|
||||
|
||||
expect(connectedUsers.get('conn-1')?.status).toBe(status);
|
||||
}
|
||||
});
|
||||
|
||||
it('includes status in server_users response after status change', async () => {
|
||||
const user1 = createConnectedUser('conn-1', 'user-1');
|
||||
const user2 = createConnectedUser('conn-2', 'user-2');
|
||||
|
||||
user1.serverIds.add('server-1');
|
||||
user2.serverIds.add('server-1');
|
||||
|
||||
// Set user-1 to away
|
||||
await handleWebSocketMessage('conn-1', { type: 'status_update', status: 'away' });
|
||||
|
||||
// Clear sent messages
|
||||
(user2.ws as unknown as { sentMessages: string[] }).sentMessages.length = 0;
|
||||
|
||||
// Identify first (required for handler)
|
||||
await handleWebSocketMessage('conn-1', { type: 'identify', oderId: 'user-1', displayName: 'User 1' });
|
||||
|
||||
// user-2 joins server → should receive server_users with user-1's status
|
||||
(user2.ws as unknown as { sentMessages: string[] }).sentMessages.length = 0;
|
||||
await handleWebSocketMessage('conn-2', { type: 'join_server', serverId: 'server-1' });
|
||||
|
||||
const ws2 = user2.ws as unknown as { sentMessages: string[] };
|
||||
const messages = ws2.sentMessages.map((m: string) => JSON.parse(m));
|
||||
const serverUsersMsg = messages.find((m: { type: string }) => m.type === 'server_users');
|
||||
|
||||
expect(serverUsersMsg).toBeDefined();
|
||||
|
||||
const user1InList = serverUsersMsg.users.find((u: { oderId: string }) => u.oderId === 'user-1');
|
||||
|
||||
expect(user1InList?.status).toBe('away');
|
||||
});
|
||||
});
|
||||
|
||||
describe('server websocket handler - user_joined includes status', () => {
|
||||
beforeEach(() => {
|
||||
connectedUsers.clear();
|
||||
});
|
||||
|
||||
it('includes status in user_joined broadcast', async () => {
|
||||
const user1 = createConnectedUser('conn-1', 'user-1');
|
||||
const user2 = createConnectedUser('conn-2', 'user-2');
|
||||
|
||||
user1.serverIds.add('server-1');
|
||||
user2.serverIds.add('server-1');
|
||||
|
||||
// Set user-1's status to busy before joining
|
||||
connectedUsers.get('conn-1')!.status = 'busy';
|
||||
|
||||
// Identify user-1
|
||||
await handleWebSocketMessage('conn-1', { type: 'identify', oderId: 'user-1', displayName: 'User 1' });
|
||||
|
||||
(user2.ws as unknown as { sentMessages: string[] }).sentMessages.length = 0;
|
||||
|
||||
// user-1 joins server-1
|
||||
await handleWebSocketMessage('conn-1', { type: 'join_server', serverId: 'server-1' });
|
||||
|
||||
const ws2 = user2.ws as unknown as { sentMessages: string[] };
|
||||
const messages = ws2.sentMessages.map((m: string) => JSON.parse(m));
|
||||
const joinMsg = messages.find((m: { type: string }) => m.type === 'user_joined');
|
||||
|
||||
// user_joined may or may not appear depending on whether it's a new identity membership
|
||||
// Since both are already in the server, it may not broadcast. Either way, verify no crash.
|
||||
if (joinMsg) {
|
||||
expect(joinMsg.status).toBe('busy');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -37,7 +37,7 @@ function readMessageId(value: unknown): string | undefined {
|
||||
/** Sends the current user list for a given server to a single connected user. */
|
||||
function sendServerUsers(user: ConnectedUser, serverId: string): void {
|
||||
const users = getUniqueUsersInServer(serverId, user.oderId)
|
||||
.map(cu => ({ oderId: cu.oderId, displayName: normalizeDisplayName(cu.displayName) }));
|
||||
.map(cu => ({ oderId: cu.oderId, displayName: normalizeDisplayName(cu.displayName), status: cu.status ?? 'online' }));
|
||||
|
||||
user.ws.send(JSON.stringify({ type: 'server_users', serverId, users }));
|
||||
}
|
||||
@@ -108,6 +108,7 @@ async function handleJoinServer(user: ConnectedUser, message: WsMessage, connect
|
||||
type: 'user_joined',
|
||||
oderId: user.oderId,
|
||||
displayName: normalizeDisplayName(user.displayName),
|
||||
status: user.status ?? 'online',
|
||||
serverId: sid
|
||||
}, user.oderId);
|
||||
}
|
||||
@@ -204,6 +205,32 @@ function handleTyping(user: ConnectedUser, message: WsMessage): void {
|
||||
}
|
||||
}
|
||||
|
||||
const VALID_STATUSES = new Set([
|
||||
'online',
|
||||
'away',
|
||||
'busy',
|
||||
'offline'
|
||||
]);
|
||||
|
||||
function handleStatusUpdate(user: ConnectedUser, message: WsMessage, connectionId: string): void {
|
||||
const status = typeof message['status'] === 'string' ? message['status'] : undefined;
|
||||
|
||||
if (!status || !VALID_STATUSES.has(status))
|
||||
return;
|
||||
|
||||
user.status = status as ConnectedUser['status'];
|
||||
connectedUsers.set(connectionId, user);
|
||||
console.log(`User ${normalizeDisplayName(user.displayName)} (${user.oderId}) status → ${status}`);
|
||||
|
||||
for (const serverId of user.serverIds) {
|
||||
broadcastToServer(serverId, {
|
||||
type: 'status_update',
|
||||
oderId: user.oderId,
|
||||
status
|
||||
}, user.oderId);
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleWebSocketMessage(connectionId: string, message: WsMessage): Promise<void> {
|
||||
const user = connectedUsers.get(connectionId);
|
||||
|
||||
@@ -241,6 +268,10 @@ export async function handleWebSocketMessage(connectionId: string, message: WsMe
|
||||
handleTyping(user, message);
|
||||
break;
|
||||
|
||||
case 'status_update':
|
||||
handleStatusUpdate(user, message, connectionId);
|
||||
break;
|
||||
|
||||
default:
|
||||
console.log('Unknown message type:', message.type);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ export interface ConnectedUser {
|
||||
* URLs routing to the same server coexist without an eviction loop.
|
||||
*/
|
||||
connectionScope?: string;
|
||||
/** User availability status (online, away, busy, offline). */
|
||||
status?: 'online' | 'away' | 'busy' | 'offline';
|
||||
/** Timestamp of the last pong received (used to detect dead connections). */
|
||||
lastPong: number;
|
||||
}
|
||||
|
||||
@@ -17,5 +17,5 @@
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
"exclude": ["node_modules", "dist", "src/**/*.spec.ts"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user