import * as R from "remeda"; import { actorIdOrNullSafe } from "~/features/auth/core/user.server"; import * as EventBus from "~/features/events/core/EventBus.server"; import { chatRoomChannel, userChannel } from "~/features/events/events-types"; import { invariant } from "~/utils/invariant"; import { logger } from "~/utils/logger"; import * as ChatRepository from "./ChatRepository.server"; import * as ChatRoomResolver from "./ChatRoomResolver.server"; import type { PersistedSystemMessageType, RevalidateScope, UnthrottledSystemMessageType, } from "./chat-types"; import { createRevalidateBroadcastThrottle } from "./revalidate-broadcast-throttle"; type RevalidateBroadcast = { /** Channel whose subscribed pages should refetch, see `chatRoomChannel`. */ channel: string; /** Actor whose own broadcast clients skip (their submission already reran the loaders). */ authorUserId?: number; revalidateScope?: RevalidateScope; type?: UnthrottledSystemMessageType; }; const REVALIDATE_BROADCAST_THROTTLE_WINDOW_MS = 2_000; const revalidateThrottle = createRevalidateBroadcastThrottle({ windowMs: REVALIDATE_BROADCAST_THROTTLE_WINDOW_MS, sendLeading: (msg) => publishRevalidate(msg), // no author on purpose: the trailing broadcast covers many actors' changes, // so no client may skip it as a duplicate of their own submission sendTrailing: (msg) => EventBus.publish([msg.channel], { kind: "revalidate", scope: msg.revalidateScope, }), }); /** Publishes a contentless revalidate broadcast to the channel(s) so subscribed pages refetch. Noisy kinds are throttled per channel, see {@link createRevalidateBroadcastThrottle}. */ export function send(broadcast: RevalidateBroadcast | RevalidateBroadcast[]) { for (const msg of Array.isArray(broadcast) ? broadcast : [broadcast]) { if (revalidateThrottle.throttles(msg)) { revalidateThrottle.handle(msg); } else { publishRevalidate(msg); } } } /** Persists a system message (e.g. a reported score) as a chat line and publishes it plus a revalidate broadcast. Fire and forget like {@link send}: failures are logged, never thrown. */ export function sendPersisted(args: { roomId: number; type: PersistedSystemMessageType; /** The user the message describes, e.g. who left the group. */ authorUserId: number; }): Promise { return persistAndPublish(args).catch((err) => logger.error(`Persisting system message "${args.type}" failed:`, err), ); } async function persistAndPublish(args: { roomId: number; type: PersistedSystemMessageType; authorUserId: number; }) { const room = await ChatRoomResolver.resolve(args.roomId); if (!room) return; const inserted = await ChatRepository.insertSystemMessage(args); const message = await ChatRepository.findMessageById(inserted.id); invariant(message, "inserted system message not found"); EventBus.publish( [...room.participantUserIds.map(userChannel), chatRoomChannel(args.roomId)], { kind: "chatMessage", roomId: args.roomId, message }, ); EventBus.publish([chatRoomChannel(args.roomId)], { kind: "revalidate", authorUserId: actorIdOrNullSafe() ?? undefined, }); } function publishRevalidate(msg: { channel: string; revalidateScope?: RevalidateScope; authorUserId?: number; }) { EventBus.publish([msg.channel], { kind: "revalidate", scope: msg.revalidateScope, authorUserId: msg.authorUserId ?? actorIdOrNullSafe() ?? undefined, }); } /** Publishes a contentless "your notifications changed" event to the users' streams. Fire and forget; a missed event only delays the refetch. */ export function notifyNotificationsChanged(userIds: number[]) { if (userIds.length === 0) return; EventBus.publish(userIds.map(userChannel), { kind: "notificationsChanged", }); } /** Publishes a contentless "your header status changed" event to the users' streams, prompting their clients to refetch the global status. Fire and forget; a missed event only delays the refetch until the next catch-up. */ export function notifyStatusChanged(userIds: number[]) { if (userIds.length === 0) return; EventBus.publish(R.unique(userIds).map(userChannel), { kind: "statusChanged", }); } /** Publishes a "your chat room set changed" event after a membership change; clients refetch their room list and drop rooms (and held history) they lost access to. */ export function notifyRoomsChanged(userIds: number[]) { if (userIds.length === 0) return; EventBus.publish(R.unique(userIds).map(userChannel), { kind: "roomsChanged", }); } /** Same, for a change to the rooms themselves (participants resolved from them), e.g. the inactive flag flipping with a match completing. Fire and forget like {@link send}. */ export function notifyRoomsChangedByRoomIds(roomIds: number[]): Promise { return notifyParticipantsOfRoomsChanged(roomIds).catch((err) => logger.error("Notifying participants of changed chat rooms failed:", err), ); } async function notifyParticipantsOfRoomsChanged(roomIds: number[]) { if (roomIds.length === 0) return; const rooms = await ChatRoomResolver.resolveAll(roomIds); notifyRoomsChanged(rooms.flatMap((room) => room.participantUserIds)); }