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,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)
);