feat: Add profile images
This commit is contained in:
@@ -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';
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
85
toju-app/src/app/store/users/user-avatar.effects.spec.ts
Normal file
85
toju-app/src/app/store/users/user-avatar.effects.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
438
toju-app/src/app/store/users/user-avatar.effects.ts
Normal file
438
toju-app/src/app/store/users/user-avatar.effects.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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'] });
|
||||
|
||||
@@ -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 } }>()
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user