Move toju-app into own its folder

This commit is contained in:
2026-03-29 23:30:37 +02:00
parent 0467a7b612
commit 8162e0444a
287 changed files with 42 additions and 34 deletions

View File

@@ -0,0 +1,313 @@
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 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;
return {
id: member.id || key,
oderId: member.oderId || undefined,
username: member.username || fallbackUsername(member),
displayName: fallbackDisplayName(member),
avatarUrl: member.avatarUrl || undefined,
role: member.role || 'member',
joinedAt,
lastSeenAt
};
}
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;
return {
id: normalizedExisting.id || normalizedIncoming.id,
oderId: normalizedIncoming.oderId || normalizedExisting.oderId,
username: preferIncoming
? (normalizedIncoming.username || normalizedExisting.username)
: (normalizedExisting.username || normalizedIncoming.username),
displayName: preferIncoming
? (normalizedIncoming.displayName || normalizedExisting.displayName)
: (normalizedExisting.displayName || normalizedIncoming.displayName),
avatarUrl: preferIncoming
? (normalizedIncoming.avatarUrl || normalizedExisting.avatarUrl)
: (normalizedExisting.avatarUrl || normalizedIncoming.avatarUrl),
role: mergeRole(normalizedExisting.role, normalizedIncoming.role, preferIncoming),
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',
avatarUrl: user.avatarUrl,
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,
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));
}