Files
Toju/toju-app/src/app/store/messages/messages.helpers.ts
Myx 54e8b9a5e4
All checks were successful
Queue Release Build / prepare (push) Successful in 23s
Deploy Web Apps / deploy (push) Successful in 7m36s
Queue Release Build / build-windows (push) Successful in 28m3s
Queue Release Build / build-linux (push) Successful in 44m14s
Queue Release Build / finalize (push) Successful in 39s
feat: Update how messages load and sync, allow plugins to import messages
2026-05-18 23:21:09 +02:00

187 lines
5.1 KiB
TypeScript

/**
* 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/rules/message.rules';
import type { InventoryItem } from '../../domains/chat/domain/rules/message-sync.rules';
// Re-export domain logic so existing callers keep working
export {
getMessageTimestamp,
getLatestTimestamp,
normaliseDeletedMessage,
canEditMessage
} from '../../domains/chat/domain/rules/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/rules/message-sync.rules';
export type { InventoryItem } from '../../domains/chat/domain/rules/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);
return msg;
}
/** Hydrates an array of messages with their reactions. */
export async function hydrateMessages(
messages: Message[],
_db: DatabaseService
): Promise<Message[]> {
return messages.map((msg) => msg.isDeleted ? normaliseDeletedMessage(msg) : msg);
}
/** Builds a sync inventory item from a message and its reaction count.
*
* Reactions are read from the already-hydrated `msg.reactions` array (the
* persistence layer joins them in via `getMessages`), and attachment counts
* only come from the in-memory override. We deliberately avoid per-message
* DB lookups here so a whole-room inventory stays O(1) DB calls even when
* the room contains tens of thousands of messages.
*/
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 item: InventoryItem = {
id: msg.id,
ts: getMessageTimestamp(msg),
rc: msg.reactions?.length ?? 0
};
if (attachmentCountOverride !== undefined) {
item.ac = attachmentCountOverride;
}
return item;
}
/** Builds a local map of `{timestamp, reactionCount, attachmentCount}` keyed by message ID.
*
* As with {@link buildInventoryItem}, reactions come from the already-hydrated
* `msg.reactions` array and attachment counts only come from the in-memory
* override map.
*/
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 }>();
for (const msg of messages) {
if (msg.isDeleted) {
map.set(msg.id, {
ts: getMessageTimestamp(msg),
rc: 0,
ac: 0
});
continue;
}
map.set(msg.id, {
ts: getMessageTimestamp(msg),
rc: msg.reactions?.length ?? 0,
ac: attachmentCountOverrides?.get(msg.id) ?? 0
});
}
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 };
}