feat: Add user statuses and cards

This commit is contained in:
2026-04-16 22:52:45 +02:00
parent b4ac0cdc92
commit 2927a86fbb
57 changed files with 1964 additions and 185 deletions

View 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');
});
});
});

View File

@@ -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 }>()
}
});

View File

@@ -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
);
})
);

View File

@@ -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) =>