Move toju-app into own its folder
This commit is contained in:
4
toju-app/src/app/store/messages/index.ts
Normal file
4
toju-app/src/app/store/messages/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './messages.actions';
|
||||
export * from './messages.reducer';
|
||||
export * from './messages.selectors';
|
||||
export * from './messages.effects';
|
||||
664
toju-app/src/app/store/messages/messages-incoming.handlers.ts
Normal file
664
toju-app/src/app/store/messages/messages-incoming.handlers.ts
Normal 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);
|
||||
}
|
||||
267
toju-app/src/app/store/messages/messages-sync.effects.ts
Normal file
267
toju-app/src/app/store/messages/messages-sync.effects.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
55
toju-app/src/app/store/messages/messages.actions.ts
Normal file
55
toju-app/src/app/store/messages/messages.actions.ts
Normal 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()
|
||||
}
|
||||
});
|
||||
427
toju-app/src/app/store/messages/messages.effects.ts
Normal file
427
toju-app/src/app/store/messages/messages.effects.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
183
toju-app/src/app/store/messages/messages.helpers.ts
Normal file
183
toju-app/src/app/store/messages/messages.helpers.ts
Normal 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 };
|
||||
}
|
||||
216
toju-app/src/app/store/messages/messages.reducer.ts
Normal file
216
toju-app/src/app/store/messages/messages.reducer.ts
Normal 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
|
||||
})
|
||||
)
|
||||
);
|
||||
87
toju-app/src/app/store/messages/messages.selectors.ts
Normal file
87
toju-app/src/app/store/messages/messages.selectors.ts
Normal 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)
|
||||
);
|
||||
Reference in New Issue
Block a user