Move toju-app into own its folder

This commit is contained in:
2026-03-29 23:30:37 +02:00
parent 0467a7b612
commit 8162e0444a
287 changed files with 42 additions and 34 deletions

View File

@@ -0,0 +1,81 @@
/**
* Root state definition and barrel exports for the NgRx store.
*
* Three feature slices:
* - **messages** - chat messages, reactions, sync state
* - **users** - online users, bans, roles, voice state
* - **rooms** - servers / rooms, channels, search results
*/
import { isDevMode } from '@angular/core';
import { ActionReducerMap, MetaReducer } from '@ngrx/store';
import { messagesReducer, MessagesState } from './messages/messages.reducer';
import { usersReducer, UsersState } from './users/users.reducer';
import { roomsReducer, RoomsState } from './rooms/rooms.reducer';
/** Combined root state of the application. */
export interface AppState {
/** Chat messages feature slice. */
messages: MessagesState;
/** Users / presence feature slice. */
users: UsersState;
/** Rooms / servers feature slice. */
rooms: RoomsState;
}
/** Top-level reducer map registered with `StoreModule.forRoot()`. */
export const reducers: ActionReducerMap<AppState> = {
messages: messagesReducer,
users: usersReducer,
rooms: roomsReducer
};
/** Meta-reducers (e.g. logging) enabled only in development builds. */
export const metaReducers: MetaReducer<AppState>[] = isDevMode() ? [] : [];
// Re-export actions
export { MessagesActions } from './messages/messages.actions';
export { UsersActions } from './users/users.actions';
export { RoomsActions } from './rooms/rooms.actions';
// Re-export selectors explicitly to avoid conflicts
export {
selectMessagesState,
selectAllMessages,
selectCurrentRoomMessages,
selectMessageById,
selectMessagesLoading,
selectCurrentRoomId as selectMessagesCurrentRoomId
} from './messages/messages.selectors';
export {
selectUsersState,
selectAllUsers,
selectCurrentUser,
selectCurrentUserId,
selectUserById,
selectOnlineUsers,
selectHostId,
selectIsCurrentUserHost as selectIsCurrentUserHostFromUsers,
selectBannedUsers
} from './users/users.selectors';
export {
selectRoomsState,
selectCurrentRoom,
selectCurrentRoomId,
selectRoomSettings,
selectIsCurrentUserHost,
selectSavedRooms,
selectRoomsLoading
} from './rooms/rooms.selectors';
// Re-export effects
export { MessagesEffects } from './messages/messages.effects';
export { MessagesSyncEffects } from './messages/messages-sync.effects';
export { UsersEffects } from './users/users.effects';
export { RoomsEffects } from './rooms/rooms.effects';
// Re-export types
export type { MessagesState } from './messages/messages.reducer';
export type { UsersState } from './users/users.reducer';
export type { RoomsState } from './rooms/rooms.reducer';

View File

@@ -0,0 +1,4 @@
export * from './messages.actions';
export * from './messages.reducer';
export * from './messages.selectors';
export * from './messages.effects';

View File

@@ -0,0 +1,664 @@
/**
* Handler functions for incoming P2P messages dispatched via WebRTC.
*
* Each handler is a pure function that receives an event and a context
* object containing the required services. Handlers return an
* `Observable<Action>` or `EMPTY` when no store action needs dispatching.
*
* The handler registry at the bottom maps event `type` strings to their
* handlers, and `dispatchIncomingMessage()` is the single entry point
* consumed by the `incomingMessages$` effect.
*/
import {
Observable,
of,
from,
EMPTY
} from 'rxjs';
import { mergeMap } from 'rxjs/operators';
import { Action } from '@ngrx/store';
import {
DELETED_MESSAGE_CONTENT,
type ChatEvent,
type Message,
type Room,
type User
} from '../../shared-kernel';
import type { RealtimeSessionFacade } from '../../core/realtime';
import type { DebuggingService } from '../../core/services';
import { AttachmentFacade, type AttachmentMeta } from '../../domains/attachment';
import { DatabaseService } from '../../infrastructure/persistence';
import { trackDebuggingTaskFailure } from '../../core/helpers/debugging-helpers';
import { MessagesActions } from './messages.actions';
import {
INVENTORY_LIMIT,
CHUNK_SIZE,
FULL_SYNC_LIMIT,
type InventoryItem,
chunkArray,
buildInventoryItem,
buildLocalInventoryMap,
findMissingIds,
hydrateMessage,
mergeIncomingMessage
} from './messages.helpers';
type AnnouncedAttachment = Pick<AttachmentMeta, 'id' | 'filename' | 'size' | 'mime' | 'isImage' | 'uploaderPeerId'>;
type AttachmentMetaMap = Record<string, AttachmentMeta[]>;
type IncomingMessageType =
| ChatEvent['type']
| 'chat-inventory'
| 'chat-sync-request-ids'
| 'chat-sync-batch'
| 'chat-sync-summary'
| 'chat-sync-request'
| 'chat-sync-full'
| 'file-announce'
| 'file-chunk'
| 'file-request'
| 'file-cancel'
| 'file-not-found';
interface IncomingMessageEvent extends Omit<ChatEvent, 'type'> {
type: IncomingMessageType;
items?: InventoryItem[];
ids?: string[];
messages?: Message[];
attachments?: AttachmentMetaMap;
total?: number;
index?: number;
count?: number;
lastUpdated?: number;
file?: AnnouncedAttachment;
fileId?: string;
}
type SyncBatchEvent = IncomingMessageEvent & {
messages: Message[];
attachments?: AttachmentMetaMap;
};
function hasMessageBatch(event: IncomingMessageEvent): event is SyncBatchEvent {
return Array.isArray(event.messages);
}
function hasAttachmentMetaMap(
attachmentMap: IncomingMessageEvent['attachments']
): attachmentMap is AttachmentMetaMap {
return typeof attachmentMap === 'object' && attachmentMap !== null;
}
/** Shared context injected into each handler function. */
export interface IncomingMessageContext {
db: DatabaseService;
webrtc: RealtimeSessionFacade;
attachments: AttachmentFacade;
debugging: DebuggingService;
currentUser: User | null;
currentRoom: Room | null;
}
/** Signature for an incoming-message handler function. */
type MessageHandler = (
event: IncomingMessageEvent,
ctx: IncomingMessageContext,
) => Observable<Action>;
/**
* Responds to a peer's inventory request by building and sending
* our local message inventory in chunks.
*/
function handleInventoryRequest(
event: IncomingMessageEvent,
{ db, webrtc, attachments }: IncomingMessageContext
): Observable<Action> {
const { roomId, fromPeerId } = event;
if (!roomId || !fromPeerId)
return EMPTY;
return from(
(async () => {
const messages = await db.getMessages(roomId, INVENTORY_LIMIT, 0);
const items = await Promise.all(
messages.map((msg) => {
const inMemoryAttachmentCount = attachments.getForMessage(msg.id).length;
return buildInventoryItem(
msg,
db,
inMemoryAttachmentCount > 0 ? inMemoryAttachmentCount : undefined
);
})
);
items.sort((firstItem, secondItem) => firstItem.ts - secondItem.ts);
for (const chunk of chunkArray(items, CHUNK_SIZE)) {
const inventoryEvent: ChatEvent = {
type: 'chat-inventory',
roomId,
items: chunk,
total: items.length,
index: 0
};
webrtc.sendToPeer(fromPeerId, inventoryEvent);
}
})()
).pipe(mergeMap(() => EMPTY));
}
/**
* Compares a peer's inventory against local state
* and requests any missing or stale messages.
*/
function handleInventory(
event: IncomingMessageEvent,
{ db, webrtc, attachments }: IncomingMessageContext
): Observable<Action> {
const { roomId, fromPeerId, items } = event;
if (!roomId || !Array.isArray(items) || !fromPeerId)
return EMPTY;
return from(
(async () => {
const local = await db.getMessages(roomId, INVENTORY_LIMIT, 0);
const inMemoryAttachmentCounts = new Map<string, number>();
for (const message of local) {
const count = attachments.getForMessage(message.id).length;
if (count > 0) {
inMemoryAttachmentCounts.set(message.id, count);
}
}
const localMap = await buildLocalInventoryMap(local, db, inMemoryAttachmentCounts);
const missing = findMissingIds(items, localMap);
for (const chunk of chunkArray(missing, CHUNK_SIZE)) {
const syncRequestIdsEvent: ChatEvent = {
type: 'chat-sync-request-ids',
roomId,
ids: chunk
};
webrtc.sendToPeer(fromPeerId, syncRequestIdsEvent);
}
})()
).pipe(mergeMap(() => EMPTY));
}
/**
* Responds to a peer's request for specific message IDs by sending
* hydrated messages along with their attachment metadata.
*/
function handleSyncRequestIds(
event: IncomingMessageEvent,
{ db, webrtc, attachments }: IncomingMessageContext
): Observable<Action> {
const { roomId, ids, fromPeerId } = event;
if (!Array.isArray(ids) || !fromPeerId)
return EMPTY;
return from(
(async () => {
const maybeMessages = await Promise.all(
(ids as string[]).map((id) => db.getMessageById(id))
);
const messages = maybeMessages.filter(
(msg): msg is Message => !!msg
);
const hydrated = await Promise.all(
messages.map((msg) => hydrateMessage(msg, db))
);
const msgIds = hydrated.map((msg) => msg.id);
const attachmentMetas =
attachments.getAttachmentMetasForMessages(msgIds);
for (const chunk of chunkArray(hydrated, CHUNK_SIZE)) {
const chunkAttachments: AttachmentMetaMap = {};
for (const hydratedMessage of chunk) {
if (attachmentMetas[hydratedMessage.id])
chunkAttachments[hydratedMessage.id] = attachmentMetas[hydratedMessage.id];
}
const syncBatchEvent: ChatEvent = {
type: 'chat-sync-batch',
roomId: roomId || '',
messages: chunk,
attachments:
Object.keys(chunkAttachments).length > 0
? chunkAttachments
: undefined
};
webrtc.sendToPeer(fromPeerId, syncBatchEvent);
}
})()
).pipe(mergeMap(() => EMPTY));
}
/**
* Processes a batch of synced messages from a peer: merges each into
* the local DB, registers attachment metadata, and auto-requests any
* missing image attachments.
*/
function handleSyncBatch(
event: IncomingMessageEvent,
{ db, attachments }: IncomingMessageContext
): Observable<Action> {
if (!hasMessageBatch(event))
return EMPTY;
if (hasAttachmentMetaMap(event.attachments)) {
attachments.registerSyncedAttachments(
event.attachments,
Object.fromEntries(event.messages.map((message) => [message.id, message.roomId]))
);
}
return from(processSyncBatch(event, db, attachments)).pipe(
mergeMap((toUpsert) =>
toUpsert.length > 0
? of(MessagesActions.syncMessages({ messages: toUpsert }))
: EMPTY
)
);
}
/** Merges each incoming message and collects those that changed. */
async function processSyncBatch(
event: SyncBatchEvent,
db: DatabaseService,
attachments: AttachmentFacade
): Promise<Message[]> {
const toUpsert: Message[] = [];
for (const incoming of event.messages) {
attachments.rememberMessageRoom(incoming.id, incoming.roomId);
const { message, changed } = await mergeIncomingMessage(incoming, db);
if (incoming.isDeleted) {
try {
await attachments.deleteForMessage(incoming.id);
} catch (error) {
throw new Error(`Failed to delete attachments for message ${incoming.id} during sync: ${message.id}. Error: ${error}`);
}
}
if (changed)
toUpsert.push(message);
}
if (hasAttachmentMetaMap(event.attachments)) {
queueWatchedAttachmentDownloads(event.attachments, attachments);
}
return toUpsert;
}
/** Queue best-effort auto-downloads for watched-room attachments. */
function queueWatchedAttachmentDownloads(
attachmentMap: AttachmentMetaMap,
attachments: AttachmentFacade
): void {
for (const msgId of Object.keys(attachmentMap)) {
attachments.queueAutoDownloadsForMessage(msgId);
}
}
/** Saves an incoming chat message to DB and dispatches receiveMessage. */
function handleChatMessage(
event: IncomingMessageEvent,
{
db,
debugging,
attachments,
currentUser
}: IncomingMessageContext
): Observable<Action> {
const msg = event.message;
if (!msg)
return EMPTY;
// Skip our own messages (reflected via server relay)
const isOwnMessage =
msg.senderId === currentUser?.id ||
msg.senderId === currentUser?.oderId;
if (isOwnMessage)
return EMPTY;
attachments.rememberMessageRoom(msg.id, msg.roomId);
trackBackgroundOperation(
db.saveMessage(msg),
debugging,
'Failed to persist incoming chat message',
{
channelId: msg.channelId || 'general',
fromPeerId: event.fromPeerId ?? null,
messageId: msg.id,
roomId: msg.roomId,
senderId: msg.senderId
}
);
return of(MessagesActions.receiveMessage({ message: msg }));
}
/** Applies a remote message edit to the local DB and store. */
function handleMessageEdited(
event: IncomingMessageEvent,
{ db, debugging }: IncomingMessageContext
): Observable<Action> {
if (!event.messageId || !event.content)
return EMPTY;
const editedAt = typeof event.editedAt === 'number'
? event.editedAt
: Date.now();
trackBackgroundOperation(
db.updateMessage(event.messageId, {
content: event.content,
editedAt
}),
debugging,
'Failed to persist incoming message edit',
{
editedAt,
fromPeerId: event.fromPeerId ?? null,
messageId: event.messageId
}
);
return of(
MessagesActions.editMessageSuccess({
messageId: event.messageId,
content: event.content,
editedAt
})
);
}
/** Applies a remote message deletion to the local DB and store. */
function handleMessageDeleted(
event: IncomingMessageEvent,
{ db, debugging, attachments }: IncomingMessageContext
): Observable<Action> {
if (!event.messageId)
return EMPTY;
const deletedAt = typeof event.deletedAt === 'number'
? event.deletedAt
: Date.now();
trackBackgroundOperation(
db.updateMessage(event.messageId, {
content: DELETED_MESSAGE_CONTENT,
editedAt: deletedAt,
isDeleted: true
}),
debugging,
'Failed to persist incoming message deletion',
{
deletedBy: event.deletedBy ?? null,
deletedAt,
fromPeerId: event.fromPeerId ?? null,
messageId: event.messageId
}
);
trackBackgroundOperation(
attachments.deleteForMessage(event.messageId),
debugging,
'Failed to delete incoming message attachments',
{
deletedBy: event.deletedBy ?? null,
fromPeerId: event.fromPeerId ?? null,
messageId: event.messageId
}
);
return of(
MessagesActions.deleteMessageSuccess({ messageId: event.messageId })
);
}
/** Saves an incoming reaction to DB and updates the store. */
function handleReactionAdded(
event: IncomingMessageEvent,
{ db, debugging }: IncomingMessageContext
): Observable<Action> {
if (!event.messageId || !event.reaction)
return EMPTY;
trackBackgroundOperation(
db.saveReaction(event.reaction),
debugging,
'Failed to persist incoming reaction',
{
emoji: event.reaction.emoji,
fromPeerId: event.fromPeerId ?? null,
messageId: event.messageId,
reactionId: event.reaction.id
}
);
return of(MessagesActions.addReactionSuccess({ reaction: event.reaction }));
}
/** Removes a reaction from DB and updates the store. */
function handleReactionRemoved(
event: IncomingMessageEvent,
{ db, debugging }: IncomingMessageContext
): Observable<Action> {
if (!event.messageId || !event.oderId || !event.emoji)
return EMPTY;
trackBackgroundOperation(
db.removeReaction(event.messageId, event.oderId, event.emoji),
debugging,
'Failed to persist incoming reaction removal',
{
emoji: event.emoji,
fromPeerId: event.fromPeerId ?? null,
messageId: event.messageId,
oderId: event.oderId
}
);
return of(
MessagesActions.removeReactionSuccess({
messageId: event.messageId,
oderId: event.oderId,
emoji: event.emoji
})
);
}
function handleFileAnnounce(
event: IncomingMessageEvent,
{ attachments }: IncomingMessageContext
): Observable<Action> {
attachments.handleFileAnnounce(event);
if (event.messageId) {
attachments.queueAutoDownloadsForMessage(event.messageId, event.file?.id);
}
return EMPTY;
}
function handleFileChunk(
event: IncomingMessageEvent,
{ attachments }: IncomingMessageContext
): Observable<Action> {
attachments.handleFileChunk(event);
return EMPTY;
}
function handleFileRequest(
event: IncomingMessageEvent,
{ attachments }: IncomingMessageContext
): Observable<Action> {
attachments.handleFileRequest(event);
return EMPTY;
}
function handleFileCancel(
event: IncomingMessageEvent,
{ attachments }: IncomingMessageContext
): Observable<Action> {
attachments.handleFileCancel(event);
return EMPTY;
}
function handleFileNotFound(
event: IncomingMessageEvent,
{ attachments }: IncomingMessageContext
): Observable<Action> {
attachments.handleFileNotFound(event);
return EMPTY;
}
/**
* Compares a peer's dataset summary and requests full sync
* if the peer has newer or more data.
*/
function handleSyncSummary(
event: IncomingMessageEvent,
{ db, webrtc, currentRoom }: IncomingMessageContext
): Observable<Action> {
if (!currentRoom)
return EMPTY;
return from(
(async () => {
const local = await db.getMessages(currentRoom.id, FULL_SYNC_LIMIT, 0);
const localCount = local.length;
const localLastUpdated = local.reduce(
(maxTimestamp, message) => Math.max(maxTimestamp, message.editedAt || message.timestamp || 0),
0
);
const remoteLastUpdated = event.lastUpdated || 0;
const remoteCount = event.count || 0;
const identical =
localLastUpdated === remoteLastUpdated && localCount === remoteCount;
const needsSync =
remoteLastUpdated > localLastUpdated ||
(remoteLastUpdated === localLastUpdated && remoteCount > localCount);
const fromPeerId = event.fromPeerId;
if (!identical && needsSync && fromPeerId) {
const syncRequestEvent: ChatEvent = {
type: 'chat-sync-request',
roomId: currentRoom.id
};
webrtc.sendToPeer(fromPeerId, syncRequestEvent);
}
})()
).pipe(mergeMap(() => EMPTY));
}
/** Responds to a peer's full sync request by sending all local messages. */
function handleSyncRequest(
event: IncomingMessageEvent,
{ db, webrtc, currentRoom }: IncomingMessageContext
): Observable<Action> {
const fromPeerId = event.fromPeerId;
if (!currentRoom || !fromPeerId)
return EMPTY;
return from(
(async () => {
const all = await db.getMessages(currentRoom.id, FULL_SYNC_LIMIT, 0);
const syncFullEvent: ChatEvent = {
type: 'chat-sync-full',
roomId: currentRoom.id,
messages: all
};
webrtc.sendToPeer(fromPeerId, syncFullEvent);
})()
).pipe(mergeMap(() => EMPTY));
}
/** Merges a full message dump from a peer into the local DB and store. */
function handleSyncFull(
event: IncomingMessageEvent,
{ db, attachments }: IncomingMessageContext
): Observable<Action> {
if (!hasMessageBatch(event))
return EMPTY;
return from(processSyncBatch(event, db, attachments)).pipe(
mergeMap((toUpsert) =>
toUpsert.length > 0
? of(MessagesActions.syncMessages({ messages: toUpsert }))
: EMPTY
)
);
}
/** Map of event types to their handler functions. */
const HANDLER_MAP: Readonly<Record<string, MessageHandler>> = {
// Inventory-based sync protocol
'chat-inventory-request': handleInventoryRequest,
'chat-inventory': handleInventory,
'chat-sync-request-ids': handleSyncRequestIds,
'chat-sync-batch': handleSyncBatch,
// Chat messages
'chat-message': handleChatMessage,
'message-edited': handleMessageEdited,
'message-deleted': handleMessageDeleted,
// Reactions
'reaction-added': handleReactionAdded,
'reaction-removed': handleReactionRemoved,
// Attachments
'file-announce': handleFileAnnounce,
'file-chunk': handleFileChunk,
'file-request': handleFileRequest,
'file-cancel': handleFileCancel,
'file-not-found': handleFileNotFound,
// Legacy sync handshake
'chat-sync-summary': handleSyncSummary,
'chat-sync-request': handleSyncRequest,
'chat-sync-full': handleSyncFull
};
/**
* Routes an incoming P2P message to the appropriate handler.
* Returns `EMPTY` if the event type is unknown or has no relevant handler.
*/
export function dispatchIncomingMessage(
event: IncomingMessageEvent,
ctx: IncomingMessageContext
): Observable<Action> {
const handler = HANDLER_MAP[event.type];
return handler ? handler(event, ctx) : EMPTY;
}
function trackBackgroundOperation(
task: Promise<unknown> | unknown,
debugging: DebuggingService,
message: string,
payload: Record<string, unknown>
): void {
trackDebuggingTaskFailure(task, debugging, 'messages', message, payload);
}

View File

@@ -0,0 +1,267 @@
/**
* Sync-lifecycle effects for the messages store slice.
*
* These effects manage the periodic sync polling, peer-connect
* handshakes, and room-activation kickoff that keep message databases
* in sync across peers.
*
* Extracted from the monolithic MessagesEffects to keep each
* class focused on a single concern.
*/
/* eslint-disable @typescript-eslint/member-ordering */
import { Injectable, inject } from '@angular/core';
import {
Actions,
createEffect,
ofType
} from '@ngrx/effects';
import { Store } from '@ngrx/store';
import {
of,
from,
timer,
Subject,
EMPTY
} from 'rxjs';
import {
map,
mergeMap,
catchError,
withLatestFrom,
tap,
filter,
exhaustMap,
switchMap,
repeat,
startWith
} from 'rxjs/operators';
import { MessagesActions } from './messages.actions';
import { RoomsActions } from '../rooms/rooms.actions';
import { selectMessagesSyncing } from './messages.selectors';
import { selectCurrentRoom } from '../rooms/rooms.selectors';
import { RealtimeSessionFacade } from '../../core/realtime';
import { DatabaseService } from '../../infrastructure/persistence';
import { DebuggingService } from '../../core/services/debugging.service';
import {
INVENTORY_LIMIT,
FULL_SYNC_LIMIT,
SYNC_POLL_FAST_MS,
SYNC_POLL_SLOW_MS,
SYNC_TIMEOUT_MS,
getLatestTimestamp
} from './messages.helpers';
@Injectable()
export class MessagesSyncEffects {
private readonly actions$ = inject(Actions);
private readonly store = inject(Store);
private readonly db = inject(DatabaseService);
private readonly debugging = inject(DebuggingService);
private readonly webrtc = inject(RealtimeSessionFacade);
/** Tracks whether the last sync cycle found no new messages. */
private lastSyncClean = false;
/** Subject to reset the periodic sync timer. */
private readonly syncReset$ = new Subject<void>();
/**
* When a new peer connects, sends our dataset summary and an
* inventory request so both sides can reconcile.
*/
peerConnectedSync$ = createEffect(
() =>
this.webrtc.onPeerConnected.pipe(
withLatestFrom(this.store.select(selectCurrentRoom)),
mergeMap(([peerId, room]) => {
if (!room)
return EMPTY;
return from(
this.db.getMessages(room.id, FULL_SYNC_LIMIT, 0)
).pipe(
tap((messages) => {
const count = messages.length;
const lastUpdated = getLatestTimestamp(messages);
this.webrtc.sendToPeer(peerId, {
type: 'chat-sync-summary',
roomId: room.id,
count,
lastUpdated
});
this.webrtc.sendToPeer(peerId, {
type: 'chat-inventory-request',
roomId: room.id
});
})
);
})
),
{ dispatch: false }
);
/**
* When the user joins or views a room, sends a summary and inventory
* request to every already-connected peer.
*/
roomActivationSyncKickoff$ = createEffect(
() =>
this.actions$.pipe(
ofType(RoomsActions.joinRoomSuccess, RoomsActions.viewServerSuccess),
withLatestFrom(this.store.select(selectCurrentRoom)),
mergeMap(([{ room }, currentRoom]) => {
const activeRoom = currentRoom || room;
if (!activeRoom)
return EMPTY;
return from(
this.db.getMessages(activeRoom.id, FULL_SYNC_LIMIT, 0)
).pipe(
tap((messages) => {
const count = messages.length;
const lastUpdated = getLatestTimestamp(messages);
for (const pid of this.webrtc.getConnectedPeers()) {
try {
this.webrtc.sendToPeer(pid, {
type: 'chat-sync-summary',
roomId: activeRoom.id,
count,
lastUpdated
});
this.webrtc.sendToPeer(pid, {
type: 'chat-inventory-request',
roomId: activeRoom.id
});
} catch (error) {
this.debugging.warn('messages', 'Failed to kick off room sync for peer', {
error,
peerId: pid,
roomId: activeRoom.id
});
}
}
})
);
})
),
{ dispatch: false }
);
/**
* Reset the polling cadence when the active room changes so the next
* room does not inherit a stale slow-poll delay.
*/
resetPeriodicSyncOnRoomActivation$ = createEffect(
() =>
this.actions$.pipe(
ofType(RoomsActions.joinRoomSuccess, RoomsActions.viewServerSuccess),
tap(() => {
this.lastSyncClean = false;
this.syncReset$.next();
})
),
{ dispatch: false }
);
/**
* Alternates between fast (10 s) and slow (15 min) sync intervals.
* Sends inventory requests to all connected peers for the active room.
*/
periodicSyncPoll$ = createEffect(() =>
this.syncReset$.pipe(
startWith(undefined),
switchMap(() =>
timer(SYNC_POLL_FAST_MS).pipe(
repeat({
delay: () =>
timer(
this.lastSyncClean ? SYNC_POLL_SLOW_MS : SYNC_POLL_FAST_MS
)
}),
withLatestFrom(this.store.select(selectCurrentRoom)),
filter(
([, room]) =>
!!room && this.webrtc.getConnectedPeers().length > 0
),
exhaustMap(([, room]) => {
const peers = this.webrtc.getConnectedPeers();
if (!room || peers.length === 0) {
return of(MessagesActions.syncComplete());
}
return from(
this.db.getMessages(room.id, INVENTORY_LIMIT, 0)
).pipe(
map(() => {
for (const pid of peers) {
try {
this.webrtc.sendToPeer(pid, {
type: 'chat-inventory-request',
roomId: room.id
});
} catch (error) {
this.debugging.warn('messages', 'Failed to request peer inventory during sync poll', {
error,
peerId: pid,
roomId: room.id
});
}
}
return MessagesActions.startSync();
}),
catchError((error) => {
this.lastSyncClean = false;
this.debugging.warn('messages', 'Periodic sync poll failed', {
error,
roomId: room.id
});
return of(MessagesActions.syncComplete());
})
);
})
)
)
)
);
/**
* Auto-completes a sync cycle after a timeout if no messages arrive.
* Switches to slow polling when the cycle is clean.
*/
syncTimeout$ = createEffect(() =>
this.actions$.pipe(
ofType(MessagesActions.startSync),
switchMap(() => from(
new Promise<void>((resolve) => setTimeout(resolve, SYNC_TIMEOUT_MS))
)),
withLatestFrom(this.store.select(selectMessagesSyncing)),
filter(([, syncing]) => syncing),
map(() => {
this.lastSyncClean = true;
return MessagesActions.syncComplete();
})
)
);
/**
* When a peer (re)connects, revert to aggressive polling in case
* we missed messages while disconnected.
*/
syncReceivedMessages$ = createEffect(
() =>
this.webrtc.onPeerConnected.pipe(
tap(() => {
this.lastSyncClean = false;
})
),
{ dispatch: false }
);
}

View File

@@ -0,0 +1,55 @@
/**
* Messages store actions using `createActionGroup` for concise definitions.
*
* Action type strings follow the `[Messages] Event Name` convention and are
* generated automatically by NgRx from the `source` and event key.
*/
import {
createActionGroup,
emptyProps,
props
} from '@ngrx/store';
import { Message, Reaction } from '../../shared-kernel';
export const MessagesActions = createActionGroup({
source: 'Messages',
events: {
/** Triggers loading messages for the given room from the local database. */
'Load Messages': props<{ roomId: string }>(),
'Load Messages Success': props<{ messages: Message[] }>(),
'Load Messages Failure': props<{ error: string }>(),
/** Sends a new chat message to the current room and broadcasts to peers. */
'Send Message': props<{ content: string; replyToId?: string; channelId?: string }>(),
'Send Message Success': props<{ message: Message }>(),
'Send Message Failure': props<{ error: string }>(),
/** Applies a message received from a remote peer to the local store. */
'Receive Message': props<{ message: Message }>(),
'Edit Message': props<{ messageId: string; content: string }>(),
'Edit Message Success': props<{ messageId: string; content: string; editedAt: number }>(),
'Edit Message Failure': props<{ error: string }>(),
'Delete Message': props<{ messageId: string }>(),
'Delete Message Success': props<{ messageId: string }>(),
'Delete Message Failure': props<{ error: string }>(),
/** Soft-deletes a message by an admin (can delete any message). */
'Admin Delete Message': props<{ messageId: string }>(),
'Add Reaction': props<{ messageId: string; emoji: string }>(),
'Add Reaction Success': props<{ reaction: Reaction }>(),
'Remove Reaction': props<{ messageId: string; emoji: string }>(),
'Remove Reaction Success': props<{ messageId: string; emoji: string; oderId: string }>(),
/** Merges a batch of messages received from a peer into the local store. */
'Sync Messages': props<{ messages: Message[] }>(),
/** Marks the start of a message sync cycle. */
'Start Sync': emptyProps(),
/** Marks the end of a message sync cycle. */
'Sync Complete': emptyProps(),
/** Removes all messages from the store (e.g. when leaving a room). */
'Clear Messages': emptyProps()
}
});

View File

@@ -0,0 +1,427 @@
/**
* Core message CRUD effects (load, send, edit, delete, react)
* and the central incoming-message dispatcher.
*
* Sync-lifecycle effects (polling, peer-connect handshakes) live in
* `messages-sync.effects.ts` to keep this file focused.
*
* The giant `incomingMessages$` switch-case has been replaced by a
* handler registry in `messages-incoming.handlers.ts`.
*/
/* eslint-disable @typescript-eslint/member-ordering */
import { Injectable, inject } from '@angular/core';
import {
Actions,
createEffect,
ofType
} from '@ngrx/effects';
import { Store } from '@ngrx/store';
import {
of,
from,
EMPTY
} from 'rxjs';
import {
mergeMap,
catchError,
withLatestFrom,
switchMap
} from 'rxjs/operators';
import { v4 as uuidv4 } from 'uuid';
import { MessagesActions } from './messages.actions';
import { selectCurrentUser } from '../users/users.selectors';
import { selectCurrentRoom } from '../rooms/rooms.selectors';
import { RealtimeSessionFacade } from '../../core/realtime';
import { DatabaseService } from '../../infrastructure/persistence';
import { reportDebuggingError, trackDebuggingTaskFailure } from '../../core/helpers/debugging-helpers';
import { DebuggingService } from '../../core/services';
import { AttachmentFacade } from '../../domains/attachment';
import { TimeSyncService } from '../../core/services/time-sync.service';
import {
DELETED_MESSAGE_CONTENT,
Message,
Reaction
} from '../../shared-kernel';
import { hydrateMessages } from './messages.helpers';
import { canEditMessage } from '../../domains/chat/domain/message.rules';
import { dispatchIncomingMessage, IncomingMessageContext } from './messages-incoming.handlers';
@Injectable()
export class MessagesEffects {
private readonly actions$ = inject(Actions);
private readonly store = inject(Store);
private readonly db = inject(DatabaseService);
private readonly debugging = inject(DebuggingService);
private readonly attachments = inject(AttachmentFacade);
private readonly webrtc = inject(RealtimeSessionFacade);
private readonly timeSync = inject(TimeSyncService);
/** Loads messages for a room from the local database, hydrating reactions. */
loadMessages$ = createEffect(() =>
this.actions$.pipe(
ofType(MessagesActions.loadMessages),
switchMap(({ roomId }) =>
from(this.db.getMessages(roomId)).pipe(
mergeMap(async (messages) => {
const hydrated = await hydrateMessages(messages, this.db);
for (const message of hydrated) {
this.attachments.rememberMessageRoom(message.id, message.roomId);
}
void this.attachments.requestAutoDownloadsForRoom(roomId);
return MessagesActions.loadMessagesSuccess({ messages: hydrated });
}),
catchError((error) =>
of(MessagesActions.loadMessagesFailure({ error: error.message }))
)
)
)
)
);
/** Constructs a new message, persists it locally, and broadcasts to all peers. */
sendMessage$ = createEffect(() =>
this.actions$.pipe(
ofType(MessagesActions.sendMessage),
withLatestFrom(
this.store.select(selectCurrentUser),
this.store.select(selectCurrentRoom)
),
mergeMap(([
{ content, replyToId, channelId },
currentUser,
currentRoom
]) => {
if (!currentUser || !currentRoom) {
return of(MessagesActions.sendMessageFailure({ error: 'Not connected to a room' }));
}
const message: Message = {
id: uuidv4(),
roomId: currentRoom.id,
channelId: channelId || 'general',
senderId: currentUser.id,
senderName: currentUser.displayName || currentUser.username,
content,
timestamp: this.timeSync.now(),
reactions: [],
isDeleted: false,
replyToId
};
this.attachments.rememberMessageRoom(message.id, message.roomId);
this.trackBackgroundOperation(
this.db.saveMessage(message),
'Failed to persist outgoing chat message',
{
channelId: message.channelId,
contentLength: message.content.length,
messageId: message.id,
roomId: message.roomId
}
);
this.webrtc.broadcastMessage({ type: 'chat-message',
message });
return of(MessagesActions.sendMessageSuccess({ message }));
}),
catchError((error) =>
of(MessagesActions.sendMessageFailure({ error: error.message }))
)
)
);
/** Edits an existing message (author-only), updates DB, and broadcasts the change. */
editMessage$ = createEffect(() =>
this.actions$.pipe(
ofType(MessagesActions.editMessage),
withLatestFrom(this.store.select(selectCurrentUser)),
switchMap(([{ messageId, content }, currentUser]) => {
if (!currentUser) {
return of(MessagesActions.editMessageFailure({ error: 'Not logged in' }));
}
return from(this.db.getMessageById(messageId)).pipe(
mergeMap((existing) => {
if (!existing) {
return of(MessagesActions.editMessageFailure({ error: 'Message not found' }));
}
if (!canEditMessage(existing, currentUser.id)) {
return of(MessagesActions.editMessageFailure({ error: 'Cannot edit others messages' }));
}
const editedAt = this.timeSync.now();
this.trackBackgroundOperation(
this.db.updateMessage(messageId, { content,
editedAt }),
'Failed to persist edited chat message',
{
contentLength: content.length,
editedAt,
messageId
}
);
this.webrtc.broadcastMessage({ type: 'message-edited',
messageId,
content,
editedAt });
return of(MessagesActions.editMessageSuccess({ messageId,
content,
editedAt }));
}),
catchError((error) =>
of(MessagesActions.editMessageFailure({ error: error.message }))
)
);
})
)
);
/** Soft-deletes a message (author-only), marks it deleted in DB, and broadcasts. */
deleteMessage$ = createEffect(() =>
this.actions$.pipe(
ofType(MessagesActions.deleteMessage),
withLatestFrom(this.store.select(selectCurrentUser)),
switchMap(([{ messageId }, currentUser]) => {
if (!currentUser) {
return of(MessagesActions.deleteMessageFailure({ error: 'Not logged in' }));
}
return from(this.db.getMessageById(messageId)).pipe(
mergeMap((existing) => {
if (!existing) {
return of(MessagesActions.deleteMessageFailure({ error: 'Message not found' }));
}
if (!canEditMessage(existing, currentUser.id)) {
return of(MessagesActions.deleteMessageFailure({ error: 'Cannot delete others messages' }));
}
const deletedAt = this.timeSync.now();
this.trackBackgroundOperation(
this.db.updateMessage(messageId, {
content: DELETED_MESSAGE_CONTENT,
editedAt: deletedAt,
isDeleted: true
}),
'Failed to persist message deletion',
{
deletedAt,
messageId
}
);
this.trackBackgroundOperation(
this.attachments.deleteForMessage(messageId),
'Failed to delete message attachments',
{ messageId }
);
this.webrtc.broadcastMessage({ type: 'message-deleted',
messageId,
deletedAt });
return of(MessagesActions.deleteMessageSuccess({ messageId }));
}),
catchError((error) =>
of(MessagesActions.deleteMessageFailure({ error: error.message }))
)
);
})
)
);
/** Soft-deletes any message (admin+ only). */
adminDeleteMessage$ = createEffect(() =>
this.actions$.pipe(
ofType(MessagesActions.adminDeleteMessage),
withLatestFrom(this.store.select(selectCurrentUser)),
mergeMap(([{ messageId }, currentUser]) => {
if (!currentUser) {
return of(MessagesActions.deleteMessageFailure({ error: 'Not logged in' }));
}
const hasPermission =
currentUser.role === 'host' ||
currentUser.role === 'admin' ||
currentUser.role === 'moderator';
if (!hasPermission) {
return of(MessagesActions.deleteMessageFailure({ error: 'Permission denied' }));
}
const deletedAt = this.timeSync.now();
this.trackBackgroundOperation(
this.db.updateMessage(messageId, {
content: DELETED_MESSAGE_CONTENT,
editedAt: deletedAt,
isDeleted: true
}),
'Failed to persist admin message deletion',
{
deletedBy: currentUser.id,
deletedAt,
messageId
}
);
this.trackBackgroundOperation(
this.attachments.deleteForMessage(messageId),
'Failed to delete admin-deleted message attachments',
{
deletedBy: currentUser.id,
messageId
}
);
this.webrtc.broadcastMessage({ type: 'message-deleted',
messageId,
deletedBy: currentUser.id,
deletedAt });
return of(MessagesActions.deleteMessageSuccess({ messageId }));
}),
catchError((error) =>
of(MessagesActions.deleteMessageFailure({ error: error.message }))
)
)
);
/** Adds an emoji reaction to a message, persists it, and broadcasts to peers. */
addReaction$ = createEffect(() =>
this.actions$.pipe(
ofType(MessagesActions.addReaction),
withLatestFrom(this.store.select(selectCurrentUser)),
mergeMap(([{ messageId, emoji }, currentUser]) => {
if (!currentUser)
return EMPTY;
const reaction: Reaction = {
id: uuidv4(),
messageId,
oderId: currentUser.id,
userId: currentUser.id,
emoji,
timestamp: this.timeSync.now()
};
this.trackBackgroundOperation(
this.db.saveReaction(reaction),
'Failed to persist reaction',
{
emoji,
messageId,
reactionId: reaction.id,
userId: currentUser.id
}
);
this.webrtc.broadcastMessage({ type: 'reaction-added',
messageId,
reaction });
return of(MessagesActions.addReactionSuccess({ reaction }));
})
)
);
/** Removes the current user's reaction from a message, deletes from DB, and broadcasts. */
removeReaction$ = createEffect(() =>
this.actions$.pipe(
ofType(MessagesActions.removeReaction),
withLatestFrom(this.store.select(selectCurrentUser)),
mergeMap(([{ messageId, emoji }, currentUser]) => {
if (!currentUser)
return EMPTY;
this.trackBackgroundOperation(
this.db.removeReaction(messageId, currentUser.id, emoji),
'Failed to persist reaction removal',
{
emoji,
messageId,
userId: currentUser.id
}
);
this.webrtc.broadcastMessage({
type: 'reaction-removed',
messageId,
oderId: currentUser.id,
emoji
});
return of(
MessagesActions.removeReactionSuccess({
messageId,
oderId: currentUser.id,
emoji
})
);
})
)
);
/**
* Central dispatcher for all incoming P2P messages.
* Delegates to handler functions in `messages-incoming.handlers.ts`.
*/
incomingMessages$ = createEffect(() =>
this.webrtc.onMessageReceived.pipe(
withLatestFrom(
this.store.select(selectCurrentUser),
this.store.select(selectCurrentRoom)
),
mergeMap(([
event,
currentUser,
currentRoom
]) => {
const ctx: IncomingMessageContext = {
db: this.db,
webrtc: this.webrtc,
attachments: this.attachments,
debugging: this.debugging,
currentUser: currentUser ?? null,
currentRoom
};
return dispatchIncomingMessage(event, ctx).pipe(
catchError((error) => {
const eventRecord = event as unknown as Record<string, unknown>;
const messageRecord = (eventRecord['message'] && typeof eventRecord['message'] === 'object' && !Array.isArray(eventRecord['message']))
? eventRecord['message'] as Record<string, unknown>
: null;
reportDebuggingError(this.debugging, 'messages', 'Failed to process incoming peer message', {
eventType: typeof eventRecord['type'] === 'string' ? eventRecord['type'] : 'unknown',
fromPeerId: typeof eventRecord['fromPeerId'] === 'string' ? eventRecord['fromPeerId'] : null,
messageId: typeof eventRecord['messageId'] === 'string'
? eventRecord['messageId']
: (typeof messageRecord?.['id'] === 'string' ? messageRecord['id'] : null),
roomId: typeof eventRecord['roomId'] === 'string'
? eventRecord['roomId']
: (typeof messageRecord?.['roomId'] === 'string' ? messageRecord['roomId'] : null)
}, error);
return EMPTY;
})
);
})
)
);
private trackBackgroundOperation(task: Promise<unknown> | unknown, message: string, payload: Record<string, unknown>): void {
trackDebuggingTaskFailure(task, this.debugging, 'messages', message, payload);
}
}

View File

@@ -0,0 +1,183 @@
/**
* Message store helpers - delegates pure domain logic to `domains/chat/domain/`
* and provides DB-dependent hydration/merge operations at the application level.
*/
import { Message } from '../../shared-kernel';
import { DatabaseService } from '../../infrastructure/persistence';
import { getMessageTimestamp, normaliseDeletedMessage } from '../../domains/chat/domain/message.rules';
import type { InventoryItem } from '../../domains/chat/domain/message-sync.rules';
// Re-export domain logic so existing callers keep working
export {
getMessageTimestamp,
getLatestTimestamp,
normaliseDeletedMessage,
canEditMessage
} from '../../domains/chat/domain/message.rules';
export {
INVENTORY_LIMIT,
CHUNK_SIZE,
SYNC_POLL_FAST_MS,
SYNC_POLL_SLOW_MS,
SYNC_TIMEOUT_MS,
FULL_SYNC_LIMIT,
chunkArray,
findMissingIds
} from '../../domains/chat/domain/message-sync.rules';
export type { InventoryItem } from '../../domains/chat/domain/message-sync.rules';
/** Hydrates a single message with its reactions from the database. */
export async function hydrateMessage(
msg: Message,
db: DatabaseService
): Promise<Message> {
if (msg.isDeleted)
return normaliseDeletedMessage(msg);
const reactions = await db.getReactionsForMessage(msg.id);
return reactions.length > 0 ? { ...msg,
reactions } : msg;
}
/** Hydrates an array of messages with their reactions. */
export async function hydrateMessages(
messages: Message[],
db: DatabaseService
): Promise<Message[]> {
return Promise.all(messages.map((msg) => hydrateMessage(msg, db)));
}
/** Builds a sync inventory item from a message and its reaction count. */
export async function buildInventoryItem(
msg: Message,
db: DatabaseService,
attachmentCountOverride?: number
): Promise<InventoryItem> {
if (msg.isDeleted) {
return {
id: msg.id,
ts: getMessageTimestamp(msg),
rc: 0,
ac: 0
};
}
const reactions = await db.getReactionsForMessage(msg.id);
const attachments =
attachmentCountOverride === undefined
? await db.getAttachmentsForMessage(msg.id)
: [];
return { id: msg.id,
ts: getMessageTimestamp(msg),
rc: reactions.length,
ac: attachmentCountOverride ?? attachments.length };
}
/** Builds a local map of `{timestamp, reactionCount, attachmentCount}` keyed by message ID. */
export async function buildLocalInventoryMap(
messages: Message[],
db: DatabaseService,
attachmentCountOverrides?: ReadonlyMap<string, number>
): Promise<Map<string, { ts: number; rc: number; ac: number }>> {
const map = new Map<string, { ts: number; rc: number; ac: number }>();
await Promise.all(
messages.map(async (msg) => {
if (msg.isDeleted) {
map.set(msg.id, {
ts: getMessageTimestamp(msg),
rc: 0,
ac: 0
});
return;
}
const reactions = await db.getReactionsForMessage(msg.id);
const attachmentCountOverride = attachmentCountOverrides?.get(msg.id);
const attachments =
attachmentCountOverride === undefined
? await db.getAttachmentsForMessage(msg.id)
: [];
map.set(msg.id, { ts: getMessageTimestamp(msg),
rc: reactions.length,
ac: attachmentCountOverride ?? attachments.length });
})
);
return map;
}
/** Result of merging an incoming message into the local database. */
export interface MergeResult {
message: Message;
changed: boolean;
}
/**
* Merges an incoming message into the local database.
* Handles message upsert and reaction deduplication, then returns
* the fully hydrated message alongside a `changed` flag.
*/
export async function mergeIncomingMessage(
incoming: Message,
db: DatabaseService
): Promise<MergeResult> {
const existing = await db.getMessageById(incoming.id);
const existingTs = existing ? getMessageTimestamp(existing) : -1;
const incomingTs = getMessageTimestamp(incoming);
const isDeletedStateNewer =
!!existing &&
incomingTs === existingTs &&
incoming.isDeleted &&
!existing.isDeleted;
const isNewer = !existing || incomingTs > existingTs || isDeletedStateNewer;
if (isNewer) {
await db.saveMessage(incoming);
}
// Persist incoming reactions (deduped by the DB layer)
const incomingReactions = incoming.isDeleted ? [] : incoming.reactions ?? [];
for (const reaction of incomingReactions) {
await db.saveReaction(reaction);
}
const changed = isNewer || incomingReactions.length > 0;
if (changed) {
const baseMessage = isNewer ? incoming : existing;
if (!baseMessage) {
return { message: normaliseDeletedMessage(incoming),
changed };
}
if (baseMessage.isDeleted) {
return {
message: normaliseDeletedMessage(baseMessage),
changed
};
}
const reactions = await db.getReactionsForMessage(incoming.id);
return {
message: { ...baseMessage,
reactions },
changed
};
}
if (!existing) {
return { message: normaliseDeletedMessage(incoming),
changed: false };
}
return { message: normaliseDeletedMessage(existing),
changed: false };
}

View File

@@ -0,0 +1,216 @@
import { createReducer, on } from '@ngrx/store';
import {
EntityState,
EntityAdapter,
createEntityAdapter
} from '@ngrx/entity';
import { DELETED_MESSAGE_CONTENT, Message } from '../../shared-kernel';
import { MessagesActions } from './messages.actions';
/** State shape for the messages feature slice, extending NgRx EntityState. */
export interface MessagesState extends EntityState<Message> {
/** Whether messages are being loaded from the database. */
loading: boolean;
/** Whether a peer-to-peer sync cycle is in progress. */
syncing: boolean;
/** Most recent error message from message operations. */
error: string | null;
/** ID of the room whose messages are currently loaded. */
currentRoomId: string | null;
}
export const messagesAdapter: EntityAdapter<Message> = createEntityAdapter<Message>({
selectId: (message) => message.id,
sortComparer: (messageA, messageB) => messageA.timestamp - messageB.timestamp
});
export const initialState: MessagesState = messagesAdapter.getInitialState({
loading: false,
syncing: false,
error: null,
currentRoomId: null
});
export const messagesReducer = createReducer(
initialState,
// Load messages - clear stale messages when switching to a different room
on(MessagesActions.loadMessages, (state, { roomId }) => {
if (state.currentRoomId && state.currentRoomId !== roomId) {
return messagesAdapter.removeAll({
...state,
loading: true,
error: null,
currentRoomId: roomId
});
}
return {
...state,
loading: true,
error: null,
currentRoomId: roomId
};
}),
on(MessagesActions.loadMessagesSuccess, (state, { messages }) =>
messagesAdapter.setAll(messages, {
...state,
loading: false
})
),
on(MessagesActions.loadMessagesFailure, (state, { error }) => ({
...state,
loading: false,
error
})),
// Send message
on(MessagesActions.sendMessage, (state) => ({
...state,
loading: true
})),
on(MessagesActions.sendMessageSuccess, (state, { message }) =>
messagesAdapter.addOne(message, {
...state,
loading: false
})
),
on(MessagesActions.sendMessageFailure, (state, { error }) => ({
...state,
loading: false,
error
})),
// Receive message from peer
on(MessagesActions.receiveMessage, (state, { message }) =>
messagesAdapter.upsertOne(message, state)
),
// Edit message
on(MessagesActions.editMessageSuccess, (state, { messageId, content, editedAt }) =>
messagesAdapter.updateOne(
{
id: messageId,
changes: { content,
editedAt }
},
state
)
),
// Delete message
on(MessagesActions.deleteMessageSuccess, (state, { messageId }) =>
messagesAdapter.updateOne(
{
id: messageId,
changes: {
content: DELETED_MESSAGE_CONTENT,
isDeleted: true,
reactions: []
}
},
state
)
),
// Add reaction
on(MessagesActions.addReactionSuccess, (state, { reaction }) => {
const message = state.entities[reaction.messageId];
if (!message)
return state;
const existingReaction = message.reactions.find(
(existing) => existing.emoji === reaction.emoji && existing.userId === reaction.userId
);
if (existingReaction)
return state;
return messagesAdapter.updateOne(
{
id: reaction.messageId,
changes: {
reactions: [...message.reactions, reaction]
}
},
state
);
}),
// Remove reaction
on(MessagesActions.removeReactionSuccess, (state, { messageId, emoji, oderId }) => {
const message = state.entities[messageId];
if (!message)
return state;
return messagesAdapter.updateOne(
{
id: messageId,
changes: {
reactions: message.reactions.filter(
(existingReaction) => !(existingReaction.emoji === emoji && existingReaction.userId === oderId)
)
}
},
state
);
}),
// Sync lifecycle
on(MessagesActions.startSync, (state) => ({
...state,
syncing: true
})),
on(MessagesActions.syncComplete, (state) => ({
...state,
syncing: false
})),
// Sync messages from peer (merge reactions to avoid losing local-only reactions)
on(MessagesActions.syncMessages, (state, { messages }) => {
const merged = messages.map(message => {
const existing = state.entities[message.id];
if (existing?.reactions?.length) {
const combined = [...(message.reactions ?? [])];
for (const existingReaction of existing.reactions) {
const alreadyExists = combined.some((combinedReaction) =>
combinedReaction.userId === existingReaction.userId &&
combinedReaction.emoji === existingReaction.emoji &&
combinedReaction.messageId === existingReaction.messageId
);
if (!alreadyExists) {
combined.push(existingReaction);
}
}
return { ...message,
reactions: combined };
}
return message;
});
return messagesAdapter.upsertMany(merged, {
...state,
syncing: false
});
}),
// Clear messages
on(MessagesActions.clearMessages, (state) =>
messagesAdapter.removeAll({
...state,
currentRoomId: null
})
)
);

View File

@@ -0,0 +1,87 @@
import { createFeatureSelector, createSelector } from '@ngrx/store';
import { MessagesState, messagesAdapter } from './messages.reducer';
/** Selects the top-level messages feature state. */
export const selectMessagesState = createFeatureSelector<MessagesState>('messages');
const { selectIds, selectEntities, selectAll, selectTotal } = messagesAdapter.getSelectors();
/** Selects all message entities as a flat array. */
export const selectAllMessages = createSelector(selectMessagesState, selectAll);
/** Selects the message entity dictionary keyed by ID. */
export const selectMessagesEntities = createSelector(selectMessagesState, selectEntities);
/** Selects all message IDs. */
export const selectMessagesIds = createSelector(selectMessagesState, selectIds);
/** Selects the total count of messages. */
export const selectMessagesTotal = createSelector(selectMessagesState, selectTotal);
/** Whether messages are currently being loaded from the database. */
export const selectMessagesLoading = createSelector(
selectMessagesState,
(state) => state.loading
);
/** Selects the most recent messages-related error message. */
export const selectMessagesError = createSelector(
selectMessagesState,
(state) => state.error
);
/** Whether a peer-to-peer message sync cycle is in progress. */
export const selectMessagesSyncing = createSelector(
selectMessagesState,
(state) => state.syncing
);
/** Selects the ID of the room whose messages are currently loaded. */
export const selectCurrentRoomId = createSelector(
selectMessagesState,
(state) => state.currentRoomId
);
/** Selects all messages belonging to the currently active room. */
export const selectCurrentRoomMessages = createSelector(
selectAllMessages,
selectCurrentRoomId,
(messages, roomId) => roomId ? messages.filter((message) => message.roomId === roomId) : []
);
/** Creates a selector that returns messages for a specific text channel within the current room. */
export const selectChannelMessages = (channelId: string) =>
createSelector(
selectAllMessages,
selectCurrentRoomId,
(messages, roomId) => {
if (!roomId)
return [];
return messages.filter(
(message) => message.roomId === roomId && (message.channelId || 'general') === channelId
);
}
);
/** Creates a selector that returns a single message by its ID. */
export const selectMessageById = (id: string) =>
createSelector(selectMessagesEntities, (entities) => entities[id]);
/** Creates a selector that returns all messages for a specific room. */
export const selectMessagesByRoomId = (roomId: string) =>
createSelector(selectAllMessages, (messages) =>
messages.filter((message) => message.roomId === roomId)
);
/** Creates a selector that returns the N most recent messages. */
export const selectRecentMessages = (limit: number) =>
createSelector(selectAllMessages, (messages) =>
messages.slice(-limit)
);
/** Selects only messages that have at least one reaction. */
export const selectMessagesWithReactions = createSelector(
selectAllMessages,
(messages) => messages.filter((message) => message.reactions.length > 0)
);

View File

@@ -0,0 +1,4 @@
export * from './rooms.actions';
export * from './rooms.reducer';
export * from './rooms.selectors';
export * from './rooms.effects';

View File

@@ -0,0 +1,525 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { Injectable, inject } from '@angular/core';
import {
Actions,
createEffect,
ofType
} from '@ngrx/effects';
import { Action } from '@ngrx/store';
import { Store } from '@ngrx/store';
import { EMPTY } from 'rxjs';
import {
mergeMap,
tap,
withLatestFrom
} from 'rxjs/operators';
import {
ChatEvent,
Room,
RoomMember,
User
} from '../../shared-kernel';
import { RealtimeSessionFacade } from '../../core/realtime';
import { UsersActions } from '../users/users.actions';
import { selectCurrentUser } from '../users/users.selectors';
import { RoomsActions } from './rooms.actions';
import { selectCurrentRoom, selectSavedRooms } from './rooms.selectors';
import {
areRoomMembersEqual,
findRoomMember,
mergeRoomMembers,
pruneRoomMembers,
removeRoomMember,
roomMemberFromUser,
touchRoomMemberLastSeen,
transferRoomOwnership,
updateRoomMemberRole,
upsertRoomMember
} from './room-members.helpers';
@Injectable()
export class RoomMembersSyncEffects {
private readonly actions$ = inject(Actions);
private readonly store = inject(Store);
private readonly webrtc = inject(RealtimeSessionFacade);
/** Ensure the local user is recorded in a room as soon as it becomes active. */
ensureCurrentMemberOnRoomEntry$ = createEffect(() =>
this.actions$.pipe(
ofType(
RoomsActions.createRoomSuccess,
RoomsActions.joinRoomSuccess,
RoomsActions.viewServerSuccess
),
withLatestFrom(this.store.select(selectCurrentUser)),
mergeMap(([{ room }, currentUser]) => {
if (!currentUser)
return EMPTY;
const members = upsertRoomMember(
room.members ?? [],
this.buildCurrentUserMember(room, currentUser, true)
);
const actions = this.createRoomMemberUpdateActions(room, members);
return actions.length > 0 ? actions : EMPTY;
})
)
);
/** Keep the viewed room's local member record aligned with the current profile. */
syncCurrentUserIntoCurrentRoom$ = createEffect(() =>
this.actions$.pipe(
ofType(
UsersActions.loadCurrentUserSuccess,
UsersActions.setCurrentUser,
UsersActions.updateCurrentUser
),
withLatestFrom(
this.store.select(selectCurrentUser),
this.store.select(selectCurrentRoom)
),
mergeMap(([
, currentUser,
currentRoom
]) => {
if (!currentUser || !currentRoom)
return EMPTY;
const members = upsertRoomMember(
currentRoom.members ?? [],
this.buildCurrentUserMember(currentRoom, currentUser, true)
);
const actions = this.createRoomMemberUpdateActions(currentRoom, members);
return actions.length > 0 ? actions : EMPTY;
})
)
);
/** Persist room-role changes into the stored member roster for the active room. */
syncRoleChangesIntoCurrentRoom$ = createEffect(() =>
this.actions$.pipe(
ofType(UsersActions.updateUserRole),
withLatestFrom(this.store.select(selectCurrentRoom)),
mergeMap(([{ userId, role }, currentRoom]) => {
if (!currentRoom)
return EMPTY;
const members = updateRoomMemberRole(currentRoom.members ?? [], userId, role);
const actions = this.createRoomMemberUpdateActions(currentRoom, members);
return actions.length > 0 ? actions : EMPTY;
})
)
);
/** Update persisted room rosters when signaling presence changes arrive. */
signalingPresenceIntoRoomMembers$ = createEffect(() =>
this.webrtc.onSignalingMessage.pipe(
withLatestFrom(
this.store.select(selectCurrentRoom),
this.store.select(selectSavedRooms),
this.store.select(selectCurrentUser)
),
mergeMap(([
message,
currentRoom,
savedRooms,
currentUser
]) => {
const signalingMessage: {
type: string;
serverId?: string;
users?: { oderId: string; displayName: string }[];
oderId?: string;
displayName?: string;
} = message;
const roomId = typeof signalingMessage.serverId === 'string' ? signalingMessage.serverId : undefined;
const room = this.resolveRoom(roomId, currentRoom, savedRooms);
if (!room)
return EMPTY;
const myId = currentUser?.oderId || currentUser?.id;
switch (signalingMessage.type) {
case 'server_users': {
if (!Array.isArray(signalingMessage.users))
return EMPTY;
let members = room.members ?? [];
for (const user of signalingMessage.users as { oderId: string; displayName: string }[]) {
if (!user?.oderId || user.oderId === myId)
continue;
members = upsertRoomMember(members, this.buildPresenceMember(room, user));
}
const actions = this.createRoomMemberUpdateActions(room, members);
return actions.length > 0 ? actions : EMPTY;
}
case 'user_joined': {
if (!signalingMessage.oderId || signalingMessage.oderId === myId)
return EMPTY;
const joinedUser = {
oderId: signalingMessage.oderId,
displayName: signalingMessage.displayName
};
const members = upsertRoomMember(
room.members ?? [],
this.buildPresenceMember(room, joinedUser)
);
const actions = this.createRoomMemberUpdateActions(room, members);
return actions.length > 0 ? actions : EMPTY;
}
case 'user_left': {
if (!signalingMessage.oderId)
return EMPTY;
const members = touchRoomMemberLastSeen(room.members ?? [], signalingMessage.oderId, Date.now());
const actions = this.createRoomMemberUpdateActions(room, members);
return actions.length > 0 ? actions : EMPTY;
}
default:
return EMPTY;
}
})
)
);
/** Request the latest member roster whenever a new peer data channel opens. */
peerConnectedRosterSync$ = createEffect(
() =>
this.webrtc.onPeerConnected.pipe(
withLatestFrom(this.store.select(selectCurrentRoom)),
tap(([peerId, currentRoom]) => {
if (!currentRoom)
return;
this.webrtc.sendToPeer(peerId, {
type: 'member-roster-request',
roomId: currentRoom.id
});
})
),
{ dispatch: false }
);
/** Kick off room-member sync when entering or switching to a room. */
roomEntryRosterSync$ = createEffect(
() =>
this.actions$.pipe(
ofType(
RoomsActions.createRoomSuccess,
RoomsActions.joinRoomSuccess,
RoomsActions.viewServerSuccess
),
tap(({ room }) => {
for (const peerId of this.webrtc.getConnectedPeers()) {
try {
this.webrtc.sendToPeer(peerId, {
type: 'member-roster-request',
roomId: room.id
});
} catch {
/* peer may have disconnected */
}
}
})
),
{ dispatch: false }
);
/** Handle peer-to-peer member roster sync and explicit leave messages. */
incomingRoomMemberEvents$ = createEffect(() =>
this.webrtc.onMessageReceived.pipe(
withLatestFrom(
this.store.select(selectCurrentRoom),
this.store.select(selectSavedRooms),
this.store.select(selectCurrentUser)
),
mergeMap(([
event,
currentRoom,
savedRooms,
currentUser
]) => {
switch (event.type) {
case 'member-roster-request': {
const actions = this.handleMemberRosterRequest(event, currentRoom, savedRooms, currentUser ?? null);
return actions.length > 0 ? actions : EMPTY;
}
case 'member-roster': {
const actions = this.handleMemberRoster(event, currentRoom, savedRooms, currentUser ?? null);
return actions.length > 0 ? actions : EMPTY;
}
case 'member-leave': {
const actions = this.handleMemberLeave(event, currentRoom, savedRooms);
return actions.length > 0 ? actions : EMPTY;
}
case 'host-change': {
const actions = this.handleIncomingHostChange(event, currentRoom, savedRooms, currentUser ?? null);
return actions.length > 0 ? actions : EMPTY;
}
case 'role-change': {
const actions = this.handleIncomingRoleChange(event, currentRoom, savedRooms);
return actions.length > 0 ? actions : EMPTY;
}
default:
return EMPTY;
}
})
)
);
private resolveRoom(roomId: string | undefined, currentRoom: Room | null, savedRooms: Room[]): Room | null {
if (!roomId)
return null;
if (currentRoom?.id === roomId)
return currentRoom;
return savedRooms.find((room) => room.id === roomId) ?? null;
}
private buildCurrentUserMember(room: Room, currentUser: User, isCurrentRoom: boolean): RoomMember {
const existingMember = findRoomMember(room.members ?? [], currentUser.oderId || currentUser.id);
const role = room.hostId === currentUser.id
? 'host'
: (isCurrentRoom ? currentUser.role : existingMember?.role ?? 'member');
return {
...roomMemberFromUser(currentUser, Date.now(), role),
id: existingMember?.id ?? currentUser.id,
joinedAt: existingMember?.joinedAt ?? currentUser.joinedAt ?? Date.now(),
avatarUrl: currentUser.avatarUrl ?? existingMember?.avatarUrl,
role
};
}
private buildPresenceMember(
room: Room,
data: { oderId: string; displayName?: string }
): RoomMember {
const existingMember = findRoomMember(room.members ?? [], data.oderId);
const now = Date.now();
return {
id: existingMember?.id ?? data.oderId,
oderId: data.oderId,
username:
existingMember?.username ??
(data.displayName || 'User').toLowerCase().replace(/\s+/g, '_'),
displayName: data.displayName || existingMember?.displayName || 'User',
avatarUrl: existingMember?.avatarUrl,
role: existingMember?.role ?? 'member',
joinedAt: existingMember?.joinedAt ?? now,
lastSeenAt: now
};
}
private createRoomMemberUpdateActions(room: Room, members: RoomMember[]): Action[] {
return areRoomMembersEqual(room.members ?? [], members)
? []
: [RoomsActions.updateRoom({ roomId: room.id, changes: { members } })];
}
private handleMemberRosterRequest(
event: ChatEvent,
currentRoom: Room | null,
savedRooms: Room[],
currentUser: User | null
): Action[] {
const room = this.resolveRoom(event.roomId, currentRoom, savedRooms);
if (!room || !event.fromPeerId)
return [];
const isCurrentRoom = currentRoom?.id === room.id;
let members = room.members ?? [];
if (currentUser) {
members = upsertRoomMember(
members,
this.buildCurrentUserMember(room, currentUser, isCurrentRoom)
);
}
this.webrtc.sendToPeer(event.fromPeerId, {
type: 'member-roster',
roomId: room.id,
members
});
return this.createRoomMemberUpdateActions(room, members);
}
private handleMemberRoster(
event: ChatEvent,
currentRoom: Room | null,
savedRooms: Room[],
currentUser: User | null
): Action[] {
const room = this.resolveRoom(event.roomId, currentRoom, savedRooms);
if (!room || !Array.isArray(event.members))
return [];
let members = mergeRoomMembers(room.members ?? [], event.members);
if (currentUser) {
members = upsertRoomMember(
members,
this.buildCurrentUserMember(room, currentUser, currentRoom?.id === room.id)
);
}
return this.createRoomMemberUpdateActions(room, members);
}
private handleMemberLeave(
event: ChatEvent,
currentRoom: Room | null,
savedRooms: Room[]
): Action[] {
const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id;
const room = this.resolveRoom(roomId, currentRoom, savedRooms);
if (!room)
return [];
const actions = this.createRoomMemberUpdateActions(
room,
removeRoomMember(room.members ?? [], event.targetUserId, event.oderId)
);
const departedUserId = event.oderId ?? event.targetUserId;
if (currentRoom?.id === room.id && departedUserId) {
actions.push(
UsersActions.userLeft({ userId: departedUserId })
);
}
return actions;
}
private handleIncomingHostChange(
event: ChatEvent,
currentRoom: Room | null,
savedRooms: Room[],
currentUser: User | null
): Action[] {
const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id;
const room = this.resolveRoom(roomId, currentRoom, savedRooms);
if (!room)
return [];
const members = Array.isArray(event.members)
? pruneRoomMembers(event.members)
: transferRoomOwnership(
room.members ?? [],
event.hostId || event.hostOderId
? {
id: event.hostId,
oderId: event.hostOderId
}
: null,
{
id: event.previousHostId ?? event.previousHostOderId ?? '',
oderId: event.previousHostOderId
}
);
const hostId = typeof event.hostId === 'string' ? event.hostId : '';
const actions: Action[] = [
RoomsActions.updateRoom({
roomId: room.id,
changes: {
hostId,
members
}
})
];
for (const previousHostKey of new Set([event.previousHostId, event.previousHostOderId].filter((value): value is string => !!value))) {
actions.push(
UsersActions.updateUserRole({
userId: previousHostKey,
role: 'member'
})
);
}
for (const nextHostKey of new Set([event.hostId, event.hostOderId].filter((value): value is string => !!value))) {
actions.push(
UsersActions.updateUserRole({
userId: nextHostKey,
role: 'host'
})
);
}
if (currentUser) {
const isCurrentUserNextHost = event.hostId === currentUser.id || event.hostOderId === currentUser.oderId;
const isCurrentUserPreviousHost = event.previousHostId === currentUser.id || event.previousHostOderId === currentUser.oderId;
if (isCurrentUserPreviousHost && !isCurrentUserNextHost) {
actions.push(UsersActions.updateCurrentUser({ updates: { role: 'member' } }));
}
if (isCurrentUserNextHost) {
actions.push(UsersActions.updateCurrentUser({ updates: { role: 'host' } }));
}
}
return actions;
}
private handleIncomingRoleChange(
event: ChatEvent,
currentRoom: Room | null,
savedRooms: Room[]
): Action[] {
const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id;
const room = this.resolveRoom(roomId, currentRoom, savedRooms);
if (!room || !event.targetUserId || !event.role)
return [];
const actions = this.createRoomMemberUpdateActions(
room,
updateRoomMemberRole(room.members ?? [], event.targetUserId, event.role)
);
if (currentRoom?.id === room.id) {
actions.push(
UsersActions.updateUserRole({
userId: event.targetUserId,
role: event.role
})
);
}
return actions;
}
}

View File

@@ -0,0 +1,313 @@
import { RoomMember, User } from '../../shared-kernel';
/** Remove members that have not been seen for roughly two months. */
export const ROOM_MEMBER_STALE_MS = 1000 * 60 * 60 * 24 * 60;
function fallbackDisplayName(member: Partial<RoomMember>): string {
return member.displayName || member.username || member.oderId || member.id || 'User';
}
function fallbackUsername(member: Partial<RoomMember>): string {
const base = fallbackDisplayName(member)
.trim()
.toLowerCase()
.replace(/\s+/g, '_');
return base || member.oderId || member.id || 'user';
}
function normalizeMember(member: RoomMember, now = Date.now()): RoomMember {
const key = getRoomMemberKey(member);
const lastSeenAt =
typeof member.lastSeenAt === 'number' && Number.isFinite(member.lastSeenAt)
? member.lastSeenAt
: typeof member.joinedAt === 'number' && Number.isFinite(member.joinedAt)
? member.joinedAt
: now;
const joinedAt =
typeof member.joinedAt === 'number' && Number.isFinite(member.joinedAt)
? member.joinedAt
: lastSeenAt;
return {
id: member.id || key,
oderId: member.oderId || undefined,
username: member.username || fallbackUsername(member),
displayName: fallbackDisplayName(member),
avatarUrl: member.avatarUrl || undefined,
role: member.role || 'member',
joinedAt,
lastSeenAt
};
}
function compareMembers(firstMember: RoomMember, secondMember: RoomMember): number {
const displayNameCompare = firstMember.displayName.localeCompare(secondMember.displayName, undefined, { sensitivity: 'base' });
if (displayNameCompare !== 0)
return displayNameCompare;
return getRoomMemberKey(firstMember).localeCompare(getRoomMemberKey(secondMember));
}
function mergeRole(
existingRole: RoomMember['role'],
incomingRole: RoomMember['role'],
preferIncoming: boolean
): RoomMember['role'] {
if (existingRole === incomingRole)
return existingRole;
if (incomingRole === 'member' && existingRole !== 'member')
return existingRole;
if (existingRole === 'member' && incomingRole !== 'member')
return incomingRole;
return preferIncoming ? incomingRole : existingRole;
}
function mergeMembers(
existingMember: RoomMember | undefined,
incomingMember: RoomMember,
now = Date.now()
): RoomMember {
const normalizedIncoming = normalizeMember(incomingMember, now);
if (!existingMember)
return normalizedIncoming;
const normalizedExisting = normalizeMember(existingMember, now);
const preferIncoming = normalizedIncoming.lastSeenAt >= normalizedExisting.lastSeenAt;
return {
id: normalizedExisting.id || normalizedIncoming.id,
oderId: normalizedIncoming.oderId || normalizedExisting.oderId,
username: preferIncoming
? (normalizedIncoming.username || normalizedExisting.username)
: (normalizedExisting.username || normalizedIncoming.username),
displayName: preferIncoming
? (normalizedIncoming.displayName || normalizedExisting.displayName)
: (normalizedExisting.displayName || normalizedIncoming.displayName),
avatarUrl: preferIncoming
? (normalizedIncoming.avatarUrl || normalizedExisting.avatarUrl)
: (normalizedExisting.avatarUrl || normalizedIncoming.avatarUrl),
role: mergeRole(normalizedExisting.role, normalizedIncoming.role, preferIncoming),
joinedAt: Math.min(normalizedExisting.joinedAt, normalizedIncoming.joinedAt),
lastSeenAt: Math.max(normalizedExisting.lastSeenAt, normalizedIncoming.lastSeenAt)
};
}
/** Stable member key, preferring `oderId` when available. */
export function getRoomMemberKey(member: Pick<RoomMember, 'id' | 'oderId'>): string {
return member.oderId || member.id || '';
}
/** Find a room member by either their local ID or their `oderId`. */
export function findRoomMember(
members: RoomMember[] = [],
identifier?: string
): RoomMember | undefined {
if (!identifier)
return undefined;
return members.find((member) => member.id === identifier || member.oderId === identifier);
}
/** Convert a live `User` into a persisted room-member record. */
export function roomMemberFromUser(
user: User,
seenAt = Date.now(),
roleOverride?: RoomMember['role']
): RoomMember {
return normalizeMember(
{
id: user.id || user.oderId,
oderId: user.oderId || undefined,
username: user.username || '',
displayName: user.displayName || user.username || 'User',
avatarUrl: user.avatarUrl,
role: roleOverride || user.role || 'member',
joinedAt: user.joinedAt || seenAt,
lastSeenAt: seenAt
},
seenAt
);
}
/** Deduplicate, sanitize, sort, and prune stale room members. */
export function pruneRoomMembers(
members: RoomMember[] = [],
now = Date.now()
): RoomMember[] {
const cutoff = now - ROOM_MEMBER_STALE_MS;
const deduplicatedMembers = new Map<string, RoomMember>();
for (const member of members) {
const key = getRoomMemberKey(member);
if (!key)
continue;
const normalizedMember = normalizeMember(member, now);
if (normalizedMember.lastSeenAt < cutoff)
continue;
deduplicatedMembers.set(
key,
mergeMembers(deduplicatedMembers.get(key), normalizedMember, now)
);
}
return Array.from(deduplicatedMembers.values()).sort(compareMembers);
}
/** Upsert a member into a room roster while preserving the best known data. */
export function upsertRoomMember(
members: RoomMember[] = [],
member: RoomMember,
now = Date.now()
): RoomMember[] {
const key = getRoomMemberKey(member);
const nextMembers = pruneRoomMembers(members, now);
if (!key)
return nextMembers;
const memberIndex = nextMembers.findIndex((entry) => getRoomMemberKey(entry) === key);
const mergedMember = mergeMembers(memberIndex >= 0 ? nextMembers[memberIndex] : undefined, member, now);
if (memberIndex >= 0) {
const updatedMembers = [...nextMembers];
updatedMembers[memberIndex] = mergedMember;
return pruneRoomMembers(updatedMembers, now);
}
return pruneRoomMembers([...nextMembers, mergedMember], now);
}
/** Merge a remote roster into the local roster. */
export function mergeRoomMembers(
localMembers: RoomMember[] = [],
incomingMembers: RoomMember[] = [],
now = Date.now()
): RoomMember[] {
let mergedMembers = pruneRoomMembers(localMembers, now);
for (const incomingMember of incomingMembers) {
mergedMembers = upsertRoomMember(mergedMembers, incomingMember, now);
}
return pruneRoomMembers(mergedMembers, now);
}
/** Update the last-seen timestamp of a known room member. */
export function touchRoomMemberLastSeen(
members: RoomMember[] = [],
identifier: string,
seenAt = Date.now()
): RoomMember[] {
const nextMembers = pruneRoomMembers(members, seenAt);
const memberIndex = nextMembers.findIndex((member) => member.id === identifier || member.oderId === identifier);
if (memberIndex < 0)
return nextMembers;
const updatedMembers = [...nextMembers];
updatedMembers[memberIndex] = normalizeMember(
{
...updatedMembers[memberIndex],
lastSeenAt: Math.max(updatedMembers[memberIndex].lastSeenAt, seenAt)
},
seenAt
);
return pruneRoomMembers(updatedMembers, seenAt);
}
/** Remove a member from a room roster by either ID flavor. */
export function removeRoomMember(
members: RoomMember[] = [],
...identifiers: (string | undefined)[]
): RoomMember[] {
const ids = new Set(identifiers.filter((identifier): identifier is string => !!identifier));
if (ids.size === 0)
return pruneRoomMembers(members);
return pruneRoomMembers(members).filter(
(member) => !ids.has(member.id) && !ids.has(member.oderId || '')
);
}
/** Reassign ownership within a room roster, optionally leaving the room ownerless. */
export function transferRoomOwnership(
members: RoomMember[] = [],
nextOwner: Partial<RoomMember> | null,
previousOwner?: Pick<RoomMember, 'id' | 'oderId'>,
now = Date.now()
): RoomMember[] {
const nextMembers = pruneRoomMembers(members, now).map((member) => {
const isPreviousOwner =
member.role === 'host'
|| (!!previousOwner?.id && member.id === previousOwner.id)
|| (!!previousOwner?.oderId && member.oderId === previousOwner.oderId);
return isPreviousOwner
? { ...member,
role: 'member' as const }
: member;
});
if (!nextOwner || !(nextOwner.id || nextOwner.oderId))
return pruneRoomMembers(nextMembers, now);
const existingNextOwner = findRoomMember(nextMembers, nextOwner.id || nextOwner.oderId);
const nextOwnerMember: RoomMember = {
id: existingNextOwner?.id || nextOwner.id || nextOwner.oderId || '',
oderId: existingNextOwner?.oderId || nextOwner.oderId || undefined,
username: existingNextOwner?.username || nextOwner.username || '',
displayName: existingNextOwner?.displayName || nextOwner.displayName || 'User',
avatarUrl: existingNextOwner?.avatarUrl || nextOwner.avatarUrl || undefined,
role: 'host',
joinedAt: existingNextOwner?.joinedAt || nextOwner.joinedAt || now,
lastSeenAt: existingNextOwner?.lastSeenAt || nextOwner.lastSeenAt || now
};
return upsertRoomMember(nextMembers, nextOwnerMember, now);
}
/** Update a persisted member role without touching presence timestamps. */
export function updateRoomMemberRole(
members: RoomMember[] = [],
identifier: string,
role: RoomMember['role']
): RoomMember[] {
const nextMembers = pruneRoomMembers(members);
const memberIndex = nextMembers.findIndex((member) => member.id === identifier || member.oderId === identifier);
if (memberIndex < 0)
return nextMembers;
const updatedMembers = [...nextMembers];
updatedMembers[memberIndex] = {
...updatedMembers[memberIndex],
role
};
return pruneRoomMembers(updatedMembers);
}
/** Compare two room rosters after normalization and pruning. */
export function areRoomMembersEqual(
firstMembers: RoomMember[] = [],
secondMembers: RoomMember[] = []
): boolean {
const now = Date.now();
return JSON.stringify(pruneRoomMembers(firstMembers, now)) === JSON.stringify(pruneRoomMembers(secondMembers, now));
}

View File

@@ -0,0 +1,82 @@
/**
* Rooms store actions using `createActionGroup`.
*/
import {
createActionGroup,
emptyProps,
props
} from '@ngrx/store';
import {
Room,
RoomSettings,
RoomPermissions,
Channel
} from '../../shared-kernel';
import { type ServerInfo } from '../../domains/server-directory';
export const RoomsActions = createActionGroup({
source: 'Rooms',
events: {
'Load Rooms': emptyProps(),
'Load Rooms Success': props<{ rooms: Room[] }>(),
'Load Rooms Failure': props<{ error: string }>(),
'Search Servers': props<{ query: string }>(),
'Search Servers Success': props<{ servers: ServerInfo[] }>(),
'Search Servers Failure': props<{ error: string }>(),
'Create Room': props<{
name: string;
description?: string;
topic?: string;
isPrivate?: boolean;
password?: string;
sourceId?: string;
sourceUrl?: string;
}>(),
'Create Room Success': props<{ room: Room }>(),
'Create Room Failure': props<{ error: string }>(),
'Join Room': props<{ roomId: string; password?: string; serverInfo?: Partial<ServerInfo> & { name: string } }>(),
'Join Room Success': props<{ room: Room }>(),
'Join Room Failure': props<{ error: string }>(),
'Leave Room': emptyProps(),
'Leave Room Success': emptyProps(),
'View Server': props<{ room: Room; skipBanCheck?: boolean }>(),
'View Server Success': props<{ room: Room }>(),
'Delete Room': props<{ roomId: string }>(),
'Delete Room Success': props<{ roomId: string }>(),
'Forget Room': props<{ roomId: string; nextOwnerKey?: string }>(),
'Forget Room Success': props<{ roomId: string }>(),
'Update Room Settings': props<{ roomId: string; settings: Partial<RoomSettings> }>(),
'Update Room Settings Success': props<{ roomId: string; settings: RoomSettings }>(),
'Update Room Settings Failure': props<{ error: string }>(),
'Update Room Permissions': props<{ roomId: string; permissions: Partial<RoomPermissions> }>(),
'Update Server Icon': props<{ roomId: string; icon: string }>(),
'Update Server Icon Success': props<{ roomId: string; icon: string; iconUpdatedAt: number }>(),
'Update Server Icon Failure': props<{ error: string }>(),
'Set Current Room': props<{ room: Room }>(),
'Clear Current Room': emptyProps(),
'Update Room': props<{ roomId: string; changes: Partial<Room> }>(),
'Receive Room Update': props<{ room: Partial<Room> }>(),
'Select Channel': props<{ channelId: string }>(),
'Add Channel': props<{ channel: Channel }>(),
'Remove Channel': props<{ channelId: string }>(),
'Rename Channel': props<{ channelId: string; name: string }>(),
'Clear Search Results': emptyProps(),
'Set Connecting': props<{ isConnecting: boolean }>(),
'Set Signal Server Reconnecting': props<{ isReconnecting: boolean }>(),
'Set Signal Server Compatibility Error': props<{ message: string | null }>()
}
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,459 @@
import { createReducer, on } from '@ngrx/store';
import {
Room,
RoomSettings,
Channel
} from '../../shared-kernel';
import { type ServerInfo } from '../../domains/server-directory';
import { RoomsActions } from './rooms.actions';
import { pruneRoomMembers } from './room-members.helpers';
/** Default channels for a new server */
export function defaultChannels(): Channel[] {
return [
{ id: 'general',
name: 'general',
type: 'text',
position: 0 },
{ id: 'random',
name: 'random',
type: 'text',
position: 1 },
{ id: 'vc-general',
name: 'General',
type: 'voice',
position: 0 },
{ id: 'vc-afk',
name: 'AFK',
type: 'voice',
position: 1 }
];
}
/** Deduplicate rooms by id, keeping the last occurrence */
function deduplicateRooms(rooms: Room[]): Room[] {
const seen = new Map<string, Room>();
for (const room of rooms) {
seen.set(room.id, room);
}
return Array.from(seen.values());
}
/** Normalize room defaults and prune any stale persisted member entries. */
function enrichRoom(room: Room): Room {
return {
...room,
hasPassword: typeof room.hasPassword === 'boolean' ? room.hasPassword : !!room.password,
channels: room.channels || defaultChannels(),
members: pruneRoomMembers(room.members || [])
};
}
/** Upsert a room into a saved-rooms list (add or replace by id) */
function upsertRoom(savedRooms: Room[], room: Room): Room[] {
const normalizedRoom = enrichRoom(room);
const idx = savedRooms.findIndex(existingRoom => existingRoom.id === room.id);
if (idx >= 0) {
const updated = [...savedRooms];
updated[idx] = normalizedRoom;
return updated;
}
return [...savedRooms, normalizedRoom];
}
/** State shape for the rooms feature slice. */
export interface RoomsState {
/** The room the user is currently viewing. */
currentRoom: Room | null;
/** All rooms persisted locally (joined or created). */
savedRooms: Room[];
/** Editable settings for the current room. */
roomSettings: RoomSettings | null;
/** Results returned from the server directory search. */
searchResults: ServerInfo[];
/** Whether a server directory search is in progress. */
isSearching: boolean;
/** Whether a connection to a room is being established. */
isConnecting: boolean;
/** Whether the user is connected to a room. */
isConnected: boolean;
/** Whether the current room is using locally cached data while reconnecting. */
isSignalServerReconnecting: boolean;
/** Banner message when the viewed room's signaling endpoint is incompatible. */
signalServerCompatibilityError: string | null;
/** Whether rooms are being loaded from local storage. */
loading: boolean;
/** Most recent error message, if any. */
error: string | null;
/** ID of the currently selected text channel. */
activeChannelId: string;
}
export const initialState: RoomsState = {
currentRoom: null,
savedRooms: [],
roomSettings: null,
searchResults: [],
isSearching: false,
isConnecting: false,
isConnected: false,
isSignalServerReconnecting: false,
signalServerCompatibilityError: null,
loading: false,
error: null,
activeChannelId: 'general'
};
export const roomsReducer = createReducer(
initialState,
// Load rooms
on(RoomsActions.loadRooms, (state) => ({
...state,
loading: true,
error: null
})),
on(RoomsActions.loadRoomsSuccess, (state, { rooms }) => ({
...state,
savedRooms: deduplicateRooms(rooms.map(enrichRoom)),
loading: false
})),
on(RoomsActions.loadRoomsFailure, (state, { error }) => ({
...state,
loading: false,
error
})),
// Search servers
on(RoomsActions.searchServers, (state) => ({
...state,
isSearching: true,
error: null
})),
on(RoomsActions.searchServersSuccess, (state, { servers }) => ({
...state,
searchResults: servers,
isSearching: false
})),
on(RoomsActions.searchServersFailure, (state, { error }) => ({
...state,
isSearching: false,
error
})),
// Create room
on(RoomsActions.createRoom, (state) => ({
...state,
isConnecting: true,
signalServerCompatibilityError: null,
error: null
})),
on(RoomsActions.createRoomSuccess, (state, { room }) => {
const enriched = enrichRoom(room);
return {
...state,
currentRoom: enriched,
savedRooms: upsertRoom(state.savedRooms, enriched),
isConnecting: false,
isSignalServerReconnecting: false,
signalServerCompatibilityError: null,
isConnected: true,
activeChannelId: 'general'
};
}),
on(RoomsActions.createRoomFailure, (state, { error }) => ({
...state,
isConnecting: false,
error
})),
// Join room
on(RoomsActions.joinRoom, (state) => ({
...state,
isConnecting: true,
signalServerCompatibilityError: null,
error: null
})),
on(RoomsActions.joinRoomSuccess, (state, { room }) => {
const enriched = enrichRoom(room);
return {
...state,
currentRoom: enriched,
savedRooms: upsertRoom(state.savedRooms, enriched),
isConnecting: false,
isSignalServerReconnecting: false,
signalServerCompatibilityError: null,
isConnected: true,
activeChannelId: 'general'
};
}),
on(RoomsActions.joinRoomFailure, (state, { error }) => ({
...state,
isConnecting: false,
error
})),
// Leave room
on(RoomsActions.leaveRoom, (state) => ({
...state,
isConnecting: true
})),
on(RoomsActions.leaveRoomSuccess, (state) => ({
...state,
currentRoom: null,
roomSettings: null,
isSignalServerReconnecting: false,
signalServerCompatibilityError: null,
isConnecting: false,
isConnected: false
})),
// View server - just switch the viewed room, stay connected
on(RoomsActions.viewServer, (state) => ({
...state,
isConnecting: true,
signalServerCompatibilityError: null,
error: null
})),
on(RoomsActions.viewServerSuccess, (state, { room }) => {
const enriched = enrichRoom(room);
return {
...state,
currentRoom: enriched,
savedRooms: upsertRoom(state.savedRooms, enriched),
isConnecting: false,
signalServerCompatibilityError: null,
isConnected: true,
activeChannelId: 'general'
};
}),
// Update room settings
on(RoomsActions.updateRoomSettings, (state) => ({
...state,
error: null
})),
on(RoomsActions.updateRoomSettingsSuccess, (state, { roomId, settings }) => {
const baseRoom = state.savedRooms.find((savedRoom) => savedRoom.id === roomId)
|| (state.currentRoom?.id === roomId ? state.currentRoom : null);
if (!baseRoom) {
return {
...state,
roomSettings: state.currentRoom?.id === roomId ? settings : state.roomSettings
};
}
const updatedRoom = enrichRoom({
...baseRoom,
name: settings.name,
description: settings.description,
topic: settings.topic,
isPrivate: settings.isPrivate,
password: settings.password === '' ? undefined : (settings.password ?? baseRoom.password),
hasPassword:
typeof settings.hasPassword === 'boolean'
? settings.hasPassword
: (typeof settings.password === 'string'
? settings.password.trim().length > 0
: baseRoom.hasPassword),
maxUsers: settings.maxUsers
});
return {
...state,
roomSettings: state.currentRoom?.id === roomId ? settings : state.roomSettings,
currentRoom: state.currentRoom?.id === roomId ? updatedRoom : state.currentRoom,
savedRooms: upsertRoom(state.savedRooms, updatedRoom)
};
}),
on(RoomsActions.updateRoomSettingsFailure, (state, { error }) => ({
...state,
error
})),
// Delete room
on(RoomsActions.deleteRoomSuccess, (state, { roomId }) => ({
...state,
isSignalServerReconnecting: state.currentRoom?.id === roomId ? false : state.isSignalServerReconnecting,
signalServerCompatibilityError: state.currentRoom?.id === roomId ? null : state.signalServerCompatibilityError,
savedRooms: state.savedRooms.filter((room) => room.id !== roomId),
currentRoom: state.currentRoom?.id === roomId ? null : state.currentRoom
})),
// Forget room (local only)
on(RoomsActions.forgetRoomSuccess, (state, { roomId }) => ({
...state,
isSignalServerReconnecting: state.currentRoom?.id === roomId ? false : state.isSignalServerReconnecting,
signalServerCompatibilityError: state.currentRoom?.id === roomId ? null : state.signalServerCompatibilityError,
savedRooms: state.savedRooms.filter((room) => room.id !== roomId),
currentRoom: state.currentRoom?.id === roomId ? null : state.currentRoom
})),
// Set current room
on(RoomsActions.setCurrentRoom, (state, { room }) => ({
...state,
currentRoom: enrichRoom(room),
savedRooms: upsertRoom(state.savedRooms, room),
isSignalServerReconnecting: false,
signalServerCompatibilityError: null,
isConnected: true
})),
// Clear current room
on(RoomsActions.clearCurrentRoom, (state) => ({
...state,
currentRoom: null,
roomSettings: null,
isSignalServerReconnecting: false,
signalServerCompatibilityError: null,
isConnected: false
})),
// Update room
on(RoomsActions.updateRoom, (state, { roomId, changes }) => {
const baseRoom = state.savedRooms.find((savedRoom) => savedRoom.id === roomId)
|| (state.currentRoom?.id === roomId ? state.currentRoom : null);
if (!baseRoom)
return state;
const updatedRoom = enrichRoom({ ...baseRoom,
...changes });
return {
...state,
currentRoom: state.currentRoom?.id === roomId ? updatedRoom : state.currentRoom,
savedRooms: upsertRoom(state.savedRooms, updatedRoom)
};
}),
// Update server icon success
on(RoomsActions.updateServerIconSuccess, (state, { roomId, icon, iconUpdatedAt }) => {
if (state.currentRoom?.id !== roomId)
return state;
const updatedRoom = enrichRoom({ ...state.currentRoom,
icon,
iconUpdatedAt });
return {
...state,
currentRoom: updatedRoom,
savedRooms: upsertRoom(state.savedRooms, updatedRoom)
};
}),
// Receive room update
on(RoomsActions.receiveRoomUpdate, (state, { room }) => {
if (!state.currentRoom)
return state;
const updatedRoom = enrichRoom({ ...state.currentRoom,
...room });
return {
...state,
currentRoom: updatedRoom,
savedRooms: upsertRoom(state.savedRooms, updatedRoom)
};
}),
// Clear search results
on(RoomsActions.clearSearchResults, (state) => ({
...state,
searchResults: []
})),
// Set connecting
on(RoomsActions.setConnecting, (state, { isConnecting }) => ({
...state,
isConnecting
})),
on(RoomsActions.setSignalServerReconnecting, (state, { isReconnecting }) => ({
...state,
isSignalServerReconnecting: isReconnecting
})),
on(RoomsActions.setSignalServerCompatibilityError, (state, { message }) => ({
...state,
signalServerCompatibilityError: message
})),
// Channel management
on(RoomsActions.selectChannel, (state, { channelId }) => ({
...state,
activeChannelId: channelId
})),
on(RoomsActions.addChannel, (state, { channel }) => {
if (!state.currentRoom)
return state;
const existing = state.currentRoom.channels || defaultChannels();
const updatedChannels = [...existing, channel];
const updatedRoom = { ...state.currentRoom,
channels: updatedChannels };
return {
...state,
currentRoom: updatedRoom,
savedRooms: upsertRoom(state.savedRooms, updatedRoom)
};
}),
on(RoomsActions.removeChannel, (state, { channelId }) => {
if (!state.currentRoom)
return state;
const existing = state.currentRoom.channels || defaultChannels();
const updatedChannels = existing.filter(channel => channel.id !== channelId);
const updatedRoom = { ...state.currentRoom,
channels: updatedChannels };
return {
...state,
currentRoom: updatedRoom,
savedRooms: upsertRoom(state.savedRooms, updatedRoom),
activeChannelId: state.activeChannelId === channelId ? 'general' : state.activeChannelId
};
}),
on(RoomsActions.renameChannel, (state, { channelId, name }) => {
if (!state.currentRoom)
return state;
const existing = state.currentRoom.channels || defaultChannels();
const updatedChannels = existing.map(channel => channel.id === channelId ? { ...channel,
name } : channel);
const updatedRoom = { ...state.currentRoom,
channels: updatedChannels };
return {
...state,
currentRoom: updatedRoom,
savedRooms: upsertRoom(state.savedRooms, updatedRoom)
};
})
);

View File

@@ -0,0 +1,80 @@
import { createFeatureSelector, createSelector } from '@ngrx/store';
import { RoomsState } from './rooms.reducer';
export const selectRoomsState = createFeatureSelector<RoomsState>('rooms');
export const selectCurrentRoom = createSelector(
selectRoomsState,
(state) => state.currentRoom
);
export const selectRoomSettings = createSelector(
selectRoomsState,
(state) => state.roomSettings
);
export const selectSearchResults = createSelector(
selectRoomsState,
(state) => state.searchResults
);
export const selectIsSearching = createSelector(
selectRoomsState,
(state) => state.isSearching
);
export const selectIsConnecting = createSelector(
selectRoomsState,
(state) => state.isConnecting
);
export const selectIsConnected = createSelector(
selectRoomsState,
(state) => state.isConnected
);
export const selectIsSignalServerReconnecting = createSelector(
selectRoomsState,
(state) => state.isSignalServerReconnecting
);
export const selectSignalServerCompatibilityError = createSelector(
selectRoomsState,
(state) => state.signalServerCompatibilityError
);
export const selectRoomsError = createSelector(
selectRoomsState,
(state) => state.error
);
export const selectCurrentRoomId = createSelector(
selectCurrentRoom,
(room) => room?.id ?? null
);
export const selectCurrentRoomName = createSelector(
selectCurrentRoom,
(room) => room?.name ?? ''
);
export const selectIsCurrentUserHost = createSelector(
selectCurrentRoom,
(room) => room?.hostId
);
export const selectSavedRooms = createSelector(
selectRoomsState,
(state) => state.savedRooms
);
export const selectRoomsLoading = createSelector(
selectRoomsState,
(state) => state.loading
);
export const selectActiveChannelId = createSelector(
selectRoomsState,
(state) => state.activeChannelId
);
export const selectCurrentRoomChannels = createSelector(
selectCurrentRoom,
(room) => room?.channels ?? []
);
export const selectTextChannels = createSelector(
selectCurrentRoomChannels,
(channels) => channels
.filter((channel) => channel.type === 'text')
.sort((channelA, channelB) => channelA.position - channelB.position)
);
export const selectVoiceChannels = createSelector(
selectCurrentRoomChannels,
(channels) => channels
.filter((channel) => channel.type === 'voice')
.sort((channelA, channelB) => channelA.position - channelB.position)
);

View File

@@ -0,0 +1,4 @@
export * from './users.actions';
export * from './users.reducer';
export * from './users.selectors';
export * from './users.effects';

View File

@@ -0,0 +1,57 @@
/**
* Users store actions using `createActionGroup`.
*/
import {
createActionGroup,
emptyProps,
props
} from '@ngrx/store';
import {
User,
BanEntry,
VoiceState,
ScreenShareState
} from '../../shared-kernel';
export const UsersActions = createActionGroup({
source: 'Users',
events: {
'Load Current User': emptyProps(),
'Load Current User Success': props<{ user: User }>(),
'Load Current User Failure': props<{ error: string }>(),
'Set Current User': props<{ user: User }>(),
'Update Current User': props<{ updates: Partial<User> }>(),
'Load Room Users': props<{ roomId: string }>(),
'Load Room Users Success': props<{ users: User[] }>(),
'Load Room Users Failure': props<{ error: string }>(),
'User Joined': props<{ user: User }>(),
'User Left': props<{ userId: string }>(),
'Update User': props<{ userId: string; updates: Partial<User> }>(),
'Update User Role': props<{ userId: string; role: User['role'] }>(),
'Kick User': props<{ userId: string; roomId?: string }>(),
'Kick User Success': props<{ userId: string; roomId: string }>(),
'Ban User': props<{ userId: string; roomId?: string; displayName?: string; reason?: string; expiresAt?: number }>(),
'Ban User Success': props<{ userId: string; roomId: string; ban: BanEntry }>(),
'Unban User': props<{ roomId: string; oderId: string }>(),
'Unban User Success': props<{ oderId: string }>(),
'Load Bans': emptyProps(),
'Load Bans Success': props<{ bans: BanEntry[] }>(),
'Admin Mute User': props<{ userId: string }>(),
'Admin Unmute User': props<{ userId: string }>(),
'Sync Users': props<{ users: User[] }>(),
'Clear Users': emptyProps(),
'Update Host': props<{ userId: string }>(),
'Update Voice State': props<{ userId: string; voiceState: Partial<VoiceState> }>(),
'Update Screen Share State': props<{ userId: string; screenShareState: Partial<ScreenShareState> }>()
}
});

View File

@@ -0,0 +1,669 @@
/**
* Users store effects (load, kick, ban, host election, profile persistence).
*/
/* eslint-disable @typescript-eslint/member-ordering */
import { Injectable, inject } from '@angular/core';
import {
Actions,
createEffect,
ofType
} from '@ngrx/effects';
import { Store } from '@ngrx/store';
import {
of,
from,
EMPTY
} from 'rxjs';
import {
map,
mergeMap,
catchError,
withLatestFrom,
tap,
switchMap
} from 'rxjs/operators';
import { v4 as uuidv4 } from 'uuid';
import { UsersActions } from './users.actions';
import { RoomsActions } from '../rooms/rooms.actions';
import {
selectAllUsers,
selectCurrentUser,
selectCurrentUserId,
selectHostId
} from './users.selectors';
import { selectCurrentRoom, selectSavedRooms } from '../rooms/rooms.selectors';
import { RealtimeSessionFacade } from '../../core/realtime';
import { DatabaseService } from '../../infrastructure/persistence';
import { ServerDirectoryFacade } from '../../domains/server-directory';
import {
BanEntry,
ChatEvent,
Room,
User
} from '../../shared-kernel';
import { findRoomMember, removeRoomMember } from '../rooms/room-members.helpers';
type IncomingModerationExtraAction =
| ReturnType<typeof RoomsActions.forgetRoom>
| ReturnType<typeof UsersActions.kickUserSuccess>
| ReturnType<typeof UsersActions.banUserSuccess>;
type IncomingModerationAction =
| ReturnType<typeof RoomsActions.updateRoom>
| IncomingModerationExtraAction;
@Injectable()
export class UsersEffects {
private actions$ = inject(Actions);
private store = inject(Store);
private db = inject(DatabaseService);
private serverDirectory = inject(ServerDirectoryFacade);
private webrtc = inject(RealtimeSessionFacade);
// Load current user from storage
/** Loads the persisted current user from the local database on startup. */
loadCurrentUser$ = createEffect(() =>
this.actions$.pipe(
ofType(UsersActions.loadCurrentUser),
switchMap(() =>
from(this.db.getCurrentUser()).pipe(
switchMap((user) => {
if (!user) {
return of(UsersActions.loadCurrentUserFailure({ error: 'No current user' }));
}
const sanitizedUser = this.clearStartupVoiceConnection(user);
if (sanitizedUser === user) {
return of(UsersActions.loadCurrentUserSuccess({ user }));
}
return from(this.db.updateUser(user.id, { voiceState: sanitizedUser.voiceState })).pipe(
map(() => UsersActions.loadCurrentUserSuccess({ user: sanitizedUser })),
// If persistence fails, still load a sanitized in-memory user to keep UI correct.
catchError(() => of(UsersActions.loadCurrentUserSuccess({ user: sanitizedUser })))
);
}),
catchError((error) =>
of(UsersActions.loadCurrentUserFailure({ error: error.message }))
)
)
)
)
);
private clearStartupVoiceConnection(user: User): User {
const voiceState = user.voiceState;
if (!voiceState)
return user;
const hasStaleConnectionState =
voiceState.isConnected ||
voiceState.isSpeaking ||
voiceState.roomId !== undefined ||
voiceState.serverId !== undefined;
if (!hasStaleConnectionState)
return user;
return {
...user,
voiceState: {
...voiceState,
isConnected: false,
isSpeaking: false,
roomId: undefined,
serverId: undefined
}
};
}
/** Loads all users associated with a specific room from the local database. */
loadRoomUsers$ = createEffect(() =>
this.actions$.pipe(
ofType(UsersActions.loadRoomUsers),
switchMap(({ roomId }) =>
from(this.db.getUsersByRoom(roomId)).pipe(
map((users) => UsersActions.loadRoomUsersSuccess({ users })),
catchError((error) =>
of(UsersActions.loadRoomUsersFailure({ error: error.message }))
)
)
)
)
);
/** Kicks a user from the room (requires moderator+ role). Broadcasts a kick signal. */
kickUser$ = createEffect(() =>
this.actions$.pipe(
ofType(UsersActions.kickUser),
withLatestFrom(
this.store.select(selectCurrentUser),
this.store.select(selectCurrentRoom),
this.store.select(selectSavedRooms)
),
mergeMap(([
{ userId, roomId },
currentUser,
currentRoom,
savedRooms
]) => {
if (!currentUser)
return EMPTY;
const room = this.resolveRoom(roomId, currentRoom, savedRooms);
if (!room)
return EMPTY;
const canKick = this.canKickInRoom(room, currentUser, currentRoom);
if (!canKick)
return EMPTY;
const nextMembers = removeRoomMember(room.members ?? [], userId, userId);
return this.serverDirectory.kickServerMember(
room.id,
{
actorUserId: currentUser.id,
actorRole: currentUser.role,
targetUserId: userId
},
this.toSourceSelector(room)
).pipe(
catchError((error) => {
console.error('Failed to revoke server membership on kick:', error);
return of(void 0);
}),
mergeMap(() => {
this.webrtc.broadcastMessage({
type: 'kick',
targetUserId: userId,
roomId: room.id,
kickedBy: currentUser.id
});
return currentRoom?.id === room.id
? [
RoomsActions.updateRoom({ roomId: room.id,
changes: { members: nextMembers } }),
UsersActions.kickUserSuccess({ userId,
roomId: room.id })
]
: of(
RoomsActions.updateRoom({ roomId: room.id,
changes: { members: nextMembers } })
);
})
);
})
)
);
/** Bans a user, persists the ban locally, and broadcasts a ban signal to peers. */
banUser$ = createEffect(() =>
this.actions$.pipe(
ofType(UsersActions.banUser),
withLatestFrom(
this.store.select(selectCurrentUser),
this.store.select(selectCurrentRoom),
this.store.select(selectSavedRooms),
this.store.select(selectAllUsers)
),
mergeMap(([
{ userId, roomId, displayName, reason, expiresAt },
currentUser,
currentRoom,
savedRooms,
allUsers
]) => {
if (!currentUser)
return EMPTY;
const room = this.resolveRoom(roomId, currentRoom, savedRooms);
if (!room)
return EMPTY;
const canBan = this.canBanInRoom(room, currentUser, currentRoom);
if (!canBan)
return EMPTY;
const targetUser = allUsers.find((user) => user.id === userId || user.oderId === userId);
const targetMember = findRoomMember(room.members ?? [], userId);
const nextMembers = removeRoomMember(room.members ?? [], userId, userId);
const ban: BanEntry = {
oderId: uuidv4(),
userId,
roomId: room.id,
bannedBy: currentUser.id,
displayName: displayName || targetUser?.displayName || targetMember?.displayName,
reason,
expiresAt,
timestamp: Date.now()
};
return this.serverDirectory.banServerMember(
room.id,
{
actorUserId: currentUser.id,
actorRole: currentUser.role,
targetUserId: userId,
banId: ban.oderId,
displayName: ban.displayName,
reason,
expiresAt
},
this.toSourceSelector(room)
).pipe(
catchError((error) => {
console.error('Failed to persist server ban:', error);
return of(void 0);
}),
switchMap(() =>
from(this.db.saveBan(ban)).pipe(
tap(() => {
this.webrtc.broadcastMessage({
type: 'ban',
targetUserId: userId,
roomId: room.id,
bannedBy: currentUser.id,
ban
});
}),
mergeMap(() => {
const actions: (ReturnType<typeof RoomsActions.updateRoom>
| ReturnType<typeof UsersActions.banUserSuccess>)[] = [
RoomsActions.updateRoom({ roomId: room.id,
changes: { members: nextMembers } })
];
if (currentRoom?.id === room.id) {
actions.push(UsersActions.banUserSuccess({ userId,
roomId: room.id,
ban }));
}
return actions;
}),
catchError(() => EMPTY)
)
)
);
})
)
);
/** Removes a ban entry locally and broadcasts the change to peers in the same room. */
unbanUser$ = createEffect(() =>
this.actions$.pipe(
ofType(UsersActions.unbanUser),
withLatestFrom(
this.store.select(selectCurrentUser),
this.store.select(selectCurrentRoom),
this.store.select(selectSavedRooms)
),
switchMap(([
{ roomId, oderId },
currentUser,
currentRoom,
savedRooms
]) => {
const room = this.resolveRoom(roomId, currentRoom, savedRooms);
if (!currentUser || !room || !this.canModerateRoom(room, currentUser, currentRoom))
return EMPTY;
return this.serverDirectory.unbanServerMember(
room.id,
{
actorUserId: currentUser.id,
actorRole: currentUser.role,
banId: oderId
},
this.toSourceSelector(room)
).pipe(
catchError((error) => {
console.error('Failed to remove server ban:', error);
return of(void 0);
}),
switchMap(() =>
from(this.db.removeBan(oderId)).pipe(
tap(() => {
this.webrtc.broadcastMessage({
type: 'unban',
roomId: room.id,
banOderId: oderId
});
}),
map(() => UsersActions.unbanUserSuccess({ oderId })),
catchError(() => EMPTY)
)
)
);
})
)
);
/** Loads all active bans for the current room from the local database. */
loadBans$ = createEffect(() =>
this.actions$.pipe(
ofType(UsersActions.loadBans),
withLatestFrom(this.store.select(selectCurrentRoom)),
switchMap(([, currentRoom]) => {
if (!currentRoom) {
return of(UsersActions.loadBansSuccess({ bans: [] }));
}
return from(this.db.getBansForRoom(currentRoom.id)).pipe(
map((bans) => UsersActions.loadBansSuccess({ bans })),
catchError(() => of(UsersActions.loadBansSuccess({ bans: [] })))
);
})
)
);
/** Applies incoming moderation events from peers to local persistence and UI state. */
incomingModerationEvents$ = createEffect(() =>
this.webrtc.onMessageReceived.pipe(
withLatestFrom(
this.store.select(selectCurrentUser),
this.store.select(selectCurrentRoom),
this.store.select(selectSavedRooms)
),
mergeMap(([
event,
currentUser,
currentRoom,
savedRooms
]) => {
switch (event.type) {
case 'kick':
return this.handleIncomingKick(event, currentUser ?? null, currentRoom, savedRooms);
case 'ban':
return this.handleIncomingBan(event, currentUser ?? null, currentRoom, savedRooms);
case 'unban':
return this.handleIncomingUnban(event, currentRoom, savedRooms);
default:
return EMPTY;
}
})
)
);
/** Elects the current user as host if the previous host leaves. */
handleHostLeave$ = createEffect(() =>
this.actions$.pipe(
ofType(UsersActions.userLeft),
withLatestFrom(
this.store.select(selectHostId),
this.store.select(selectCurrentUserId)
),
mergeMap(([
{ userId },
hostId,
currentUserId
]) =>
userId === hostId && currentUserId
? of(UsersActions.updateHost({ userId: currentUserId }))
: EMPTY
)
)
);
/** Persists user profile changes to the local database whenever the current user is updated. */
persistUser$ = createEffect(
() =>
this.actions$.pipe(
ofType(
UsersActions.setCurrentUser,
UsersActions.loadCurrentUserSuccess,
UsersActions.updateCurrentUser
),
withLatestFrom(this.store.select(selectCurrentUser)),
tap(([, user]) => {
if (user) {
this.db.saveUser(user);
// Ensure current user ID is persisted when explicitly set
this.db.setCurrentUserId(user.id);
}
})
),
{ dispatch: false }
);
/** Keep signaling identity aligned with the current profile to avoid stale fallback names. */
syncSignalingIdentity$ = createEffect(
() =>
this.actions$.pipe(
ofType(
UsersActions.setCurrentUser,
UsersActions.loadCurrentUserSuccess
),
withLatestFrom(this.store.select(selectCurrentUser)),
tap(([, user]) => {
if (!user)
return;
this.webrtc.identify(user.oderId || user.id, this.resolveDisplayName(user));
})
),
{ dispatch: false }
);
private resolveRoom(roomId: string | undefined, currentRoom: Room | null, savedRooms: Room[]): Room | null {
if (!roomId)
return currentRoom;
if (currentRoom?.id === roomId)
return currentRoom;
return savedRooms.find((room) => room.id === roomId) ?? null;
}
private resolveDisplayName(user: Pick<User, 'displayName' | 'username'>): string {
const displayName = user.displayName?.trim();
if (displayName) {
return displayName;
}
return user.username?.trim() || 'User';
}
private toSourceSelector(room: Room): { sourceId?: string; sourceUrl?: string } {
return {
sourceId: room.sourceId,
sourceUrl: room.sourceUrl
};
}
private canModerateRoom(room: Room, currentUser: User, currentRoom: Room | null): boolean {
const role = this.getCurrentUserRoleForRoom(room, currentUser, currentRoom);
return role === 'host' || role === 'admin';
}
private canKickInRoom(room: Room, currentUser: User, currentRoom: Room | null): boolean {
const role = this.getCurrentUserRoleForRoom(room, currentUser, currentRoom);
return role === 'host' || role === 'admin' || role === 'moderator';
}
private canBanInRoom(room: Room, currentUser: User, currentRoom: Room | null): boolean {
const role = this.getCurrentUserRoleForRoom(room, currentUser, currentRoom);
return role === 'host' || role === 'admin';
}
private getCurrentUserRoleForRoom(room: Room, currentUser: User, currentRoom: Room | null): User['role'] | null {
return (
room.hostId === currentUser.id || room.hostId === currentUser.oderId
)
? 'host'
: (currentRoom?.id === room.id
? currentUser.role
: (findRoomMember(room.members ?? [], currentUser.id)?.role
|| findRoomMember(room.members ?? [], currentUser.oderId)?.role
|| null));
}
private removeMemberFromRoom(room: Room, targetUserId: string): Partial<Room> {
return {
members: removeRoomMember(room.members ?? [], targetUserId, targetUserId)
};
}
private resolveIncomingModerationActions(
room: Room,
targetUserId: string,
currentRoom: Room | null,
extra: IncomingModerationExtraAction[] = []
) {
const actions: IncomingModerationAction[] = [
RoomsActions.updateRoom({
roomId: room.id,
changes: this.removeMemberFromRoom(room, targetUserId)
})
];
if (currentRoom?.id === room.id) {
actions.push(...extra);
} else {
actions.push(...extra.filter((action) => action.type === RoomsActions.forgetRoom.type));
}
return actions;
}
private shouldAffectVisibleUsers(room: Room, currentRoom: Room | null): boolean {
return currentRoom?.id === room.id;
}
private canForgetForTarget(
targetUserId: string,
currentUser: User | null
): ReturnType<typeof RoomsActions.forgetRoom> | null {
return this.isCurrentUserTarget(targetUserId, currentUser)
? RoomsActions.forgetRoom({ roomId: '' })
: null;
}
private isCurrentUserTarget(targetUserId: string, currentUser: User | null): boolean {
return !!currentUser && (targetUserId === currentUser.id || targetUserId === currentUser.oderId);
}
private buildIncomingBan(event: ChatEvent, targetUserId: string, roomId: string): BanEntry {
const payloadBan = event.ban && typeof event.ban === 'object'
? event.ban as Partial<BanEntry>
: null;
return {
oderId: typeof payloadBan?.oderId === 'string' ? payloadBan.oderId : uuidv4(),
userId: typeof payloadBan?.userId === 'string' ? payloadBan.userId : targetUserId,
roomId,
bannedBy:
typeof payloadBan?.bannedBy === 'string'
? payloadBan.bannedBy
: (typeof event.bannedBy === 'string' ? event.bannedBy : 'unknown'),
displayName:
typeof payloadBan?.displayName === 'string'
? payloadBan.displayName
: (typeof event.displayName === 'string' ? event.displayName : undefined),
reason:
typeof payloadBan?.reason === 'string'
? payloadBan.reason
: (typeof event.reason === 'string' ? event.reason : undefined),
expiresAt:
typeof payloadBan?.expiresAt === 'number'
? payloadBan.expiresAt
: (typeof event.expiresAt === 'number' ? event.expiresAt : undefined),
timestamp: typeof payloadBan?.timestamp === 'number' ? payloadBan.timestamp : Date.now()
};
}
private handleIncomingKick(
event: ChatEvent,
currentUser: User | null,
currentRoom: Room | null,
savedRooms: Room[]
) {
const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id;
const room = this.resolveRoom(roomId, currentRoom, savedRooms);
const targetUserId = typeof event.targetUserId === 'string' ? event.targetUserId : '';
if (!room || !targetUserId)
return EMPTY;
const actions = this.resolveIncomingModerationActions(
room,
targetUserId,
currentRoom,
this.isCurrentUserTarget(targetUserId, currentUser)
? [RoomsActions.forgetRoom({ roomId: room.id })]
: [
UsersActions.kickUserSuccess({ userId: targetUserId,
roomId: room.id })
]
);
return actions;
}
private handleIncomingBan(
event: ChatEvent,
currentUser: User | null,
currentRoom: Room | null,
savedRooms: Room[]
) {
const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id;
const room = this.resolveRoom(roomId, currentRoom, savedRooms);
const targetUserId = typeof event.targetUserId === 'string' ? event.targetUserId : '';
if (!room || !targetUserId)
return EMPTY;
const ban = this.buildIncomingBan(event, targetUserId, room.id);
const actions = this.resolveIncomingModerationActions(
room,
targetUserId,
currentRoom,
this.isCurrentUserTarget(targetUserId, currentUser)
? [RoomsActions.forgetRoom({ roomId: room.id })]
: [
UsersActions.banUserSuccess({ userId: targetUserId,
roomId: room.id,
ban })
]
);
return from(this.db.saveBan(ban)).pipe(
mergeMap(() => (actions.length > 0 ? actions : EMPTY)),
catchError(() => EMPTY)
);
}
private handleIncomingUnban(event: ChatEvent, currentRoom: Room | null, savedRooms: Room[]) {
const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id;
const room = this.resolveRoom(roomId, currentRoom, savedRooms);
const banOderId = typeof event.banOderId === 'string'
? event.banOderId
: (typeof event.oderId === 'string' ? event.oderId : '');
if (!room || !banOderId)
return EMPTY;
return from(this.db.removeBan(banOderId)).pipe(
mergeMap(() => (currentRoom?.id === room.id
? of(UsersActions.unbanUserSuccess({ oderId: banOderId }))
: EMPTY)),
catchError(() => EMPTY)
);
}
}

View File

@@ -0,0 +1,250 @@
import { createReducer, on } from '@ngrx/store';
import {
EntityState,
EntityAdapter,
createEntityAdapter
} from '@ngrx/entity';
import { User, BanEntry } from '../../shared-kernel';
import { UsersActions } from './users.actions';
export interface UsersState extends EntityState<User> {
currentUserId: string | null;
hostId: string | null;
loading: boolean;
error: string | null;
bans: BanEntry[];
}
export const usersAdapter: EntityAdapter<User> = createEntityAdapter<User>({
selectId: (user) => user.id,
sortComparer: (userA, userB) => userA.username.localeCompare(userB.username)
});
export const initialState: UsersState = usersAdapter.getInitialState({
currentUserId: null,
hostId: null,
loading: false,
error: null,
bans: []
});
export const usersReducer = createReducer(
initialState,
on(UsersActions.loadCurrentUser, (state) => ({
...state,
loading: true,
error: null
})),
on(UsersActions.loadCurrentUserSuccess, (state, { user }) =>
usersAdapter.upsertOne(user, {
...state,
currentUserId: user.id,
loading: false
})
),
on(UsersActions.loadCurrentUserFailure, (state, { error }) => ({
...state,
loading: false,
error
})),
on(UsersActions.setCurrentUser, (state, { user }) =>
usersAdapter.upsertOne(user, {
...state,
currentUserId: user.id
})
),
on(UsersActions.updateCurrentUser, (state, { updates }) => {
if (!state.currentUserId)
return state;
return usersAdapter.updateOne(
{
id: state.currentUserId,
changes: updates
},
state
);
}),
on(UsersActions.loadRoomUsers, (state) => ({
...state,
loading: true,
error: null
})),
on(UsersActions.loadRoomUsersSuccess, (state, { users }) =>
usersAdapter.upsertMany(users, {
...state,
loading: false
})
),
on(UsersActions.loadRoomUsersFailure, (state, { error }) => ({
...state,
loading: false,
error
})),
on(UsersActions.userJoined, (state, { user }) =>
usersAdapter.upsertOne(user, state)
),
on(UsersActions.userLeft, (state, { userId }) =>
usersAdapter.removeOne(userId, state)
),
on(UsersActions.updateUser, (state, { userId, updates }) =>
usersAdapter.updateOne(
{
id: userId,
changes: updates
},
state
)
),
on(UsersActions.updateUserRole, (state, { userId, role }) =>
usersAdapter.updateOne(
{
id: userId,
changes: { role }
},
state
)
),
on(UsersActions.kickUserSuccess, (state, { userId }) =>
usersAdapter.removeOne(userId, state)
),
on(UsersActions.banUserSuccess, (state, { userId, ban }) => {
const newState = usersAdapter.removeOne(userId, state);
return {
...newState,
bans: [...state.bans, ban]
};
}),
on(UsersActions.unbanUserSuccess, (state, { oderId }) => ({
...state,
bans: state.bans.filter((ban) => ban.oderId !== oderId)
})),
on(UsersActions.loadBansSuccess, (state, { bans }) => ({
...state,
bans
})),
on(UsersActions.adminMuteUser, (state, { userId }) =>
usersAdapter.updateOne(
{
id: userId,
changes: {
voiceState: {
...state.entities[userId]?.voiceState,
isConnected: state.entities[userId]?.voiceState?.isConnected ?? false,
isMuted: true,
isDeafened: state.entities[userId]?.voiceState?.isDeafened ?? false,
isSpeaking: false,
isMutedByAdmin: true
}
}
},
state
)
),
on(UsersActions.adminUnmuteUser, (state, { userId }) =>
usersAdapter.updateOne(
{
id: userId,
changes: {
voiceState: {
...state.entities[userId]?.voiceState,
isConnected: state.entities[userId]?.voiceState?.isConnected ?? false,
isMuted: state.entities[userId]?.voiceState?.isMuted ?? false,
isDeafened: state.entities[userId]?.voiceState?.isDeafened ?? false,
isSpeaking: state.entities[userId]?.voiceState?.isSpeaking ?? false,
isMutedByAdmin: false
}
}
},
state
)
),
on(UsersActions.updateVoiceState, (state, { userId, voiceState }) => {
const prev = state.entities[userId]?.voiceState || {
isConnected: false,
isMuted: false,
isDeafened: false,
isSpeaking: false
};
return usersAdapter.updateOne(
{
id: userId,
changes: {
voiceState: {
isConnected: voiceState.isConnected ?? prev.isConnected,
isMuted: voiceState.isMuted ?? prev.isMuted,
isDeafened: voiceState.isDeafened ?? prev.isDeafened,
isSpeaking: voiceState.isSpeaking ?? prev.isSpeaking,
isMutedByAdmin: voiceState.isMutedByAdmin ?? prev.isMutedByAdmin,
volume: voiceState.volume ?? prev.volume,
// Use explicit undefined check - if undefined is passed, clear the value
roomId: voiceState.roomId !== undefined ? voiceState.roomId : prev.roomId,
serverId: voiceState.serverId !== undefined ? voiceState.serverId : prev.serverId
}
}
},
state
);
}),
on(UsersActions.updateScreenShareState, (state, { userId, screenShareState }) => {
const prev = state.entities[userId]?.screenShareState || {
isSharing: false
};
return usersAdapter.updateOne(
{
id: userId,
changes: {
screenShareState: {
isSharing: screenShareState.isSharing ?? prev.isSharing,
streamId: screenShareState.streamId ?? prev.streamId,
sourceId: screenShareState.sourceId ?? prev.sourceId,
sourceName: screenShareState.sourceName ?? prev.sourceName
}
}
},
state
);
}),
on(UsersActions.syncUsers, (state, { users }) =>
usersAdapter.upsertMany(users, state)
),
on(UsersActions.clearUsers, (state) => {
const idsToRemove = Object.keys(state.entities).filter((id) => id !== state.currentUserId);
return usersAdapter.removeMany(idsToRemove, {
...state,
hostId: null
});
}),
on(UsersActions.updateHost, (state, { userId }) => {
let newState = state;
if (state.hostId && state.hostId !== userId) {
newState = usersAdapter.updateOne(
{
id: state.hostId,
changes: { role: 'member' }
},
state
);
}
return usersAdapter.updateOne(
{
id: userId,
changes: { role: 'host' }
},
{
...newState,
hostId: userId
}
);
})
);

View File

@@ -0,0 +1,104 @@
import { createFeatureSelector, createSelector } from '@ngrx/store';
import { UsersState, usersAdapter } from './users.reducer';
/** Selects the top-level users feature state. */
export const selectUsersState = createFeatureSelector<UsersState>('users');
const { selectIds, selectEntities, selectAll, selectTotal } = usersAdapter.getSelectors();
/** Selects all user entities as a flat array. */
export const selectAllUsers = createSelector(selectUsersState, selectAll);
/** Selects the user entity dictionary keyed by ID. */
export const selectUsersEntities = createSelector(selectUsersState, selectEntities);
/** Selects all user IDs. */
export const selectUsersIds = createSelector(selectUsersState, selectIds);
/** Selects the total count of users. */
export const selectUsersTotal = createSelector(selectUsersState, selectTotal);
/** Whether a user-loading operation is in progress. */
export const selectUsersLoading = createSelector(
selectUsersState,
(state) => state.loading
);
/** Selects the most recent users-related error message. */
export const selectUsersError = createSelector(
selectUsersState,
(state) => state.error
);
/** Selects the current (local) user's ID, or null. */
export const selectCurrentUserId = createSelector(
selectUsersState,
(state) => state.currentUserId
);
/** Selects the host's user ID. */
export const selectHostId = createSelector(
selectUsersState,
(state) => state.hostId
);
/** Selects all active ban entries for the current room. */
export const selectBannedUsers = createSelector(
selectUsersState,
(state) => state.bans
);
/** Selects the full User entity for the current (local) user. */
export const selectCurrentUser = createSelector(
selectUsersEntities,
selectCurrentUserId,
(entities, currentUserId) => (currentUserId ? entities[currentUserId] : null)
);
/** Selects the full User entity for the room host. */
export const selectHost = createSelector(
selectUsersEntities,
selectHostId,
(entities, hostId) => (hostId ? entities[hostId] : null)
);
/** Creates a selector that returns a single user by their ID. */
export const selectUserById = (id: string) =>
createSelector(selectUsersEntities, (entities) => entities[id]);
/** Whether the current user is the room host. */
export const selectIsCurrentUserHost = createSelector(
selectCurrentUserId,
selectHostId,
(currentUserId, hostId) => currentUserId === hostId
);
/** Whether the current user holds an elevated role (host, admin, or moderator). */
export const selectIsCurrentUserAdmin = createSelector(
selectCurrentUser,
(user) => user?.role === 'host' || user?.role === 'admin' || user?.role === 'moderator'
);
/** Selects users who are currently online (not offline). */
export const selectOnlineUsers = createSelector(
selectAllUsers,
(users) => users.filter((user) => user.status !== 'offline' || user.isOnline === true)
);
/** Creates a selector that returns users with a specific role. */
export const selectUsersByRole = (role: string) =>
createSelector(selectAllUsers, (users) =>
users.filter((user) => user.role === role)
);
/** Selects all users with an elevated role (host, admin, or moderator). */
export const selectAdmins = createSelector(
selectAllUsers,
(users) => users.filter((user) => user.role === 'host' || user.role === 'admin' || user.role === 'moderator')
);
/** Whether the current user is the room owner (host role). */
export const selectIsCurrentUserOwner = createSelector(
selectCurrentUser,
(user) => user?.role === 'host'
);