feat: Add profile images

This commit is contained in:
2026-04-17 03:05:47 +02:00
parent 35b616fb77
commit 17738ec484
49 changed files with 2622 additions and 89 deletions

View File

@@ -72,6 +72,7 @@ export {
// Re-export effects
export { MessagesEffects } from './messages/messages.effects';
export { MessagesSyncEffects } from './messages/messages-sync.effects';
export { UserAvatarEffects } from './users/user-avatar.effects';
export { UsersEffects } from './users/users.effects';
export { RoomsEffects } from './rooms/rooms.effects';

View File

@@ -30,6 +30,37 @@ function normalizeRoleIds(roleIds: readonly string[] | undefined): string[] | un
return normalized.length > 0 ? normalized : undefined;
}
function normalizeAvatarUpdatedAt(value: unknown): number | undefined {
return typeof value === 'number' && Number.isFinite(value) && value > 0
? value
: 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 =
@@ -49,6 +80,9 @@ function normalizeMember(member: RoomMember, now = Date.now()): RoomMember {
username: member.username || fallbackUsername(member),
displayName: fallbackDisplayName(member),
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,
@@ -94,6 +128,7 @@ function mergeMembers(
const normalizedExisting = normalizeMember(existingMember, now);
const preferIncoming = normalizedIncoming.lastSeenAt >= normalizedExisting.lastSeenAt;
const avatarFields = mergeAvatarFields(normalizedExisting, normalizedIncoming, preferIncoming);
return {
id: normalizedExisting.id || normalizedIncoming.id,
@@ -104,9 +139,7 @@ function mergeMembers(
displayName: preferIncoming
? (normalizedIncoming.displayName || normalizedExisting.displayName)
: (normalizedExisting.displayName || normalizedIncoming.displayName),
avatarUrl: preferIncoming
? (normalizedIncoming.avatarUrl || normalizedExisting.avatarUrl)
: (normalizedExisting.avatarUrl || normalizedIncoming.avatarUrl),
...avatarFields,
role: mergeRole(normalizedExisting.role, normalizedIncoming.role, preferIncoming),
roleIds: preferIncoming
? (normalizedIncoming.roleIds || normalizedExisting.roleIds)
@@ -145,6 +178,9 @@ export function roomMemberFromUser(
username: user.username || '',
displayName: user.displayName || user.username || 'User',
avatarUrl: user.avatarUrl,
avatarHash: user.avatarHash,
avatarMime: user.avatarMime,
avatarUpdatedAt: user.avatarUpdatedAt,
role: roleOverride || user.role || 'member',
joinedAt: user.joinedAt || seenAt,
lastSeenAt: seenAt
@@ -290,6 +326,9 @@ export function transferRoomOwnership(
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

View File

@@ -49,6 +49,9 @@ export function buildKnownUserExtras(room: Room | null, identifier: string): Rec
return {
username: knownMember.username,
avatarUrl: knownMember.avatarUrl,
avatarHash: knownMember.avatarHash,
avatarMime: knownMember.avatarMime,
avatarUpdatedAt: knownMember.avatarUpdatedAt,
role: knownMember.role,
joinedAt: knownMember.joinedAt
};

View File

@@ -0,0 +1,85 @@
import { User } from '../../shared-kernel';
import {
shouldApplyAvatarTransfer,
shouldRequestAvatarData
} from './user-avatar.effects';
function createUser(overrides: Partial<User> = {}): User {
return {
id: 'user-1',
oderId: 'oder-1',
username: 'alice',
displayName: 'Alice',
status: 'online',
role: 'member',
joinedAt: Date.now(),
...overrides
};
}
describe('user avatar sync helpers', () => {
it('requests avatar data when the remote version is newer', () => {
const existingUser = createUser({
avatarUrl: 'data:image/webp;base64,older',
avatarHash: 'hash-1',
avatarUpdatedAt: 100
});
expect(shouldRequestAvatarData(existingUser, {
avatarHash: 'hash-2',
avatarUpdatedAt: 200
})).toBe(true);
});
it('requests avatar data when metadata matches but the local payload is missing', () => {
const existingUser = createUser({
avatarHash: 'hash-1',
avatarMime: 'image/gif',
avatarUpdatedAt: 200
});
expect(shouldRequestAvatarData(existingUser, {
avatarHash: 'hash-1',
avatarUpdatedAt: 200
})).toBe(true);
});
it('does not request avatar data when the same payload is already present', () => {
const existingUser = createUser({
avatarUrl: 'data:image/gif;base64,current',
avatarHash: 'hash-1',
avatarMime: 'image/gif',
avatarUpdatedAt: 200
});
expect(shouldRequestAvatarData(existingUser, {
avatarHash: 'hash-1',
avatarUpdatedAt: 200
})).toBe(false);
});
it('applies equal-version transfers when the local payload is missing', () => {
const existingUser = createUser({
avatarHash: 'hash-1',
avatarUpdatedAt: 200
});
expect(shouldApplyAvatarTransfer(existingUser, {
hash: 'hash-1',
updatedAt: 200
})).toBe(true);
});
it('rejects older avatar transfers', () => {
const existingUser = createUser({
avatarUrl: 'data:image/gif;base64,current',
avatarHash: 'hash-2',
avatarUpdatedAt: 200
});
expect(shouldApplyAvatarTransfer(existingUser, {
hash: 'hash-1',
updatedAt: 100
})).toBe(false);
});
});

View File

@@ -0,0 +1,438 @@
import { Injectable, inject } from '@angular/core';
import {
Actions,
createEffect,
ofType
} from '@ngrx/effects';
import { Action, Store } from '@ngrx/store';
import {
EMPTY,
from,
of
} from 'rxjs';
import {
mergeMap,
tap,
withLatestFrom
} from 'rxjs/operators';
import { ProfileAvatarFacade } from '../../domains/profile-avatar';
import {
ChatEvent,
P2P_BASE64_CHUNK_SIZE_BYTES,
User,
decodeBase64,
iterateBlobChunks
} from '../../shared-kernel';
import { RealtimeSessionFacade } from '../../core/realtime';
import { DatabaseService } from '../../infrastructure/persistence';
import { UsersActions } from './users.actions';
import {
selectAllUsers,
selectCurrentUser
} from './users.selectors';
import { selectCurrentRoom, selectSavedRooms } from '../rooms/rooms.selectors';
import { RoomsActions } from '../rooms/rooms.actions';
import { findRoomMember } from '../rooms/room-members.helpers';
interface PendingAvatarTransfer {
displayName: string;
mime: string;
oderId: string;
total: number;
updatedAt: number;
username: string;
chunks: (string | undefined)[];
hash?: string;
}
type AvatarVersionState = Pick<User, 'avatarUrl' | 'avatarHash' | 'avatarUpdatedAt'> | undefined;
function shouldAcceptAvatarPayload(
existingUser: AvatarVersionState,
incomingUpdatedAt: number,
incomingHash?: string
): boolean {
const localUpdatedAt = existingUser?.avatarUpdatedAt ?? 0;
if (incomingUpdatedAt > localUpdatedAt) {
return true;
}
if (incomingUpdatedAt < localUpdatedAt || incomingUpdatedAt === 0) {
return false;
}
if (!existingUser?.avatarUrl) {
return true;
}
return !!incomingHash && incomingHash !== existingUser.avatarHash;
}
export function shouldRequestAvatarData(
existingUser: AvatarVersionState,
incomingAvatar: Pick<ChatEvent, 'avatarHash' | 'avatarUpdatedAt'>
): boolean {
return shouldAcceptAvatarPayload(existingUser, incomingAvatar.avatarUpdatedAt ?? 0, incomingAvatar.avatarHash);
}
export function shouldApplyAvatarTransfer(
existingUser: AvatarVersionState,
transfer: Pick<PendingAvatarTransfer, 'hash' | 'updatedAt'>
): boolean {
return shouldAcceptAvatarPayload(existingUser, transfer.updatedAt, transfer.hash);
}
@Injectable()
export class UserAvatarEffects {
private readonly actions$ = inject(Actions);
private readonly store = inject(Store);
private readonly webrtc = inject(RealtimeSessionFacade);
private readonly db = inject(DatabaseService);
private readonly avatars = inject(ProfileAvatarFacade);
private readonly pendingTransfers = new Map<string, PendingAvatarTransfer>();
persistCurrentAvatar$ = createEffect(
() =>
this.actions$.pipe(
ofType(UsersActions.updateCurrentUserAvatar),
withLatestFrom(this.store.select(selectCurrentUser)),
tap(([, currentUser]) => {
if (currentUser) {
this.db.saveUser(currentUser);
}
})
),
{ dispatch: false }
);
persistRemoteAvatar$ = createEffect(
() =>
this.actions$.pipe(
ofType(UsersActions.upsertRemoteUserAvatar),
withLatestFrom(this.store.select(selectAllUsers)),
tap(([{ user }, allUsers]) => {
const mergedUser = allUsers.find((entry) => entry.id === user.id || entry.oderId === user.oderId);
const avatarUrl = mergedUser?.avatarUrl ?? user.avatarUrl;
if (!avatarUrl) {
return;
}
if (mergedUser) {
this.db.saveUser(mergedUser);
}
void this.avatars.persistAvatarDataUrl({
id: mergedUser?.id || user.id,
username: mergedUser?.username || user.username,
displayName: mergedUser?.displayName || user.displayName
}, avatarUrl);
})
),
{ dispatch: false }
);
syncRoomMemberAvatars$ = createEffect(() =>
this.actions$.pipe(
ofType(UsersActions.updateCurrentUserAvatar, UsersActions.upsertRemoteUserAvatar),
withLatestFrom(
this.store.select(selectCurrentUser),
this.store.select(selectCurrentRoom),
this.store.select(selectSavedRooms)
),
mergeMap(([
action,
currentUser,
currentRoom,
savedRooms
]) => {
const avatarOwner = action.type === UsersActions.updateCurrentUserAvatar.type
? currentUser
: ('user' in action ? action.user : null);
if (!avatarOwner) {
return EMPTY;
}
const actions = this.buildRoomAvatarActions(avatarOwner, currentRoom, savedRooms);
return actions.length > 0 ? actions : EMPTY;
})
)
);
broadcastCurrentAvatarSummary$ = createEffect(
() =>
this.actions$.pipe(
ofType(UsersActions.updateCurrentUserAvatar),
withLatestFrom(this.store.select(selectCurrentUser)),
tap(([, currentUser]) => {
if (!currentUser?.avatarUpdatedAt) {
return;
}
this.webrtc.broadcastMessage(this.buildAvatarSummary(currentUser));
})
),
{ dispatch: false }
);
peerConnectedAvatarSummary$ = createEffect(
() =>
this.webrtc.onPeerConnected.pipe(
withLatestFrom(this.store.select(selectCurrentUser)),
tap(([peerId, currentUser]) => {
if (!currentUser?.avatarUpdatedAt) {
return;
}
this.webrtc.sendToPeer(peerId, this.buildAvatarSummary(currentUser));
})
),
{ dispatch: false }
);
incomingAvatarEvents$ = createEffect(() =>
this.webrtc.onMessageReceived.pipe(
withLatestFrom(this.store.select(selectAllUsers), this.store.select(selectCurrentUser)),
mergeMap(([
event,
allUsers,
currentUser
]) => {
switch (event.type) {
case 'user-avatar-summary':
return this.handleAvatarSummary(event, allUsers);
case 'user-avatar-request':
return this.handleAvatarRequest(event, currentUser ?? null);
case 'user-avatar-full':
return this.handleAvatarFull(event);
case 'user-avatar-chunk':
return this.handleAvatarChunk(event, allUsers);
default:
return EMPTY;
}
})
)
);
private buildAvatarSummary(user: Pick<User, 'oderId' | 'id' | 'username' | 'displayName' | 'avatarHash' | 'avatarMime' | 'avatarUpdatedAt'>): ChatEvent {
return {
type: 'user-avatar-summary',
oderId: user.oderId || user.id,
username: user.username,
displayName: user.displayName,
avatarHash: user.avatarHash,
avatarMime: user.avatarMime,
avatarUpdatedAt: user.avatarUpdatedAt || 0
};
}
private handleAvatarSummary(event: ChatEvent, allUsers: User[]) {
if (!event.fromPeerId || !event.oderId || !event.avatarUpdatedAt) {
return EMPTY;
}
const existingUser = allUsers.find((user) => user.id === event.oderId || user.oderId === event.oderId);
if (!shouldRequestAvatarData(existingUser, event)) {
return EMPTY;
}
this.webrtc.sendToPeer(event.fromPeerId, {
type: 'user-avatar-request',
oderId: event.oderId
});
return EMPTY;
}
private handleAvatarRequest(event: ChatEvent, currentUser: User | null) {
const currentUserKey = currentUser?.oderId || currentUser?.id;
if (!event.fromPeerId || !currentUser || !currentUserKey || event.oderId !== currentUserKey || !currentUser.avatarUrl) {
return EMPTY;
}
return from(this.sendAvatarToPeer(event.fromPeerId, currentUser)).pipe(mergeMap(() => EMPTY));
}
private handleAvatarFull(event: ChatEvent) {
if (!event.oderId || !event.avatarMime || typeof event.total !== 'number' || event.total < 1) {
return EMPTY;
}
this.pendingTransfers.set(event.oderId, {
chunks: new Array<string | undefined>(event.total),
displayName: event.displayName || 'User',
mime: event.avatarMime,
oderId: event.oderId,
total: event.total,
updatedAt: event.avatarUpdatedAt || Date.now(),
username: event.username || (event.displayName || 'User').toLowerCase().replace(/\s+/g, '_'),
hash: event.avatarHash
});
return EMPTY;
}
private handleAvatarChunk(event: ChatEvent, allUsers: User[]) {
if (!event.oderId || typeof event.index !== 'number' || typeof event.total !== 'number' || typeof event.data !== 'string') {
return EMPTY;
}
const transfer = this.pendingTransfers.get(event.oderId);
if (!transfer || transfer.total !== event.total || event.index < 0 || event.index >= transfer.total) {
return EMPTY;
}
transfer.chunks[event.index] = event.data;
if (transfer.chunks.some((chunk) => !chunk)) {
return EMPTY;
}
this.pendingTransfers.delete(event.oderId);
return from(this.buildRemoteAvatarAction(transfer, allUsers)).pipe(
mergeMap((action) => action ? of(action) : EMPTY)
);
}
private async buildRemoteAvatarAction(transfer: PendingAvatarTransfer, allUsers: User[]): Promise<Action | null> {
const existingUser = allUsers.find((user) => user.id === transfer.oderId || user.oderId === transfer.oderId);
if (!shouldApplyAvatarTransfer(existingUser, transfer)) {
return null;
}
const blob = new Blob(transfer.chunks.map((chunk) => this.decodeBase64ToArrayBuffer(chunk!)), { type: transfer.mime });
const dataUrl = await this.readBlobAsDataUrl(blob);
return UsersActions.upsertRemoteUserAvatar({
user: {
id: existingUser?.id || transfer.oderId,
oderId: existingUser?.oderId || transfer.oderId,
username: existingUser?.username || transfer.username,
displayName: existingUser?.displayName || transfer.displayName,
avatarUrl: dataUrl,
avatarHash: transfer.hash,
avatarMime: transfer.mime,
avatarUpdatedAt: transfer.updatedAt
}
});
}
private buildRoomAvatarActions(
avatarOwner: Pick<User, 'id' | 'oderId' | 'avatarUrl' | 'avatarHash' | 'avatarMime' | 'avatarUpdatedAt'>,
currentRoom: ReturnType<typeof selectCurrentRoom['projector']> | null,
savedRooms: ReturnType<typeof selectSavedRooms['projector']>
): Action[] {
const rooms = [currentRoom, ...savedRooms.filter((room) => room.id !== currentRoom?.id)].filter((room): room is NonNullable<typeof currentRoom> => !!room);
const roomActions: Action[] = [];
const avatarOwnerId = avatarOwner.oderId || avatarOwner.id;
for (const room of rooms) {
const member = findRoomMember(room.members ?? [], avatarOwnerId);
if (!member) {
continue;
}
const nextMembers = (room.members ?? []).map((roomMember) => {
if (roomMember.id !== member.id && roomMember.oderId !== member.oderId) {
return roomMember;
}
return {
...roomMember,
avatarUrl: avatarOwner.avatarUrl,
avatarHash: avatarOwner.avatarHash,
avatarMime: avatarOwner.avatarMime,
avatarUpdatedAt: avatarOwner.avatarUpdatedAt
};
});
roomActions.push(RoomsActions.updateRoom({
roomId: room.id,
changes: { members: nextMembers }
}));
}
return roomActions;
}
private async sendAvatarToPeer(targetPeerId: string, user: User): Promise<void> {
if (!user.avatarUrl) {
return;
}
const blob = await this.dataUrlToBlob(user.avatarUrl, user.avatarMime || 'image/webp');
const total = Math.ceil(blob.size / P2P_BASE64_CHUNK_SIZE_BYTES);
const userKey = user.oderId || user.id;
this.webrtc.sendToPeer(targetPeerId, {
type: 'user-avatar-full',
oderId: userKey,
username: user.username,
displayName: user.displayName,
avatarHash: user.avatarHash,
avatarMime: user.avatarMime || blob.type || 'image/webp',
avatarUpdatedAt: user.avatarUpdatedAt || Date.now(),
total
});
for await (const chunk of iterateBlobChunks(blob, P2P_BASE64_CHUNK_SIZE_BYTES)) {
await this.webrtc.sendToPeerBuffered(targetPeerId, {
type: 'user-avatar-chunk',
oderId: userKey,
avatarHash: user.avatarHash,
avatarMime: user.avatarMime || blob.type || 'image/webp',
avatarUpdatedAt: user.avatarUpdatedAt || Date.now(),
index: chunk.index,
total: chunk.total,
data: chunk.base64
});
}
}
private dataUrlToBlob(dataUrl: string, mimeType: string): Promise<Blob> {
const base64 = dataUrl.split(',', 2)[1] ?? '';
return Promise.resolve(new Blob([this.decodeBase64ToArrayBuffer(base64)], { type: mimeType }));
}
private decodeBase64ToArrayBuffer(base64: string): ArrayBuffer {
const decodedBytes = decodeBase64(base64);
return decodedBytes.buffer.slice(
decodedBytes.byteOffset,
decodedBytes.byteOffset + decodedBytes.byteLength
) as ArrayBuffer;
}
private readBlobAsDataUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result === 'string') {
resolve(reader.result);
return;
}
reject(new Error('Failed to encode avatar image'));
};
reader.onerror = () => reject(reader.error ?? new Error('Failed to read avatar image'));
reader.readAsDataURL(blob);
});
}
}

View File

@@ -87,6 +87,61 @@ describe('users reducer - status', () => {
});
});
describe('avatar updates', () => {
it('updates current user avatar metadata', () => {
const state = usersReducer(baseState, UsersActions.updateCurrentUserAvatar({
avatar: {
avatarUrl: 'data:image/webp;base64,abc123',
avatarHash: 'hash-1',
avatarMime: 'image/webp',
avatarUpdatedAt: 1234
}
}));
expect(state.entities['user-1']?.avatarUrl).toBe('data:image/webp;base64,abc123');
expect(state.entities['user-1']?.avatarHash).toBe('hash-1');
expect(state.entities['user-1']?.avatarMime).toBe('image/webp');
expect(state.entities['user-1']?.avatarUpdatedAt).toBe(1234);
});
it('keeps newer remote avatar when stale update arrives later', () => {
const withRemote = usersReducer(
baseState,
UsersActions.upsertRemoteUserAvatar({
user: {
id: 'remote-1',
oderId: 'oder-remote-1',
username: 'remote',
displayName: 'Remote',
avatarUrl: 'data:image/webp;base64,newer',
avatarHash: 'hash-newer',
avatarMime: 'image/webp',
avatarUpdatedAt: 200
}
})
);
const state = usersReducer(
withRemote,
UsersActions.upsertRemoteUserAvatar({
user: {
id: 'remote-1',
oderId: 'oder-remote-1',
username: 'remote',
displayName: 'Remote',
avatarUrl: 'data:image/webp;base64,older',
avatarHash: 'hash-older',
avatarMime: 'image/webp',
avatarUpdatedAt: 100
}
})
);
expect(state.entities['remote-1']?.avatarUrl).toBe('data:image/webp;base64,newer');
expect(state.entities['remote-1']?.avatarHash).toBe('hash-newer');
expect(state.entities['remote-1']?.avatarUpdatedAt).toBe(200);
});
});
describe('presence-aware user with status', () => {
it('preserves incoming status on user join', () => {
const user = createUser({ id: 'away-user', oderId: 'oder-away', status: 'away', presenceServerIds: ['server-1'] });

View File

@@ -59,6 +59,9 @@ export const UsersActions = createActionGroup({
'Update Camera State': props<{ userId: string; cameraState: Partial<CameraState> }>(),
'Set Manual Status': props<{ status: UserStatus | null }>(),
'Update Remote User Status': props<{ userId: string; status: UserStatus }>()
'Update Remote User Status': props<{ userId: string; status: UserStatus }>(),
'Update Current User Avatar': props<{ avatar: { avatarUrl: string; avatarHash: string; avatarMime: string; avatarUpdatedAt: number } }>(),
'Upsert Remote User Avatar': props<{ user: { id: string; oderId: string; username: string; displayName: string; avatarUrl: string; avatarHash?: string; avatarMime?: string; avatarUpdatedAt?: number } }>()
}
});

View File

@@ -30,6 +30,39 @@ function mergePresenceServerIds(
return normalizePresenceServerIds([...(existingServerIds ?? []), ...(incomingServerIds ?? [])]);
}
interface AvatarFields {
avatarUrl?: string;
avatarHash?: string;
avatarMime?: string;
avatarUpdatedAt?: number;
}
function mergeAvatarFields(
existingValue: AvatarFields | undefined,
incomingValue: AvatarFields,
preferIncomingFallback = true
): AvatarFields {
const existingUpdatedAt = existingValue?.avatarUpdatedAt ?? 0;
const incomingUpdatedAt = incomingValue.avatarUpdatedAt ?? 0;
const preferIncoming = incomingUpdatedAt === existingUpdatedAt
? preferIncomingFallback
: incomingUpdatedAt > existingUpdatedAt;
return {
avatarUrl: preferIncoming
? (incomingValue.avatarUrl || existingValue?.avatarUrl)
: (existingValue?.avatarUrl || incomingValue.avatarUrl),
avatarHash: preferIncoming
? (incomingValue.avatarHash || existingValue?.avatarHash)
: (existingValue?.avatarHash || incomingValue.avatarHash),
avatarMime: preferIncoming
? (incomingValue.avatarMime || existingValue?.avatarMime)
: (existingValue?.avatarMime || incomingValue.avatarMime),
avatarUpdatedAt: Math.max(existingUpdatedAt, incomingUpdatedAt) || undefined
};
}
function buildDisconnectedVoiceState(user: User): User['voiceState'] {
if (!user.voiceState) {
return undefined;
@@ -83,12 +116,36 @@ function buildPresenceAwareUser(existingUser: User | undefined, incomingUser: Us
return {
...existingUser,
...incomingUser,
...mergeAvatarFields(existingUser, incomingUser, true),
presenceServerIds,
isOnline,
status
};
}
function buildAvatarUser(existingUser: User | undefined, incomingUser: {
id: string;
oderId: string;
username: string;
displayName: string;
avatarUrl: string;
avatarHash?: string;
avatarMime?: string;
avatarUpdatedAt?: number;
}): User {
return {
...existingUser,
id: incomingUser.id,
oderId: incomingUser.oderId,
username: incomingUser.username || existingUser?.username || 'user',
displayName: incomingUser.displayName || existingUser?.displayName || 'User',
status: existingUser?.status ?? 'offline',
role: existingUser?.role ?? 'member',
joinedAt: existingUser?.joinedAt ?? Date.now(),
...mergeAvatarFields(existingUser, incomingUser, true)
};
}
function buildPresenceRemovalChanges(
user: User,
update: { serverId?: string; serverIds?: readonly string[] }
@@ -173,6 +230,18 @@ export const usersReducer = createReducer(
state
);
}),
on(UsersActions.updateCurrentUserAvatar, (state, { avatar }) => {
if (!state.currentUserId)
return state;
return usersAdapter.updateOne(
{
id: state.currentUserId,
changes: mergeAvatarFields(state.entities[state.currentUserId], avatar, true)
},
state
);
}),
on(UsersActions.loadRoomUsers, (state) => ({
...state,
loading: true,
@@ -256,6 +325,9 @@ export const usersReducer = createReducer(
state
)
),
on(UsersActions.upsertRemoteUserAvatar, (state, { user }) =>
usersAdapter.upsertOne(buildAvatarUser(state.entities[user.id], user), state)
),
on(UsersActions.updateUserRole, (state, { userId, role }) =>
usersAdapter.updateOne(
{