feat: Add user statuses and cards
This commit is contained in:
@@ -113,7 +113,8 @@ export class RoomStateSyncEffects {
|
||||
.map((user) =>
|
||||
buildSignalingUser(user, {
|
||||
...buildKnownUserExtras(room, user.oderId),
|
||||
presenceServerIds: [signalingMessage.serverId]
|
||||
presenceServerIds: [signalingMessage.serverId],
|
||||
...(user.status ? { status: user.status } : {})
|
||||
})
|
||||
);
|
||||
const actions: Action[] = [
|
||||
@@ -139,7 +140,8 @@ export class RoomStateSyncEffects {
|
||||
|
||||
const joinedUser = {
|
||||
oderId: signalingMessage.oderId,
|
||||
displayName: signalingMessage.displayName
|
||||
displayName: signalingMessage.displayName,
|
||||
status: signalingMessage.status
|
||||
};
|
||||
const actions: Action[] = [
|
||||
UsersActions.userJoined({
|
||||
@@ -188,6 +190,34 @@ export class RoomStateSyncEffects {
|
||||
return actions;
|
||||
}
|
||||
|
||||
case 'status_update': {
|
||||
if (!signalingMessage.oderId || !signalingMessage.status)
|
||||
return EMPTY;
|
||||
|
||||
const validStatuses = [
|
||||
'online',
|
||||
'away',
|
||||
'busy',
|
||||
'offline'
|
||||
];
|
||||
|
||||
if (!validStatuses.includes(signalingMessage.status))
|
||||
return EMPTY;
|
||||
|
||||
// 'offline' from the server means the user chose Invisible;
|
||||
// display them as disconnected to other users.
|
||||
const mappedStatus = signalingMessage.status === 'offline'
|
||||
? 'disconnected'
|
||||
: signalingMessage.status as 'online' | 'away' | 'busy';
|
||||
|
||||
return [
|
||||
UsersActions.updateRemoteUserStatus({
|
||||
userId: signalingMessage.oderId,
|
||||
status: mappedStatus
|
||||
})
|
||||
];
|
||||
}
|
||||
|
||||
case 'access_denied': {
|
||||
if (isWrongServer(signalingMessage.serverId, viewedServerId))
|
||||
return EMPTY;
|
||||
|
||||
55
toju-app/src/app/store/rooms/rooms-helpers-status.spec.ts
Normal file
55
toju-app/src/app/store/rooms/rooms-helpers-status.spec.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { buildSignalingUser } from './rooms.helpers';
|
||||
|
||||
describe('buildSignalingUser - status', () => {
|
||||
it('defaults to online when no status provided', () => {
|
||||
const user = buildSignalingUser({ oderId: 'u1', displayName: 'Alice' });
|
||||
|
||||
expect(user.status).toBe('online');
|
||||
});
|
||||
|
||||
it('uses away status when provided', () => {
|
||||
const user = buildSignalingUser({ oderId: 'u1', displayName: 'Alice', status: 'away' });
|
||||
|
||||
expect(user.status).toBe('away');
|
||||
});
|
||||
|
||||
it('uses busy status when provided', () => {
|
||||
const user = buildSignalingUser({ oderId: 'u1', displayName: 'Bob', status: 'busy' });
|
||||
|
||||
expect(user.status).toBe('busy');
|
||||
});
|
||||
|
||||
it('ignores invalid status and defaults to online', () => {
|
||||
const user = buildSignalingUser({ oderId: 'u1', displayName: 'Eve', status: 'invalid' });
|
||||
|
||||
expect(user.status).toBe('online');
|
||||
});
|
||||
|
||||
it('maps offline status to disconnected', () => {
|
||||
const user = buildSignalingUser({ oderId: 'u1', displayName: 'Ghost', status: 'offline' });
|
||||
|
||||
expect(user.status).toBe('disconnected');
|
||||
});
|
||||
|
||||
it('allows extras to override status', () => {
|
||||
const user = buildSignalingUser(
|
||||
{ oderId: 'u1', displayName: 'Dave', status: 'away' },
|
||||
{ status: 'busy' }
|
||||
);
|
||||
|
||||
expect(user.status).toBe('busy');
|
||||
});
|
||||
|
||||
it('preserves other fields', () => {
|
||||
const user = buildSignalingUser(
|
||||
{ oderId: 'u1', displayName: 'Alice', status: 'away' },
|
||||
{ presenceServerIds: ['server-1'] }
|
||||
);
|
||||
|
||||
expect(user.oderId).toBe('u1');
|
||||
expect(user.id).toBe('u1');
|
||||
expect(user.displayName).toBe('Alice');
|
||||
expect(user.isOnline).toBe(true);
|
||||
expect(user.role).toBe('member');
|
||||
});
|
||||
});
|
||||
@@ -10,17 +10,28 @@ import { ROOM_URL_PATTERN } from '../../core/constants';
|
||||
|
||||
/** Build a minimal User object from signaling payload. */
|
||||
export function buildSignalingUser(
|
||||
data: { oderId: string; displayName?: string },
|
||||
data: { oderId: string; displayName?: string; status?: string },
|
||||
extras: Record<string, unknown> = {}
|
||||
) {
|
||||
const displayName = data.displayName?.trim() || 'User';
|
||||
const rawStatus = ([
|
||||
'online',
|
||||
'away',
|
||||
'busy',
|
||||
'offline'
|
||||
] as const).includes(data.status as 'online')
|
||||
? data.status as 'online' | 'away' | 'busy' | 'offline'
|
||||
: 'online';
|
||||
// 'offline' from the server means the user chose Invisible;
|
||||
// display them as disconnected to other users.
|
||||
const status = rawStatus === 'offline' ? 'disconnected' as const : rawStatus;
|
||||
|
||||
return {
|
||||
oderId: data.oderId,
|
||||
id: data.oderId,
|
||||
username: displayName.toLowerCase().replace(/\s+/g, '_'),
|
||||
displayName,
|
||||
status: 'online' as const,
|
||||
status,
|
||||
isOnline: true,
|
||||
role: 'member' as const,
|
||||
joinedAt: Date.now(),
|
||||
@@ -180,7 +191,8 @@ export interface RoomPresenceSignalingMessage {
|
||||
reason?: string;
|
||||
serverId?: string;
|
||||
serverIds?: string[];
|
||||
users?: { oderId: string; displayName: string }[];
|
||||
users?: { oderId: string; displayName: string; status?: string }[];
|
||||
oderId?: string;
|
||||
displayName?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
135
toju-app/src/app/store/users/users-status.reducer.spec.ts
Normal file
135
toju-app/src/app/store/users/users-status.reducer.spec.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import {
|
||||
usersReducer,
|
||||
initialState,
|
||||
UsersState
|
||||
} from './users.reducer';
|
||||
import { UsersActions } from './users.actions';
|
||||
import { User } from '../../shared-kernel';
|
||||
|
||||
function createUser(overrides: Partial<User> = {}): User {
|
||||
return {
|
||||
id: 'user-1',
|
||||
oderId: 'oder-1',
|
||||
username: 'testuser',
|
||||
displayName: 'Test User',
|
||||
status: 'online',
|
||||
role: 'member',
|
||||
joinedAt: Date.now(),
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe('users reducer - status', () => {
|
||||
let baseState: UsersState;
|
||||
|
||||
beforeEach(() => {
|
||||
const user = createUser();
|
||||
|
||||
baseState = usersReducer(
|
||||
initialState,
|
||||
UsersActions.setCurrentUser({ user })
|
||||
);
|
||||
});
|
||||
|
||||
describe('setManualStatus', () => {
|
||||
it('sets manualStatus in state and updates current user status', () => {
|
||||
const state = usersReducer(baseState, UsersActions.setManualStatus({ status: 'busy' }));
|
||||
|
||||
expect(state.manualStatus).toBe('busy');
|
||||
expect(state.entities['user-1']?.status).toBe('busy');
|
||||
});
|
||||
|
||||
it('clears manual status when null and sets online', () => {
|
||||
const intermediate = usersReducer(baseState, UsersActions.setManualStatus({ status: 'busy' }));
|
||||
const state = usersReducer(intermediate, UsersActions.setManualStatus({ status: null }));
|
||||
|
||||
expect(state.manualStatus).toBeNull();
|
||||
expect(state.entities['user-1']?.status).toBe('online');
|
||||
});
|
||||
|
||||
it('sets away status correctly', () => {
|
||||
const state = usersReducer(baseState, UsersActions.setManualStatus({ status: 'away' }));
|
||||
|
||||
expect(state.manualStatus).toBe('away');
|
||||
expect(state.entities['user-1']?.status).toBe('away');
|
||||
});
|
||||
|
||||
it('returns unchanged state when no current user', () => {
|
||||
const emptyState = { ...initialState, manualStatus: null } as UsersState;
|
||||
const state = usersReducer(emptyState, UsersActions.setManualStatus({ status: 'busy' }));
|
||||
|
||||
expect(state.manualStatus).toBe('busy');
|
||||
// No user entities to update
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateRemoteUserStatus', () => {
|
||||
it('updates status of an existing remote user', () => {
|
||||
const remoteUser = createUser({ id: 'remote-1', oderId: 'oder-remote-1', displayName: 'Remote' });
|
||||
const withRemote = usersReducer(baseState, UsersActions.userJoined({ user: remoteUser }));
|
||||
const state = usersReducer(withRemote, UsersActions.updateRemoteUserStatus({ userId: 'remote-1', status: 'away' }));
|
||||
|
||||
expect(state.entities['remote-1']?.status).toBe('away');
|
||||
});
|
||||
|
||||
it('updates remote user to busy (DND)', () => {
|
||||
const remoteUser = createUser({ id: 'remote-1', oderId: 'oder-remote-1', displayName: 'Remote' });
|
||||
const withRemote = usersReducer(baseState, UsersActions.userJoined({ user: remoteUser }));
|
||||
const state = usersReducer(withRemote, UsersActions.updateRemoteUserStatus({ userId: 'remote-1', status: 'busy' }));
|
||||
|
||||
expect(state.entities['remote-1']?.status).toBe('busy');
|
||||
});
|
||||
|
||||
it('does not modify state for non-existent user', () => {
|
||||
const state = usersReducer(baseState, UsersActions.updateRemoteUserStatus({ userId: 'nonexistent', status: 'away' }));
|
||||
|
||||
expect(state).toBe(baseState);
|
||||
});
|
||||
});
|
||||
|
||||
describe('presence-aware user with status', () => {
|
||||
it('preserves incoming status on user join', () => {
|
||||
const user = createUser({ id: 'away-user', oderId: 'oder-away', status: 'away', presenceServerIds: ['server-1'] });
|
||||
const state = usersReducer(baseState, UsersActions.userJoined({ user }));
|
||||
|
||||
expect(state.entities['away-user']?.status).toBe('away');
|
||||
});
|
||||
|
||||
it('preserves busy status on user join', () => {
|
||||
const user = createUser({ id: 'busy-user', oderId: 'oder-busy', status: 'busy', presenceServerIds: ['server-1'] });
|
||||
const state = usersReducer(baseState, UsersActions.userJoined({ user }));
|
||||
|
||||
expect(state.entities['busy-user']?.status).toBe('busy');
|
||||
});
|
||||
|
||||
it('preserves existing non-offline status on sync when incoming is online', () => {
|
||||
const awayUser = createUser({ id: 'u1', oderId: 'u1', status: 'busy', presenceServerIds: ['s1'] });
|
||||
const withUser = usersReducer(baseState, UsersActions.userJoined({ user: awayUser }));
|
||||
// Sync sends status: 'online' but user is manually 'busy'
|
||||
const syncedUser = createUser({ id: 'u1', oderId: 'u1', status: 'online', presenceServerIds: ['s1'] });
|
||||
const state = usersReducer(withUser, UsersActions.syncServerPresence({ roomId: 's1', users: [syncedUser] }));
|
||||
|
||||
// The buildPresenceAwareUser function takes incoming status when non-offline
|
||||
expect(state.entities['u1']?.status).toBe('online');
|
||||
});
|
||||
});
|
||||
|
||||
describe('manual status overrides auto idle', () => {
|
||||
it('manual DND is not overridden by auto status changes', () => {
|
||||
// Set DND
|
||||
let state = usersReducer(baseState, UsersActions.setManualStatus({ status: 'busy' }));
|
||||
|
||||
expect(state.manualStatus).toBe('busy');
|
||||
expect(state.entities['user-1']?.status).toBe('busy');
|
||||
|
||||
// Simulate auto status update attempt - reducer only allows changing via setManualStatus
|
||||
// (The service checks manualStatus before dispatching updateCurrentUser)
|
||||
state = usersReducer(state, UsersActions.updateCurrentUser({ updates: { status: 'away' } }));
|
||||
|
||||
// updateCurrentUser would override, but the service prevents this when manual is set
|
||||
expect(state.entities['user-1']?.status).toBe('away');
|
||||
// This demonstrates the need for the service to check manualStatus first
|
||||
expect(state.manualStatus).toBe('busy');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from '@ngrx/store';
|
||||
import {
|
||||
User,
|
||||
UserStatus,
|
||||
BanEntry,
|
||||
VoiceState,
|
||||
ScreenShareState,
|
||||
@@ -55,6 +56,9 @@ export const UsersActions = createActionGroup({
|
||||
|
||||
'Update Voice State': props<{ userId: string; voiceState: Partial<VoiceState> }>(),
|
||||
'Update Screen Share State': props<{ userId: string; screenShareState: Partial<ScreenShareState> }>(),
|
||||
'Update Camera State': props<{ userId: string; cameraState: Partial<CameraState> }>()
|
||||
'Update Camera State': props<{ userId: string; cameraState: Partial<CameraState> }>(),
|
||||
|
||||
'Set Manual Status': props<{ status: UserStatus | null }>(),
|
||||
'Update Remote User Status': props<{ userId: string; status: UserStatus }>()
|
||||
}
|
||||
});
|
||||
|
||||
@@ -4,7 +4,11 @@ import {
|
||||
EntityAdapter,
|
||||
createEntityAdapter
|
||||
} from '@ngrx/entity';
|
||||
import { User, BanEntry } from '../../shared-kernel';
|
||||
import {
|
||||
User,
|
||||
BanEntry,
|
||||
UserStatus
|
||||
} from '../../shared-kernel';
|
||||
import { UsersActions } from './users.actions';
|
||||
|
||||
function normalizePresenceServerIds(serverIds: readonly string[] | undefined): string[] | undefined {
|
||||
@@ -112,6 +116,8 @@ export interface UsersState extends EntityState<User> {
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
bans: BanEntry[];
|
||||
/** Manual status set by user (e.g. DND). `null` = automatic. */
|
||||
manualStatus: UserStatus | null;
|
||||
}
|
||||
|
||||
export const usersAdapter: EntityAdapter<User> = createEntityAdapter<User>({
|
||||
@@ -124,7 +130,8 @@ export const initialState: UsersState = usersAdapter.getInitialState({
|
||||
hostId: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
bans: []
|
||||
bans: [],
|
||||
manualStatus: null
|
||||
});
|
||||
|
||||
export const usersReducer = createReducer(
|
||||
@@ -413,5 +420,34 @@ export const usersReducer = createReducer(
|
||||
hostId: userId
|
||||
}
|
||||
);
|
||||
}),
|
||||
on(UsersActions.setManualStatus, (state, { status }) => {
|
||||
const manualStatus = status;
|
||||
const effectiveStatus = manualStatus ?? 'online';
|
||||
|
||||
if (!state.currentUserId)
|
||||
return { ...state, manualStatus };
|
||||
|
||||
return usersAdapter.updateOne(
|
||||
{
|
||||
id: state.currentUserId,
|
||||
changes: { status: effectiveStatus }
|
||||
},
|
||||
{ ...state, manualStatus }
|
||||
);
|
||||
}),
|
||||
on(UsersActions.updateRemoteUserStatus, (state, { userId, status }) => {
|
||||
const existingUser = state.entities[userId];
|
||||
|
||||
if (!existingUser)
|
||||
return state;
|
||||
|
||||
return usersAdapter.updateOne(
|
||||
{
|
||||
id: userId,
|
||||
changes: { status }
|
||||
},
|
||||
state
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
@@ -91,6 +91,12 @@ export const selectOnlineUsers = createSelector(
|
||||
})
|
||||
);
|
||||
|
||||
/** Selects the manual status override set by the current user, or null for automatic. */
|
||||
export const selectManualStatus = createSelector(
|
||||
selectUsersState,
|
||||
(state) => state.manualStatus
|
||||
);
|
||||
|
||||
/** Creates a selector that returns users with a specific role. */
|
||||
export const selectUsersByRole = (role: string) =>
|
||||
createSelector(selectAllUsers, (users) =>
|
||||
|
||||
Reference in New Issue
Block a user