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