All checks were successful
Queue Release Build / prepare (push) Successful in 28s
Deploy Web Apps / deploy (push) Successful in 5m2s
Queue Release Build / build-windows (push) Successful in 16m44s
Queue Release Build / build-linux (push) Successful in 27m12s
Queue Release Build / finalize (push) Successful in 22s
423 lines
14 KiB
TypeScript
423 lines
14 KiB
TypeScript
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<RoomMember>): string {
|
|
return member.displayName || member.username || member.oderId || member.id || 'User';
|
|
}
|
|
|
|
function fallbackUsername(member: Partial<RoomMember>): 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<RoomMember, 'displayName' | 'description' | 'profileUpdatedAt'>,
|
|
incomingMember: Pick<RoomMember, 'displayName' | 'description' | 'profileUpdatedAt'>,
|
|
preferIncomingFallback: boolean
|
|
): Pick<RoomMember, 'displayName' | 'description' | 'profileUpdatedAt'> {
|
|
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<RoomMember, 'avatarUrl' | 'avatarHash' | 'avatarMime' | 'avatarUpdatedAt'>,
|
|
incomingMember: Pick<RoomMember, 'avatarUrl' | 'avatarHash' | 'avatarMime' | 'avatarUpdatedAt'>,
|
|
preferIncomingFallback: boolean
|
|
): Pick<RoomMember, 'avatarUrl' | 'avatarHash' | 'avatarMime' | 'avatarUpdatedAt'> {
|
|
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<RoomMember, 'id' | 'oderId'>): 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<string, RoomMember>();
|
|
|
|
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<RoomMember> | null,
|
|
previousOwner?: Pick<RoomMember, 'id' | 'oderId'>,
|
|
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));
|
|
}
|