Compare commits
1 Commits
v1.0.124
...
180333dc35
| Author | SHA1 | Date | |
|---|---|---|---|
| 180333dc35 |
@@ -1,4 +1,7 @@
|
|||||||
import { mkdtemp, rm } from 'node:fs/promises';
|
import {
|
||||||
|
mkdtemp,
|
||||||
|
rm
|
||||||
|
} from 'node:fs/promises';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import {
|
import {
|
||||||
@@ -6,7 +9,10 @@ import {
|
|||||||
type BrowserContext,
|
type BrowserContext,
|
||||||
type Page
|
type Page
|
||||||
} from '@playwright/test';
|
} from '@playwright/test';
|
||||||
import { test, expect } from '../../fixtures/multi-client';
|
import {
|
||||||
|
test,
|
||||||
|
expect
|
||||||
|
} from '../../fixtures/multi-client';
|
||||||
import { installTestServerEndpoint } from '../../helpers/seed-test-endpoint';
|
import { installTestServerEndpoint } from '../../helpers/seed-test-endpoint';
|
||||||
import { installWebRTCTracking } from '../../helpers/webrtc-helpers';
|
import { installWebRTCTracking } from '../../helpers/webrtc-helpers';
|
||||||
import { LoginPage } from '../../pages/login.page';
|
import { LoginPage } from '../../pages/login.page';
|
||||||
@@ -41,33 +47,16 @@ interface ProfileMetadata {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const STATIC_GIF_BASE64 = 'R0lGODlhAQABAPAAAP///wAAACH5BAAAAAAALAAAAAABAAEAAAICRAEAOw==';
|
const STATIC_GIF_BASE64 = 'R0lGODlhAQABAPAAAP///wAAACH5BAAAAAAALAAAAAABAAEAAAICRAEAOw==';
|
||||||
const GIF_FRAME_MARKER = Buffer.from([
|
const GIF_FRAME_MARKER = Buffer.from([0x21, 0xF9, 0x04]);
|
||||||
0x21,
|
|
||||||
0xF9,
|
|
||||||
0x04
|
|
||||||
]);
|
|
||||||
const NETSCAPE_LOOP_EXTENSION = Buffer.from([
|
const NETSCAPE_LOOP_EXTENSION = Buffer.from([
|
||||||
0x21,
|
0x21, 0xFF, 0x0B,
|
||||||
0xFF,
|
0x4E, 0x45, 0x54, 0x53, 0x43, 0x41, 0x50, 0x45, 0x32, 0x2E, 0x30,
|
||||||
0x0B,
|
0x03, 0x01, 0x00, 0x00, 0x00
|
||||||
0x4E,
|
|
||||||
0x45,
|
|
||||||
0x54,
|
|
||||||
0x53,
|
|
||||||
0x43,
|
|
||||||
0x41,
|
|
||||||
0x50,
|
|
||||||
0x45,
|
|
||||||
0x32,
|
|
||||||
0x2E,
|
|
||||||
0x30,
|
|
||||||
0x03,
|
|
||||||
0x01,
|
|
||||||
0x00,
|
|
||||||
0x00,
|
|
||||||
0x00
|
|
||||||
]);
|
]);
|
||||||
const CLIENT_LAUNCH_ARGS = ['--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream'];
|
const CLIENT_LAUNCH_ARGS = [
|
||||||
|
'--use-fake-device-for-media-stream',
|
||||||
|
'--use-fake-ui-for-media-stream'
|
||||||
|
];
|
||||||
const VOICE_CHANNEL = 'General';
|
const VOICE_CHANNEL = 'General';
|
||||||
|
|
||||||
test.describe('Profile avatar sync', () => {
|
test.describe('Profile avatar sync', () => {
|
||||||
@@ -567,8 +556,7 @@ function getUserRow(page: Page, displayName: string) {
|
|||||||
|
|
||||||
return usersSidePanel.locator('[role="button"]').filter({
|
return usersSidePanel.locator('[role="button"]').filter({
|
||||||
has: page.getByText(displayName, { exact: true })
|
has: page.getByText(displayName, { exact: true })
|
||||||
})
|
}).first();
|
||||||
.first();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function expectUserRowVisible(page: Page, displayName: string): Promise<void> {
|
async function expectUserRowVisible(page: Page, displayName: string): Promise<void> {
|
||||||
@@ -682,11 +670,7 @@ function buildAnimatedGifUpload(label: string): AvatarUploadPayload {
|
|||||||
const frame = baseGif.subarray(frameStart, baseGif.length - 1);
|
const frame = baseGif.subarray(frameStart, baseGif.length - 1);
|
||||||
const commentData = Buffer.from(label, 'ascii');
|
const commentData = Buffer.from(label, 'ascii');
|
||||||
const commentExtension = Buffer.concat([
|
const commentExtension = Buffer.concat([
|
||||||
Buffer.from([
|
Buffer.from([0x21, 0xFE, commentData.length]),
|
||||||
0x21,
|
|
||||||
0xFE,
|
|
||||||
commentData.length
|
|
||||||
]),
|
|
||||||
commentData,
|
commentData,
|
||||||
Buffer.from([0x00])
|
Buffer.from([0x00])
|
||||||
]);
|
]);
|
||||||
@@ -709,6 +693,5 @@ function buildAnimatedGifUpload(label: string): AvatarUploadPayload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function uniqueName(prefix: string): string {
|
function uniqueName(prefix: string): string {
|
||||||
return `${prefix}-${Date.now()}-${Math.random().toString(36)
|
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
.slice(2, 8)}`;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -346,6 +346,7 @@ function normalizeRoomMember(rawMember: Record<string, unknown>, now: number): R
|
|||||||
const avatarHash = trimmedString(rawMember, 'avatarHash');
|
const avatarHash = trimmedString(rawMember, 'avatarHash');
|
||||||
const avatarMime = trimmedString(rawMember, 'avatarMime');
|
const avatarMime = trimmedString(rawMember, 'avatarMime');
|
||||||
const avatarUpdatedAt = isFiniteNumber(rawMember['avatarUpdatedAt']) ? rawMember['avatarUpdatedAt'] : undefined;
|
const avatarUpdatedAt = isFiniteNumber(rawMember['avatarUpdatedAt']) ? rawMember['avatarUpdatedAt'] : undefined;
|
||||||
|
|
||||||
const member: RoomMemberRecord = {
|
const member: RoomMemberRecord = {
|
||||||
id: normalizedId || normalizedKey,
|
id: normalizedId || normalizedKey,
|
||||||
oderId: normalizedOderId || undefined,
|
oderId: normalizedOderId || undefined,
|
||||||
|
|||||||
@@ -208,36 +208,6 @@ describe('server websocket handler - profile metadata in presence messages', ()
|
|||||||
connectedUsers.clear();
|
connectedUsers.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('broadcasts updated profile metadata when an identified user changes it', async () => {
|
|
||||||
const alice = createConnectedUser('conn-1', 'user-1', {
|
|
||||||
displayName: 'Alice',
|
|
||||||
viewedServerId: 'server-1'
|
|
||||||
});
|
|
||||||
const bob = createConnectedUser('conn-2', 'user-2', {
|
|
||||||
viewedServerId: 'server-1'
|
|
||||||
});
|
|
||||||
|
|
||||||
alice.serverIds.add('server-1');
|
|
||||||
bob.serverIds.add('server-1');
|
|
||||||
getSentMessagesStore(bob).sentMessages.length = 0;
|
|
||||||
|
|
||||||
await handleWebSocketMessage('conn-1', {
|
|
||||||
type: 'identify',
|
|
||||||
oderId: 'user-1',
|
|
||||||
displayName: 'Alice Updated',
|
|
||||||
description: 'Updated bio',
|
|
||||||
profileUpdatedAt: 789
|
|
||||||
});
|
|
||||||
|
|
||||||
const messages = getSentMessagesStore(bob).sentMessages.map((messageText: string) => JSON.parse(messageText));
|
|
||||||
const joinMsg = messages.find((message: { type: string }) => message.type === 'user_joined');
|
|
||||||
|
|
||||||
expect(joinMsg?.displayName).toBe('Alice Updated');
|
|
||||||
expect(joinMsg?.description).toBe('Updated bio');
|
|
||||||
expect(joinMsg?.profileUpdatedAt).toBe(789);
|
|
||||||
expect(joinMsg?.serverId).toBe('server-1');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('includes description and profileUpdatedAt in server_users responses', async () => {
|
it('includes description and profileUpdatedAt in server_users responses', async () => {
|
||||||
const alice = createConnectedUser('conn-1', 'user-1');
|
const alice = createConnectedUser('conn-1', 'user-1');
|
||||||
const bob = createConnectedUser('conn-2', 'user-2');
|
const bob = createConnectedUser('conn-2', 'user-2');
|
||||||
|
|||||||
@@ -67,9 +67,6 @@ function sendServerUsers(user: ConnectedUser, serverId: string): void {
|
|||||||
function handleIdentify(user: ConnectedUser, message: WsMessage, connectionId: string): void {
|
function handleIdentify(user: ConnectedUser, message: WsMessage, connectionId: string): void {
|
||||||
const newOderId = readMessageId(message['oderId']) ?? connectionId;
|
const newOderId = readMessageId(message['oderId']) ?? connectionId;
|
||||||
const newScope = typeof message['connectionScope'] === 'string' ? message['connectionScope'] : undefined;
|
const newScope = typeof message['connectionScope'] === 'string' ? message['connectionScope'] : undefined;
|
||||||
const previousDisplayName = normalizeDisplayName(user.displayName);
|
|
||||||
const previousDescription = user.description;
|
|
||||||
const previousProfileUpdatedAt = user.profileUpdatedAt;
|
|
||||||
|
|
||||||
// Close stale connections from the same identity AND the same connection
|
// Close stale connections from the same identity AND the same connection
|
||||||
// scope so offer routing always targets the freshest socket (e.g. after
|
// scope so offer routing always targets the freshest socket (e.g. after
|
||||||
@@ -92,38 +89,15 @@ function handleIdentify(user: ConnectedUser, message: WsMessage, connectionId: s
|
|||||||
|
|
||||||
user.oderId = newOderId;
|
user.oderId = newOderId;
|
||||||
user.displayName = normalizeDisplayName(message['displayName'], normalizeDisplayName(user.displayName));
|
user.displayName = normalizeDisplayName(message['displayName'], normalizeDisplayName(user.displayName));
|
||||||
|
|
||||||
if (Object.prototype.hasOwnProperty.call(message, 'description')) {
|
if (Object.prototype.hasOwnProperty.call(message, 'description')) {
|
||||||
user.description = normalizeDescription(message['description']);
|
user.description = normalizeDescription(message['description']);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Object.prototype.hasOwnProperty.call(message, 'profileUpdatedAt')) {
|
if (Object.prototype.hasOwnProperty.call(message, 'profileUpdatedAt')) {
|
||||||
user.profileUpdatedAt = normalizeProfileUpdatedAt(message['profileUpdatedAt']);
|
user.profileUpdatedAt = normalizeProfileUpdatedAt(message['profileUpdatedAt']);
|
||||||
}
|
}
|
||||||
|
|
||||||
user.connectionScope = newScope;
|
user.connectionScope = newScope;
|
||||||
connectedUsers.set(connectionId, user);
|
connectedUsers.set(connectionId, user);
|
||||||
console.log(`User identified: ${user.displayName} (${user.oderId})`);
|
console.log(`User identified: ${user.displayName} (${user.oderId})`);
|
||||||
|
|
||||||
if (
|
|
||||||
user.displayName === previousDisplayName
|
|
||||||
&& user.description === previousDescription
|
|
||||||
&& user.profileUpdatedAt === previousProfileUpdatedAt
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const serverId of user.serverIds) {
|
|
||||||
broadcastToServer(serverId, {
|
|
||||||
type: 'user_joined',
|
|
||||||
oderId: user.oderId,
|
|
||||||
displayName: normalizeDisplayName(user.displayName),
|
|
||||||
description: user.description,
|
|
||||||
profileUpdatedAt: user.profileUpdatedAt,
|
|
||||||
status: user.status ?? 'online',
|
|
||||||
serverId
|
|
||||||
}, user.oderId);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleJoinServer(user: ConnectedUser, message: WsMessage, connectionId: string): Promise<void> {
|
async function handleJoinServer(user: ConnectedUser, message: WsMessage, connectionId: string): Promise<void> {
|
||||||
|
|||||||
@@ -11,11 +11,7 @@ import { UserAvatarComponent } from '../../../../shared';
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-user-bar',
|
selector: 'app-user-bar',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [
|
imports: [CommonModule, NgIcon, UserAvatarComponent],
|
||||||
CommonModule,
|
|
||||||
NgIcon,
|
|
||||||
UserAvatarComponent
|
|
||||||
],
|
|
||||||
viewProviders: [
|
viewProviders: [
|
||||||
provideIcons({
|
provideIcons({
|
||||||
lucideLogIn,
|
lucideLogIn,
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<div
|
<div
|
||||||
class="fixed inset-0 z-[112] bg-black/70 backdrop-blur-sm"
|
class="fixed inset-0 z-[112] bg-black/70 backdrop-blur-sm"
|
||||||
(click)="cancelled.emit(undefined)"
|
(click)="cancelled.emit()"
|
||||||
(keydown.enter)="cancelled.emit(undefined)"
|
(keydown.enter)="cancelled.emit()"
|
||||||
(keydown.space)="cancelled.emit(undefined)"
|
(keydown.space)="cancelled.emit()"
|
||||||
role="button"
|
role="button"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
aria-label="Close profile image editor"
|
aria-label="Close profile image editor"
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
<div class="fixed inset-0 z-[113] flex items-center justify-center p-4 pointer-events-none">
|
<div class="fixed inset-0 z-[113] flex items-center justify-center p-4 pointer-events-none">
|
||||||
<div
|
<div
|
||||||
class="pointer-events-auto flex max-h-[calc(100vh-2rem)] w-full max-w-4xl flex-col overflow-hidden rounded-2xl border border-border bg-card shadow-2xl"
|
class="pointer-events-auto flex max-h-[calc(100vh-2rem)] w-full max-w-4xl flex-col overflow-hidden rounded-2xl border border-border bg-card shadow-2xl"
|
||||||
|
(click)="$event.stopPropagation()"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
@@ -134,7 +135,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded-lg bg-secondary px-4 py-2 text-sm text-foreground transition-colors hover:bg-secondary/80"
|
class="rounded-lg bg-secondary px-4 py-2 text-sm text-foreground transition-colors hover:bg-secondary/80"
|
||||||
(click)="cancelled.emit(undefined)"
|
(click)="cancelled.emit()"
|
||||||
[disabled]="processing()"
|
[disabled]="processing()"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import {
|
|||||||
export class ProfileAvatarEditorComponent {
|
export class ProfileAvatarEditorComponent {
|
||||||
readonly source = input.required<EditableProfileAvatarSource>();
|
readonly source = input.required<EditableProfileAvatarSource>();
|
||||||
|
|
||||||
readonly cancelled = output<undefined>();
|
readonly cancelled = output<void>();
|
||||||
readonly confirmed = output<ProcessedProfileAvatar>();
|
readonly confirmed = output<ProcessedProfileAvatar>();
|
||||||
|
|
||||||
readonly frameSize = PROFILE_AVATAR_EDITOR_FRAME_SIZE;
|
readonly frameSize = PROFILE_AVATAR_EDITOR_FRAME_SIZE;
|
||||||
@@ -53,7 +53,7 @@ export class ProfileAvatarEditorComponent {
|
|||||||
@HostListener('document:keydown.escape')
|
@HostListener('document:keydown.escape')
|
||||||
onEscape(): void {
|
onEscape(): void {
|
||||||
if (!this.processing()) {
|
if (!this.processing()) {
|
||||||
this.cancelled.emit(undefined);
|
this.cancelled.emit();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
import { Overlay, OverlayRef } from '@angular/cdk/overlay';
|
import {
|
||||||
|
Overlay,
|
||||||
|
OverlayRef
|
||||||
|
} from '@angular/cdk/overlay';
|
||||||
import { ComponentPortal } from '@angular/cdk/portal';
|
import { ComponentPortal } from '@angular/cdk/portal';
|
||||||
import { EditableProfileAvatarSource, ProcessedProfileAvatar } from '../../domain/profile-avatar.models';
|
import {
|
||||||
|
EditableProfileAvatarSource,
|
||||||
|
ProcessedProfileAvatar
|
||||||
|
} from '../../domain/profile-avatar.models';
|
||||||
import { ProfileAvatarEditorComponent } from './profile-avatar-editor.component';
|
import { ProfileAvatarEditorComponent } from './profile-avatar-editor.component';
|
||||||
|
|
||||||
export const PROFILE_AVATAR_EDITOR_OVERLAY_CLASS = 'profile-avatar-editor-overlay-pane';
|
export const PROFILE_AVATAR_EDITOR_OVERLAY_CLASS = 'profile-avatar-editor-overlay-pane';
|
||||||
@@ -19,9 +25,7 @@ export class ProfileAvatarEditorService {
|
|||||||
const overlayRef = this.overlay.create({
|
const overlayRef = this.overlay.create({
|
||||||
disposeOnNavigation: true,
|
disposeOnNavigation: true,
|
||||||
panelClass: PROFILE_AVATAR_EDITOR_OVERLAY_CLASS,
|
panelClass: PROFILE_AVATAR_EDITOR_OVERLAY_CLASS,
|
||||||
positionStrategy: this.overlay.position().global()
|
positionStrategy: this.overlay.position().global().centerHorizontally().centerVertically(),
|
||||||
.centerHorizontally()
|
|
||||||
.centerVertically(),
|
|
||||||
scrollStrategy: this.overlay.scrollStrategies.block()
|
scrollStrategy: this.overlay.scrollStrategies.block()
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -51,6 +55,7 @@ export class ProfileAvatarEditorService {
|
|||||||
overlayRef.dispose();
|
overlayRef.dispose();
|
||||||
resolve(result);
|
resolve(result);
|
||||||
};
|
};
|
||||||
|
|
||||||
const cancelSub = componentRef.instance.cancelled.subscribe(() => finish(null));
|
const cancelSub = componentRef.instance.cancelled.subscribe(() => finish(null));
|
||||||
const confirmSub = componentRef.instance.confirmed.subscribe((avatar) => finish(avatar));
|
const confirmSub = componentRef.instance.confirmed.subscribe((avatar) => finish(avatar));
|
||||||
const detachSub = overlayRef.detachments().subscribe(() => finish(null));
|
const detachSub = overlayRef.detachments().subscribe(() => finish(null));
|
||||||
|
|||||||
@@ -2,6 +2,6 @@ export * from './domain/profile-avatar.models';
|
|||||||
export { ProfileAvatarFacade } from './application/services/profile-avatar.facade';
|
export { ProfileAvatarFacade } from './application/services/profile-avatar.facade';
|
||||||
export { ProfileAvatarEditorComponent } from './feature/profile-avatar-editor/profile-avatar-editor.component';
|
export { ProfileAvatarEditorComponent } from './feature/profile-avatar-editor/profile-avatar-editor.component';
|
||||||
export {
|
export {
|
||||||
PROFILE_AVATAR_EDITOR_OVERLAY_CLASS,
|
PROFILE_AVATAR_EDITOR_OVERLAY_CLASS,
|
||||||
ProfileAvatarEditorService
|
ProfileAvatarEditorService
|
||||||
} from './feature/profile-avatar-editor/profile-avatar-editor.service';
|
} from './feature/profile-avatar-editor/profile-avatar-editor.service';
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
/* eslint-disable @stylistic/js/array-element-newline */
|
import {
|
||||||
import { isAnimatedGif, isAnimatedWebp } from './profile-avatar-image.service';
|
isAnimatedGif,
|
||||||
|
isAnimatedWebp
|
||||||
|
} from './profile-avatar-image.service';
|
||||||
|
|
||||||
describe('profile-avatar image animation detection', () => {
|
describe('profile-avatar image animation detection', () => {
|
||||||
it('detects animated gifs with multiple frames', () => {
|
it('detects animated gifs with multiple frames', () => {
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
|
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
|
||||||
import type { User } from '../../../../shared-kernel';
|
import { User } from '../../../../shared-kernel';
|
||||||
import { resolveProfileAvatarStorageFileName, type ProcessedProfileAvatar } from '../../domain/profile-avatar.models';
|
import {
|
||||||
|
ProcessedProfileAvatar,
|
||||||
|
resolveProfileAvatarStorageFileName
|
||||||
|
} from '../../domain/profile-avatar.models';
|
||||||
|
|
||||||
const LEGACY_PROFILE_FILE_NAMES = [
|
const LEGACY_PROFILE_FILE_NAMES = [
|
||||||
'profile.webp',
|
'profile.webp',
|
||||||
|
|||||||
@@ -174,9 +174,7 @@ export class SignalingTransportHandler<TMessage> {
|
|||||||
const normalizedDescription = typeof profile?.description === 'string'
|
const normalizedDescription = typeof profile?.description === 'string'
|
||||||
? (profile.description.trim() || undefined)
|
? (profile.description.trim() || undefined)
|
||||||
: undefined;
|
: undefined;
|
||||||
const normalizedProfileUpdatedAt = typeof profile?.profileUpdatedAt === 'number'
|
const normalizedProfileUpdatedAt = typeof profile?.profileUpdatedAt === 'number' && Number.isFinite(profile.profileUpdatedAt) && profile.profileUpdatedAt > 0
|
||||||
&& Number.isFinite(profile.profileUpdatedAt)
|
|
||||||
&& profile.profileUpdatedAt > 0
|
|
||||||
? profile.profileUpdatedAt
|
? profile.profileUpdatedAt
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,7 @@
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
class="w-full rounded-md border border-border bg-background/70 px-2 py-1.5 text-base font-semibold text-foreground outline-none focus:border-primary/70"
|
class="w-full rounded-md border border-border bg-background/70 px-2 py-1.5 text-base font-semibold text-foreground outline-none focus:border-primary/70"
|
||||||
|
autofocus
|
||||||
[value]="displayNameDraft()"
|
[value]="displayNameDraft()"
|
||||||
(input)="onDisplayNameInput($event)"
|
(input)="onDisplayNameInput($event)"
|
||||||
(blur)="finishEdit('displayName')"
|
(blur)="finishEdit('displayName')"
|
||||||
@@ -67,7 +67,7 @@
|
|||||||
<textarea
|
<textarea
|
||||||
rows="3"
|
rows="3"
|
||||||
class="w-full resize-none rounded-md border border-border bg-background/70 px-2 py-2 text-sm leading-5 text-foreground outline-none focus:border-primary/70"
|
class="w-full resize-none rounded-md border border-border bg-background/70 px-2 py-2 text-sm leading-5 text-foreground outline-none focus:border-primary/70"
|
||||||
|
autofocus
|
||||||
[value]="descriptionDraft()"
|
[value]="descriptionDraft()"
|
||||||
placeholder="Add a description"
|
placeholder="Add a description"
|
||||||
(input)="onDescriptionInput($event)"
|
(input)="onDescriptionInput($event)"
|
||||||
|
|||||||
103
toju-app/src/app/store/rooms/room-members-sync.effects.spec.ts
Normal file
103
toju-app/src/app/store/rooms/room-members-sync.effects.spec.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { RoomMember, User } from '../../shared-kernel';
|
||||||
|
import { UsersActions } from '../users/users.actions';
|
||||||
|
import { buildRosterAvatarBackfillActions } from './room-members-sync.effects';
|
||||||
|
|
||||||
|
function createMember(overrides: Partial<RoomMember> = {}): RoomMember {
|
||||||
|
return {
|
||||||
|
id: 'user-1',
|
||||||
|
oderId: 'oder-1',
|
||||||
|
username: 'alice',
|
||||||
|
displayName: 'Alice',
|
||||||
|
role: 'member',
|
||||||
|
joinedAt: Date.now(),
|
||||||
|
lastSeenAt: Date.now(),
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCurrentUser(overrides: Partial<User> = {}): User {
|
||||||
|
return {
|
||||||
|
id: 'current-user',
|
||||||
|
oderId: 'current-oder',
|
||||||
|
username: 'current',
|
||||||
|
displayName: 'Current User',
|
||||||
|
status: 'online',
|
||||||
|
role: 'member',
|
||||||
|
joinedAt: Date.now(),
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('room member roster avatar backfill', () => {
|
||||||
|
it('creates avatar upsert actions from roster members with avatar data', () => {
|
||||||
|
const actions = buildRosterAvatarBackfillActions([
|
||||||
|
createMember({
|
||||||
|
avatarUrl: 'data:image/gif;base64,abc',
|
||||||
|
avatarHash: 'hash-1',
|
||||||
|
avatarMime: 'image/gif',
|
||||||
|
avatarUpdatedAt: 123
|
||||||
|
})
|
||||||
|
], null);
|
||||||
|
|
||||||
|
expect(actions).toEqual([
|
||||||
|
UsersActions.upsertRemoteUserAvatar({
|
||||||
|
user: {
|
||||||
|
id: 'user-1',
|
||||||
|
oderId: 'oder-1',
|
||||||
|
username: 'alice',
|
||||||
|
displayName: 'Alice',
|
||||||
|
description: undefined,
|
||||||
|
profileUpdatedAt: undefined,
|
||||||
|
avatarUrl: 'data:image/gif;base64,abc',
|
||||||
|
avatarHash: 'hash-1',
|
||||||
|
avatarMime: 'image/gif',
|
||||||
|
avatarUpdatedAt: 123
|
||||||
|
}
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates avatar upsert actions from roster members with only profile metadata', () => {
|
||||||
|
const actions = buildRosterAvatarBackfillActions([
|
||||||
|
createMember({
|
||||||
|
description: 'Synced from roster',
|
||||||
|
profileUpdatedAt: 456
|
||||||
|
})
|
||||||
|
], null);
|
||||||
|
|
||||||
|
expect(actions).toEqual([
|
||||||
|
UsersActions.upsertRemoteUserAvatar({
|
||||||
|
user: {
|
||||||
|
id: 'user-1',
|
||||||
|
oderId: 'oder-1',
|
||||||
|
username: 'alice',
|
||||||
|
displayName: 'Alice',
|
||||||
|
description: 'Synced from roster',
|
||||||
|
profileUpdatedAt: 456,
|
||||||
|
avatarUrl: undefined,
|
||||||
|
avatarHash: undefined,
|
||||||
|
avatarMime: undefined,
|
||||||
|
avatarUpdatedAt: undefined
|
||||||
|
}
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips the current user and members without syncable data', () => {
|
||||||
|
const currentUser = createCurrentUser();
|
||||||
|
const actions = buildRosterAvatarBackfillActions([
|
||||||
|
createMember({
|
||||||
|
id: currentUser.id,
|
||||||
|
oderId: currentUser.oderId
|
||||||
|
}),
|
||||||
|
createMember({
|
||||||
|
id: 'user-2',
|
||||||
|
oderId: 'oder-2',
|
||||||
|
username: 'bob',
|
||||||
|
displayName: 'Bob'
|
||||||
|
})
|
||||||
|
], currentUser);
|
||||||
|
|
||||||
|
expect(actions).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,14 +5,15 @@ import {
|
|||||||
createEffect,
|
createEffect,
|
||||||
ofType
|
ofType
|
||||||
} from '@ngrx/effects';
|
} from '@ngrx/effects';
|
||||||
import { Store, type Action } from '@ngrx/store';
|
import { Action } from '@ngrx/store';
|
||||||
|
import { Store } from '@ngrx/store';
|
||||||
import { EMPTY } from 'rxjs';
|
import { EMPTY } from 'rxjs';
|
||||||
import {
|
import {
|
||||||
mergeMap,
|
mergeMap,
|
||||||
tap,
|
tap,
|
||||||
withLatestFrom
|
withLatestFrom
|
||||||
} from 'rxjs/operators';
|
} from 'rxjs/operators';
|
||||||
import type {
|
import {
|
||||||
ChatEvent,
|
ChatEvent,
|
||||||
Room,
|
Room,
|
||||||
RoomMember,
|
RoomMember,
|
||||||
@@ -36,6 +37,41 @@ import {
|
|||||||
upsertRoomMember
|
upsertRoomMember
|
||||||
} from './room-members.helpers';
|
} from './room-members.helpers';
|
||||||
|
|
||||||
|
export function buildRosterAvatarBackfillActions(
|
||||||
|
members: RoomMember[],
|
||||||
|
currentUser: Pick<User, 'id' | 'oderId'> | null | undefined
|
||||||
|
): ReturnType<typeof UsersActions.upsertRemoteUserAvatar>[] {
|
||||||
|
const currentUserId = currentUser?.oderId || currentUser?.id;
|
||||||
|
|
||||||
|
return members.flatMap((member) => {
|
||||||
|
const memberId = member.oderId || member.id;
|
||||||
|
const hasProfileData = !!member.avatarUrl
|
||||||
|
|| !!member.avatarHash
|
||||||
|
|| !!member.avatarUpdatedAt
|
||||||
|
|| !!member.profileUpdatedAt
|
||||||
|
|| typeof member.description === 'string';
|
||||||
|
|
||||||
|
if (!memberId || memberId === currentUserId || !hasProfileData) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [UsersActions.upsertRemoteUserAvatar({
|
||||||
|
user: {
|
||||||
|
id: member.id,
|
||||||
|
oderId: memberId,
|
||||||
|
username: member.username,
|
||||||
|
displayName: member.displayName,
|
||||||
|
description: member.description,
|
||||||
|
profileUpdatedAt: member.profileUpdatedAt,
|
||||||
|
avatarUrl: member.avatarUrl,
|
||||||
|
avatarHash: member.avatarHash,
|
||||||
|
avatarMime: member.avatarMime,
|
||||||
|
avatarUpdatedAt: member.avatarUpdatedAt
|
||||||
|
}
|
||||||
|
})];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class RoomMembersSyncEffects {
|
export class RoomMembersSyncEffects {
|
||||||
private readonly actions$ = inject(Actions);
|
private readonly actions$ = inject(Actions);
|
||||||
@@ -393,28 +429,10 @@ export class RoomMembersSyncEffects {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const actions = this.createRoomMemberUpdateActions(room, members);
|
return [
|
||||||
const currentUserId = currentUser?.oderId || currentUser?.id;
|
...this.createRoomMemberUpdateActions(room, members),
|
||||||
|
...buildRosterAvatarBackfillActions(members, currentUser)
|
||||||
for (const member of members) {
|
];
|
||||||
const memberId = member.oderId || member.id;
|
|
||||||
|
|
||||||
if (!member.avatarUrl || !memberId || memberId === currentUserId) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
actions.push(UsersActions.upsertRemoteUserAvatar({
|
|
||||||
user: {
|
|
||||||
id: member.id,
|
|
||||||
oderId: memberId,
|
|
||||||
username: member.username,
|
|
||||||
displayName: member.displayName,
|
|
||||||
avatarUrl: member.avatarUrl
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
return actions;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleMemberLeave(
|
private handleMemberLeave(
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Store } from '@ngrx/store';
|
import { Store } from '@ngrx/store';
|
||||||
import { firstValueFrom } from 'rxjs';
|
import { firstValueFrom } from 'rxjs';
|
||||||
import type { Room, User } from '../../shared-kernel';
|
import { Room, User } from '../../shared-kernel';
|
||||||
import {
|
import {
|
||||||
type RoomSignalSource,
|
type RoomSignalSource,
|
||||||
type ServerSourceSelector,
|
type ServerSourceSelector,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
createEffect,
|
createEffect,
|
||||||
ofType
|
ofType
|
||||||
} from '@ngrx/effects';
|
} from '@ngrx/effects';
|
||||||
import { Store, type Action } from '@ngrx/store';
|
import { Action, Store } from '@ngrx/store';
|
||||||
import {
|
import {
|
||||||
of,
|
of,
|
||||||
from,
|
from,
|
||||||
@@ -30,7 +30,7 @@ import {
|
|||||||
import { RealtimeSessionFacade } from '../../core/realtime';
|
import { RealtimeSessionFacade } from '../../core/realtime';
|
||||||
import { DatabaseService } from '../../infrastructure/persistence';
|
import { DatabaseService } from '../../infrastructure/persistence';
|
||||||
import { resolveRoomPermission } from '../../domains/access-control';
|
import { resolveRoomPermission } from '../../domains/access-control';
|
||||||
import type {
|
import {
|
||||||
ChatEvent,
|
ChatEvent,
|
||||||
Room,
|
Room,
|
||||||
RoomSettings,
|
RoomSettings,
|
||||||
@@ -50,9 +50,9 @@ import {
|
|||||||
resolveRoom,
|
resolveRoom,
|
||||||
sanitizeRoomSnapshot,
|
sanitizeRoomSnapshot,
|
||||||
normalizeIncomingBans,
|
normalizeIncomingBans,
|
||||||
getPersistedCurrentUserId
|
getPersistedCurrentUserId,
|
||||||
|
RoomPresenceSignalingMessage
|
||||||
} from './rooms.helpers';
|
} from './rooms.helpers';
|
||||||
import type { RoomPresenceSignalingMessage } from './rooms.helpers';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* NgRx effects for real-time state synchronisation: signaling presence
|
* NgRx effects for real-time state synchronisation: signaling presence
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { User } from '../../shared-kernel';
|
import { User } from '../../shared-kernel';
|
||||||
import { shouldApplyAvatarTransfer, shouldRequestAvatarData } from './user-avatar.effects';
|
import {
|
||||||
|
shouldApplyAvatarTransfer,
|
||||||
|
shouldRequestAvatarData
|
||||||
|
} from './user-avatar.effects';
|
||||||
|
|
||||||
function createUser(overrides: Partial<User> = {}): User {
|
function createUser(overrides: Partial<User> = {}): User {
|
||||||
return {
|
return {
|
||||||
@@ -55,6 +58,17 @@ describe('user avatar sync helpers', () => {
|
|||||||
})).toBe(false);
|
})).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('requests profile data when the remote profile version is newer', () => {
|
||||||
|
const existingUser = createUser({
|
||||||
|
profileUpdatedAt: 100
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(shouldRequestAvatarData(existingUser, {
|
||||||
|
avatarUpdatedAt: 0,
|
||||||
|
profileUpdatedAt: 200
|
||||||
|
})).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it('applies equal-version transfers when the local payload is missing', () => {
|
it('applies equal-version transfers when the local payload is missing', () => {
|
||||||
const existingUser = createUser({
|
const existingUser = createUser({
|
||||||
avatarHash: 'hash-1',
|
avatarHash: 'hash-1',
|
||||||
@@ -67,6 +81,19 @@ describe('user avatar sync helpers', () => {
|
|||||||
})).toBe(true);
|
})).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('applies transfers when the remote profile version is newer', () => {
|
||||||
|
const existingUser = createUser({
|
||||||
|
displayName: 'Alice',
|
||||||
|
profileUpdatedAt: 100
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(shouldApplyAvatarTransfer(existingUser, {
|
||||||
|
hash: undefined,
|
||||||
|
profileUpdatedAt: 250,
|
||||||
|
updatedAt: 0
|
||||||
|
})).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects older avatar transfers', () => {
|
it('rejects older avatar transfers', () => {
|
||||||
const existingUser = createUser({
|
const existingUser = createUser({
|
||||||
avatarUrl: 'data:image/gif;base64,current',
|
avatarUrl: 'data:image/gif;base64,current',
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
|
||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
import {
|
import {
|
||||||
Actions,
|
Actions,
|
||||||
createEffect,
|
createEffect,
|
||||||
ofType
|
ofType
|
||||||
} from '@ngrx/effects';
|
} from '@ngrx/effects';
|
||||||
import { Store, type Action } from '@ngrx/store';
|
import { Action, Store } from '@ngrx/store';
|
||||||
import {
|
import {
|
||||||
EMPTY,
|
EMPTY,
|
||||||
from,
|
from,
|
||||||
@@ -18,23 +17,29 @@ import {
|
|||||||
} from 'rxjs/operators';
|
} from 'rxjs/operators';
|
||||||
import { ProfileAvatarFacade } from '../../domains/profile-avatar';
|
import { ProfileAvatarFacade } from '../../domains/profile-avatar';
|
||||||
import {
|
import {
|
||||||
|
ChatEvent,
|
||||||
P2P_BASE64_CHUNK_SIZE_BYTES,
|
P2P_BASE64_CHUNK_SIZE_BYTES,
|
||||||
|
User,
|
||||||
decodeBase64,
|
decodeBase64,
|
||||||
iterateBlobChunks
|
iterateBlobChunks
|
||||||
} from '../../shared-kernel';
|
} from '../../shared-kernel';
|
||||||
import type { ChatEvent, User } from '../../shared-kernel';
|
|
||||||
import { RealtimeSessionFacade } from '../../core/realtime';
|
import { RealtimeSessionFacade } from '../../core/realtime';
|
||||||
import { DatabaseService } from '../../infrastructure/persistence';
|
import { DatabaseService } from '../../infrastructure/persistence';
|
||||||
import { UsersActions } from './users.actions';
|
import { UsersActions } from './users.actions';
|
||||||
import { selectAllUsers, selectCurrentUser } from './users.selectors';
|
import {
|
||||||
|
selectAllUsers,
|
||||||
|
selectCurrentUser
|
||||||
|
} from './users.selectors';
|
||||||
import { selectCurrentRoom, selectSavedRooms } from '../rooms/rooms.selectors';
|
import { selectCurrentRoom, selectSavedRooms } from '../rooms/rooms.selectors';
|
||||||
import { RoomsActions } from '../rooms/rooms.actions';
|
import { RoomsActions } from '../rooms/rooms.actions';
|
||||||
import { findRoomMember } from '../rooms/room-members.helpers';
|
import { findRoomMember } from '../rooms/room-members.helpers';
|
||||||
|
|
||||||
interface PendingAvatarTransfer {
|
interface PendingAvatarTransfer {
|
||||||
displayName: string;
|
displayName: string;
|
||||||
|
description?: string;
|
||||||
mime?: string;
|
mime?: string;
|
||||||
oderId: string;
|
oderId: string;
|
||||||
|
profileUpdatedAt?: number;
|
||||||
total: number;
|
total: number;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
username: string;
|
username: string;
|
||||||
@@ -42,18 +47,7 @@ interface PendingAvatarTransfer {
|
|||||||
hash?: string;
|
hash?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type AvatarVersionState = Pick<User, 'avatarUrl' | 'avatarHash' | 'avatarUpdatedAt'> | undefined;
|
type AvatarVersionState = Pick<User, 'avatarUrl' | 'avatarHash' | 'avatarUpdatedAt' | 'displayName' | 'profileUpdatedAt'> | undefined;
|
||||||
type RoomProfileState = Pick<User,
|
|
||||||
| 'id'
|
|
||||||
| 'oderId'
|
|
||||||
| 'displayName'
|
|
||||||
| 'description'
|
|
||||||
| 'profileUpdatedAt'
|
|
||||||
| 'avatarUrl'
|
|
||||||
| 'avatarHash'
|
|
||||||
| 'avatarMime'
|
|
||||||
| 'avatarUpdatedAt'
|
|
||||||
>;
|
|
||||||
|
|
||||||
function shouldAcceptAvatarPayload(
|
function shouldAcceptAvatarPayload(
|
||||||
existingUser: AvatarVersionState,
|
existingUser: AvatarVersionState,
|
||||||
@@ -77,22 +71,51 @@ function shouldAcceptAvatarPayload(
|
|||||||
return !!incomingHash && incomingHash !== existingUser.avatarHash;
|
return !!incomingHash && incomingHash !== existingUser.avatarHash;
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasSyncableUserData(user: Pick<User, 'avatarUpdatedAt' | 'profileUpdatedAt'> | null | undefined): boolean {
|
function shouldAcceptProfilePayload(
|
||||||
return (user?.avatarUpdatedAt ?? 0) > 0;
|
existingUser: AvatarVersionState,
|
||||||
|
incomingUpdatedAt: number
|
||||||
|
): boolean {
|
||||||
|
const localUpdatedAt = existingUser?.profileUpdatedAt ?? 0;
|
||||||
|
|
||||||
|
if (incomingUpdatedAt > localUpdatedAt) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (incomingUpdatedAt < localUpdatedAt || incomingUpdatedAt === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !existingUser?.displayName?.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldAcceptUserPayload(
|
||||||
|
existingUser: AvatarVersionState,
|
||||||
|
incoming: Pick<ChatEvent, 'avatarHash' | 'avatarUpdatedAt' | 'profileUpdatedAt'>
|
||||||
|
): boolean {
|
||||||
|
return shouldAcceptAvatarPayload(existingUser, incoming.avatarUpdatedAt ?? 0, incoming.avatarHash)
|
||||||
|
|| shouldAcceptProfilePayload(existingUser, incoming.profileUpdatedAt ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasSyncableProfileData(user: Pick<User, 'avatarUpdatedAt' | 'profileUpdatedAt'> | null | undefined): boolean {
|
||||||
|
return (user?.avatarUpdatedAt ?? 0) > 0 || (user?.profileUpdatedAt ?? 0) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function shouldRequestAvatarData(
|
export function shouldRequestAvatarData(
|
||||||
existingUser: AvatarVersionState,
|
existingUser: AvatarVersionState,
|
||||||
incomingAvatar: Pick<ChatEvent, 'avatarHash' | 'avatarUpdatedAt' | 'profileUpdatedAt'>
|
incomingAvatar: Pick<ChatEvent, 'avatarHash' | 'avatarUpdatedAt' | 'profileUpdatedAt'>
|
||||||
): boolean {
|
): boolean {
|
||||||
return shouldAcceptAvatarPayload(existingUser, incomingAvatar.avatarUpdatedAt ?? 0, incomingAvatar.avatarHash);
|
return shouldAcceptUserPayload(existingUser, incomingAvatar);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function shouldApplyAvatarTransfer(
|
export function shouldApplyAvatarTransfer(
|
||||||
existingUser: AvatarVersionState,
|
existingUser: AvatarVersionState,
|
||||||
transfer: Pick<PendingAvatarTransfer, 'hash' | 'updatedAt'>
|
transfer: Pick<PendingAvatarTransfer, 'hash' | 'profileUpdatedAt' | 'updatedAt'>
|
||||||
): boolean {
|
): boolean {
|
||||||
return shouldAcceptAvatarPayload(existingUser, transfer.updatedAt, transfer.hash);
|
return shouldAcceptUserPayload(existingUser, {
|
||||||
|
avatarHash: transfer.hash,
|
||||||
|
avatarUpdatedAt: transfer.updatedAt,
|
||||||
|
profileUpdatedAt: transfer.profileUpdatedAt
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -174,15 +197,7 @@ export class UserAvatarEffects {
|
|||||||
]) => {
|
]) => {
|
||||||
const avatarOwner = action.type === UsersActions.upsertRemoteUserAvatar.type
|
const avatarOwner = action.type === UsersActions.upsertRemoteUserAvatar.type
|
||||||
? action.user
|
? action.user
|
||||||
: action.type === UsersActions.updateCurrentUserProfile.type
|
: currentUser;
|
||||||
? (currentUser ? {
|
|
||||||
...currentUser,
|
|
||||||
...action.profile
|
|
||||||
} : null)
|
|
||||||
: (currentUser ? {
|
|
||||||
...currentUser,
|
|
||||||
...action.avatar
|
|
||||||
} : null);
|
|
||||||
|
|
||||||
if (!avatarOwner) {
|
if (!avatarOwner) {
|
||||||
return EMPTY;
|
return EMPTY;
|
||||||
@@ -201,7 +216,7 @@ export class UserAvatarEffects {
|
|||||||
ofType(UsersActions.updateCurrentUserAvatar, UsersActions.updateCurrentUserProfile),
|
ofType(UsersActions.updateCurrentUserAvatar, UsersActions.updateCurrentUserProfile),
|
||||||
withLatestFrom(this.store.select(selectCurrentUser)),
|
withLatestFrom(this.store.select(selectCurrentUser)),
|
||||||
tap(([, currentUser]) => {
|
tap(([, currentUser]) => {
|
||||||
if (!currentUser || !hasSyncableUserData(currentUser)) {
|
if (!currentUser || !hasSyncableProfileData(currentUser)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,7 +231,7 @@ export class UserAvatarEffects {
|
|||||||
this.webrtc.onPeerConnected.pipe(
|
this.webrtc.onPeerConnected.pipe(
|
||||||
withLatestFrom(this.store.select(selectCurrentUser)),
|
withLatestFrom(this.store.select(selectCurrentUser)),
|
||||||
tap(([peerId, currentUser]) => {
|
tap(([peerId, currentUser]) => {
|
||||||
if (!currentUser || !hasSyncableUserData(currentUser)) {
|
if (!currentUser || !hasSyncableProfileData(currentUser)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,17 +269,22 @@ export class UserAvatarEffects {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
private buildAvatarSummary(user: Pick<User, 'oderId' | 'id' | 'avatarHash' | 'avatarUpdatedAt'>): ChatEvent {
|
private buildAvatarSummary(user: Pick<User, 'oderId' | 'id' | 'username' | 'displayName' | 'description' | 'profileUpdatedAt' | 'avatarHash' | 'avatarMime' | 'avatarUpdatedAt'>): ChatEvent {
|
||||||
return {
|
return {
|
||||||
type: 'user-avatar-summary',
|
type: 'user-avatar-summary',
|
||||||
oderId: user.oderId || user.id,
|
oderId: user.oderId || user.id,
|
||||||
|
username: user.username,
|
||||||
|
displayName: user.displayName,
|
||||||
|
description: user.description,
|
||||||
|
profileUpdatedAt: user.profileUpdatedAt,
|
||||||
avatarHash: user.avatarHash,
|
avatarHash: user.avatarHash,
|
||||||
|
avatarMime: user.avatarMime,
|
||||||
avatarUpdatedAt: user.avatarUpdatedAt || 0
|
avatarUpdatedAt: user.avatarUpdatedAt || 0
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleAvatarSummary(event: ChatEvent, allUsers: User[]) {
|
private handleAvatarSummary(event: ChatEvent, allUsers: User[]) {
|
||||||
if (!event.fromPeerId || !event.oderId || !event.avatarUpdatedAt) {
|
if (!event.fromPeerId || !event.oderId || (!event.avatarUpdatedAt && !event.profileUpdatedAt)) {
|
||||||
return EMPTY;
|
return EMPTY;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,7 +305,7 @@ export class UserAvatarEffects {
|
|||||||
private handleAvatarRequest(event: ChatEvent, currentUser: User | null) {
|
private handleAvatarRequest(event: ChatEvent, currentUser: User | null) {
|
||||||
const currentUserKey = currentUser?.oderId || currentUser?.id;
|
const currentUserKey = currentUser?.oderId || currentUser?.id;
|
||||||
|
|
||||||
if (!event.fromPeerId || !currentUser || !currentUserKey || event.oderId !== currentUserKey || !hasSyncableUserData(currentUser)) {
|
if (!event.fromPeerId || !currentUser || !currentUserKey || event.oderId !== currentUserKey || !hasSyncableProfileData(currentUser)) {
|
||||||
return EMPTY;
|
return EMPTY;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -300,9 +320,11 @@ export class UserAvatarEffects {
|
|||||||
if (event.total === 0) {
|
if (event.total === 0) {
|
||||||
return from(this.buildRemoteAvatarAction({
|
return from(this.buildRemoteAvatarAction({
|
||||||
chunks: [],
|
chunks: [],
|
||||||
|
description: event.description,
|
||||||
displayName: event.displayName || 'User',
|
displayName: event.displayName || 'User',
|
||||||
mime: event.avatarMime,
|
mime: event.avatarMime,
|
||||||
oderId: event.oderId,
|
oderId: event.oderId,
|
||||||
|
profileUpdatedAt: event.profileUpdatedAt,
|
||||||
total: 0,
|
total: 0,
|
||||||
updatedAt: event.avatarUpdatedAt || 0,
|
updatedAt: event.avatarUpdatedAt || 0,
|
||||||
username: event.username || (event.displayName || 'User').toLowerCase().replace(/\s+/g, '_'),
|
username: event.username || (event.displayName || 'User').toLowerCase().replace(/\s+/g, '_'),
|
||||||
@@ -318,9 +340,11 @@ export class UserAvatarEffects {
|
|||||||
|
|
||||||
this.pendingTransfers.set(event.oderId, {
|
this.pendingTransfers.set(event.oderId, {
|
||||||
chunks: new Array<string | undefined>(event.total),
|
chunks: new Array<string | undefined>(event.total),
|
||||||
|
description: event.description,
|
||||||
displayName: event.displayName || 'User',
|
displayName: event.displayName || 'User',
|
||||||
mime: event.avatarMime,
|
mime: event.avatarMime,
|
||||||
oderId: event.oderId,
|
oderId: event.oderId,
|
||||||
|
profileUpdatedAt: event.profileUpdatedAt,
|
||||||
total: event.total,
|
total: event.total,
|
||||||
updatedAt: event.avatarUpdatedAt || Date.now(),
|
updatedAt: event.avatarUpdatedAt || Date.now(),
|
||||||
username: event.username || (event.displayName || 'User').toLowerCase().replace(/\s+/g, '_'),
|
username: event.username || (event.displayName || 'User').toLowerCase().replace(/\s+/g, '_'),
|
||||||
@@ -354,29 +378,16 @@ export class UserAvatarEffects {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async buildRemoteAvatarAction(
|
private async buildRemoteAvatarAction(transfer: PendingAvatarTransfer, allUsers: User[]): Promise<Action | null> {
|
||||||
transfer: PendingAvatarTransfer,
|
const existingUser = allUsers.find((user) => user.id === transfer.oderId || user.oderId === transfer.oderId);
|
||||||
allUsers: User[]
|
|
||||||
): Promise<Action | null> {
|
|
||||||
const existingUser = allUsers.find(
|
|
||||||
(user) => user.id === transfer.oderId || user.oderId === transfer.oderId
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!shouldApplyAvatarTransfer(existingUser, transfer)) {
|
if (!shouldApplyAvatarTransfer(existingUser, transfer)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const base64Chunks = transfer.chunks.filter(
|
|
||||||
(chunk): chunk is string => typeof chunk === 'string'
|
|
||||||
);
|
|
||||||
|
|
||||||
if (transfer.total > 0 && base64Chunks.length !== transfer.total) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dataUrl = transfer.total > 0
|
const dataUrl = transfer.total > 0
|
||||||
? await this.readBlobAsDataUrl(new Blob(
|
? await this.readBlobAsDataUrl(new Blob(
|
||||||
base64Chunks.map((chunk) => this.decodeBase64ToArrayBuffer(chunk)),
|
transfer.chunks.map((chunk) => this.decodeBase64ToArrayBuffer(chunk!)),
|
||||||
{ type: transfer.mime || 'image/webp' }
|
{ type: transfer.mime || 'image/webp' }
|
||||||
))
|
))
|
||||||
: undefined;
|
: undefined;
|
||||||
@@ -387,6 +398,8 @@ export class UserAvatarEffects {
|
|||||||
oderId: existingUser?.oderId || transfer.oderId,
|
oderId: existingUser?.oderId || transfer.oderId,
|
||||||
username: existingUser?.username || transfer.username,
|
username: existingUser?.username || transfer.username,
|
||||||
displayName: transfer.displayName || existingUser?.displayName || 'User',
|
displayName: transfer.displayName || existingUser?.displayName || 'User',
|
||||||
|
description: transfer.description,
|
||||||
|
profileUpdatedAt: transfer.profileUpdatedAt,
|
||||||
avatarUrl: dataUrl,
|
avatarUrl: dataUrl,
|
||||||
avatarHash: transfer.hash,
|
avatarHash: transfer.hash,
|
||||||
avatarMime: transfer.mime,
|
avatarMime: transfer.mime,
|
||||||
@@ -396,13 +409,11 @@ export class UserAvatarEffects {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private buildRoomProfileActions(
|
private buildRoomProfileActions(
|
||||||
avatarOwner: RoomProfileState,
|
avatarOwner: Pick<User, 'id' | 'oderId' | 'displayName' | 'description' | 'profileUpdatedAt' | 'avatarUrl' | 'avatarHash' | 'avatarMime' | 'avatarUpdatedAt'>,
|
||||||
currentRoom: ReturnType<typeof selectCurrentRoom['projector']> | null,
|
currentRoom: ReturnType<typeof selectCurrentRoom['projector']> | null,
|
||||||
savedRooms: ReturnType<typeof selectSavedRooms['projector']>
|
savedRooms: ReturnType<typeof selectSavedRooms['projector']>
|
||||||
): Action[] {
|
): Action[] {
|
||||||
const rooms = [currentRoom, ...savedRooms.filter((room) => room.id !== currentRoom?.id)].filter(
|
const rooms = [currentRoom, ...savedRooms.filter((room) => room.id !== currentRoom?.id)].filter((room): room is NonNullable<typeof currentRoom> => !!room);
|
||||||
(room): room is NonNullable<typeof currentRoom> => !!room
|
|
||||||
);
|
|
||||||
const roomActions: Action[] = [];
|
const roomActions: Action[] = [];
|
||||||
const avatarOwnerId = avatarOwner.oderId || avatarOwner.id;
|
const avatarOwnerId = avatarOwner.oderId || avatarOwner.id;
|
||||||
|
|
||||||
@@ -451,6 +462,8 @@ export class UserAvatarEffects {
|
|||||||
oderId: userKey,
|
oderId: userKey,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
displayName: user.displayName,
|
displayName: user.displayName,
|
||||||
|
description: user.description,
|
||||||
|
profileUpdatedAt: user.profileUpdatedAt,
|
||||||
avatarHash: user.avatarHash,
|
avatarHash: user.avatarHash,
|
||||||
avatarMime: blob ? (user.avatarMime || blob.type || 'image/webp') : undefined,
|
avatarMime: blob ? (user.avatarMime || blob.type || 'image/webp') : undefined,
|
||||||
avatarUpdatedAt: user.avatarUpdatedAt || 0,
|
avatarUpdatedAt: user.avatarUpdatedAt || 0,
|
||||||
|
|||||||
@@ -39,13 +39,7 @@ export const UsersActions = createActionGroup({
|
|||||||
'Kick User': props<{ userId: string; roomId?: string }>(),
|
'Kick User': props<{ userId: string; roomId?: string }>(),
|
||||||
'Kick User Success': props<{ userId: string; roomId: string }>(),
|
'Kick User Success': props<{ userId: string; roomId: string }>(),
|
||||||
|
|
||||||
'Ban User': props<{
|
'Ban User': props<{ userId: string; roomId?: string; displayName?: string; reason?: string; expiresAt?: number }>(),
|
||||||
userId: string;
|
|
||||||
roomId?: string;
|
|
||||||
displayName?: string;
|
|
||||||
reason?: string;
|
|
||||||
expiresAt?: number;
|
|
||||||
}>(),
|
|
||||||
'Ban User Success': props<{ userId: string; roomId: string; ban: BanEntry }>(),
|
'Ban User Success': props<{ userId: string; roomId: string; ban: BanEntry }>(),
|
||||||
'Unban User': props<{ roomId: string; oderId: string }>(),
|
'Unban User': props<{ roomId: string; oderId: string }>(),
|
||||||
'Unban User Success': props<{ oderId: string }>(),
|
'Unban User Success': props<{ oderId: string }>(),
|
||||||
@@ -67,34 +61,8 @@ export const UsersActions = createActionGroup({
|
|||||||
'Set Manual Status': props<{ status: UserStatus | null }>(),
|
'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 Profile': props<{
|
'Update Current User Profile': props<{ profile: { displayName: string; description?: string; profileUpdatedAt: number } }>(),
|
||||||
profile: {
|
'Update Current User Avatar': props<{ avatar: { avatarUrl: string; avatarHash: string; avatarMime: string; avatarUpdatedAt: number } }>(),
|
||||||
displayName: string;
|
'Upsert Remote User Avatar': props<{ user: { id: string; oderId: string; username: string; displayName: string; description?: string; profileUpdatedAt?: number; avatarUrl?: string; avatarHash?: string; avatarMime?: string; avatarUpdatedAt?: number } }>()
|
||||||
description?: string;
|
|
||||||
profileUpdatedAt: number;
|
|
||||||
};
|
|
||||||
}>(),
|
|
||||||
'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;
|
|
||||||
description?: string;
|
|
||||||
profileUpdatedAt?: number;
|
|
||||||
avatarUrl?: string;
|
|
||||||
avatarHash?: string;
|
|
||||||
avatarMime?: string;
|
|
||||||
avatarUpdatedAt?: number;
|
|
||||||
};
|
|
||||||
}>()
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
{
|
{
|
||||||
"extends": "./tsconfig.json",
|
"extends": "./tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"rootDir": "./src",
|
|
||||||
"outDir": "./out-tsc/spec",
|
"outDir": "./out-tsc/spec",
|
||||||
"types": [
|
"types": [
|
||||||
"vitest/globals"
|
"vitest/globals"
|
||||||
|
|||||||
Reference in New Issue
Block a user